Merge branch 'main' into spike/block2-derisking
# Conflicts: # apps/app_seeds/lib/i18n/strings.g.dart
This commit is contained in:
commit
1fb8e37536
25 changed files with 2396 additions and 36 deletions
|
|
@ -206,7 +206,11 @@ class TaneApp extends StatelessWidget {
|
||||||
// desktop (Flutter disables that by default), so the photo carousel and
|
// desktop (Flutter disables that by default), so the photo carousel and
|
||||||
// lists swipe there too.
|
// lists swipe there too.
|
||||||
scrollBehavior: const _DragScrollBehavior(),
|
scrollBehavior: const _DragScrollBehavior(),
|
||||||
locale: TranslationProvider.of(context).flutterLocale,
|
// Flutter ships no Material/Cupertino localizations for Asturian
|
||||||
|
// (`ast`), so render the framework chrome (date pickers, native dialog
|
||||||
|
// buttons) in Spanish — the closest shipped language — while the app's
|
||||||
|
// own text stays Asturian via slang. Every other locale maps 1:1.
|
||||||
|
locale: materialLocaleFor(TranslationProvider.of(context).flutterLocale),
|
||||||
supportedLocales: AppLocaleUtils.supportedLocales,
|
supportedLocales: AppLocaleUtils.supportedLocales,
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
GlobalMaterialLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
|
@ -224,6 +228,14 @@ class TaneApp extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maps the app's active locale to one Flutter's bundled Material/Cupertino
|
||||||
|
/// localizations can serve. Asturian (`ast`) has none, so it borrows Spanish
|
||||||
|
/// (`es`) for the framework chrome; the app's own strings stay Asturian (they
|
||||||
|
/// come from slang, not from `Localizations`). All other locales pass through.
|
||||||
|
@visibleForTesting
|
||||||
|
Locale materialLocaleFor(Locale locale) =>
|
||||||
|
locale.languageCode == 'ast' ? const Locale('es') : locale;
|
||||||
|
|
||||||
/// Enables mouse/trackpad drag scrolling (off by default on desktop) so the
|
/// Enables mouse/trackpad drag scrolling (off by default on desktop) so the
|
||||||
/// photo carousel and lists can be swiped with a pointer.
|
/// photo carousel and lists can be swiped with a pointer.
|
||||||
class _DragScrollBehavior extends MaterialScrollBehavior {
|
class _DragScrollBehavior extends MaterialScrollBehavior {
|
||||||
|
|
|
||||||
|
|
@ -31,3 +31,16 @@ Future<List<SpeciesSeed>> loadBundledSpecies() async {
|
||||||
final jsonString = await rootBundle.loadString(_catalogAsset);
|
final jsonString = await rootBundle.loadString(_catalogAsset);
|
||||||
return parseSpeciesCatalog(jsonString);
|
return parseSpeciesCatalog(jsonString);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Version of the bundled catalog, mirrored from the asset's `version` field
|
||||||
|
/// (written by `tool/gen_species_catalog.dart`). A test asserts the two stay in
|
||||||
|
/// sync. The app records the version it last seeded and skips the ~1.5 MB parse
|
||||||
|
/// and the table scan in [SpeciesRepository.seedBundled] on every subsequent
|
||||||
|
/// launch — so bumping this (alongside the asset) is what triggers a one-time
|
||||||
|
/// re-seed after the catalog changes.
|
||||||
|
const int speciesCatalogVersion = 3;
|
||||||
|
|
||||||
|
/// Reads just the `version` field from a catalog JSON string. Used by the sync
|
||||||
|
/// test to keep [speciesCatalogVersion] aligned with the asset.
|
||||||
|
int parseSpeciesCatalogVersion(String jsonString) =>
|
||||||
|
(jsonDecode(jsonString) as Map<String, dynamic>)['version'] as int;
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,24 @@ class SpeciesRepository {
|
||||||
/// Reads all existing species once and writes in a single batch: with a
|
/// Reads all existing species once and writes in a single batch: with a
|
||||||
/// catalog of ~1000 species a per-row SELECT on every startup would be a real
|
/// catalog of ~1000 species a per-row SELECT on every startup would be a real
|
||||||
/// cost on the encrypted database.
|
/// cost on the encrypted database.
|
||||||
|
/// Seeds the bundled catalog only when [readVersion] doesn't already report
|
||||||
|
/// [version]. [loadSeeds] is awaited lazily and skipped entirely when this
|
||||||
|
/// install already holds [version] — so the ~1.5 MB catalog parse and the
|
||||||
|
/// table scan in [seedBundled] are paid once per catalog version, not on
|
||||||
|
/// every launch (the main startup cost). On a (re)seed, [writeVersion] records
|
||||||
|
/// [version] only after [seedBundled] commits, so an interrupted seed is
|
||||||
|
/// retried on the next launch.
|
||||||
|
Future<void> seedBundledIfNeeded({
|
||||||
|
required int version,
|
||||||
|
required Future<String?> Function() readVersion,
|
||||||
|
required Future<void> Function(String version) writeVersion,
|
||||||
|
required Future<List<SpeciesSeed>> Function() loadSeeds,
|
||||||
|
}) async {
|
||||||
|
if (await readVersion() == version.toString()) return;
|
||||||
|
await seedBundled(await loadSeeds());
|
||||||
|
await writeVersion(version.toString());
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> seedBundled(List<SpeciesSeed> seeds) async {
|
Future<void> seedBundled(List<SpeciesSeed> seeds) async {
|
||||||
final stamp = Hlc.zero(nodeId).pack();
|
final stamp = Hlc.zero(nodeId).pack();
|
||||||
await _db.transaction(() async {
|
await _db.transaction(() async {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import '../services/auto_backup_store.dart';
|
||||||
import '../services/export_import_service.dart';
|
import '../services/export_import_service.dart';
|
||||||
import '../services/file_picker_file_service.dart';
|
import '../services/file_picker_file_service.dart';
|
||||||
import '../services/file_service.dart';
|
import '../services/file_service.dart';
|
||||||
|
import '../services/locale_store.dart';
|
||||||
import '../services/ocr/label_text_extractor.dart';
|
import '../services/ocr/label_text_extractor.dart';
|
||||||
import '../services/ocr/ocr_language.dart';
|
import '../services/ocr/ocr_language.dart';
|
||||||
import '../services/ocr/tesseract_label_extractor.dart';
|
import '../services/ocr/tesseract_label_extractor.dart';
|
||||||
|
|
@ -68,9 +69,18 @@ Future<void> configureDependencies() async {
|
||||||
// contacted here (local-first — the social layer only enriches).
|
// contacted here (local-first — the social layer only enriches).
|
||||||
final socialService = await SocialService.fromRootSeedHex(rootSeedHex);
|
final socialService = await SocialService.fromRootSeedHex(rootSeedHex);
|
||||||
|
|
||||||
// Seed the bundled species catalog (idempotent) before the UI opens.
|
// Seed the bundled species catalog before the UI opens — but only once per
|
||||||
|
// catalog version. Parsing the ~1.5 MB catalog and scanning the species table
|
||||||
|
// on every launch was the main startup jank; the version marker skips both
|
||||||
|
// when this install already holds the current catalog.
|
||||||
|
const catalogVersionKey = 'species_catalog_version';
|
||||||
final speciesRepository = SpeciesRepository(database, idGen: IdGen());
|
final speciesRepository = SpeciesRepository(database, idGen: IdGen());
|
||||||
await speciesRepository.seedBundled(await loadBundledSpecies());
|
await speciesRepository.seedBundledIfNeeded(
|
||||||
|
version: speciesCatalogVersion,
|
||||||
|
readVersion: () => secretStore.read(catalogVersionKey),
|
||||||
|
writeVersion: (v) => secretStore.write(catalogVersionKey, v),
|
||||||
|
loadSeeds: loadBundledSpecies,
|
||||||
|
);
|
||||||
|
|
||||||
final varietyRepository = VarietyRepository(
|
final varietyRepository = VarietyRepository(
|
||||||
database,
|
database,
|
||||||
|
|
@ -97,6 +107,7 @@ Future<void> configureDependencies() async {
|
||||||
..registerSingleton<FileService>(fileService)
|
..registerSingleton<FileService>(fileService)
|
||||||
..registerSingleton<LabelTextExtractor>(labelExtractor)
|
..registerSingleton<LabelTextExtractor>(labelExtractor)
|
||||||
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
|
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
|
||||||
|
..registerSingleton<LocaleStore>(LocaleStore(secretStore))
|
||||||
..registerSingleton<SocialService>(socialService)
|
..registerSingleton<SocialService>(socialService)
|
||||||
..registerSingleton<SocialSettings>(SocialSettings(secretStore))
|
..registerSingleton<SocialSettings>(SocialSettings(secretStore))
|
||||||
..registerSingleton<OfferOutbox>(OfferOutbox(secretStore))
|
..registerSingleton<OfferOutbox>(OfferOutbox(secretStore))
|
||||||
|
|
|
||||||
410
apps/app_seeds/lib/i18n/ast.i18n.json
Normal file
410
apps/app_seeds/lib/i18n/ast.i18n.json
Normal file
|
|
@ -0,0 +1,410 @@
|
||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"title": "Tanemaki"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"save": "Guardar",
|
||||||
|
"cancel": "Encaboxar",
|
||||||
|
"delete": "Desaniciar",
|
||||||
|
"edit": "Editar",
|
||||||
|
"type": "Triba",
|
||||||
|
"comingSoon": "Aína"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"tagline": "Comparte y cultiva simiente llocal",
|
||||||
|
"openMarket": "Mercáu",
|
||||||
|
"openMarketSubtitle": "Descubri y comparte simiente cerca",
|
||||||
|
"yourInventory": "El to inventariu",
|
||||||
|
"yourInventorySubtitle": "Remana la to simiente"
|
||||||
|
},
|
||||||
|
"photo": {
|
||||||
|
"camera": "Facer una semeya",
|
||||||
|
"gallery": "Escoyer de la galería",
|
||||||
|
"setAsCover": "Poner de portada",
|
||||||
|
"isCover": "Semeya de portada",
|
||||||
|
"deleteConfirm": "¿Desaniciar esta semeya?"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"tagline": "el to bancu de simiente",
|
||||||
|
"inventory": "Inventariu",
|
||||||
|
"market": "Mercáu",
|
||||||
|
"profile": "El to perfil",
|
||||||
|
"chat": "Charra",
|
||||||
|
"wishlist": "Llista de deseos",
|
||||||
|
"following": "Siguiendo",
|
||||||
|
"settings": "Axustes"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"language": "Llingua",
|
||||||
|
"systemLanguage": "Llingua del sistema",
|
||||||
|
"langEs": "Español",
|
||||||
|
"langEn": "English",
|
||||||
|
"langPt": "Português",
|
||||||
|
"langAst": "Asturianu",
|
||||||
|
"about": "Tocante a",
|
||||||
|
"aboutText": "Inventariu llocal y cifráu pa simiente tradicional. AGPL-3.0.",
|
||||||
|
"aboutOpen": "Tocante a Tanemaki"
|
||||||
|
},
|
||||||
|
"backup": {
|
||||||
|
"title": "Copia de seguranza",
|
||||||
|
"autoBackupTitle": "Copies automátiques",
|
||||||
|
"autoBackupLast": "Cabera copia guardada'l {date}",
|
||||||
|
"autoBackupNone": "Guárdase una copia automáticamente en segundu planu",
|
||||||
|
"exportJson": "Guardar una copia de seguranza",
|
||||||
|
"exportJsonSubtitle": "Una copia completa pa guardar a salvo, restaurar dempués o pasar a otru preséu",
|
||||||
|
"importJson": "Restaurar una copia",
|
||||||
|
"importJsonSubtitle": "Recupera una copia guardada — nun se dobla nada",
|
||||||
|
"exportCsv": "Esportar a una fueya de cálculu",
|
||||||
|
"exportCsvSubtitle": "Una llista cenciella pa Excel o LibreOffice — ensin semeyes",
|
||||||
|
"importCsv": "Importar una llista",
|
||||||
|
"importCsvSubtitle": "Amiesta entraes dende una fueya de cálculu",
|
||||||
|
"importConfirmTitle": "¿Restaurar una copia?",
|
||||||
|
"importConfirmBody": "Les entraes fusiónense col to inventariu; si una entrada esiste en dambos llaos, gana la versión más nueva. Nun se dobla nada.",
|
||||||
|
"importCsvConfirmTitle": "¿Importar una llista?",
|
||||||
|
"importCsvConfirmBody": "Cada filera amiéstase como una entrada nueva. Nun fusiona nin troca, asina qu'importar el mesmu ficheru dos veces amiéstalu dos veces.",
|
||||||
|
"importAction": "Importar",
|
||||||
|
"exportSaved": "Copia guardada",
|
||||||
|
"cancelled": "Encaboxáu",
|
||||||
|
"importDone": "Importáu: {added} nueves, {updated} anovaes",
|
||||||
|
"importCsvDone": "Amestaes {count} entraes",
|
||||||
|
"importFailed": "Esti ficheru nun se pudo lleer como una copia de Tanemaki",
|
||||||
|
"failed": "Daqué salió mal",
|
||||||
|
"recoveryTitle": "El to códigu de recuperación",
|
||||||
|
"recoverySubtitle": "Imprémelu y guárdalu bien: abre les tos copies n'otru preséu",
|
||||||
|
"recoveryIntro": "Esti códigu abre les tos copies guardaes y recupera'l to bancu en cualquier preséu. Guarda dos copies en papel en sitios seguros, como la to meyor simiente. Quien lu tenga pue lleer les tos copies, asina que nun lu compartas con naide.",
|
||||||
|
"recoveryCopy": "Copiar",
|
||||||
|
"recoverySave": "Guardar la fueya",
|
||||||
|
"recoverySheetTitle": "Tanemaki — la to fueya de recuperación",
|
||||||
|
"recoveryPromptTitle": "Escribi'l to códigu de recuperación",
|
||||||
|
"recoveryPromptBody": "Esta copia guardóse con otru códigu. Escribi'l códigu de la to fueya de recuperación p'abrilla.",
|
||||||
|
"recoveryWrongCode": "Esi códigu nun abre esta copia"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "Tocante a",
|
||||||
|
"kanji": "種まき",
|
||||||
|
"tagline": "Una app local-first y descentralizada pa remanar y compartir simiente y plantones tradicionales.",
|
||||||
|
"intro": "Tanemaki (種まき, «semar / esparder simiente» en xaponés; nome curtiu Tane, 種 «grana») ayuda a persones y coleutivos a llevar un inventariu amable del so bancu de simiente, decidir qué ufierten y compartilo llocalmente — ensin un intermediariu central que pueda controlalo, censuralo o ser multáu por ello. L'oxetivu ye a la vez práuticu y políticu: sofitar les variedaes tradicionales de simiente y plantar cara al monopoliu simienteru.",
|
||||||
|
"heritage": "El nome honra les vieyes tradiciones xaponeses d'ayuda mutua alredor del arroz — yui (trabayu comunitariu compartíu) y tanomoshi (fondos de reciprocidá) — qu'inspiraron el papel Plantare, la «moneda comunitaria pal intercambiu de simiente» (BAH-Semilleru, 2009, CC-BY-SA). Tanemaki ye'l Plantare dixital.",
|
||||||
|
"version": "Versión",
|
||||||
|
"license": "Llicencia",
|
||||||
|
"licenseValue": "AGPL-3.0",
|
||||||
|
"website": "Sitiu web",
|
||||||
|
"openSourceLicenses": "Llicencies de códigu abiertu",
|
||||||
|
"openSourceLicensesSubtitle": "Biblioteques de terceros y les sos llicencies",
|
||||||
|
"copyright": "© {years} Asociación Comunes, baxo AGPLv3"
|
||||||
|
},
|
||||||
|
"intro": {
|
||||||
|
"skip": "Saltar",
|
||||||
|
"next": "Siguiente",
|
||||||
|
"start": "Entamar",
|
||||||
|
"menuEntry": "Cómo furrula Tanemaki",
|
||||||
|
"slides": {
|
||||||
|
"welcome": {
|
||||||
|
"title": "La grana que te traxo hasta equí",
|
||||||
|
"body": "Cada simiente tradicional ye una carta escrita por miles de xeneraciones, que pasó de mano en mano. Somos lo que somos gracies a esi intercambiu."
|
||||||
|
},
|
||||||
|
"inventory": {
|
||||||
|
"title": "El to bancu de simiente, nel bolsu",
|
||||||
|
"body": "Apunta qué tienes, de qué añu, cuánto y d'ónde vieno, col nome que tu uses. Una semeya y un nome abasten pa entamar."
|
||||||
|
},
|
||||||
|
"privacy": {
|
||||||
|
"title": "Tuyo y namás tuyo",
|
||||||
|
"body": "Ensin cuenta, ensin internet, ensin rastrexadores. Los tos datos viven cifraos nel to preséu y namás se comparte lo que tu decidas."
|
||||||
|
},
|
||||||
|
"share": {
|
||||||
|
"title": "Compartir, como siempres se fizo",
|
||||||
|
"body": "Ufierta lo que te sobra —regalu, truecu o venta— y qu'alguien cerca lo atope. Namás s'amuesa la distancia averada, enxamás la to direición; el tratu ciérraslu en persona."
|
||||||
|
},
|
||||||
|
"plantare": {
|
||||||
|
"title": "Semar ye multiplicar",
|
||||||
|
"body": "Col Plantare, quien recibe promete devolver simiente más alantre. Y como pa devolvela hai que cultivala, cada préstamu multiplica'l común."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inventory": {
|
||||||
|
"title": "Inventariu",
|
||||||
|
"searchHint": "Guetar simiente",
|
||||||
|
"empty": "Entá nun hai simiente. Toca + p'amestar la primera.",
|
||||||
|
"noMatches": "Nenguna simiente concasa colos filtros.",
|
||||||
|
"clearFilters": "Quitar filtros",
|
||||||
|
"uncategorized": "Ensin categoría",
|
||||||
|
"needsReproductionFilter": "Por reproducir"
|
||||||
|
},
|
||||||
|
"draft": {
|
||||||
|
"capture": "Capturar semeyes",
|
||||||
|
"captured": "{n} capturaes por catalogar",
|
||||||
|
"triageTitle": "Por catalogar",
|
||||||
|
"triageCount": "{n} por catalogar",
|
||||||
|
"untitled": "Ensin nome",
|
||||||
|
"nameField": "Ponle nome a esta simiente",
|
||||||
|
"nameHint": "¿Qué ye?",
|
||||||
|
"suggestFromPhoto": "Suxerir nome de la semeya",
|
||||||
|
"discard": "Descartar"
|
||||||
|
},
|
||||||
|
"quickAdd": {
|
||||||
|
"title": "Amestar una simiente",
|
||||||
|
"labelField": "Nome",
|
||||||
|
"labelRequired": "Ponle un nome",
|
||||||
|
"addPhoto": "Amestar semeya",
|
||||||
|
"quantity": "¿Cuánta?",
|
||||||
|
"more": "Amestar más…",
|
||||||
|
"save": "Guardar",
|
||||||
|
"saveAndAddAnother": "Guardar y amestar otra",
|
||||||
|
"addedCount": "{n} amestaes",
|
||||||
|
"cancel": "Encaboxar"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"notFound": "Esta simiente yá nun ta equí.",
|
||||||
|
"lots": "Llotes",
|
||||||
|
"noLots": "Entá nun hai llotes.",
|
||||||
|
"names": "Conocida tamién como",
|
||||||
|
"addName": "Amestar nome",
|
||||||
|
"links": "Enllaces",
|
||||||
|
"addLink": "Amestar enllaz",
|
||||||
|
"linkUrl": "URL",
|
||||||
|
"linkTitle": "Títulu (opcional)",
|
||||||
|
"reference": "Saber más",
|
||||||
|
"refGbif": "GBIF",
|
||||||
|
"refWikipedia": "Wikipedia",
|
||||||
|
"refWikispecies": "Wikispecies",
|
||||||
|
"notes": "Notes",
|
||||||
|
"addLot": "Amestar llote",
|
||||||
|
"editLot": "Editar llote",
|
||||||
|
"deleteConfirm": "¿Desaniciar esta simiente?",
|
||||||
|
"year": "Añu {year}",
|
||||||
|
"noYear": "Añu desconocíu"
|
||||||
|
},
|
||||||
|
"germination": {
|
||||||
|
"title": "Germinación",
|
||||||
|
"add": "Amestar prueba",
|
||||||
|
"sampleSize": "Amuesa",
|
||||||
|
"germinated": "Germinaes",
|
||||||
|
"none": "Entá nun hai pruebes de germinación.",
|
||||||
|
"result": "{percent}%"
|
||||||
|
},
|
||||||
|
"viability": {
|
||||||
|
"expiringSoon": "Úsala o multiplícala esta temporada",
|
||||||
|
"expiringSoonYears": "Úsala o multiplícala esta temporada · dura ~{years} años",
|
||||||
|
"expired": "Pasa la so viabilidá típica — a multiplicar",
|
||||||
|
"expiredYears": "Pasa la so viabilidá típica (~{years} años) — a multiplicar"
|
||||||
|
},
|
||||||
|
"editVariety": {
|
||||||
|
"title": "Editar simiente",
|
||||||
|
"name": "Nome",
|
||||||
|
"category": "Categoría",
|
||||||
|
"notes": "Notes",
|
||||||
|
"species": "Especie (del catálogu)",
|
||||||
|
"speciesHint": "Guetar una especie…",
|
||||||
|
"speciesSuggested": "Suxerida pol nome",
|
||||||
|
"organic": "Ecolóxica",
|
||||||
|
"organicHint": "Cultivada de mou ecolóxicu (eco)"
|
||||||
|
},
|
||||||
|
"addLot": {
|
||||||
|
"title": "Amestar llote",
|
||||||
|
"year": "Data de coyecha",
|
||||||
|
"quantity": "¿Cuánta?",
|
||||||
|
"amount": "Cantidá"
|
||||||
|
},
|
||||||
|
"harvest": {
|
||||||
|
"pickTitle": "Escoyer mes / añu",
|
||||||
|
"anyMonth": "Cualquier mes",
|
||||||
|
"noDate": "Indicar data de coyecha",
|
||||||
|
"monthNames": [
|
||||||
|
"xineru", "febreru", "marzu", "abril", "mayu", "xunu",
|
||||||
|
"xunetu", "agostu", "setiembre", "ochobre", "payares", "avientu"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lotType": {
|
||||||
|
"seed": "Simiente",
|
||||||
|
"plant": "Planta",
|
||||||
|
"seedling": "Planton",
|
||||||
|
"tree": "Árbol / matu",
|
||||||
|
"bulb": "Bulbu / tubérculu",
|
||||||
|
"cutting": "Esqueje"
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"title": "Envase",
|
||||||
|
"none": "Ensin especificar",
|
||||||
|
"pot": "Tiestu",
|
||||||
|
"tray": "Bandexa",
|
||||||
|
"plug": "Alvéolu",
|
||||||
|
"bareRoot": "Raíz esnuda",
|
||||||
|
"rootBall": "Cepellón"
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"section": "D'ónde vien",
|
||||||
|
"seedsFrom": "Simiente de",
|
||||||
|
"seedsFromHint": "Quién la cultivó o la dio",
|
||||||
|
"place": "Llugar",
|
||||||
|
"placeHint": "D'ónde vien (con provincia)",
|
||||||
|
"addSeedsFrom": "Simiente de",
|
||||||
|
"addPlace": "Llugar"
|
||||||
|
},
|
||||||
|
"abundance": {
|
||||||
|
"add": "Cuánta tengo",
|
||||||
|
"title": "Cuánta tengo",
|
||||||
|
"none": "Ensin indicar",
|
||||||
|
"plentyToShare": "A esgaya pa compartir",
|
||||||
|
"enoughToShare": "Abondo, pa compartir con mesura",
|
||||||
|
"enoughForMe": "Abondo pa min",
|
||||||
|
"runningLow": "Queda poca"
|
||||||
|
},
|
||||||
|
"share": {
|
||||||
|
"add": "¿Compártesla?",
|
||||||
|
"title": "¿Compártesla?",
|
||||||
|
"nudge": "Tienes a esgaya: podríes compartir un poco.",
|
||||||
|
"private": "Namás pa min",
|
||||||
|
"gift": "Pa regalar",
|
||||||
|
"exchange": "Pa trocar",
|
||||||
|
"sell": "En venta",
|
||||||
|
"filterChip": "Comparto",
|
||||||
|
"printCatalog": "Imprentar lo que comparto",
|
||||||
|
"catalogTitle": "Lo que comparto",
|
||||||
|
"catalogSaved": "Catálogu guardáu",
|
||||||
|
"cancelled": "Encaboxáu"
|
||||||
|
},
|
||||||
|
"cropCalendar": {
|
||||||
|
"add": "Calendariu de cultivu",
|
||||||
|
"title": "Calendariu de cultivu",
|
||||||
|
"sow": "Sema",
|
||||||
|
"transplant": "Tresplante",
|
||||||
|
"flowering": "Floriamientu",
|
||||||
|
"fruiting": "Fructificación",
|
||||||
|
"seedHarvest": "Coyecha de simiente",
|
||||||
|
"unset": "—"
|
||||||
|
},
|
||||||
|
"needsReproduction": {
|
||||||
|
"label": "Reproducir esta temporada",
|
||||||
|
"hint": "Cultívala enantes de que s'acabe la simiente",
|
||||||
|
"badge": "Por reproducir"
|
||||||
|
},
|
||||||
|
"preservation": {
|
||||||
|
"add": "Cómo se caltién",
|
||||||
|
"title": "Cómo se caltién",
|
||||||
|
"none": "Ensin especificar",
|
||||||
|
"jarWithDesiccant": "Bote con desecante",
|
||||||
|
"glassJar": "Bote de cristal",
|
||||||
|
"paperEnvelope": "Sobre de papel",
|
||||||
|
"paperBag": "Bolsa de papel",
|
||||||
|
"plasticBag": "Bolsa de plásticu"
|
||||||
|
},
|
||||||
|
"conditionCheck": {
|
||||||
|
"advanced": "Caltenimientu y detalles del bancu",
|
||||||
|
"title": "Revisiones de caltenimientu",
|
||||||
|
"add": "Amestar revisión",
|
||||||
|
"containers": "Botes / recipientes",
|
||||||
|
"desiccant": "Desecante",
|
||||||
|
"none": "Entá nun hai revisiones.",
|
||||||
|
"summary": "{count} bote(s) · {state}"
|
||||||
|
},
|
||||||
|
"desiccant": {
|
||||||
|
"none": "Nun tien",
|
||||||
|
"add": "Pónse",
|
||||||
|
"replace": "Cámbiase",
|
||||||
|
"dry": "Azul — secu",
|
||||||
|
"fresh": "Recién puestu"
|
||||||
|
},
|
||||||
|
"unit": {
|
||||||
|
"aFew": "delles poques",
|
||||||
|
"some": "dalgunes",
|
||||||
|
"plenty": "munches",
|
||||||
|
"pinch": "una migaya",
|
||||||
|
"handful": { "singular": "puñáu", "plural": "puñaos" },
|
||||||
|
"teaspoon": { "singular": "cuyaradina", "plural": "cuyaradines" },
|
||||||
|
"spoon": { "singular": "cuyar", "plural": "cuyares" },
|
||||||
|
"cup": { "singular": "taza", "plural": "tazes" },
|
||||||
|
"jar": { "singular": "bote", "plural": "botes" },
|
||||||
|
"sack": { "singular": "sacu", "plural": "sacos" },
|
||||||
|
"packet": { "singular": "sobre", "plural": "sobres" },
|
||||||
|
"cob": { "singular": "panoya", "plural": "panoyes" },
|
||||||
|
"pod": { "singular": "vaina", "plural": "vaines" },
|
||||||
|
"ear": { "singular": "espiga", "plural": "espigues" },
|
||||||
|
"head": { "singular": "cabezuela", "plural": "cabezueles" },
|
||||||
|
"fruit": { "singular": "frutu", "plural": "frutos" },
|
||||||
|
"bulb": { "singular": "bulbu", "plural": "bulbos" },
|
||||||
|
"tuber": { "singular": "tubérculu", "plural": "tubérculos" },
|
||||||
|
"seedHead": { "singular": "cabeza de simiente", "plural": "cabeces de simiente" },
|
||||||
|
"bunch": { "singular": "manoyu", "plural": "manoyos" },
|
||||||
|
"plant": { "singular": "planta", "plural": "plantes" },
|
||||||
|
"pot": { "singular": "tiestu", "plural": "tiestos" },
|
||||||
|
"tray": { "singular": "bandexa", "plural": "bandexes" },
|
||||||
|
"seedling": { "singular": "plántula", "plural": "plántules" },
|
||||||
|
"tree": { "singular": "árbol", "plural": "árboles" },
|
||||||
|
"cutting": { "singular": "esqueje", "plural": "esquejes" },
|
||||||
|
"grams": { "singular": "gramu", "plural": "gramos" },
|
||||||
|
"count": { "singular": "grana", "plural": "granes" }
|
||||||
|
},
|
||||||
|
"market": {
|
||||||
|
"title": "Simiente cerca de ti",
|
||||||
|
"subtitle": "Lo qu'otres persones comparten cerca",
|
||||||
|
"notSetUp": "Entá nun configurasti'l compartir",
|
||||||
|
"notSetUpBody": "Activa'l compartir pa ver y dar simiente a xente cercano. Caltiénse averao — la to zona, enxamás la to direición esacta.",
|
||||||
|
"setUp": "Configurar el compartir",
|
||||||
|
"cantReach": "Nun se pue coneutar colos servidores agora mesmo",
|
||||||
|
"cantReachBody": "Inténtalo otra vez, o revisa los servidores na configuración avanzada.",
|
||||||
|
"retry": "Retentar",
|
||||||
|
"setArea": "Indica la to zona",
|
||||||
|
"setAreaBody": "Dile al mercáu la to zona averada pa ver simiente cerca.",
|
||||||
|
"searching": "Guetando pela to zona…",
|
||||||
|
"empty": "Entá nun hai simiente compartida cerca de ti",
|
||||||
|
"near": "Cerca de ti",
|
||||||
|
"contact": "Mensaxe",
|
||||||
|
"mine": "Tu",
|
||||||
|
"configTitle": "Configuración de compartir",
|
||||||
|
"setupIntro": "Compartir con xente cercano ye opcional. Namás indica la to zona averada — yá tas coneutáu a servidores comunitarios compartíos pa qu'otres persones atopen lo qu'ufiertes, ensin nenguna empresa en mediu.",
|
||||||
|
"areaLabel": "La to zona",
|
||||||
|
"areaHelp": "Caltiénse averao a costafecha — la to zona, enxamás un puntu esactu.",
|
||||||
|
"areaSet": "La to zona ta puesta — averada, enxamás el to puntu esactu",
|
||||||
|
"areaNotSet": "Zona ensin definir — usa'l to allugamientu, o amiesta un códigu n'Avanzáu",
|
||||||
|
"advanced": "Avanzáu",
|
||||||
|
"areaCodeLabel": "Códigu de zona",
|
||||||
|
"areaCodeHint": "Un códigu curtiu como sp3e9 — non un nome de llugar",
|
||||||
|
"serversLabel": "Servidores de la comunidá",
|
||||||
|
"serversHelp": "Una direición per llinia (wss://…). Déxalo asina si nun sabes.",
|
||||||
|
"serversAdvanced": "Amestar otru servidor",
|
||||||
|
"save": "Guardar",
|
||||||
|
"saved": "Guardáu",
|
||||||
|
"wanted": "Gueto",
|
||||||
|
"shareMine": "Compartir la mio simiente",
|
||||||
|
"sharedCount": "Compartíes {n} simientes",
|
||||||
|
"nothingToShare": "Marca primero dalguna simiente pa regalar, trocar o vender",
|
||||||
|
"useLocation": "Usar el mio allugamientu averáu",
|
||||||
|
"locationFailed": "Nun se pudo consiguir el to allugamientu — comprueba que l'allugamientu ta activáu y el permisu concedíu",
|
||||||
|
"queued": "Guardáu — compartirémosles cuando tengas conexón"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "El to perfil",
|
||||||
|
"name": "Nome",
|
||||||
|
"nameHint": "Cómo te ven los demás",
|
||||||
|
"about": "Sobre ti",
|
||||||
|
"aboutHint": "Una llinia — qué cultives, ónde",
|
||||||
|
"g1": "Direición Ğ1 (opcional)",
|
||||||
|
"g1Hint": "Pa que te paguen en Ğ1 — aparte de la to clave",
|
||||||
|
"yourId": "La to identidá",
|
||||||
|
"idHelp": "Compártela pa que te reconozan",
|
||||||
|
"copy": "Copiar",
|
||||||
|
"copied": "Copiao",
|
||||||
|
"save": "Guardar",
|
||||||
|
"saved": "Perfil guardáu"
|
||||||
|
},
|
||||||
|
"chatList": {
|
||||||
|
"title": "Mensaxes",
|
||||||
|
"empty": "Entá nun hai charres. Escríbi-y a alguien dende'l mercáu."
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "Charra",
|
||||||
|
"hint": "Escribi un mensaxe…",
|
||||||
|
"send": "Unviar",
|
||||||
|
"empty": "Entá nun hai mensaxes — saluda",
|
||||||
|
"offline": "Configura'l compartir pa unviar mensaxes",
|
||||||
|
"payG1": "Pagar en Ğ1",
|
||||||
|
"g1Copied": "Direición Ğ1 copiada — apégala na to cartera"
|
||||||
|
},
|
||||||
|
"trust": {
|
||||||
|
"none": "Naide los avala entá",
|
||||||
|
"count": "Avalada por {n}",
|
||||||
|
"vouch": "Conozo a esta persona",
|
||||||
|
"vouched": "Avales a esta persona",
|
||||||
|
"circle": "Nel to círculu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
"langEs": "Español",
|
"langEs": "Español",
|
||||||
"langEn": "English",
|
"langEn": "English",
|
||||||
"langPt": "Português",
|
"langPt": "Português",
|
||||||
|
"langAst": "Asturianu",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"aboutText": "Local-first, encrypted inventory for traditional seeds. AGPL-3.0.",
|
"aboutText": "Local-first, encrypted inventory for traditional seeds. AGPL-3.0.",
|
||||||
"aboutOpen": "About Tanemaki"
|
"aboutOpen": "About Tanemaki"
|
||||||
|
|
@ -347,6 +348,8 @@
|
||||||
"setAreaBody": "Tell the market roughly where you are to see seeds nearby.",
|
"setAreaBody": "Tell the market roughly where you are to see seeds nearby.",
|
||||||
"searching": "Looking around your area…",
|
"searching": "Looking around your area…",
|
||||||
"empty": "No seeds shared near you yet",
|
"empty": "No seeds shared near you yet",
|
||||||
|
"searchHint": "Search these seeds",
|
||||||
|
"noMatches": "No shared seeds match your search",
|
||||||
"near": "Near you",
|
"near": "Near you",
|
||||||
"contact": "Message",
|
"contact": "Message",
|
||||||
"mine": "You",
|
"mine": "You",
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,8 @@
|
||||||
"setAreaBody": "Dile al mercado tu zona aproximada para ver semillas cerca.",
|
"setAreaBody": "Dile al mercado tu zona aproximada para ver semillas cerca.",
|
||||||
"searching": "Buscando por tu zona…",
|
"searching": "Buscando por tu zona…",
|
||||||
"empty": "Aún no hay semillas compartidas cerca de ti",
|
"empty": "Aún no hay semillas compartidas cerca de ti",
|
||||||
|
"searchHint": "Buscar entre estas semillas",
|
||||||
|
"noMatches": "Ninguna semilla compartida coincide con tu búsqueda",
|
||||||
"near": "Cerca de ti",
|
"near": "Cerca de ti",
|
||||||
"contact": "Mensaje",
|
"contact": "Mensaje",
|
||||||
"mine": "Tú",
|
"mine": "Tú",
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
"langEs": "Español",
|
"langEs": "Español",
|
||||||
"langEn": "English",
|
"langEn": "English",
|
||||||
"langPt": "Português",
|
"langPt": "Português",
|
||||||
|
"langAst": "Asturianu",
|
||||||
"about": "Acerca de",
|
"about": "Acerca de",
|
||||||
"aboutText": "Inventário local e cifrado para sementes tradicionais. AGPL-3.0.",
|
"aboutText": "Inventário local e cifrado para sementes tradicionais. AGPL-3.0.",
|
||||||
"aboutOpen": "Acerca do Tanemaki"
|
"aboutOpen": "Acerca do Tanemaki"
|
||||||
|
|
@ -343,6 +344,8 @@
|
||||||
"setAreaBody": "Diz ao mercado a tua zona aproximada para ver sementes por perto.",
|
"setAreaBody": "Diz ao mercado a tua zona aproximada para ver sementes por perto.",
|
||||||
"searching": "A procurar pela tua zona…",
|
"searching": "A procurar pela tua zona…",
|
||||||
"empty": "Ainda não há sementes partilhadas perto de ti",
|
"empty": "Ainda não há sementes partilhadas perto de ti",
|
||||||
|
"searchHint": "Procurar nestas sementes",
|
||||||
|
"noMatches": "Nenhuma semente partilhada corresponde à procura",
|
||||||
"near": "Perto de ti",
|
"near": "Perto de ti",
|
||||||
"contact": "Mensagem",
|
"contact": "Mensagem",
|
||||||
"mine": "Tu",
|
"mine": "Tu",
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
/// Source: lib/i18n
|
/// Source: lib/i18n
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 3
|
/// Locales: 4
|
||||||
/// Strings: 1076 (358 per locale)
|
/// Strings: 1444 (361 per locale)
|
||||||
///
|
///
|
||||||
/// Built on 2026-07-10 at 13:27 UTC
|
/// Built on 2026-07-10 at 13:48 UTC
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
@ -18,6 +18,7 @@ import 'package:slang/generated.dart';
|
||||||
import 'package:slang_flutter/slang_flutter.dart';
|
import 'package:slang_flutter/slang_flutter.dart';
|
||||||
export 'package:slang_flutter/slang_flutter.dart';
|
export 'package:slang_flutter/slang_flutter.dart';
|
||||||
|
|
||||||
|
import 'strings_ast.g.dart' as l_ast;
|
||||||
import 'strings_es.g.dart' as l_es;
|
import 'strings_es.g.dart' as l_es;
|
||||||
import 'strings_pt.g.dart' as l_pt;
|
import 'strings_pt.g.dart' as l_pt;
|
||||||
part 'strings_en.g.dart';
|
part 'strings_en.g.dart';
|
||||||
|
|
@ -30,6 +31,7 @@ part 'strings_en.g.dart';
|
||||||
/// - if (LocaleSettings.currentLocale == AppLocale.en) // locale check
|
/// - if (LocaleSettings.currentLocale == AppLocale.en) // locale check
|
||||||
enum AppLocale with BaseAppLocale<AppLocale, Translations> {
|
enum AppLocale with BaseAppLocale<AppLocale, Translations> {
|
||||||
en(languageCode: 'en'),
|
en(languageCode: 'en'),
|
||||||
|
ast(languageCode: 'ast'),
|
||||||
es(languageCode: 'es'),
|
es(languageCode: 'es'),
|
||||||
pt(languageCode: 'pt');
|
pt(languageCode: 'pt');
|
||||||
|
|
||||||
|
|
@ -69,6 +71,12 @@ enum AppLocale with BaseAppLocale<AppLocale, Translations> {
|
||||||
cardinalResolver: cardinalResolver,
|
cardinalResolver: cardinalResolver,
|
||||||
ordinalResolver: ordinalResolver,
|
ordinalResolver: ordinalResolver,
|
||||||
);
|
);
|
||||||
|
case AppLocale.ast:
|
||||||
|
return l_ast.TranslationsAst(
|
||||||
|
overrides: overrides,
|
||||||
|
cardinalResolver: cardinalResolver,
|
||||||
|
ordinalResolver: ordinalResolver,
|
||||||
|
);
|
||||||
case AppLocale.es:
|
case AppLocale.es:
|
||||||
return l_es.TranslationsEs(
|
return l_es.TranslationsEs(
|
||||||
overrides: overrides,
|
overrides: overrides,
|
||||||
|
|
|
||||||
1417
apps/app_seeds/lib/i18n/strings_ast.g.dart
Normal file
1417
apps/app_seeds/lib/i18n/strings_ast.g.dart
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -222,6 +222,9 @@ class Translations$settings$en {
|
||||||
/// en: 'Português'
|
/// en: 'Português'
|
||||||
String get langPt => 'Português';
|
String get langPt => 'Português';
|
||||||
|
|
||||||
|
/// en: 'Asturianu'
|
||||||
|
String get langAst => 'Asturianu';
|
||||||
|
|
||||||
/// en: 'About'
|
/// en: 'About'
|
||||||
String get about => 'About';
|
String get about => 'About';
|
||||||
|
|
||||||
|
|
@ -1104,6 +1107,12 @@ class Translations$market$en {
|
||||||
/// en: 'No seeds shared near you yet'
|
/// en: 'No seeds shared near you yet'
|
||||||
String get empty => 'No seeds shared near you yet';
|
String get empty => 'No seeds shared near you yet';
|
||||||
|
|
||||||
|
/// en: 'Search these seeds'
|
||||||
|
String get searchHint => 'Search these seeds';
|
||||||
|
|
||||||
|
/// en: 'No shared seeds match your search'
|
||||||
|
String get noMatches => 'No shared seeds match your search';
|
||||||
|
|
||||||
/// en: 'Near you'
|
/// en: 'Near you'
|
||||||
String get near => 'Near you';
|
String get near => 'Near you';
|
||||||
|
|
||||||
|
|
@ -1782,6 +1791,7 @@ extension on Translations {
|
||||||
'settings.langEs' => 'Español',
|
'settings.langEs' => 'Español',
|
||||||
'settings.langEn' => 'English',
|
'settings.langEn' => 'English',
|
||||||
'settings.langPt' => 'Português',
|
'settings.langPt' => 'Português',
|
||||||
|
'settings.langAst' => 'Asturianu',
|
||||||
'settings.about' => 'About',
|
'settings.about' => 'About',
|
||||||
'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.',
|
'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.',
|
||||||
'settings.aboutOpen' => 'About Tanemaki',
|
'settings.aboutOpen' => 'About Tanemaki',
|
||||||
|
|
@ -2060,6 +2070,8 @@ extension on Translations {
|
||||||
'market.setAreaBody' => 'Tell the market roughly where you are to see seeds nearby.',
|
'market.setAreaBody' => 'Tell the market roughly where you are to see seeds nearby.',
|
||||||
'market.searching' => 'Looking around your area…',
|
'market.searching' => 'Looking around your area…',
|
||||||
'market.empty' => 'No seeds shared near you yet',
|
'market.empty' => 'No seeds shared near you yet',
|
||||||
|
'market.searchHint' => 'Search these seeds',
|
||||||
|
'market.noMatches' => 'No shared seeds match your search',
|
||||||
'market.near' => 'Near you',
|
'market.near' => 'Near you',
|
||||||
'market.contact' => 'Message',
|
'market.contact' => 'Message',
|
||||||
'market.mine' => 'You',
|
'market.mine' => 'You',
|
||||||
|
|
|
||||||
|
|
@ -621,6 +621,8 @@ class _Translations$market$es extends Translations$market$en {
|
||||||
@override String get setAreaBody => 'Dile al mercado tu zona aproximada para ver semillas cerca.';
|
@override String get setAreaBody => 'Dile al mercado tu zona aproximada para ver semillas cerca.';
|
||||||
@override String get searching => 'Buscando por tu zona…';
|
@override String get searching => 'Buscando por tu zona…';
|
||||||
@override String get empty => 'Aún no hay semillas compartidas cerca de ti';
|
@override String get empty => 'Aún no hay semillas compartidas cerca de ti';
|
||||||
|
@override String get searchHint => 'Buscar entre estas semillas';
|
||||||
|
@override String get noMatches => 'Ninguna semilla compartida coincide con tu búsqueda';
|
||||||
@override String get near => 'Cerca de ti';
|
@override String get near => 'Cerca de ti';
|
||||||
@override String get contact => 'Mensaje';
|
@override String get contact => 'Mensaje';
|
||||||
@override String get mine => 'Tú';
|
@override String get mine => 'Tú';
|
||||||
|
|
@ -1360,6 +1362,8 @@ extension on TranslationsEs {
|
||||||
'market.setAreaBody' => 'Dile al mercado tu zona aproximada para ver semillas cerca.',
|
'market.setAreaBody' => 'Dile al mercado tu zona aproximada para ver semillas cerca.',
|
||||||
'market.searching' => 'Buscando por tu zona…',
|
'market.searching' => 'Buscando por tu zona…',
|
||||||
'market.empty' => 'Aún no hay semillas compartidas cerca de ti',
|
'market.empty' => 'Aún no hay semillas compartidas cerca de ti',
|
||||||
|
'market.searchHint' => 'Buscar entre estas semillas',
|
||||||
|
'market.noMatches' => 'Ninguna semilla compartida coincide con tu búsqueda',
|
||||||
'market.near' => 'Cerca de ti',
|
'market.near' => 'Cerca de ti',
|
||||||
'market.contact' => 'Mensaje',
|
'market.contact' => 'Mensaje',
|
||||||
'market.mine' => 'Tú',
|
'market.mine' => 'Tú',
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,7 @@ class _Translations$settings$pt extends Translations$settings$en {
|
||||||
@override String get langEs => 'Español';
|
@override String get langEs => 'Español';
|
||||||
@override String get langEn => 'English';
|
@override String get langEn => 'English';
|
||||||
@override String get langPt => 'Português';
|
@override String get langPt => 'Português';
|
||||||
|
@override String get langAst => 'Asturianu';
|
||||||
@override String get about => 'Acerca de';
|
@override String get about => 'Acerca de';
|
||||||
@override String get aboutText => 'Inventário local e cifrado para sementes tradicionais. AGPL-3.0.';
|
@override String get aboutText => 'Inventário local e cifrado para sementes tradicionais. AGPL-3.0.';
|
||||||
@override String get aboutOpen => 'Acerca do Tanemaki';
|
@override String get aboutOpen => 'Acerca do Tanemaki';
|
||||||
|
|
@ -617,6 +618,8 @@ class _Translations$market$pt extends Translations$market$en {
|
||||||
@override String get setAreaBody => 'Diz ao mercado a tua zona aproximada para ver sementes por perto.';
|
@override String get setAreaBody => 'Diz ao mercado a tua zona aproximada para ver sementes por perto.';
|
||||||
@override String get searching => 'A procurar pela tua zona…';
|
@override String get searching => 'A procurar pela tua zona…';
|
||||||
@override String get empty => 'Ainda não há sementes partilhadas perto de ti';
|
@override String get empty => 'Ainda não há sementes partilhadas perto de ti';
|
||||||
|
@override String get searchHint => 'Procurar nestas sementes';
|
||||||
|
@override String get noMatches => 'Nenhuma semente partilhada corresponde à procura';
|
||||||
@override String get near => 'Perto de ti';
|
@override String get near => 'Perto de ti';
|
||||||
@override String get contact => 'Mensagem';
|
@override String get contact => 'Mensagem';
|
||||||
@override String get mine => 'Tu';
|
@override String get mine => 'Tu';
|
||||||
|
|
@ -1078,6 +1081,7 @@ extension on TranslationsPt {
|
||||||
'settings.langEs' => 'Español',
|
'settings.langEs' => 'Español',
|
||||||
'settings.langEn' => 'English',
|
'settings.langEn' => 'English',
|
||||||
'settings.langPt' => 'Português',
|
'settings.langPt' => 'Português',
|
||||||
|
'settings.langAst' => 'Asturianu',
|
||||||
'settings.about' => 'Acerca de',
|
'settings.about' => 'Acerca de',
|
||||||
'settings.aboutText' => 'Inventário local e cifrado para sementes tradicionais. AGPL-3.0.',
|
'settings.aboutText' => 'Inventário local e cifrado para sementes tradicionais. AGPL-3.0.',
|
||||||
'settings.aboutOpen' => 'Acerca do Tanemaki',
|
'settings.aboutOpen' => 'Acerca do Tanemaki',
|
||||||
|
|
@ -1352,6 +1356,8 @@ extension on TranslationsPt {
|
||||||
'market.setAreaBody' => 'Diz ao mercado a tua zona aproximada para ver sementes por perto.',
|
'market.setAreaBody' => 'Diz ao mercado a tua zona aproximada para ver sementes por perto.',
|
||||||
'market.searching' => 'A procurar pela tua zona…',
|
'market.searching' => 'A procurar pela tua zona…',
|
||||||
'market.empty' => 'Ainda não há sementes partilhadas perto de ti',
|
'market.empty' => 'Ainda não há sementes partilhadas perto de ti',
|
||||||
|
'market.searchHint' => 'Procurar nestas sementes',
|
||||||
|
'market.noMatches' => 'Nenhuma semente partilhada corresponde à procura',
|
||||||
'market.near' => 'Perto de ti',
|
'market.near' => 'Perto de ti',
|
||||||
'market.contact' => 'Mensagem',
|
'market.contact' => 'Mensagem',
|
||||||
'market.mine' => 'Tu',
|
'market.mine' => 'Tu',
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import 'di/injector.dart';
|
||||||
import 'i18n/strings.g.dart';
|
import 'i18n/strings.g.dart';
|
||||||
import 'services/auto_backup_service.dart';
|
import 'services/auto_backup_service.dart';
|
||||||
import 'services/coarse_location.dart';
|
import 'services/coarse_location.dart';
|
||||||
|
import 'services/locale_store.dart';
|
||||||
import 'services/message_store.dart';
|
import 'services/message_store.dart';
|
||||||
import 'services/offer_outbox.dart';
|
import 'services/offer_outbox.dart';
|
||||||
import 'services/onboarding_store.dart';
|
import 'services/onboarding_store.dart';
|
||||||
|
|
@ -17,8 +18,13 @@ import 'services/social_settings.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
// Start from the device language, then let an explicit in-app choice win —
|
||||||
|
// it's the only reliable way to reach languages the OS picker doesn't offer
|
||||||
|
// (e.g. Asturian). Restored after DI, since the store lives in the keystore.
|
||||||
LocaleSettings.useDeviceLocaleSync();
|
LocaleSettings.useDeviceLocaleSync();
|
||||||
await configureDependencies();
|
await configureDependencies();
|
||||||
|
final savedLocale = await getIt<LocaleStore>().saved();
|
||||||
|
if (savedLocale != null) LocaleSettings.setLocaleSync(savedLocale);
|
||||||
final onboarding = getIt<OnboardingStore>();
|
final onboarding = getIt<OnboardingStore>();
|
||||||
runApp(
|
runApp(
|
||||||
TranslationProvider(
|
TranslationProvider(
|
||||||
|
|
|
||||||
45
apps/app_seeds/lib/services/locale_store.dart
Normal file
45
apps/app_seeds/lib/services/locale_store.dart
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../security/secret_store.dart';
|
||||||
|
|
||||||
|
/// Remembers the language the user picked in Settings so it survives restarts.
|
||||||
|
/// An empty/absent value means "follow the device language". Backed by the OS
|
||||||
|
/// keystore (via [SecretStore]), like the rest of the app's small preferences —
|
||||||
|
/// honouring "no plaintext at rest".
|
||||||
|
///
|
||||||
|
/// Why persist at all: the device language picker is an unreliable way to reach
|
||||||
|
/// minority languages (Android often doesn't offer Asturian, and slang would
|
||||||
|
/// fall back to the device default on every launch), so the choice made in-app
|
||||||
|
/// must be remembered explicitly. Asturian itself is modelled as the `es-AST`
|
||||||
|
/// locale (Spanish language + `AST` region), not `ast`, so Flutter's
|
||||||
|
/// Material/Cupertino localizations — which don't ship `ast` — resolve it
|
||||||
|
/// through Spanish instead of throwing. See [SettingsScreen] for the picker.
|
||||||
|
class LocaleStore {
|
||||||
|
LocaleStore(this._store);
|
||||||
|
|
||||||
|
final SecretStore _store;
|
||||||
|
|
||||||
|
static const _key = 'tane.locale';
|
||||||
|
|
||||||
|
/// The saved locale, or null when the user follows the device language.
|
||||||
|
/// Unknown tags (e.g. a locale removed in a later build) also read as null.
|
||||||
|
Future<AppLocale?> saved() async {
|
||||||
|
final raw = await _store.read(_key);
|
||||||
|
if (raw == null || raw.isEmpty) return null;
|
||||||
|
for (final locale in AppLocale.values) {
|
||||||
|
if (locale.name == raw) return locale;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists [locale] and applies it immediately.
|
||||||
|
Future<void> setLocale(AppLocale locale) async {
|
||||||
|
await _store.write(_key, locale.name);
|
||||||
|
await LocaleSettings.setLocale(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forgets the explicit choice and follows the device language from now on.
|
||||||
|
Future<void> useDeviceLocale() async {
|
||||||
|
await _store.write(_key, '');
|
||||||
|
await LocaleSettings.useDeviceLocale();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ class OffersState extends Equatable {
|
||||||
const OffersState({
|
const OffersState({
|
||||||
this.offers = const [],
|
this.offers = const [],
|
||||||
this.areaGeohash = '',
|
this.areaGeohash = '',
|
||||||
|
this.query = '',
|
||||||
this.searching = false,
|
this.searching = false,
|
||||||
this.publishing = false,
|
this.publishing = false,
|
||||||
this.hasSearched = false,
|
this.hasSearched = false,
|
||||||
|
|
@ -28,6 +29,9 @@ class OffersState extends Equatable {
|
||||||
/// The coarse area currently being browsed.
|
/// The coarse area currently being browsed.
|
||||||
final String areaGeohash;
|
final String areaGeohash;
|
||||||
|
|
||||||
|
/// Free-text filter over [offers] typed in the search box (empty = show all).
|
||||||
|
final String query;
|
||||||
|
|
||||||
final bool searching;
|
final bool searching;
|
||||||
final bool publishing;
|
final bool publishing;
|
||||||
|
|
||||||
|
|
@ -38,9 +42,18 @@ class OffersState extends Equatable {
|
||||||
/// Last error, in human terms for the UI (null when fine).
|
/// Last error, in human terms for the UI (null when fine).
|
||||||
final String? error;
|
final String? error;
|
||||||
|
|
||||||
|
/// [offers] narrowed by [query], matched against the offer summary. Kept
|
||||||
|
/// separate from [offers] so a search never drops the underlying discoveries.
|
||||||
|
List<Offer> get visibleOffers {
|
||||||
|
final q = query.trim().toLowerCase();
|
||||||
|
if (q.isEmpty) return offers;
|
||||||
|
return offers.where((o) => o.summary.toLowerCase().contains(q)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
OffersState copyWith({
|
OffersState copyWith({
|
||||||
List<Offer>? offers,
|
List<Offer>? offers,
|
||||||
String? areaGeohash,
|
String? areaGeohash,
|
||||||
|
String? query,
|
||||||
bool? searching,
|
bool? searching,
|
||||||
bool? publishing,
|
bool? publishing,
|
||||||
bool? hasSearched,
|
bool? hasSearched,
|
||||||
|
|
@ -49,6 +62,7 @@ class OffersState extends Equatable {
|
||||||
return OffersState(
|
return OffersState(
|
||||||
offers: offers ?? this.offers,
|
offers: offers ?? this.offers,
|
||||||
areaGeohash: areaGeohash ?? this.areaGeohash,
|
areaGeohash: areaGeohash ?? this.areaGeohash,
|
||||||
|
query: query ?? this.query,
|
||||||
searching: searching ?? this.searching,
|
searching: searching ?? this.searching,
|
||||||
publishing: publishing ?? this.publishing,
|
publishing: publishing ?? this.publishing,
|
||||||
hasSearched: hasSearched ?? this.hasSearched,
|
hasSearched: hasSearched ?? this.hasSearched,
|
||||||
|
|
@ -58,7 +72,7 @@ class OffersState extends Equatable {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props =>
|
List<Object?> get props =>
|
||||||
[offers, areaGeohash, searching, publishing, hasSearched, error];
|
[offers, areaGeohash, query, searching, publishing, hasSearched, error];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drives offer discovery and publishing over an [OfferTransport]. Depends on
|
/// Drives offer discovery and publishing over an [OfferTransport]. Depends on
|
||||||
|
|
@ -92,6 +106,7 @@ class OffersCubit extends Cubit<OffersState> {
|
||||||
_searchTimeout?.cancel();
|
_searchTimeout?.cancel();
|
||||||
emit(OffersState(
|
emit(OffersState(
|
||||||
areaGeohash: geohashPrefix,
|
areaGeohash: geohashPrefix,
|
||||||
|
query: state.query, // keep the text filter across a refresh
|
||||||
searching: true,
|
searching: true,
|
||||||
hasSearched: true,
|
hasSearched: true,
|
||||||
));
|
));
|
||||||
|
|
@ -111,6 +126,10 @@ class OffersCubit extends Cubit<OffersState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Narrows the visible offers to those whose summary matches [query]. Purely
|
||||||
|
/// local over the already-discovered list; does not re-hit the transport.
|
||||||
|
void search(String query) => emit(state.copyWith(query: query));
|
||||||
|
|
||||||
/// Publishes [offer]; returns the transport's verdict. No-op result when
|
/// Publishes [offer]; returns the transport's verdict. No-op result when
|
||||||
/// offline.
|
/// offline.
|
||||||
Future<PublishResult> publish(Offer offer) async {
|
Future<PublishResult> publish(Offer offer) async {
|
||||||
|
|
|
||||||
|
|
@ -360,8 +360,11 @@ class _AutoBackupTileState extends State<_AutoBackupTile> {
|
||||||
final subtitle = last == null
|
final subtitle = last == null
|
||||||
? t.backup.autoBackupNone
|
? t.backup.autoBackupNone
|
||||||
: t.backup.autoBackupLast(
|
: t.backup.autoBackupLast(
|
||||||
|
// Format via the Localizations locale, not the raw app locale:
|
||||||
|
// Asturian (`ast`) has no `intl` date symbols and would throw,
|
||||||
|
// but it's mapped to Spanish for the framework (see app.dart).
|
||||||
date: DateFormat.yMMMd(
|
date: DateFormat.yMMMd(
|
||||||
LocaleSettings.currentLocale.languageCode,
|
Localizations.localeOf(context).languageCode,
|
||||||
).format(last),
|
).format(last),
|
||||||
);
|
);
|
||||||
return ListTile(
|
return ListTile(
|
||||||
|
|
|
||||||
|
|
@ -240,26 +240,95 @@ class MarketBody extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
final cubit = context.read<OffersCubit>();
|
||||||
|
// Pull-to-refresh re-runs discovery for the current area (keeping the
|
||||||
|
// text filter), so the list is never stuck on a stale scan.
|
||||||
|
Future<void> refresh() => cubit.discover(state.areaGeohash);
|
||||||
|
|
||||||
|
// Nothing discovered at all: keep the friendly empty state, but make it
|
||||||
|
// pull-to-refreshable so a swipe re-scans.
|
||||||
if (state.offers.isEmpty) {
|
if (state.offers.isEmpty) {
|
||||||
return _EmptyState(
|
return RefreshIndicator(
|
||||||
icon: Icons.grass_outlined,
|
onRefresh: refresh,
|
||||||
title: t.market.empty,
|
child: ListView(
|
||||||
body: '',
|
// AlwaysScrollable so the pull gesture works with a single child.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.6,
|
||||||
|
child: _EmptyState(
|
||||||
|
icon: Icons.grass_outlined,
|
||||||
|
title: t.market.empty,
|
||||||
|
body: '',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ListView.separated(
|
|
||||||
padding: const EdgeInsets.all(16),
|
final visible = state.visibleOffers;
|
||||||
itemCount: state.offers.length,
|
return Column(
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
children: [
|
||||||
itemBuilder: (context, i) {
|
Padding(
|
||||||
final o = state.offers[i];
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||||
final mine = o.authorPubkeyHex == selfPubkey;
|
child: TextField(
|
||||||
return _OfferCard(
|
key: const Key('market.search'),
|
||||||
offer: o,
|
decoration: InputDecoration(
|
||||||
mine: mine,
|
hintText: t.market.searchHint,
|
||||||
onContact: mine ? null : () => context.push('/chat/${o.authorPubkeyHex}'),
|
prefixIcon: const Icon(Icons.search, color: seedMuted),
|
||||||
);
|
hintStyle: const TextStyle(color: seedMuted),
|
||||||
},
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: cubit.search,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: refresh,
|
||||||
|
child: visible.isEmpty
|
||||||
|
// Discovered offers exist, but the search filtered them out.
|
||||||
|
? ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.5,
|
||||||
|
child: _EmptyState(
|
||||||
|
icon: Icons.search_off,
|
||||||
|
title: t.market.noMatches,
|
||||||
|
body: '',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: ListView.separated(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: visible.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final o = visible[i];
|
||||||
|
final mine = o.authorPubkeyHex == selfPubkey;
|
||||||
|
return _OfferCard(
|
||||||
|
offer: o,
|
||||||
|
mine: mine,
|
||||||
|
onContact: mine
|
||||||
|
? null
|
||||||
|
: () =>
|
||||||
|
context.push('/chat/${o.authorPubkeyHex}'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,30 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../di/injector.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../services/export_import_service.dart';
|
import '../services/export_import_service.dart';
|
||||||
|
import '../services/locale_store.dart';
|
||||||
import 'backup_section.dart';
|
import 'backup_section.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
/// Basic settings: app language, backup (export/import) and an "about"
|
/// Basic settings: app language, backup (export/import) and an "about"
|
||||||
/// section. [exportImport] is injectable so widget tests can pass a fake;
|
/// section. [exportImport] and [localeStore] are injectable so widget tests can
|
||||||
/// the app resolves it lazily from the service locator.
|
/// pass fakes; the app resolves them lazily from the service locator.
|
||||||
class SettingsScreen extends StatelessWidget {
|
class SettingsScreen extends StatelessWidget {
|
||||||
const SettingsScreen({this.exportImport, super.key});
|
const SettingsScreen({this.exportImport, this.localeStore, super.key});
|
||||||
|
|
||||||
final ExportImportService? exportImport;
|
final ExportImportService? exportImport;
|
||||||
|
final LocaleStore? localeStore;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
final current = TranslationProvider.of(context).flutterLocale.languageCode;
|
// Compare the full locale (not just the language code) so `es` and the
|
||||||
|
// Asturian `es-AST` — which share the `es` language — are told apart.
|
||||||
|
final current = TranslationProvider.of(context).locale;
|
||||||
|
void pick(AppLocale locale) =>
|
||||||
|
(localeStore ?? getIt<LocaleStore>()).setLocale(locale);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(t.menu.settings)),
|
appBar: AppBar(title: Text(t.menu.settings)),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
|
|
@ -25,23 +32,28 @@ class SettingsScreen extends StatelessWidget {
|
||||||
_SectionHeader(t.settings.language),
|
_SectionHeader(t.settings.language),
|
||||||
_LanguageTile(
|
_LanguageTile(
|
||||||
label: t.settings.langEs,
|
label: t.settings.langEs,
|
||||||
selected: current == 'es',
|
selected: current == AppLocale.es,
|
||||||
onTap: () => LocaleSettings.setLocale(AppLocale.es),
|
onTap: () => pick(AppLocale.es),
|
||||||
|
),
|
||||||
|
_LanguageTile(
|
||||||
|
label: t.settings.langAst,
|
||||||
|
selected: current == AppLocale.ast,
|
||||||
|
onTap: () => pick(AppLocale.ast),
|
||||||
),
|
),
|
||||||
_LanguageTile(
|
_LanguageTile(
|
||||||
label: t.settings.langEn,
|
label: t.settings.langEn,
|
||||||
selected: current == 'en',
|
selected: current == AppLocale.en,
|
||||||
onTap: () => LocaleSettings.setLocale(AppLocale.en),
|
onTap: () => pick(AppLocale.en),
|
||||||
),
|
),
|
||||||
_LanguageTile(
|
_LanguageTile(
|
||||||
label: t.settings.langPt,
|
label: t.settings.langPt,
|
||||||
selected: current == 'pt',
|
selected: current == AppLocale.pt,
|
||||||
onTap: () => LocaleSettings.setLocale(AppLocale.pt),
|
onTap: () => pick(AppLocale.pt),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.smartphone_outlined),
|
leading: const Icon(Icons.smartphone_outlined),
|
||||||
title: Text(t.settings.systemLanguage),
|
title: Text(t.settings.systemLanguage),
|
||||||
onTap: () => LocaleSettings.useDeviceLocale(),
|
onTap: () => (localeStore ?? getIt<LocaleStore>()).useDeviceLocale(),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_SectionHeader(t.backup.title),
|
_SectionHeader(t.backup.title),
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,16 @@ void main() {
|
||||||
expect(json['version'], 3);
|
expect(json['version'], 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('speciesCatalogVersion mirrors the asset version', () {
|
||||||
|
// The startup seed guard skips the reseed by comparing this constant to the
|
||||||
|
// version it last recorded; it must track the asset or a bumped catalog is
|
||||||
|
// never re-seeded.
|
||||||
|
expect(
|
||||||
|
speciesCatalogVersion,
|
||||||
|
parseSpeciesCatalogVersion(file.readAsStringSync()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('is a broad catalog, not the old starter set', () {
|
test('is a broad catalog, not the old starter set', () {
|
||||||
// The expanded catalog holds ~1200 species (was 14 by hand). The floor
|
// The expanded catalog holds ~1200 species (was 14 by hand). The floor
|
||||||
// leaves headroom for Wikidata drift across regenerations.
|
// leaves headroom for Wikidata drift across regenerations.
|
||||||
|
|
|
||||||
|
|
@ -237,4 +237,50 @@ void main() {
|
||||||
// No species named → no suggestion.
|
// No species named → no suggestion.
|
||||||
expect(await repo.classifyLabel('Girasol gigante'), isNull);
|
expect(await repo.classifyLabel('Girasol gigante'), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('seedBundledIfNeeded', () {
|
||||||
|
test('seeds on first run and skips the reload once recorded', () async {
|
||||||
|
String? stored;
|
||||||
|
var loads = 0;
|
||||||
|
Future<List<SpeciesSeed>> load() async {
|
||||||
|
loads++;
|
||||||
|
return _seeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
await repo.seedBundledIfNeeded(
|
||||||
|
version: 3,
|
||||||
|
readVersion: () async => stored,
|
||||||
|
writeVersion: (v) async => stored = v,
|
||||||
|
loadSeeds: load,
|
||||||
|
);
|
||||||
|
expect(loads, 1);
|
||||||
|
expect(stored, '3');
|
||||||
|
expect(await repo.search('Tomato'), isNotEmpty);
|
||||||
|
|
||||||
|
// Same version already recorded → the ~1.5 MB parse is never loaded again.
|
||||||
|
await repo.seedBundledIfNeeded(
|
||||||
|
version: 3,
|
||||||
|
readVersion: () async => stored,
|
||||||
|
writeVersion: (v) async => stored = v,
|
||||||
|
loadSeeds: load,
|
||||||
|
);
|
||||||
|
expect(loads, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reseeds when the catalog version changes', () async {
|
||||||
|
var stored = '3';
|
||||||
|
var loads = 0;
|
||||||
|
await repo.seedBundledIfNeeded(
|
||||||
|
version: 4,
|
||||||
|
readVersion: () async => stored,
|
||||||
|
writeVersion: (v) async => stored = v,
|
||||||
|
loadSeeds: () async {
|
||||||
|
loads++;
|
||||||
|
return _seeds;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(loads, 1);
|
||||||
|
expect(stored, '4');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
54
apps/app_seeds/test/services/locale_store_test.dart
Normal file
54
apps/app_seeds/test/services/locale_store_test.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/i18n/strings.g.dart';
|
||||||
|
import 'package:tane/security/secret_store.dart';
|
||||||
|
import 'package:tane/services/locale_store.dart';
|
||||||
|
|
||||||
|
/// In-memory [SecretStore] for tests.
|
||||||
|
class FakeSecretStore implements SecretStore {
|
||||||
|
final Map<String, String> _data = {};
|
||||||
|
@override
|
||||||
|
Future<String?> read(String key) async => _data[key];
|
||||||
|
@override
|
||||||
|
Future<void> write(String key, String value) async => _data[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// setLocale/useDeviceLocale touch the Flutter locale, which needs a binding.
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
late FakeSecretStore secrets;
|
||||||
|
late LocaleStore store;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
secrets = FakeSecretStore();
|
||||||
|
store = LocaleStore(secrets);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no choice saved yet → follows the device (null)', () async {
|
||||||
|
expect(await store.saved(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picking Asturian persists it and applies it', () async {
|
||||||
|
await store.setLocale(AppLocale.ast);
|
||||||
|
|
||||||
|
expect(await store.saved(), AppLocale.ast);
|
||||||
|
expect(LocaleSettings.currentLocale, AppLocale.ast);
|
||||||
|
// Asturian is modelled as its real `ast` code so the schema stays honest;
|
||||||
|
// Material chrome borrows Spanish elsewhere (see app.dart).
|
||||||
|
expect(AppLocale.ast.languageCode, 'ast');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('choosing "system language" forgets the explicit choice', () async {
|
||||||
|
await store.setLocale(AppLocale.es);
|
||||||
|
expect(await store.saved(), AppLocale.es);
|
||||||
|
|
||||||
|
await store.useDeviceLocale();
|
||||||
|
expect(await store.saved(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown stored tag reads as "follow the device", never throws',
|
||||||
|
() async {
|
||||||
|
await secrets.write('tane.locale', 'zz-ZZ-removed-in-a-later-build');
|
||||||
|
expect(await store.saved(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -102,6 +102,56 @@ void main() {
|
||||||
await cubit.close();
|
await cubit.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('search narrows visible offers by summary, keeping the full list',
|
||||||
|
() async {
|
||||||
|
final transport = FakeOfferTransport();
|
||||||
|
for (final s in ['Tomate rosa', 'Judía verde', 'Tomate rojo']) {
|
||||||
|
await transport.publish(OfferMapper.fromSharedLot(
|
||||||
|
lotId: s,
|
||||||
|
authorPubkeyHex: 'ab' * 32,
|
||||||
|
summary: s,
|
||||||
|
sharing: OfferStatus.shared,
|
||||||
|
areaGeohash: 'sp3e9',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
final cubit = OffersCubit(transport);
|
||||||
|
await cubit.discover('sp3');
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(cubit.state.offers, hasLength(3));
|
||||||
|
|
||||||
|
cubit.search('tomate'); // case-insensitive
|
||||||
|
expect(cubit.state.visibleOffers.map((o) => o.summary),
|
||||||
|
containsAll(['Tomate rosa', 'Tomate rojo']));
|
||||||
|
expect(cubit.state.visibleOffers, hasLength(2));
|
||||||
|
expect(cubit.state.offers, hasLength(3)); // underlying list untouched
|
||||||
|
|
||||||
|
cubit.search(''); // clearing shows everything again
|
||||||
|
expect(cubit.state.visibleOffers, hasLength(3));
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the search filter survives a refresh (re-discovery)', () async {
|
||||||
|
final transport = FakeOfferTransport();
|
||||||
|
await transport.publish(OfferMapper.fromSharedLot(
|
||||||
|
lotId: 'lot-1',
|
||||||
|
authorPubkeyHex: 'ab' * 32,
|
||||||
|
summary: 'Pimiento',
|
||||||
|
sharing: OfferStatus.shared,
|
||||||
|
areaGeohash: 'sp3e9',
|
||||||
|
));
|
||||||
|
final cubit = OffersCubit(transport);
|
||||||
|
await cubit.discover('sp3');
|
||||||
|
await pumpEventQueue();
|
||||||
|
cubit.search('pim');
|
||||||
|
expect(cubit.state.query, 'pim');
|
||||||
|
|
||||||
|
await cubit.discover('sp3'); // pull-to-refresh
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(cubit.state.query, 'pim'); // kept across the refresh
|
||||||
|
expect(cubit.state.visibleOffers, hasLength(1));
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('a different area finds nothing', () async {
|
test('a different area finds nothing', () async {
|
||||||
final transport = FakeOfferTransport();
|
final transport = FakeOfferTransport();
|
||||||
await transport.publish(OfferMapper.fromSharedLot(
|
await transport.publish(OfferMapper.fromSharedLot(
|
||||||
|
|
|
||||||
73
apps/app_seeds/test/ui/asturian_locale_test.dart
Normal file
73
apps/app_seeds/test/ui/asturian_locale_test.dart
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/app.dart';
|
||||||
|
import 'package:tane/i18n/strings.g.dart';
|
||||||
|
import 'package:tane/services/locale_store.dart';
|
||||||
|
import 'package:tane/ui/settings_screen.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
/// Asturian (`ast`) is a first-class language, but Flutter ships no
|
||||||
|
/// Material/Cupertino localizations for it. The app carries the honest `ast`
|
||||||
|
/// locale for its own strings and borrows Spanish for the framework chrome
|
||||||
|
/// (see [materialLocaleFor]). These tests pin both halves of that contract.
|
||||||
|
void main() {
|
||||||
|
testWidgets('picking Asturian switches app text to Asturian, keeps Material '
|
||||||
|
'chrome resolvable in Spanish, and persists the choice', (tester) async {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
final store = LocaleStore(InMemorySecretStore());
|
||||||
|
late Locale materialLocale;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
TranslationProvider(
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final appLocale = TranslationProvider.of(context).flutterLocale;
|
||||||
|
return MaterialApp(
|
||||||
|
// Mirrors app.dart: the framework localizations follow the mapped
|
||||||
|
// locale, the app's own strings follow slang.
|
||||||
|
locale: materialLocaleFor(appLocale),
|
||||||
|
supportedLocales: AppLocaleUtils.supportedLocales,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
// Resolving these must never throw for the Asturian locale.
|
||||||
|
materialLocale = Localizations.localeOf(context);
|
||||||
|
MaterialLocalizations.of(context);
|
||||||
|
return SettingsScreen(localeStore: store);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Starts in English.
|
||||||
|
expect(find.text('Language'), findsOneWidget);
|
||||||
|
expect(materialLocale.languageCode, 'en');
|
||||||
|
|
||||||
|
await tester.tap(find.text('Asturianu'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// App strings are now Asturian…
|
||||||
|
expect(find.text('Llingua'), findsOneWidget);
|
||||||
|
// …the framework chrome fell back to Spanish (not the unsupported `ast`)…
|
||||||
|
expect(materialLocale.languageCode, 'es');
|
||||||
|
// …and the choice was remembered for next launch.
|
||||||
|
expect(await store.saved(), AppLocale.ast);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('materialLocaleFor maps Asturian to Spanish and passes others through',
|
||||||
|
() {
|
||||||
|
expect(materialLocaleFor(const Locale('ast')), const Locale('es'));
|
||||||
|
expect(materialLocaleFor(const Locale('es')), const Locale('es'));
|
||||||
|
expect(materialLocaleFor(const Locale('en')), const Locale('en'));
|
||||||
|
expect(materialLocaleFor(const Locale('pt')), const Locale('pt'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import 'package:tane/services/auto_backup_store.dart';
|
||||||
import 'package:tane/services/export_import_service.dart';
|
import 'package:tane/services/export_import_service.dart';
|
||||||
import 'package:tane/services/file_service.dart';
|
import 'package:tane/services/file_service.dart';
|
||||||
import 'package:tane/services/recovery_sheet_service.dart';
|
import 'package:tane/services/recovery_sheet_service.dart';
|
||||||
|
import 'package:tane/app.dart';
|
||||||
import 'package:tane/ui/backup_section.dart';
|
import 'package:tane/ui/backup_section.dart';
|
||||||
import 'package:tane/ui/settings_screen.dart';
|
import 'package:tane/ui/settings_screen.dart';
|
||||||
|
|
||||||
|
|
@ -254,6 +255,41 @@ void main() {
|
||||||
expect(spy.calls, 1);
|
expect(spy.calls, 1);
|
||||||
expect(find.text('Copy saved'), findsOneWidget);
|
expect(find.text('Copy saved'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('the last-backup date renders under Asturian without an '
|
||||||
|
'invalid-locale crash', (tester) async {
|
||||||
|
// Asturian (`ast`) has no `intl` date symbols; formatting the "last copy"
|
||||||
|
// date used to throw "Invalid locale ast". The framework locale is mapped
|
||||||
|
// to Spanish, so the date must format through that instead.
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.ast);
|
||||||
|
addTearDown(() => LocaleSettings.setLocaleSync(AppLocale.en));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
TranslationProvider(
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: materialLocaleFor(const Locale('ast')),
|
||||||
|
supportedLocales: AppLocaleUtils.supportedLocales,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
home: Scaffold(
|
||||||
|
body: BackupSection(
|
||||||
|
service: service,
|
||||||
|
sheet: sheet,
|
||||||
|
autoBackup: _DatedAutoBackup(db),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(); // resolve the "last copy" future
|
||||||
|
|
||||||
|
// The Asturian tile (and its Spanish-formatted date subtitle) built fine.
|
||||||
|
expect(find.text('Copies automátiques'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Counts explicit "back up now" taps without touching the disk.
|
/// Counts explicit "back up now" taps without touching the disk.
|
||||||
|
|
@ -277,3 +313,21 @@ class _SpyAutoBackup extends AutoBackupService {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reports a fixed "last copy" date so the auto-backup subtitle formats a real
|
||||||
|
/// date — the path that crashed under Asturian.
|
||||||
|
class _DatedAutoBackup extends AutoBackupService {
|
||||||
|
_DatedAutoBackup(AppDatabase db)
|
||||||
|
: super(
|
||||||
|
exporter: ExportImportService(
|
||||||
|
repository: newTestRepository(db),
|
||||||
|
files: FakeFileService(),
|
||||||
|
keys: SecureKeyStore(store: InMemorySecretStore()),
|
||||||
|
),
|
||||||
|
store: AutoBackupStore(InMemorySecretStore()),
|
||||||
|
directory: () async => throw UnimplementedError(),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DateTime?> lastBackupAt() async => DateTime.utc(2026, 3, 14);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue