Merge branch 'main' into claude/trusting-shannon-ff64dd

# Conflicts:
#	apps/app_seeds/lib/i18n/strings.g.dart
This commit is contained in:
vjrj 2026-07-09 23:56:45 +02:00
commit 6eb1517ffb
37 changed files with 2998 additions and 258 deletions

View file

@ -1,3 +1,6 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
id("kotlin-android")
@ -5,6 +8,17 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
// Release signing is opt-in: drop a `key.properties` (gitignored) next to this
// module's android/ dir to sign release builds with your own keystore. Without
// it, release builds fall back to debug keys so contributors can still run
// `flutter run --release`. See docs/release.md.
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
val hasReleaseKeystore = keystorePropertiesFile.exists()
if (hasReleaseKeystore) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "org.comunes.tane"
compileSdk = flutter.compileSdkVersion
@ -20,7 +34,6 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "org.comunes.tane"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
@ -30,11 +43,27 @@ android {
versionName = flutter.versionName
}
signingConfigs {
if (hasReleaseKeystore) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
// Sign with the real release keystore when key.properties is present,
// otherwise fall back to debug keys so `flutter run --release` works
// for contributors without the signing material.
signingConfig = if (hasReleaseKeystore) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
}

View file

@ -0,0 +1,6 @@
First public beta of Block 1: the offline, encrypted seed inventory.
- Local sharing (give away / swap / sell) with a printable catalog.
- Encrypted backups and a printable recovery sheet.
- Edit and delete lots and vernacular names.
- Portuguese added; right-to-left layouts verified.

View file

@ -0,0 +1,27 @@
Tanemaki (種まき, "to sow seeds") is a local-first, decentralized app for
keeping and sharing traditional seeds.
Every traditional seed is a letter written by thousands of generations, passed
from hand to hand. Tanemaki helps people and seed-saver collectives keep a
friendly inventory of their bank, decide what they offer, and share it locally
— without a central intermediary that could control, censor, or be fined for
it.
MANAGE YOUR BANK
• Add a seed with just a photo and a name — no Latin required.
• Note the year, how much you have, where it came from, germination tests.
• Search and filter; snap a photo now and name it later.
YOURS, AND ONLY YOURS
• Works fully offline. No account, no server, no trackers.
• Everything is encrypted at rest on your device.
• Save an encrypted backup and keep a printed recovery sheet — restore your
whole bank on a new device.
SHARE, THE WAY IT'S ALWAYS BEEN DONE
• Mark what you have spare to give away, swap or sell.
• Print a catalog of what you share to take to a seed fair.
Tanemaki is free software (AGPL-3.0). No ads, no commissions, no business model
— it exists to support traditional varieties and push back against the seed
monopoly.

View file

@ -0,0 +1 @@
Your seed bank in your pocket — offline, encrypted, no account.

View file

@ -0,0 +1 @@
Tanemaki

View file

@ -0,0 +1,6 @@
Primera beta pública del Bloque 1: el inventario de semillas sin conexión y cifrado.
- Compartir localmente (regalar / intercambiar / vender) con catálogo imprimible.
- Copias cifradas y hoja de recuperación imprimible.
- Editar y borrar lotes y nombres vernáculos.
- Añadido portugués; disposiciones de derecha a izquierda verificadas.

View file

@ -0,0 +1,27 @@
Tanemaki (種まき, "sembrar semillas") es una app local-first y descentralizada
para guardar y compartir semillas tradicionales.
Cada semilla tradicional es una carta escrita por miles de generaciones, que
pasa de mano en mano. Tanemaki ayuda a personas y colectivos de guardianas de
semillas a llevar un inventario amable de su banco, decidir qué ofrecen y
compartirlo localmente, sin un intermediario central que pueda controlarlo,
censurarlo o ser multado por ello.
GESTIONA TU BANCO
• Añade una semilla con solo una foto y un nombre; sin latín.
• Anota el año, cuánta tienes, de dónde viene, pruebas de germinación.
• Busca y filtra; haz una foto ahora y ponle nombre después.
TUYO, Y SOLO TUYO
• Funciona totalmente sin conexión. Sin cuenta, sin servidor, sin rastreadores.
• Todo va cifrado en tu dispositivo.
• Guarda una copia cifrada y ten una hoja de recuperación impresa: recupera
todo tu banco en un dispositivo nuevo.
COMPARTIR, COMO SE HA HECHO SIEMPRE
• Marca lo que te sobra para regalar, intercambiar o vender.
• Imprime un catálogo de lo que compartes para llevar a una feria de semillas.
Tanemaki es software libre (AGPL-3.0). Sin anuncios, sin comisiones, sin modelo
de negocio: existe para apoyar las variedades tradicionales y plantar cara al
monopolio de las semillas.

View file

@ -0,0 +1 @@
Tu banco de semillas en el bolsillo: sin conexión, cifrado, sin cuenta.

View file

@ -0,0 +1 @@
Tanemaki

View file

