feat(screenshots): localize sample variety & market names per shot language
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 348 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 349 KiB After Width: | Height: | Size: 344 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 348 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 352 KiB After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 399 KiB After Width: | Height: | Size: 399 KiB |
|
Before Width: | Height: | Size: 351 KiB After Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 174 KiB |
|
|
@ -71,6 +71,31 @@ Future<void> loadScreenshotFonts() async {
|
||||||
_fontsLoaded = true;
|
_fontsLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Localized sample labels so each screenshot reads in its own language. Family
|
||||||
|
/// names stay botanical Latin; only the human variety/offer names are localized.
|
||||||
|
/// English is the fallback (e.g. for `ja`, whose UI is itself partly English).
|
||||||
|
const sampleLabels = <String, Map<AppLocale, String>>{
|
||||||
|
// Inventory varieties
|
||||||
|
'maize': {AppLocale.en: 'Painted flint corn', AppLocale.es: 'Maíz rojo', AppLocale.fr: 'Maïs corné rouge', AppLocale.de: 'Roter Hartmais', AppLocale.pt: 'Milho pintado'},
|
||||||
|
'buckwheat': {AppLocale.en: 'Common buckwheat', AppLocale.es: 'Trigo sarraceno', AppLocale.fr: 'Sarrasin', AppLocale.de: 'Buchweizen', AppLocale.pt: 'Trigo-mourisco'},
|
||||||
|
'raspberry': {AppLocale.en: 'Wild raspberry', AppLocale.es: 'Frambuesa', AppLocale.fr: 'Framboise sauvage', AppLocale.de: 'Wilde Himbeere', AppLocale.pt: 'Framboesa'},
|
||||||
|
'tomato': {AppLocale.en: 'Slicing tomato', AppLocale.es: 'Tomate de ensalada', AppLocale.fr: 'Tomate à trancher', AppLocale.de: 'Fleischtomate', AppLocale.pt: 'Tomate para salada'},
|
||||||
|
'chilli': {AppLocale.en: 'Guajillo chilli', AppLocale.es: 'Chile guajillo', AppLocale.fr: 'Piment guajillo', AppLocale.de: 'Guajillo-Chili', AppLocale.pt: 'Pimenta guajillo'},
|
||||||
|
'origin': {AppLocale.en: 'Community seed swap', AppLocale.es: 'Intercambio de semillas', AppLocale.fr: 'Troc de graines', AppLocale.de: 'Saatgut-Tausch', AppLocale.pt: 'Troca de sementes'},
|
||||||
|
// Market offers
|
||||||
|
'cherryTomato': {AppLocale.en: 'Cherry tomato', AppLocale.es: 'Tomate cherry', AppLocale.fr: 'Tomate cerise', AppLocale.de: 'Kirschtomate', AppLocale.pt: 'Tomate-cereja'},
|
||||||
|
'bean': {AppLocale.en: 'Climbing bean', AppLocale.es: 'Judía de enrame', AppLocale.fr: 'Haricot à rames', AppLocale.de: 'Stangenbohne', AppLocale.pt: 'Feijão-trepador'},
|
||||||
|
'sunflower': {AppLocale.en: 'Sunflower', AppLocale.es: 'Girasol', AppLocale.fr: 'Tournesol', AppLocale.de: 'Sonnenblume', AppLocale.pt: 'Girassol'},
|
||||||
|
'basil': {AppLocale.en: 'Sweet basil', AppLocale.es: 'Albahaca', AppLocale.fr: 'Basilic', AppLocale.de: 'Basilikum', AppLocale.pt: 'Manjericão'},
|
||||||
|
'carrot': {AppLocale.en: 'Purple carrot', AppLocale.es: 'Zanahoria morada', AppLocale.fr: 'Carotte violette', AppLocale.de: 'Violette Karotte', AppLocale.pt: 'Cenoura roxa'},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The sample label for [key] in [locale], falling back to English.
|
||||||
|
String labelFor(String key, AppLocale locale) {
|
||||||
|
final m = sampleLabels[key]!;
|
||||||
|
return m[locale] ?? m[AppLocale.en]!;
|
||||||
|
}
|
||||||
|
|
||||||
const _fontFallbacks = <String>['Noto Sans JP', 'Noto Sans Arabic'];
|
const _fontFallbacks = <String>['Noto Sans JP', 'Noto Sans Arabic'];
|
||||||
|
|
||||||
/// The real app theme, but with every text style pinned to the bundled
|
/// The real app theme, but with every text style pinned to the bundled
|
||||||
|
|
@ -146,7 +171,9 @@ class SeededInventory {
|
||||||
Future<SeededInventory> seedShowcase(
|
Future<SeededInventory> seedShowcase(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
VarietyRepository repo,
|
VarietyRepository repo,
|
||||||
|
AppLocale locale,
|
||||||
) async {
|
) async {
|
||||||
|
String l(String key) => labelFor(key, locale);
|
||||||
// Sow in spring, harvest seed in late summer — visible in the calendar.
|
// Sow in spring, harvest seed in late summer — visible in the calendar.
|
||||||
final sow = monthsToMask(const [3, 4, 5]);
|
final sow = monthsToMask(const [3, 4, 5]);
|
||||||
final harvest = monthsToMask(const [8, 9]);
|
final harvest = monthsToMask(const [8, 9]);
|
||||||
|
|
@ -174,16 +201,12 @@ Future<SeededInventory> seedShowcase(
|
||||||
Future<Uint8List> photo(String name) =>
|
Future<Uint8List> photo(String name) =>
|
||||||
File('test/screenshots/fixtures/$name.jpg').readAsBytes();
|
File('test/screenshots/fixtures/$name.jpg').readAsBytes();
|
||||||
|
|
||||||
final maize = await add(
|
final maize = await add(l('maize'), 'Poaceae', photo: await photo('maize'));
|
||||||
'Painted flint corn',
|
|
||||||
'Poaceae',
|
|
||||||
photo: await photo('maize'),
|
|
||||||
);
|
|
||||||
await repo.addLot(
|
await repo.addLot(
|
||||||
varietyId: maize,
|
varietyId: maize,
|
||||||
harvestYear: 2024,
|
harvestYear: 2024,
|
||||||
quantity: const Quantity(kind: QuantityKind.cob, count: 6),
|
quantity: const Quantity(kind: QuantityKind.cob, count: 6),
|
||||||
originName: 'Community seed swap',
|
originName: l('origin'),
|
||||||
abundance: Abundance.plentyToShare,
|
abundance: Abundance.plentyToShare,
|
||||||
offerStatus: OfferStatus.shared,
|
offerStatus: OfferStatus.shared,
|
||||||
);
|
);
|
||||||
|
|
@ -196,10 +219,10 @@ Future<SeededInventory> seedShowcase(
|
||||||
|
|
||||||
// The rest sit in families that sort at/after Poaceae, so the maize stays the
|
// The rest sit in families that sort at/after Poaceae, so the maize stays the
|
||||||
// first, top-of-list group in the inventory shot. Each carries a CC0/PD photo.
|
// first, top-of-list group in the inventory shot. Each carries a CC0/PD photo.
|
||||||
await add('Common buckwheat', 'Polygonaceae', photo: await photo('buckwheat'));
|
await add(l('buckwheat'), 'Polygonaceae', photo: await photo('buckwheat'));
|
||||||
await add('Wild raspberry', 'Rosaceae', photo: await photo('raspberry'));
|
await add(l('raspberry'), 'Rosaceae', photo: await photo('raspberry'));
|
||||||
await add('Slicing tomato', 'Solanaceae', photo: await photo('tomato'));
|
await add(l('tomato'), 'Solanaceae', photo: await photo('tomato'));
|
||||||
await add('Guajillo chilli', 'Solanaceae', photo: await photo('pepper'));
|
await add(l('chilli'), 'Solanaceae', photo: await photo('pepper'));
|
||||||
|
|
||||||
return SeededInventory(maize);
|
return SeededInventory(maize);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,13 +45,11 @@ void main() {
|
||||||
late AppDatabase db;
|
late AppDatabase db;
|
||||||
late VarietyRepository repo;
|
late VarietyRepository repo;
|
||||||
late SpeciesRepository species;
|
late SpeciesRepository species;
|
||||||
late SeededInventory seeded;
|
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
db = newTestDatabase();
|
db = newTestDatabase();
|
||||||
repo = newTestRepository(db);
|
repo = newTestRepository(db);
|
||||||
species = newTestSpeciesRepository(db);
|
species = newTestSpeciesRepository(db);
|
||||||
seeded = await seedShowcase(db, repo);
|
|
||||||
});
|
});
|
||||||
tearDown(() => db.close());
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
|
@ -79,45 +77,52 @@ void main() {
|
||||||
|
|
||||||
// A POPULATED market: a seeded OffersCubit (no transport) feeding MarketBody,
|
// A POPULATED market: a seeded OffersCubit (no transport) feeding MarketBody,
|
||||||
// so the offer list renders with no live discovery stream to hang
|
// so the offer list renders with no live discovery stream to hang
|
||||||
// pumpAndSettle. (Live discovery is exercised by offers_cubit_test.dart; see
|
// pumpAndSettle. Offer names are localized. (Live discovery is exercised by
|
||||||
// the note in market_screen_test.dart on why we don't drive it via widgets.)
|
// offers_cubit_test.dart; see market_screen_test.dart on why not via widgets.)
|
||||||
Future<Widget> market() async => Builder(
|
Widget marketWidget(AppLocale locale) => Builder(
|
||||||
builder: (context) => Scaffold(
|
builder: (context) => Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(context.t.market.title),
|
title: Text(context.t.market.title),
|
||||||
actions: [IconButton(icon: const Icon(Icons.tune), onPressed: () {})],
|
actions: [IconButton(icon: const Icon(Icons.tune), onPressed: () {})],
|
||||||
),
|
),
|
||||||
body: BlocProvider<OffersCubit>(
|
body: BlocProvider<OffersCubit>(
|
||||||
create: (_) => _SeededOffersCubit(_sampleOffers()),
|
create: (_) => _SeededOffersCubit(_sampleOffers(locale)),
|
||||||
child: MarketBody(onConfigure: () {}),
|
child: MarketBody(onConfigure: () {}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Each entry renders one screen; the runner re-seeds and re-locales per shot.
|
const screenNames = ['home', 'inventory', 'market', 'calendar', 'detail'];
|
||||||
final screens = <String, Future<Widget> Function()>{
|
|
||||||
'home': () async => const HomeScreen(marketEnabled: true),
|
|
||||||
'inventory': () async => const InventoryListScreen(),
|
|
||||||
'market': market,
|
|
||||||
'calendar': () async => const CalendarScreen(initialMonth: 4),
|
|
||||||
'detail': () async => BlocProvider(
|
|
||||||
create: (_) => VarietyDetailCubit(repo, seeded.heroVarietyId),
|
|
||||||
child: const VarietyDetailScreen(),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
for (final locale in screenshotLocales) {
|
for (final locale in screenshotLocales) {
|
||||||
for (final entry in screens.entries) {
|
for (final name in screenNames) {
|
||||||
testWidgets('${locale.languageCode}/${entry.key}', (tester) async {
|
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(
|
await tester.pumpWidget(
|
||||||
screenshotApp(
|
screenshotApp(
|
||||||
repository: repo,
|
repository: repo,
|
||||||
species: species,
|
species: species,
|
||||||
locale: locale,
|
locale: locale,
|
||||||
child: await entry.value(),
|
child: child,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await capture(tester, locale.languageCode, entry.key);
|
await capture(tester, locale.languageCode, name);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +130,7 @@ void main() {
|
||||||
// RTL layout demo: base (English) strings, forced right-to-left, so the
|
// RTL layout demo: base (English) strings, forced right-to-left, so the
|
||||||
// mirrored inventory proves the design is RTL-safe. Not a translated locale.
|
// mirrored inventory proves the design is RTL-safe. Not a translated locale.
|
||||||
testWidgets('rtl/inventory', (tester) async {
|
testWidgets('rtl/inventory', (tester) async {
|
||||||
|
await tester.runAsync(() => seedShowcase(db, repo, AppLocale.en));
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
screenshotApp(
|
screenshotApp(
|
||||||
repository: repo,
|
repository: repo,
|
||||||
|
|
@ -159,12 +165,12 @@ class _NoopOfferTransport implements OfferTransport {
|
||||||
Future<void> close() async {}
|
Future<void> close() async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A small, internationally-neutral set of nearby offers across gift / swap /
|
/// A small set of nearby offers across gift / swap / sale and colour-coded
|
||||||
/// sale and colour-coded botanical families.
|
/// botanical families, with names localized to [l].
|
||||||
List<Offer> _sampleOffers() => [
|
List<Offer> _sampleOffers(AppLocale l) => [
|
||||||
Offer(id: 'o1', authorPubkeyHex: 'a1' * 32, summary: 'Cherry tomato', type: OfferType.gift, category: 'Solanaceae', approxGeohash: 'sp3e9', isOrganic: true),
|
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: 'Climbing bean', type: OfferType.exchange, category: 'Fabaceae', approxGeohash: 'sp3e8'),
|
Offer(id: 'o2', authorPubkeyHex: 'a2' * 32, summary: labelFor('bean', l), type: OfferType.exchange, category: 'Fabaceae', approxGeohash: 'sp3e8'),
|
||||||
Offer(id: 'o3', authorPubkeyHex: 'a3' * 32, summary: 'Sunflower', type: OfferType.sale, category: 'Asteraceae', approxGeohash: 'sp3ec', priceAmount: 2, priceCurrency: '€'),
|
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: 'Sweet basil', type: OfferType.gift, category: 'Lamiaceae', approxGeohash: 'sp3e2', isOrganic: true),
|
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: 'Purple carrot', type: OfferType.exchange, category: 'Apiaceae', approxGeohash: 'sp3ef'),
|
Offer(id: 'o5', authorPubkeyHex: 'a5' * 32, summary: labelFor('carrot', l), type: OfferType.exchange, category: 'Apiaceae', approxGeohash: 'sp3ef'),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 349 KiB After Width: | Height: | Size: 344 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 348 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 352 KiB After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 399 KiB After Width: | Height: | Size: 399 KiB |
|
Before Width: | Height: | Size: 351 KiB After Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 331 KiB |