i18n: RTL smoke tests, Portuguese locale and a translation guide

- Add rtl_smoke_test covering inventory, detail (lot sheet) and settings
  under Directionality.rtl, with an Arabic label and vernacular name.
- Ship pt.i18n.json (Portuguese) as proof the locale pipeline scales beyond
  es/en; wire its language tile in Settings.
- docs/i18n.md documents the Weblate-ready flow for adding locales.
This commit is contained in:
vjrj 2026-07-09 23:06:49 +02:00
parent d6781870d9
commit 5d2b41a110
11 changed files with 1736 additions and 6 deletions

View file

@ -39,6 +39,7 @@
"systemLanguage": "System language",
"langEs": "Español",
"langEn": "English",
"langPt": "Português",
"about": "About",
"aboutText": "Local-first, encrypted inventory for traditional seeds. AGPL-3.0.",
"aboutOpen": "About Tanemaki"

View file

@ -39,6 +39,7 @@
"systemLanguage": "Idioma del sistema",
"langEs": "Español",
"langEn": "English",
"langPt": "Português",
"about": "Acerca de",
"aboutText": "Inventario local y cifrado para semillas tradicionales. AGPL-3.0.",
"aboutOpen": "Acerca de Tanemaki"

View file

@ -0,0 +1,327 @@
{
"app": {
"title": "Tanemaki"
},
"common": {
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"type": "Tipo",
"comingSoon": "Em breve"
},
"home": {
"tagline": "Partilha e cultiva sementes locais",
"openMarket": "Mercado aberto",
"openMarketSubtitle": "Descobre e troca",
"yourInventory": "O teu inventário",
"yourInventorySubtitle": "Gere as tuas sementes"
},
"photo": {
"camera": "Tirar uma foto",
"gallery": "Escolher da galeria",
"setAsCover": "Usar como capa",
"isCover": "Foto de capa",
"deleteConfirm": "Eliminar esta foto?"
},
"menu": {
"tagline": "o teu banco de sementes",
"inventory": "Inventário",
"market": "Mercado",
"profile": "O teu perfil",
"chat": "Conversas",
"wishlist": "Lista de desejos",
"following": "A seguir",
"settings": "Definições"
},
"settings": {
"language": "Idioma",
"systemLanguage": "Idioma do sistema",
"langEs": "Español",
"langEn": "English",
"langPt": "Português",
"about": "Acerca de",
"aboutText": "Inventário local e cifrado para sementes tradicionais. AGPL-3.0.",
"aboutOpen": "Acerca do Tanemaki"
},
"backup": {
"title": "Cópia de segurança",
"exportJson": "Guardar uma cópia de segurança",
"exportJsonSubtitle": "Uma cópia completa para guardar em segurança, restaurar depois ou levar para outro aparelho",
"importJson": "Restaurar uma cópia",
"importJsonSubtitle": "Recupera uma cópia guardada — nada fica duplicado",
"exportCsv": "Exportar para uma folha de cálculo",
"exportCsvSubtitle": "Uma lista simples para Excel ou LibreOffice — sem fotos",
"importCsv": "Importar uma lista",
"importCsvSubtitle": "Acrescenta entradas a partir de uma folha de cálculo",
"importConfirmTitle": "Restaurar uma cópia?",
"importConfirmBody": "As entradas fundem-se com o teu inventário; quando a mesma entrada existe dos dois lados, vence a versão mais recente. Nada fica duplicado.",
"importCsvConfirmTitle": "Importar uma lista?",
"importCsvConfirmBody": "Cada linha é acrescentada como uma entrada nova. Não funde nem substitui, por isso importar o mesmo ficheiro duas vezes acrescenta-o duas vezes.",
"importAction": "Importar",
"exportSaved": "Cópia guardada",
"cancelled": "Cancelado",
"importDone": "Importado: {added} novas, {updated} atualizadas",
"importCsvDone": "{count} entradas acrescentadas",
"importFailed": "Este ficheiro não pôde ser lido como uma cópia do Tanemaki",
"failed": "Algo correu mal",
"recoveryTitle": "O teu código de recuperação",
"recoverySubtitle": "Imprime-o e guarda-o bem: abre as tuas cópias noutro aparelho",
"recoveryIntro": "Este código abre as tuas cópias guardadas e recupera o teu banco em qualquer aparelho. Guarda duas cópias em papel em sítios seguros, como a tua melhor semente. Quem o tiver pode ler as tuas cópias, por isso não o partilhes com ninguém.",
"recoveryCopy": "Copiar",
"recoverySave": "Guardar a folha",
"recoverySheetTitle": "Tanemaki — a tua folha de recuperação",
"recoveryPromptTitle": "Escreve o teu código de recuperação",
"recoveryPromptBody": "Esta cópia foi guardada com outro código. Escreve o código da tua folha de recuperação para a abrir.",
"recoveryWrongCode": "Esse código não abre esta cópia"
},
"about": {
"title": "Acerca de",
"kanji": "種まき",
"tagline": "Uma aplicação local e descentralizada para gerir e partilhar sementes e plântulas tradicionais.",
"intro": "O Tanemaki (種まき, \"semear / espalhar sementes\" em japonês; nome curto Tane, 種 \"semente\") ajuda pessoas e coletivos a manter um inventário amigável do seu banco de sementes, decidir o que oferecem e partilhá-lo localmente — sem um intermediário central que possa controlar, censurar ou ser multado por isso. O objetivo é prático e político ao mesmo tempo: apoiar as variedades tradicionais e fazer frente ao monopólio das sementes.",
"heritage": "O nome honra as antigas tradições japonesas de ajuda mútua à volta do arroz — yui (trabalho comunitário partilhado) e tanomoshi (fundos de reciprocidade) — que inspiraram o Plantare em papel, a \"moeda comunitária de troca de sementes\" (BAH-Semillero, 2009, CC-BY-SA). O Tanemaki é o Plantare digital.",
"version": "Versão",
"license": "Licença",
"licenseValue": "AGPL-3.0",
"website": "Sítio web",
"openSourceLicenses": "Licenças de código aberto",
"openSourceLicensesSubtitle": "Bibliotecas de terceiros e as suas licenças"
},
"intro": {
"skip": "Saltar",
"next": "Seguinte",
"start": "Começar",
"menuEntry": "Como funciona o Tanemaki",
"slides": {
"welcome": {
"title": "A semente que te trouxe até aqui",
"body": "Cada semente tradicional é uma carta escrita por milhares de gerações, passada de mão em mão. Somos o que somos graças a essa partilha."
},
"inventory": {
"title": "O teu banco de sementes, no bolso",
"body": "Anota o que tens, de que ano, quanto e de onde veio — com o nome que tu usas. Uma foto e um nome chegam para começar."
},
"privacy": {
"title": "Teu, e só teu",
"body": "Sem conta, sem internet, sem rastreadores. Os teus dados vivem cifrados no teu aparelho, e só o que tu escolheres é partilhado."
},
"share": {
"title": "Partilhar, como sempre se fez",
"body": "Oferece o que te sobra — dar, trocar ou vender — e deixa que alguém perto de ti o encontre. Só se mostra uma distância aproximada, nunca a tua morada; o acordo fecha-se em pessoa."
},
"plantare": {
"title": "Semear é multiplicar",
"body": "Com um Plantare, quem recebe semente promete devolver uma parte mais tarde. E como devolvê-la implica cultivá-la, cada empréstimo multiplica o comum."
}
}
},
"inventory": {
"title": "Inventário",
"searchHint": "Procurar sementes",
"empty": "Ainda não há sementes. Toca em + para adicionar a primeira.",
"noMatches": "Nenhuma semente corresponde aos teus filtros.",
"clearFilters": "Limpar filtros",
"uncategorized": "Sem categoria",
"needsReproductionFilter": "Para reproduzir"
},
"draft": {
"capture": "Capturar fotos",
"captured": "{n} capturadas para catalogar",
"triageTitle": "Para catalogar",
"triageCount": "{n} para catalogar",
"untitled": "Sem nome",
"nameField": "Dá um nome a esta semente",
"nameHint": "O que é?",
"suggestFromPhoto": "Sugerir nome a partir da foto",
"discard": "Descartar"
},
"quickAdd": {
"title": "Adicionar uma semente",
"labelField": "Nome",
"labelRequired": "Dá-lhe um nome",
"addPhoto": "Adicionar foto",
"quantity": "Quanto?",
"more": "Adicionar mais…",
"save": "Guardar",
"saveAndAddAnother": "Guardar e adicionar outra",
"addedCount": "{n} adicionadas",
"cancel": "Cancelar"
},
"detail": {
"notFound": "Esta semente já não está aqui.",
"lots": "Lotes",
"noLots": "Ainda não há lotes.",
"names": "Também conhecida como",
"addName": "Adicionar nome",
"links": "Ligações",
"addLink": "Adicionar ligação",
"linkUrl": "URL",
"linkTitle": "Título (opcional)",
"notes": "Notas",
"addLot": "Adicionar lote",
"editLot": "Editar lote",
"deleteConfirm": "Eliminar esta semente?",
"year": "Ano {year}",
"noYear": "Ano desconhecido"
},
"germination": {
"title": "Germinação",
"add": "Adicionar teste",
"sampleSize": "Tamanho da amostra",
"germinated": "Germinadas",
"none": "Ainda não há testes de germinação.",
"result": "{percent}%"
},
"viability": {
"expiringSoon": "Usa ou reproduz nesta época",
"expiringSoonYears": "Usa ou reproduz nesta época · dura ~{years} anos",
"expired": "Passou a viabilidade típica — reproduzir",
"expiredYears": "Passou a viabilidade típica (~{years} anos) — reproduzir"
},
"editVariety": {
"title": "Editar semente",
"name": "Nome",
"category": "Categoria",
"notes": "Notas",
"species": "Espécie (do catálogo)",
"speciesHint": "Procura uma espécie…",
"organic": "Biológica",
"organicHint": "Cultivada em modo biológico"
},
"addLot": {
"title": "Adicionar lote",
"year": "Data de colheita",
"quantity": "Quanto?",
"amount": "Quantidade"
},
"harvest": {
"pickTitle": "Escolhe mês / ano",
"anyMonth": "Qualquer mês",
"noDate": "Definir data de colheita",
"monthNames": [
"janeiro", "fevereiro", "março", "abril", "maio", "junho",
"julho", "agosto", "setembro", "outubro", "novembro", "dezembro"
]
},
"lotType": {
"seed": "Sementes",
"plant": "Planta",
"seedling": "Plântula",
"tree": "Árvore / arbusto",
"bulb": "Bolbo / tubérculo",
"cutting": "Estaca"
},
"presentation": {
"title": "Apresentação",
"none": "Sem indicar",
"pot": "Vaso",
"tray": "Tabuleiro",
"plug": "Alvéolo",
"bareRoot": "Raiz nua",
"rootBall": "Torrão"
},
"provenance": {
"section": "De onde vem",
"seedsFrom": "Sementes de",
"seedsFromHint": "Quem as cultivou ou ofereceu",
"place": "Lugar",
"placeHint": "De onde vêm (com a região)",
"addSeedsFrom": "Sementes de",
"addPlace": "Lugar"
},
"abundance": {
"add": "Quanta tenho",
"title": "Quanta tenho",
"none": "Sem indicar",
"plentyToShare": "De sobra para partilhar",
"enoughToShare": "Bastante, para partilhar com moderação",
"enoughForMe": "Suficiente para mim",
"runningLow": "Resta pouca"
},
"share": {
"add": "Partilhas esta?",
"title": "Partilhas esta?",
"nudge": "Tens de sobra: podias partilhar um pouco.",
"private": "Só para mim",
"gift": "Para dar",
"exchange": "Para trocar",
"sell": "À venda",
"filterChip": "Partilho",
"printCatalog": "Imprimir o que partilho",
"catalogTitle": "O que partilho",
"catalogSaved": "Catálogo guardado",
"cancelled": "Cancelado"
},
"cropCalendar": {
"add": "Calendário de cultivo",
"title": "Calendário de cultivo",
"sow": "Sementeira",
"transplant": "Transplante",
"flowering": "Floração",
"fruiting": "Frutificação",
"seedHarvest": "Colheita de semente",
"unset": "—"
},
"needsReproduction": {
"label": "Reproduzir nesta época",
"hint": "Cultiva-a antes que a semente acabe",
"badge": "Por reproduzir"
},
"preservation": {
"add": "Como está guardada",
"title": "Como está guardada",
"none": "Sem indicar",
"jarWithDesiccant": "Frasco com agente secante",
"glassJar": "Frasco de vidro",
"paperEnvelope": "Envelope de papel",
"paperBag": "Saco de papel",
"plasticBag": "Saco de plástico"
},
"conditionCheck": {
"advanced": "Armazenamento e detalhes de banco de sementes",
"title": "Verificações de armazenamento",
"add": "Adicionar verificação",
"containers": "Frascos / recipientes",
"desiccant": "Agente secante",
"none": "Ainda não há verificações de armazenamento.",
"summary": "{count} frasco(s) · {state}"
},
"desiccant": {
"none": "Nenhum",
"add": "Adicionar",
"replace": "Substituir",
"dry": "Azul — seco",
"fresh": "Acabado de renovar"
},
"unit": {
"aFew": "algumas",
"some": "bastantes",
"plenty": "muitas",
"pinch": "uma pitada",
"handful": { "singular": "mão-cheia", "plural": "mãos-cheias" },
"teaspoon": { "singular": "colher de chá", "plural": "colheres de chá" },
"spoon": { "singular": "colher", "plural": "colheres" },
"cup": { "singular": "chávena", "plural": "chávenas" },
"jar": { "singular": "frasco", "plural": "frascos" },
"sack": { "singular": "saco", "plural": "sacos" },
"packet": { "singular": "pacote", "plural": "pacotes" },
"cob": { "singular": "maçaroca", "plural": "maçarocas" },
"pod": { "singular": "vagem", "plural": "vagens" },
"ear": { "singular": "espiga", "plural": "espigas" },
"head": { "singular": "cabeça", "plural": "cabeças" },
"fruit": { "singular": "fruto", "plural": "frutos" },
"bulb": { "singular": "bolbo", "plural": "bolbos" },
"tuber": { "singular": "tubérculo", "plural": "tubérculos" },
"seedHead": { "singular": "capítulo", "plural": "capítulos" },
"bunch": { "singular": "molho", "plural": "molhos" },
"plant": { "singular": "planta", "plural": "plantas" },
"pot": { "singular": "vaso", "plural": "vasos" },
"tray": { "singular": "tabuleiro", "plural": "tabuleiros" },
"seedling": { "singular": "plântula", "plural": "plântulas" },
"tree": { "singular": "árvore", "plural": "árvores" },
"cutting": { "singular": "estaca", "plural": "estacas" },
"grams": { "singular": "grama", "plural": "gramas" },
"count": { "singular": "semente", "plural": "sementes" }
}
}

