@Tags(['screenshots']) library; import 'dart:async'; import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/data/species_repository.dart'; import 'package:tane/data/variety_repository.dart'; import 'package:tane/db/database.dart'; import 'package:tane/i18n/strings.g.dart'; import 'package:tane/state/offers_cubit.dart'; import 'package:tane/state/variety_detail_cubit.dart'; import 'package:tane/ui/calendar_screen.dart'; import 'package:tane/ui/home_screen.dart'; import 'package:tane/ui/inventory_list_screen.dart'; import 'package:tane/ui/market_screen.dart'; import 'package:tane/ui/variety_detail_screen.dart'; import '../support/test_support.dart'; import 'screenshot_support.dart'; /// Generates localized marketing/store screenshots as golden PNGs. /// /// Run (writes the PNGs) — the `screenshots` tag is skipped by default /// (dart_test.yaml), so `--run-skipped` is required: /// flutter test --update-goldens --run-skipped --tags screenshots test/screenshots/screenshots_test.dart /// /// Output: test/screenshots/goldens/``/``.png for en/es/fr/de/pt/ja, /// plus goldens/rtl/ — a right-to-left *layout* demo (English strings mirrored; /// the app ships no Arabic translation yet, so this proves the design is RTL-safe /// rather than claiming an Arabic locale). These are raw device-canvas frames /// with no marketing chrome; add captions/device frames downstream if a store /// requires them. `tool/collect_screenshots.sh` copies them into site/ and fastlane/. void main() { setUpAll(() async { await loadScreenshotFonts(); // Touch mode never draws focus rings, so no highlight frames the FAB/fields. FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; }); late AppDatabase db; late VarietyRepository repo; late SpeciesRepository species; setUp(() async { db = newTestDatabase(); repo = newTestRepository(db); species = newTestSpeciesRepository(db); }); tearDown(() => db.close()); Future capture(WidgetTester tester, String dir, String name) async { tester.view.physicalSize = screenshotSize; tester.view.devicePixelRatio = screenshotPixelRatio; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpAndSettle(); // Asset images (e.g. the home logo) decode via real async that the fake // test clock doesn't drive, so they'd paint blank. Precache them for real, // then repaint, so they show up in the golden. await tester.runAsync(() async { for (final element in find.byType(Image).evaluate()) { await precacheImage((element.widget as Image).image, element); } }); await tester.pumpAndSettle(); await expectLater( find.byType(MaterialApp), matchesGoldenFile('goldens/$dir/$name.png'), ); await disposeTree(tester); } // A POPULATED market: a seeded OffersCubit (no transport) feeding MarketBody, // so the offer list renders with no live discovery stream to hang // pumpAndSettle. Offer names are localized. (Live discovery is exercised by // offers_cubit_test.dart; see market_screen_test.dart on why not via widgets.) Widget marketWidget(AppLocale locale) => Builder( builder: (context) => Scaffold( appBar: AppBar( title: Text(context.t.market.title), actions: [IconButton(icon: const Icon(Icons.tune), onPressed: () {})], ), body: BlocProvider( create: (_) => _SeededOffersCubit(_sampleOffers(locale)), child: MarketBody(onConfigure: () {}), ), ), ); const screenNames = ['home', 'inventory', 'market', 'calendar', 'detail']; for (final locale in screenshotLocales) { for (final name in screenNames) { testWidgets('${locale.languageCode}/$name', (tester) async { // Re-seed per shot so the sample variety names are in the shot's // language. Drift writes must run in REAL async (runAsync), not the // testWidgets fake clock, or they hang. late final SeededInventory seeded; await tester.runAsync(() async { seeded = await seedShowcase(db, repo, locale); }); final child = switch (name) { 'home' => const HomeScreen(marketEnabled: true), 'inventory' => const InventoryListScreen(), 'market' => marketWidget(locale), 'calendar' => const CalendarScreen(initialMonth: 4), _ => BlocProvider( create: (_) => VarietyDetailCubit(repo, seeded.heroVarietyId), child: const VarietyDetailScreen(), ), }; await tester.pumpWidget( screenshotApp( repository: repo, species: species, locale: locale, child: child, ), ); await capture(tester, locale.languageCode, name); }); } } // RTL layout demo: base (English) strings, forced right-to-left, so the // mirrored inventory proves the design is RTL-safe. Not a translated locale. testWidgets('rtl/inventory', (tester) async { await tester.runAsync(() => seedShowcase(db, repo, AppLocale.en)); await tester.pumpWidget( screenshotApp( repository: repo, species: species, locale: AppLocale.en, textDirection: TextDirection.rtl, child: const InventoryListScreen(), ), ); await capture(tester, 'rtl', 'inventory'); }); } /// An [OffersCubit] pre-loaded with a fixed offer list. It carries a no-op /// transport (never called — offers are pre-seeded) purely so `isOnline` is /// true and MarketBody renders the list instead of the "can't reach" state. class _SeededOffersCubit extends OffersCubit { _SeededOffersCubit(List offers) : super(_NoopOfferTransport()) { emit(state.copyWith(offers: offers, hasSearched: true, areaGeohash: 'sp3e')); } } /// A transport that is present (so the market reads as online) but never used. class _NoopOfferTransport implements OfferTransport { @override Stream discover(DiscoveryQuery query, {int? since}) => const Stream.empty(); @override Future discoverPage(DiscoveryQuery query) async => const OfferPage(offers: []); @override Future publish(Offer offer) => throw UnimplementedError(); @override Future retract(String offerId) async {} @override Future close() async {} } /// A small set of nearby offers across gift / swap / sale and colour-coded /// botanical families, with names localized to [l]. List _sampleOffers(AppLocale l) => [ Offer(id: 'o1', authorPubkeyHex: 'a1' * 32, summary: labelFor('cherryTomato', l), type: OfferType.gift, category: 'Solanaceae', approxGeohash: 'sp3e9', isOrganic: true), Offer(id: 'o2', authorPubkeyHex: 'a2' * 32, summary: labelFor('bean', l), type: OfferType.exchange, category: 'Fabaceae', approxGeohash: 'sp3e8'), Offer(id: 'o3', authorPubkeyHex: 'a3' * 32, summary: labelFor('sunflower', l), type: OfferType.sale, category: 'Asteraceae', approxGeohash: 'sp3ec', priceAmount: 2, priceCurrency: '€'), Offer(id: 'o4', authorPubkeyHex: 'a4' * 32, summary: labelFor('basil', l), type: OfferType.gift, category: 'Lamiaceae', approxGeohash: 'sp3e2', isOrganic: true), Offer(id: 'o5', authorPubkeyHex: 'a5' * 32, summary: labelFor('carrot', l), type: OfferType.exchange, category: 'Apiaceae', approxGeohash: 'sp3ef'), ];