@ -22,6 +22,7 @@ import '../services/ocr/label_text_extractor.dart';
import '../services/ocr/ocr_language.dart';
import '../services/ocr/tesseract_label_extractor.dart';
import '../services/onboarding_store.dart';
import '../services/recovery_sheet_service.dart';
import '../services/share_catalog_service.dart';
/// The app's service locator. Kept to the composition root — widgets get their
@ -75,10 +76,17 @@ Future<void> configureDependencies() async {
..registerSingleton<LabelTextExtractor>(labelExtractor)
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
..registerSingleton<ExportImportService>(
ExportImportService(repository: varietyRepository, files: fileService),
ExportImportService(
repository: varietyRepository,
files: fileService,
keys: keyStore,
),
)
..registerSingleton<ShareCatalogService>(
ShareCatalogService(files: fileService),
)
..registerSingleton<RecoverySheetService>(
RecoverySheetService(files: fileService),
);
}

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"
@ -63,7 +64,16 @@
"importDone": "Imported: {added} new, {updated} updated",
"importCsvDone": "Added {count} entries",
"importFailed": "This file could not be read as a Tanemaki copy",
"failed": "Something went wrong"
"failed": "Something went wrong",
"recoveryTitle": "Your recovery code",
"recoverySubtitle": "Print it and keep it safe — it opens your copies on a new device",
"recoveryIntro": "This code opens your saved copies and brings your bank back on any device. Keep two paper copies in safe places, like your best seed. Anyone holding it can read your copies, so share it with no one.",
"recoveryCopy": "Copy",
"recoverySave": "Save the sheet",
"recoverySheetTitle": "Tanemaki — your recovery sheet",
"recoveryPromptTitle": "Enter your recovery code",
"recoveryPromptBody": "This copy was saved with another code. Type the code from your recovery sheet to open it.",
"recoveryWrongCode": "That code doesn't open this copy"
},
"about": {
"title": "About",

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"
@ -63,7 +64,16 @@
"importDone": "Importado: {added} nuevas, {updated} actualizadas",
"importCsvDone": "Añadidas {count} entradas",
"importFailed": "Este fichero no se pudo leer como una copia de Tanemaki",
"failed": "Algo ha salido mal"
"failed": "Algo ha salido mal",
"recoveryTitle": "Tu código de recuperación",
"recoverySubtitle": "Imprímelo y guárdalo bien: abre tus copias en otro dispositivo",
"recoveryIntro": "Este código abre tus copias guardadas y recupera tu banco en cualquier dispositivo. Guarda dos copias en papel en sitios seguros, como tu mejor semilla. Quien lo tenga puede leer tus copias, así que no lo compartas con nadie.",
"recoveryCopy": "Copiar",
"recoverySave": "Guardar la hoja",
"recoverySheetTitle": "Tanemaki — tu hoja de recuperación",
"recoveryPromptTitle": "Escribe tu código de recuperación",
"recoveryPromptBody": "Esta copia se guardó con otro código. Escribe el código de tu hoja de recuperación para abrirla.",
"recoveryWrongCode": "Ese código no abre esta copia"
},
"about": {
"title": "Acerca de",

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

@ -1,173 +0,0 @@
/// Generated file. Do not edit.
///
/// Source: lib/i18n
/// To regenerate, run: `dart run slang`
///
/// Locales: 2
/// Strings: 562 (281 per locale)
///
/// Built on 2026-07-09 at 20:38 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
// dart format off
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'package:slang/generated.dart';
import 'package:slang_flutter/slang_flutter.dart';
export 'package:slang_flutter/slang_flutter.dart';
import 'strings_es.g.dart' as l_es;
part 'strings_en.g.dart';
/// Supported locales.
///
/// Usage:
/// - LocaleSettings.setLocale(AppLocale.en) // set locale
/// - Locale locale = AppLocale.en.flutterLocale // get flutter locale from enum
/// - if (LocaleSettings.currentLocale == AppLocale.en) // locale check
enum AppLocale with BaseAppLocale<AppLocale, Translations> {
en(languageCode: 'en'),
es(languageCode: 'es');
const AppLocale({
required this.languageCode,
this.scriptCode, // ignore: unused_element, unused_element_parameter
this.countryCode, // ignore: unused_element, unused_element_parameter
});
@override final String languageCode;
@override final String? scriptCode;
@override final String? countryCode;
@override
Future<Translations> build({
Map<String, Node>? overrides,
PluralResolver? cardinalResolver,
PluralResolver? ordinalResolver,
}) async {
return buildSync(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
}
@override
Translations buildSync({
Map<String, Node>? overrides,
PluralResolver? cardinalResolver,
PluralResolver? ordinalResolver,
}) {
switch (this) {
case AppLocale.en:
return TranslationsEn(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
case AppLocale.es:
return l_es.TranslationsEs(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
}
}
/// Gets current instance managed by [LocaleSettings].
Translations get translations => LocaleSettings.instance.getTranslations(this);
}
/// Method A: Simple
///
/// No rebuild after locale change.
/// Translation happens during initialization of the widget (call of t).
/// Configurable via 'translate_var'.
///
/// Usage:
/// String a = t.someKey.anotherKey;
/// String b = t['someKey.anotherKey']; // Only for edge cases!
Translations get t => LocaleSettings.instance.currentTranslations;
/// Method B: Advanced
///
/// All widgets using this method will trigger a rebuild when locale changes.
/// Use this if you have e.g. a settings page where the user can select the locale during runtime.
///
/// Step 1:
/// wrap your App with
/// TranslationProvider(
/// child: MyApp()
/// );
///
/// Step 2:
/// final t = Translations.of(context); // Get t variable.
/// String a = t.someKey.anotherKey; // Use t variable.
/// String b = t['someKey.anotherKey']; // Only for edge cases!
class TranslationProvider extends BaseTranslationProvider<AppLocale, Translations> {
TranslationProvider({required super.child}) : super(settings: LocaleSettings.instance);
static InheritedLocaleData<AppLocale, Translations> of(BuildContext context) => InheritedLocaleData.of<AppLocale, Translations>(context);
}
/// Method B shorthand via [BuildContext] extension method.
/// Configurable via 'translate_var'.
///
/// Usage (e.g. in a widget's build method):
/// context.t.someKey.anotherKey
extension BuildContextTranslationsExtension on BuildContext {
Translations get t => TranslationProvider.of(this).translations;
}
/// Manages all translation instances and the current locale
class LocaleSettings extends BaseFlutterLocaleSettings<AppLocale, Translations> {
LocaleSettings._() : super(
utils: AppLocaleUtils.instance,
lazy: false,
);
static final instance = LocaleSettings._();
// static aliases (checkout base methods for documentation)
static AppLocale get currentLocale => instance.currentLocale;
static Stream<AppLocale> getLocaleStream() => instance.getLocaleStream();
static Future<AppLocale> setLocale(AppLocale locale, {bool? listenToDeviceLocale = false}) => instance.setLocale(locale, listenToDeviceLocale: listenToDeviceLocale);
static Future<AppLocale> setLocaleRaw(String rawLocale, {bool? listenToDeviceLocale = false}) => instance.setLocaleRaw(rawLocale, listenToDeviceLocale: listenToDeviceLocale);
static Future<AppLocale> useDeviceLocale() => instance.useDeviceLocale();
static Future<void> setPluralResolver({String? language, AppLocale? locale, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) => instance.setPluralResolver(
language: language,
locale: locale,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
// synchronous versions
static AppLocale setLocaleSync(AppLocale locale, {bool? listenToDeviceLocale = false}) => instance.setLocaleSync(locale, listenToDeviceLocale: listenToDeviceLocale);
static AppLocale setLocaleRawSync(String rawLocale, {bool? listenToDeviceLocale = false}) => instance.setLocaleRawSync(rawLocale, listenToDeviceLocale: listenToDeviceLocale);
static AppLocale useDeviceLocaleSync() => instance.useDeviceLocaleSync();
static void setPluralResolverSync({String? language, AppLocale? locale, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) => instance.setPluralResolverSync(
language: language,
locale: locale,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
}
/// Provides utility functions without any side effects.
class AppLocaleUtils extends BaseAppLocaleUtils<AppLocale, Translations> {
AppLocaleUtils._() : super(
baseLocale: AppLocale.en,
locales: AppLocale.values,
);
static final instance = AppLocaleUtils._();
// static aliases (checkout base methods for documentation)
static AppLocale parse(String rawLocale) => instance.parse(rawLocale);
static AppLocale parseLocaleParts({required String languageCode, String? scriptCode, String? countryCode}) => instance.parseLocaleParts(languageCode: languageCode, scriptCode: scriptCode, countryCode: countryCode);
static AppLocale findDeviceLocale() => instance.findDeviceLocale();
static List<Locale> get supportedLocales => instance.supportedLocales;
static List<String> get supportedLocalesRaw => instance.supportedLocalesRaw;
}

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';
@ -288,6 +291,33 @@ class Translations$backup$en {
/// en: 'Something went wrong'
String get failed => 'Something went wrong';
/// en: 'Your recovery code'
String get recoveryTitle => 'Your recovery code';
/// en: 'Print it and keep it safe — it opens your copies on a new device'
String get recoverySubtitle => 'Print it and keep it safe — it opens your copies on a new device';
/// en: 'This code opens your saved copies and brings your bank back on any device. Keep two paper copies in safe places, like your best seed. Anyone holding it can read your copies, so share it with no one.'
String get recoveryIntro => 'This code opens your saved copies and brings your bank back on any device. Keep two paper copies in safe places, like your best seed. Anyone holding it can read your copies, so share it with no one.';
/// en: 'Copy'
String get recoveryCopy => 'Copy';
/// en: 'Save the sheet'
String get recoverySave => 'Save the sheet';
/// en: 'Tanemaki — your recovery sheet'
String get recoverySheetTitle => 'Tanemaki — your recovery sheet';
/// en: 'Enter your recovery code'
String get recoveryPromptTitle => 'Enter your recovery code';
/// en: 'This copy was saved with another code. Type the code from your recovery sheet to open it.'
String get recoveryPromptBody => 'This copy was saved with another code. Type the code from your recovery sheet to open it.';
/// en: 'That code doesn't open this copy'
String get recoveryWrongCode => 'That code doesn\'t open this copy';
}
// Path: about
@ -1493,6 +1523,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',
@ -1516,6 +1547,15 @@ extension on Translations {
'backup.importCsvDone' => ({required Object count}) => 'Added ${count} entries',
'backup.importFailed' => 'This file could not be read as a Tanemaki copy',
'backup.failed' => 'Something went wrong',
'backup.recoveryTitle' => 'Your recovery code',
'backup.recoverySubtitle' => 'Print it and keep it safe — it opens your copies on a new device',
'backup.recoveryIntro' => 'This code opens your saved copies and brings your bank back on any device. Keep two paper copies in safe places, like your best seed. Anyone holding it can read your copies, so share it with no one.',
'backup.recoveryCopy' => 'Copy',
'backup.recoverySave' => 'Save the sheet',
'backup.recoverySheetTitle' => 'Tanemaki — your recovery sheet',
'backup.recoveryPromptTitle' => 'Enter your recovery code',
'backup.recoveryPromptBody' => 'This copy was saved with another code. Type the code from your recovery sheet to open it.',
'backup.recoveryWrongCode' => 'That code doesn\'t open this copy',
'about.title' => 'About',
'about.kanji' => '種まき',
'about.tagline' => 'A local-first, decentralized app for managing and sharing traditional seeds and seedlings.',

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';
@ -183,6 +184,15 @@ class _Translations$backup$es extends Translations$backup$en {
@override String importCsvDone({required Object count}) => 'Añadidas ${count} entradas';
@override String get importFailed => 'Este fichero no se pudo leer como una copia de Tanemaki';
@override String get failed => 'Algo ha salido mal';
@override String get recoveryTitle => 'Tu código de recuperación';
@override String get recoverySubtitle => 'Imprímelo y guárdalo bien: abre tus copias en otro dispositivo';
@override String get recoveryIntro => 'Este código abre tus copias guardadas y recupera tu banco en cualquier dispositivo. Guarda dos copias en papel en sitios seguros, como tu mejor semilla. Quien lo tenga puede leer tus copias, así que no lo compartas con nadie.';
@override String get recoveryCopy => 'Copiar';
@override String get recoverySave => 'Guardar la hoja';
@override String get recoverySheetTitle => 'Tanemaki — tu hoja de recuperación';
@override String get recoveryPromptTitle => 'Escribe tu código de recuperación';
@override String get recoveryPromptBody => 'Esta copia se guardó con otro código. Escribe el código de tu hoja de recuperación para abrirla.';
@override String get recoveryWrongCode => 'Ese código no abre esta copia';
}
// Path: about
@ -951,6 +961,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',
@ -974,6 +985,15 @@ extension on TranslationsEs {
'backup.importCsvDone' => ({required Object count}) => 'Añadidas ${count} entradas',
'backup.importFailed' => 'Este fichero no se pudo leer como una copia de Tanemaki',
'backup.failed' => 'Algo ha salido mal',
'backup.recoveryTitle' => 'Tu código de recuperación',
'backup.recoverySubtitle' => 'Imprímelo y guárdalo bien: abre tus copias en otro dispositivo',
'backup.recoveryIntro' => 'Este código abre tus copias guardadas y recupera tu banco en cualquier dispositivo. Guarda dos copias en papel en sitios seguros, como tu mejor semilla. Quien lo tenga puede leer tus copias, así que no lo compartas con nadie.',
'backup.recoveryCopy' => 'Copiar',
'backup.recoverySave' => 'Guardar la hoja',
'backup.recoverySheetTitle' => 'Tanemaki — tu hoja de recuperación',
'backup.recoveryPromptTitle' => 'Escribe tu código de recuperación',
'backup.recoveryPromptBody' => 'Esta copia se guardó con otro código. Escribe el código de tu hoja de recuperación para abrirla.',
'backup.recoveryWrongCode' => 'Ese código no abre esta copia',
'about.title' => 'Acerca de',
'about.kanji' => '種まき',
'about.tagline' => 'Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.',

File diff suppressed because it is too large Load diff

View file

@ -29,6 +29,12 @@ class SecureKeyStore {
Future<String> rootSeedHex() =>
_readOrCreate(rootSeedName, _identity.generateRootSeed);
/// Replaces the root seed with one recovered from a printed code (restoring
/// your bank on a new device recovers your identity too). The DB key is
/// untouched it never leaves this device's keystore.
Future<void> adoptRootSeed(String seedHex) =>
_store.write(rootSeedName, seedHex);
Future<String> _readOrCreate(
String key,
List<int> Function() generate,

View file

@ -1,40 +1,111 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:commons_core/commons_core.dart';
import '../data/export_import/inventory_csv_codec.dart';
import '../data/export_import/inventory_json_codec.dart';
import '../data/export_import/inventory_snapshot.dart';
import '../data/variety_repository.dart';
import '../security/secure_key_store.dart';
import 'file_service.dart';
/// A picked backup file awaiting restore kept in memory so asking the user
/// for a recovery code never forces a second file dialog.
class PendingBackup {
const PendingBackup(this.bytes);
final Uint8List bytes;
/// Sealed (encrypted) copy vs. a legacy plain interchange export.
bool get isSealed => BackupBox.looksSealed(bytes);
}
/// End-to-end export/import flows: repository snapshot codec the file the
/// user picked. UI-free screens call these and render the outcome.
///
/// Backups are **sealed** (encrypted under a key derived from the root seed;
/// backup-and-recovery.md): a copy that lands on someone's cloud folder
/// reveals nothing. The printed recovery code opens them on any device.
class ExportImportService {
ExportImportService({
required VarietyRepository repository,
required FileService files,
required SecureKeyStore keys,
DateTime Function()? now,
}) : _repository = repository,
_files = files,
_keys = keys,
_now = now ?? DateTime.now;
final VarietyRepository _repository;
final FileService _files;
final SecureKeyStore _keys;
final DateTime Function() _now;
static const _jsonCodec = InventoryJsonCodec();
static const _csvCodec = InventoryCsvCodec();
/// Exports the inventory as interchange JSON (data-model §7). Returns true
/// when saved, false when the user cancelled the save dialog.
Future<bool> exportJson() async {
/// The backup file extension. The content is the sealed interchange JSON.
static const backupExtension = 'tanemaki';
/// Exports the full inventory as a sealed backup file. Returns true when
/// saved, false when the user cancelled the save dialog.
Future<bool> exportBackup() async {
final snapshot = await _repository.exportInventory();
final plain = utf8.encode(_jsonCodec.encode(snapshot));
final sealed = await BackupBox().seal(
plain,
rootSeed: _hexToBytes(await _keys.rootSeedHex()),
);
final path = await _files.saveFile(
suggestedName: _fileName('json'),
bytes: utf8.encode(_jsonCodec.encode(snapshot)),
suggestedName: _fileName(backupExtension),
bytes: sealed,
);
return path != null;
}
/// Asks the user to pick a backup file. Returns null when they cancelled.
Future<PendingBackup?> pickBackup() async {
final bytes = await _files.pickFileBytes(
allowedExtensions: [backupExtension, 'json'],
);
return bytes == null ? null : PendingBackup(bytes);
}
/// Restores [pending], merging by id (LWW). A sealed copy opens with this
/// device's seed, or with the [recoveryCode] from the printed sheet — which
/// is then adopted as the identity (recovering your bank recovers *you*).
/// Legacy plain exports import directly.
///
/// Throws [BackupAuthException] when the copy was sealed under a different
/// seed (ask for the code and retry), and [FormatException] when the file or
/// code is unreadable.
Future<ImportSummary> restoreBackup(
PendingBackup pending, {
String? recoveryCode,
}) async {
Uint8List plain;
if (pending.isSealed) {
final seedHex = recoveryCode == null
? await _keys.rootSeedHex()
: RecoveryCode.decode(recoveryCode);
plain = await BackupBox().open(
pending.bytes,
rootSeed: _hexToBytes(seedHex),
);
if (recoveryCode != null) await _keys.adoptRootSeed(seedHex);
} else {
plain = pending.bytes;
}
final snapshot = _jsonCodec.decode(utf8.decode(plain));
return _repository.importInventory(snapshot);
}
/// The printable recovery code for this device's identity seed.
Future<String> recoveryCode() async =>
RecoveryCode.encode(await _keys.rootSeedHex());
/// Exports the inventory as flat CSV (export-only). Returns true when
/// saved, false when the user cancelled the save dialog.
Future<bool> exportCsv() async {
@ -46,16 +117,6 @@ class ExportImportService {
return path != null;
}
/// Imports an interchange JSON file, merging by id (LWW). Returns the
/// summary, or null when the user cancelled the file dialog. Throws
/// [FormatException] when the file is not a readable inventory export.
Future<ImportSummary?> importJson() async {
final bytes = await _files.pickFileBytes(allowedExtensions: ['json']);
if (bytes == null) return null;
final snapshot = _jsonCodec.decode(utf8.decode(bytes));
return _repository.importInventory(snapshot);
}
/// Imports a spreadsheet CSV additively (every row becomes a new variety/lot;
/// see [VarietyRepository.importCsv]). Returns the summary (`inserted` =
/// varieties added), or null when the user cancelled the file dialog.
@ -68,6 +129,11 @@ class ExportImportService {
String _fileName(String extension) {
final date = _now().toIso8601String().substring(0, 10);
return 'tanemaki-inventory-$date.$extension';
return 'tanemaki-backup-$date.$extension';
}
static List<int> _hexToBytes(String hex) => [
for (var i = 0; i < hex.length; i += 2)
int.parse(hex.substring(i, i + 2), radix: 16),
];
}

View file

@ -0,0 +1,82 @@
import 'dart:typed_data';
import 'package:flutter/services.dart' show rootBundle;
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'file_service.dart';
/// Renders the printable recovery sheet a QR plus the typed code and short
/// instructions ("keep two paper copies, like your best seed"). All strings
/// arrive already localized.
class RecoverySheetService {
RecoverySheetService({required FileService files}) : _files = files;
final FileService _files;
Future<Uint8List> buildPdf({
required String title,
required String intro,
required String code,
}) async {
final base = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'),
);
final doc = pw.Document(
theme: pw.ThemeData.withFont(base: base, bold: bold),
);
doc.addPage(
pw.Page(
pageFormat: PdfPageFormat.a4,
build: (context) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(title, style: pw.TextStyle(font: bold, fontSize: 20)),
pw.SizedBox(height: 12),
pw.Text(intro, style: const pw.TextStyle(fontSize: 11)),
pw.SizedBox(height: 24),
pw.Center(
child: pw.BarcodeWidget(
barcode: pw.Barcode.qrCode(),
data: code,
width: 220,
height: 220,
// The code is printed below with the Unicode-capable face;
// the widget's built-in caption would fall back to Courier.
drawText: false,
),
),
pw.SizedBox(height: 24),
pw.Center(
child: pw.Text(
code,
textAlign: pw.TextAlign.center,
style: pw.TextStyle(font: bold, fontSize: 12),
),
),
],
),
),
);
return doc.save();
}
/// Builds the sheet and asks the user where to keep it. Returns true when
/// saved, false when they cancelled.
Future<bool> saveSheet({
required String title,
required String intro,
required String code,
required String suggestedName,
}) async {
final bytes = await buildPdf(title: title, intro: intro, code: code);
final path = await _files.saveFile(
suggestedName: suggestedName,
bytes: bytes,
);
return path != null;
}
}

View file

@ -1,22 +1,29 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../data/export_import/inventory_snapshot.dart';
import '../di/injector.dart';
import '../i18n/strings.g.dart';
import '../services/export_import_service.dart';
import '../services/recovery_sheet_service.dart';
/// The backup actions shown in Settings. The primary pair save/restore a
/// full copy comes first; the spreadsheet list export/import is a secondary
/// convenience below. File formats (JSON/CSV) are an implementation detail and
/// never surface in the UI. Restore/import asks for confirmation first (it
/// merges into the live inventory), then reports what happened in a SnackBar.
/// full copy comes first, then the recovery sheet, then the spreadsheet
/// list export/import as a secondary convenience. File formats and crypto are
/// implementation details and never surface in the UI. Restore/import asks
/// for confirmation first (it merges into the live inventory), then reports
/// what happened in a SnackBar.
class BackupSection extends StatelessWidget {
const BackupSection({this.service, super.key});
const BackupSection({this.service, this.sheet, super.key});
/// Injectable for widget tests; the app resolves it lazily (on tap) from
/// Injectable for widget tests; the app resolves them lazily (on tap) from
/// the service locator so merely mounting Settings needs no DI setup.
final ExportImportService? service;
final RecoverySheetService? sheet;
ExportImportService get _service => service ?? getIt<ExportImportService>();
RecoverySheetService get _sheet => sheet ?? getIt<RecoverySheetService>();
@override
Widget build(BuildContext context) {
@ -27,13 +34,20 @@ class BackupSection extends StatelessWidget {
leading: const Icon(Icons.save_alt_outlined),
title: Text(t.backup.exportJson),
subtitle: Text(t.backup.exportJsonSubtitle),
onTap: () => _runExport(context, () => _service.exportJson()),
onTap: () => _runExport(context, () => _service.exportBackup()),
),
ListTile(
leading: const Icon(Icons.settings_backup_restore_outlined),
title: Text(t.backup.importJson),
subtitle: Text(t.backup.importJsonSubtitle),
onTap: () => _runImport(context),
onTap: () => _runRestore(context),
),
ListTile(
key: const Key('backup.recovery'),
leading: const Icon(Icons.qr_code_2_outlined),
title: Text(t.backup.recoveryTitle),
subtitle: Text(t.backup.recoverySubtitle),
onTap: () => _showRecoverySheet(context),
),
const Divider(),
ListTile(
@ -66,65 +80,169 @@ class BackupSection extends StatelessWidget {
}
}
Future<void> _runImport(BuildContext context) async {
/// Shows this device's recovery code and offers to save the printable
/// sheet (QR + code + instructions).
Future<void> _showRecoverySheet(BuildContext context) async {
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
final confirmed = await showDialog<bool>(
final code = await _service.recoveryCode();
if (!context.mounted) return;
final save = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(t.backup.importConfirmTitle),
content: Text(t.backup.importConfirmBody),
title: Text(t.backup.recoveryTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t.backup.recoveryIntro),
const SizedBox(height: 12),
SelectableText(
code,
key: const Key('backup.recoveryCode'),
style: const TextStyle(fontWeight: FontWeight.w600),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(t.common.cancel),
key: const Key('backup.recoveryCopy'),
onPressed: () async {
await Clipboard.setData(ClipboardData(text: code));
if (dialogContext.mounted) Navigator.pop(dialogContext, false);
},
child: Text(t.backup.recoveryCopy),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(t.backup.importAction),
key: const Key('backup.recoverySave'),
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(t.backup.recoverySave),
),
],
),
);
if (confirmed != true) return;
if (save != true) return;
try {
final summary = await _service.importJson();
_show(
messenger,
summary == null
? t.backup.cancelled
: t.backup.importDone(
added: summary.inserted,
updated: summary.updated,
),
final saved = await _sheet.saveSheet(
title: t.backup.recoverySheetTitle,
intro: t.backup.recoveryIntro,
code: code,
suggestedName: 'tanemaki-recovery.pdf',
);
} on FormatException {
_show(messenger, t.backup.importFailed);
_show(messenger, saved ? t.backup.exportSaved : t.backup.cancelled);
} on Object {
_show(messenger, t.backup.failed);
}
}
Future<void> _runImportCsv(BuildContext context) async {
Future<void> _runRestore(BuildContext context) async {
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
final confirmed = await showDialog<bool>(
final confirmed = await _confirm(
context,
title: t.backup.importConfirmTitle,
body: t.backup.importConfirmBody,
);
if (confirmed != true || !context.mounted) return;
final PendingBackup? pending;
try {
pending = await _service.pickBackup();
} on Object {
_show(messenger, t.backup.failed);
return;
}
if (pending == null) {
_show(messenger, t.backup.cancelled);
return;
}
try {
_report(messenger, t, await _service.restoreBackup(pending));
return;
} on BackupAuthException {
// Sealed under another identity: ask for the printed code below.
} on FormatException {
_show(messenger, t.backup.importFailed);
return;
} on Object {
_show(messenger, t.backup.failed);
return;
}
if (!context.mounted) return;
final code = await _askRecoveryCode(context);
if (code == null) {
_show(messenger, t.backup.cancelled);
return;
}
try {
_report(
messenger,
t,
await _service.restoreBackup(pending, recoveryCode: code),
);
} on BackupAuthException {
_show(messenger, t.backup.recoveryWrongCode);
} on FormatException {
_show(messenger, t.backup.recoveryWrongCode);
} on Object {
_show(messenger, t.backup.failed);
}
}
/// Asks for the code printed on the recovery sheet.
Future<String?> _askRecoveryCode(BuildContext context) {
final t = context.t;
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(t.backup.importCsvConfirmTitle),
content: Text(t.backup.importCsvConfirmBody),
title: Text(t.backup.recoveryPromptTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t.backup.recoveryPromptBody),
const SizedBox(height: 12),
TextField(
key: const Key('backup.recoveryField'),
controller: controller,
autofocus: true,
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(
labelText: t.backup.recoveryTitle,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
onPressed: () => Navigator.pop(dialogContext),
child: Text(t.common.cancel),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
key: const Key('backup.recoveryConfirm'),
onPressed: () =>
Navigator.pop(dialogContext, controller.text.trim()),
child: Text(t.backup.importAction),
),
],
),
);
}
Future<void> _runImportCsv(BuildContext context) async {
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
final confirmed = await _confirm(
context,
title: t.backup.importCsvConfirmTitle,
body: t.backup.importCsvConfirmBody,
);
if (confirmed != true) return;
try {
final summary = await _service.importCsv();
@ -141,6 +259,42 @@ class BackupSection extends StatelessWidget {
}
}
Future<bool?> _confirm(
BuildContext context, {
required String title,
required String body,
}) {
final t = context.t;
return showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: Text(body),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(t.common.cancel),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(t.backup.importAction),
),
],
),
);
}
void _report(
ScaffoldMessengerState messenger,
Translations t,
ImportSummary summary,
) {
_show(
messenger,
t.backup.importDone(added: summary.inserted, updated: summary.updated),
);
}
void _show(ScaffoldMessengerState messenger, String message) {
messenger.showSnackBar(SnackBar(content: Text(message)));
}

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

@ -0,0 +1,54 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
import '../support/test_support.dart';
/// A real seed bank can hold thousands of accessions. This guards that the
/// list query stays a handful of batched queries (not N+1) and returns in a
/// human blink even at that scale the pre-beta performance check.
void main() {
late AppDatabase db;
late VarietyRepository repo;
setUp(() {
db = newTestDatabase();
repo = newTestRepository(db);
});
tearDown(() => db.close());
test('the inventory view loads 3000 varieties quickly', () async {
const count = 3000;
for (var i = 0; i < count; i++) {
final id = await repo.addQuickVariety(
label: 'Variety $i',
category: i.isEven ? 'Poaceae' : 'Fabaceae',
);
// Every few varieties, add a lot so the batched lot/type/share queries
// do real work.
if (i % 5 == 0) {
await repo.addLot(
varietyId: id,
harvestYear: 2020 + (i % 5),
quantity: const Quantity(kind: QuantityKind.handful),
offerStatus: i % 25 == 0 ? OfferStatus.shared : OfferStatus.private,
);
}
}
final sw = Stopwatch()..start();
final view = await repo.watchInventoryView().first;
sw.stop();
expect(view.items, hasLength(count));
// Generous ceiling for CI; typical local runs are well under 500ms. The
// point is to catch an accidental N+1 regression, not to micro-benchmark.
expect(
sw.elapsedMilliseconds,
lessThan(3000),
reason: 'inventory view took ${sw.elapsedMilliseconds}ms for $count rows',
);
});
}

View file

@ -0,0 +1,158 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/export_import/inventory_json_codec.dart';
import 'package:tane/db/database.dart';
import 'package:tane/security/secure_key_store.dart';
import 'package:tane/services/export_import_service.dart';
import 'package:tane/services/file_service.dart';
import '../support/test_support.dart';
class _FakeFiles implements FileService {
Uint8List? saved;
String? savedName;
Uint8List? toPick;
@override
Future<String?> saveFile({
required String suggestedName,
required Uint8List bytes,
}) async {
saved = bytes;
savedName = suggestedName;
return '/picked/$suggestedName';
}
@override
Future<Uint8List?> pickFileBytes({List<String>? allowedExtensions}) async =>
toPick;
}
void main() {
late AppDatabase dbA;
late AppDatabase dbB;
setUp(() {
dbA = newTestDatabase();
dbB = newTestDatabase();
});
tearDown(() async {
await dbA.close();
await dbB.close();
});
(ExportImportService, _FakeFiles, SecureKeyStore) newService(
AppDatabase db, {
String nodeId = 'node',
}) {
final files = _FakeFiles();
final keys = SecureKeyStore(store: InMemorySecretStore());
final service = ExportImportService(
repository: newTestRepository(db, nodeId: nodeId),
files: files,
keys: keys,
);
return (service, files, keys);
}
test('a saved backup is sealed — no plaintext leaves the app', () async {
final (service, files, _) = newService(dbA);
final repo = newTestRepository(dbA);
await repo.addQuickVariety(label: 'Secret heirloom');
await service.exportBackup();
expect(files.savedName, endsWith('.tanemaki'));
expect(BackupBox.looksSealed(files.saved!), isTrue);
expect(String.fromCharCodes(files.saved!).contains('Secret'), isFalse);
});
test('restore with the local seed needs no code (same identity)', () async {
final (serviceA, filesA, keysA) = newService(dbA);
final repoA = newTestRepository(dbA);
await repoA.addQuickVariety(label: 'Maize');
await serviceA.exportBackup();
// A fresh DB but the SAME keystore = your device after a reinstall.
final filesB = _FakeFiles()..toPick = filesA.saved;
final serviceB = ExportImportService(
repository: newTestRepository(dbB),
files: filesB,
keys: keysA,
);
final summary = await serviceB.restoreBackup(
(await serviceB.pickBackup())!,
);
expect(summary.inserted, 1);
final restored = await dbB.select(dbB.varieties).get();
expect(restored.single.label, 'Maize');
});
test(
'restore on a new device needs the recovery code and adopts the seed',
() async {
final (serviceA, filesA, keysA) = newService(dbA, nodeId: 'device-a');
final repoA = newTestRepository(dbA, nodeId: 'device-a');
await repoA.addQuickVariety(label: 'Grandma tomato');
await serviceA.exportBackup();
final code = await serviceA.recoveryCode();
final (serviceB, filesB, keysB) = newService(dbB, nodeId: 'device-b');
// Force device B to have its own (different) identity first.
final seedB = await keysB.rootSeedHex();
expect(seedB, isNot(await keysA.rootSeedHex()));
filesB.toPick = filesA.saved;
final pending = (await serviceB.pickBackup())!;
// Without the code: authentication fails, nothing imported.
await expectLater(
serviceB.restoreBackup(pending),
throwsA(isA<BackupAuthException>()),
);
expect(await dbB.select(dbB.varieties).get(), isEmpty);
// With the printed code: data restored AND identity recovered.
final summary = await serviceB.restoreBackup(pending, recoveryCode: code);
expect(summary.inserted, 1);
final restored = await dbB.select(dbB.varieties).get();
expect(restored.single.label, 'Grandma tomato');
expect(await keysB.rootSeedHex(), await keysA.rootSeedHex());
},
);
test('a sloppy typed code (lowercase, spaces) still opens', () async {
final (serviceA, filesA, _) = newService(dbA);
final repoA = newTestRepository(dbA);
await repoA.addQuickVariety(label: 'Bean');
await serviceA.exportBackup();
final code = await serviceA.recoveryCode();
final (serviceB, filesB, _) = newService(dbB);
filesB.toPick = filesA.saved;
final pending = (await serviceB.pickBackup())!;
final summary = await serviceB.restoreBackup(
pending,
recoveryCode: code.toLowerCase().replaceAll('-', ' '),
);
expect(summary.inserted, 1);
});
test('legacy plain exports still restore (no seal, direct parse)', () async {
final repoA = newTestRepository(dbA);
await repoA.addQuickVariety(label: 'Old export');
final legacy = utf8.encode(
const InventoryJsonCodec().encode(await repoA.exportInventory()),
);
final (serviceB, filesB, _) = newService(dbB);
filesB.toPick = Uint8List.fromList(legacy);
final summary = await serviceB.restoreBackup(
(await serviceB.pickBackup())!,
);
expect(summary.inserted, 1);
});
}

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

@ -1,15 +1,17 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/export_import/inventory_json_codec.dart';
import 'package:tane/data/export_import/inventory_snapshot.dart';
import 'package:tane/db/database.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/security/secure_key_store.dart';
import 'package:tane/services/export_import_service.dart';
import 'package:tane/services/file_service.dart';
import 'package:tane/services/recovery_sheet_service.dart';
import 'package:tane/ui/backup_section.dart';
import 'package:tane/ui/settings_screen.dart';
@ -39,15 +41,20 @@ class FakeFileService implements FileService {
void main() {
late AppDatabase db;
late FakeFileService files;
late SecureKeyStore keys;
late ExportImportService service;
late RecoverySheetService sheet;
setUp(() {
db = newTestDatabase();
files = FakeFileService();
keys = SecureKeyStore(store: InMemorySecretStore());
service = ExportImportService(
repository: newTestRepository(db),
files: files,
keys: keys,
);
sheet = RecoverySheetService(files: files);
});
tearDown(() => db.close());
@ -66,6 +73,12 @@ void main() {
);
}
Widget section() => wrap(
Scaffold(
body: BackupSection(service: service, sheet: sheet),
),
);
testWidgets('settings shows the backup section with its actions', (
tester,
) async {
@ -76,6 +89,7 @@ void main() {
expect(find.text('Backup & restore'), findsOneWidget);
expect(find.text('Save a backup'), findsOneWidget);
expect(find.text('Restore a backup'), findsOneWidget);
expect(find.text('Your recovery code'), findsOneWidget);
expect(find.text('Export to a spreadsheet'), findsOneWidget);
expect(find.text('Import a list'), findsOneWidget);
});
@ -83,9 +97,7 @@ void main() {
testWidgets('spreadsheet export saves a .csv file and confirms', (
tester,
) async {
await tester.pumpWidget(
wrap(Scaffold(body: BackupSection(service: service))),
);
await tester.pumpWidget(section());
await tester.tap(find.text('Export to a spreadsheet'));
await tester.pumpAndSettle();
@ -94,35 +106,31 @@ void main() {
expect(find.text('Copy saved'), findsOneWidget);
});
testWidgets('saving a backup writes a versioned .json file', (tester) async {
await tester.pumpWidget(
wrap(Scaffold(body: BackupSection(service: service))),
);
testWidgets('saving a backup writes a sealed .tanemaki file', (tester) async {
await tester.pumpWidget(section());
await tester.tap(find.text('Save a backup'));
await tester.pumpAndSettle();
expect(files.savedName, endsWith('.json'));
final root =
jsonDecode(utf8.decode(files.savedBytes!)) as Map<String, dynamic>;
expect(root['formatVersion'], inventoryFormatVersion);
expect(files.savedName, endsWith('.tanemaki'));
expect(BackupBox.looksSealed(files.savedBytes!), isTrue);
expect(find.text('Copy saved'), findsOneWidget);
});
testWidgets('import asks for confirmation, imports and reports counts', (
tester,
) async {
// A valid one-variety export produced by another repository.
testWidgets('restoring my own sealed copy needs no code', (tester) async {
final otherDb = newTestDatabase();
addTearDown(otherDb.close);
final other = newTestRepository(otherDb, nodeId: 'node-other');
final otherFiles = FakeFileService();
final mine = ExportImportService(
repository: newTestRepository(otherDb, nodeId: 'other'),
files: otherFiles,
keys: keys, // same identity
);
final other = newTestRepository(otherDb, nodeId: 'other');
await other.addQuickVariety(label: 'Imported bean');
files.bytesToPick = utf8.encode(
const InventoryJsonCodec().encode(await other.exportInventory()),
);
await mine.exportBackup();
files.bytesToPick = otherFiles.savedBytes;
await tester.pumpWidget(
wrap(Scaffold(body: BackupSection(service: service))),
);
await tester.pumpWidget(section());
await tester.tap(find.text('Restore a backup'));
await tester.pumpAndSettle();
expect(find.text('Restore a backup?'), findsOneWidget);
@ -135,11 +143,64 @@ void main() {
expect(varieties.single.label, 'Imported bean');
});
testWidgets('a copy from another identity asks for the recovery code', (
tester,
) async {
final otherDb = newTestDatabase();
addTearDown(otherDb.close);
final otherFiles = FakeFileService();
final otherKeys = SecureKeyStore(store: InMemorySecretStore());
final theirs = ExportImportService(
repository: newTestRepository(otherDb, nodeId: 'other'),
files: otherFiles,
keys: otherKeys,
);
final other = newTestRepository(otherDb, nodeId: 'other');
await other.addQuickVariety(label: 'Recovered bean');
await theirs.exportBackup();
final code = await theirs.recoveryCode();
files.bytesToPick = otherFiles.savedBytes;
await tester.pumpWidget(section());
await tester.tap(find.text('Restore a backup'));
await tester.pumpAndSettle();
await tester.tap(find.text('Import'));
await tester.pumpAndSettle();
// Wrong seed the code prompt appears; type the printed code.
expect(find.text('Enter your recovery code'), findsOneWidget);
await tester.enterText(find.byKey(const Key('backup.recoveryField')), code);
await tester.tap(find.byKey(const Key('backup.recoveryConfirm')));
await tester.pumpAndSettle();
expect(find.text('Imported: 1 new, 0 updated'), findsOneWidget);
final varieties = await db.select(db.varieties).get();
expect(varieties.single.label, 'Recovered bean');
// The identity travelled with the code.
expect(await keys.rootSeedHex(), await otherKeys.rootSeedHex());
});
testWidgets('legacy plain exports still restore', (tester) async {
final otherDb = newTestDatabase();
addTearDown(otherDb.close);
final other = newTestRepository(otherDb, nodeId: 'node-other');
await other.addQuickVariety(label: 'Old bean');
files.bytesToPick = utf8.encode(
const InventoryJsonCodec().encode(await other.exportInventory()),
);
await tester.pumpWidget(section());
await tester.tap(find.text('Restore a backup'));
await tester.pumpAndSettle();
await tester.tap(find.text('Import'));
await tester.pumpAndSettle();
expect(find.text('Imported: 1 new, 0 updated'), findsOneWidget);
});
testWidgets('an unreadable file reports a friendly error', (tester) async {
files.bytesToPick = utf8.encode('this is not json');
await tester.pumpWidget(
wrap(Scaffold(body: BackupSection(service: service))),
);
await tester.pumpWidget(section());
await tester.tap(find.text('Restore a backup'));
await tester.pumpAndSettle();
await tester.tap(find.text('Import'));
@ -150,4 +211,25 @@ void main() {
findsOneWidget,
);
});
testWidgets('the recovery dialog shows the code and saves the sheet', (
tester,
) async {
await tester.pumpWidget(section());
await tester.tap(find.byKey(const Key('backup.recovery')));
await tester.pumpAndSettle();
final code = await service.recoveryCode();
expect(find.text(code), findsOneWidget);
await tester.tap(find.byKey(const Key('backup.recoverySave')));
await tester.pumpAndSettle();
expect(files.savedName, 'tanemaki-recovery.pdf');
expect(String.fromCharCodes(files.savedBytes!.take(5)), '%PDF-');
// The seed itself never appears in the clear in the PDF stream metadata;
// the QR encodes the human code, which IS the secret that's the point
// of the sheet. Just assert it saved and confirmed.
expect(find.text('Copy saved'), findsOneWidget);
});
}

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);
});
}