View file

@ -3,10 +3,10 @@
/// Source: lib/i18n
/// To regenerate, run: `dart run slang`
///
/// Locales: 2
/// Strings: 572 (286 per locale)
/// Locales: 3
/// Strings: 861 (287 per locale)
///
/// Built on 2026-07-09 at 20:28 UTC
/// Built on 2026-07-09 at 20:35 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
@ -19,6 +19,7 @@ import 'package:slang_flutter/slang_flutter.dart';
export 'package:slang_flutter/slang_flutter.dart';
import 'strings_es.g.dart' as l_es;
import 'strings_pt.g.dart' as l_pt;
part 'strings_en.g.dart';
/// Supported locales.
@ -29,7 +30,8 @@ part 'strings_en.g.dart';
/// - if (LocaleSettings.currentLocale == AppLocale.en) // locale check
enum AppLocale with BaseAppLocale<AppLocale, Translations> {
en(languageCode: 'en'),
es(languageCode: 'es');
es(languageCode: 'es'),
pt(languageCode: 'pt');
const AppLocale({
required this.languageCode,
@ -73,6 +75,12 @@ enum AppLocale with BaseAppLocale<AppLocale, Translations> {
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
case AppLocale.pt:
return l_pt.TranslationsPt(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
}
}

View file

@ -211,6 +211,9 @@ class Translations$settings$en {
/// en: 'English'
String get langEn => 'English';
/// en: 'Português'
String get langPt => 'Português';
/// en: 'About'
String get about => 'About';
@ -1508,6 +1511,7 @@ extension on Translations {
'settings.systemLanguage' => 'System language',
'settings.langEs' => 'Español',
'settings.langEn' => 'English',
'settings.langPt' => 'Português',
'settings.about' => 'About',
'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.',
'settings.aboutOpen' => 'About Tanemaki',

View file

@ -151,6 +151,7 @@ class _Translations$settings$es extends Translations$settings$en {
@override String get systemLanguage => 'Idioma del sistema';
@override String get langEs => 'Español';
@override String get langEn => 'English';
@override String get langPt => 'Português';
@override String get about => 'Acerca de';
@override String get aboutText => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.';
@override String get aboutOpen => 'Acerca de Tanemaki';
@ -956,6 +957,7 @@ extension on TranslationsEs {
'settings.systemLanguage' => 'Idioma del sistema',
'settings.langEs' => 'Español',
'settings.langEn' => 'English',
'settings.langPt' => 'Português',
'settings.about' => 'Acerca de',
'settings.aboutText' => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.',
'settings.aboutOpen' => 'Acerca de Tanemaki',

File diff suppressed because it is too large Load diff

View file

@ -33,6 +33,11 @@ class SettingsScreen extends StatelessWidget {
selected: current == 'en',
onTap: () => LocaleSettings.setLocale(AppLocale.en),
),
_LanguageTile(
label: t.settings.langPt,
selected: current == 'pt',
onTap: () => LocaleSettings.setLocale(AppLocale.pt),
),
ListTile(
leading: const Icon(Icons.smartphone_outlined),
title: Text(t.settings.systemLanguage),

View file

@ -60,6 +60,7 @@ Widget wrapScreen({
required VarietyRepository repository,
required Widget child,
AppLocale locale = AppLocale.en,
TextDirection? textDirection,
}) {
LocaleSettings.setLocaleSync(locale);
return TranslationProvider(
@ -75,7 +76,9 @@ Widget wrapScreen({
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: child,
home: textDirection == null
? child
: Directionality(textDirection: textDirection, child: child),
),
),
),
@ -88,6 +91,7 @@ Widget wrapDetail({
required String varietyId,
SpeciesRepository? species,
AppLocale locale = AppLocale.en,
TextDirection? textDirection,
}) {
LocaleSettings.setLocaleSync(locale);
return TranslationProvider(
@ -106,7 +110,12 @@ Widget wrapDetail({
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: const VarietyDetailScreen(),
home: textDirection == null
? const VarietyDetailScreen()
: Directionality(
textDirection: textDirection,
child: const VarietyDetailScreen(),
),
),
),
),

View file

@ -0,0 +1,99 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
import 'package:tane/ui/inventory_list_screen.dart';
import 'package:tane/ui/settings_screen.dart';
import '../support/test_support.dart';
/// RTL is a first-class requirement (CLAUDE.md): the main screens must build
/// and lay out under right-to-left without overflowing or misplacing
/// directional affordances. Overflow errors fail these tests automatically.
void main() {
late AppDatabase db;
setUp(() => db = newTestDatabase());
tearDown(() => db.close());
testWidgets('the inventory list renders under RTL', (tester) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(
label: 'طماطم بلدية', // an Arabic label, as a keeper would write it
category: 'Solanaceae',
);
await repo.addLot(
varietyId: id,
harvestYear: 2024,
quantity: const Quantity(kind: QuantityKind.handful),
offerStatus: OfferStatus.shared,
);
await repo.addQuickVariety(label: 'Maize', category: 'Poaceae');
await tester.pumpWidget(
wrapScreen(
repository: repo,
textDirection: TextDirection.rtl,
child: const InventoryListScreen(),
),
);
await tester.pumpAndSettle();
expect(find.text('طماطم بلدية'), findsOneWidget);
expect(find.byKey(const Key('inventory.filter.sharing')), findsOneWidget);
await disposeTree(tester);
});
testWidgets('the variety detail (lots, calendar, names) renders under RTL', (
tester,
) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(
label: 'Grandma tomato',
category: 'Solanaceae',
);
await repo.addVernacularName(id, 'طماطم');
await repo.addLot(
varietyId: id,
harvestYear: 2023,
quantity: const Quantity(kind: QuantityKind.packet, count: 3),
abundance: Abundance.plentyToShare,
offerStatus: OfferStatus.exchange,
);
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
textDirection: TextDirection.rtl,
),
);
await tester.pumpAndSettle();
expect(find.text('Grandma tomato'), findsOneWidget);
expect(find.text('طماطم'), findsOneWidget);
// The lot sheet (the busiest form) must also lay out under RTL.
await tester.tap(find.byKey(const Key('detail.addLot')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('addLot.save')), findsOneWidget);
await disposeTree(tester);
});
testWidgets('settings renders under RTL', (tester) async {
final repo = newTestRepository(db);
await tester.pumpWidget(
wrapScreen(
repository: repo,
textDirection: TextDirection.rtl,
child: const SettingsScreen(),
),
);
await tester.pumpAndSettle();
expect(find.text('Português'), findsOneWidget);
await disposeTree(tester);
});
}

53
docs/i18n.md Normal file
View file

@ -0,0 +1,53 @@
# Translating Tanemaki
Tanemaki is multilingual **by design** — international, not any-country-first
(see the golden rules in [`../CLAUDE.md`](../CLAUDE.md)). Translations live as
one JSON file per locale, a format Weblate (and any translation platform that
speaks nested JSON) handles natively.
## Where strings live
```
apps/app_seeds/lib/i18n/
en.i18n.json ← base locale (source of truth for keys)
es.i18n.json
pt.i18n.json
strings*.g.dart ← generated, do not edit
```
- Placeholders use braces: `"{count} entries added"`. Keep them verbatim.
- Keys are code and never change per locale; only values are translated.
- Missing keys fall back to English at runtime (`fallback_strategy:
base_locale` in [`slang.yaml`](../apps/app_seeds/slang.yaml)), so a partial
translation is shippable from day one.
## Adding a locale
1. Copy `en.i18n.json` to `<code>.i18n.json` (e.g. `fr.i18n.json`) and
translate what you can.
2. Regenerate the Dart bindings:
```bash
cd apps/app_seeds && dart run slang
```
3. Add a language tile in
[`settings_screen.dart`](../apps/app_seeds/lib/ui/settings_screen.dart)
(label = the language's own name, e.g. "Français") plus the `langXx` key in
every locale file.
4. Run the tests: `flutter test` (an i18n test guards against hardcoded
strings; the RTL smoke test guards right-to-left layouts).
## RTL and scripts
Right-to-left locales (Arabic, Hebrew, Persian) are first-class: layouts use
directional insets and are exercised by
[`rtl_smoke_test.dart`](../apps/app_seeds/test/ui/rtl_smoke_test.dart).
Locale-specific resources ship with their locale, never hardcoded: OCR
language packs (`assets/tessdata/`), PDF fonts (`assets/fonts/`), and species
common names (`assets/catalog/species.json`, a locale-keyed map).
## Weblate
The JSON-per-locale layout is Weblate-ready (component per file, base
`en.i18n.json`). Hosting is not set up yet; when it is, this page will link
to the project. Until then, translation PRs/patches are welcome directly on
the files above.