From de6938d5d7b58bab8318eae9e92f9141de21eca4 Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 11 Jul 2026 02:11:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(plantare):=20usable=20UI=20=E2=80=94=20com?= =?UTF-8?q?mitments=20screen,=20add=20sheet,=20entry=20points?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reproduction-commitment (Plantare) UI, local-first and plain-spoken: - A Plantares screen (drawer entry, /plantares) listing your commitments, grouped Open / Done, with a big 'mark returned', a menu (let it go / reopen / remove) and an empty-state that explains what a Plantare is. - An add sheet (scrollable): a two-choice 'who reproduces & returns' (I return / owed to me), an optional 'with whom', an optional 'what comes back' in the grower's own words, and a note. No sale, no currency — a promise. - Reachable from a seed too: an 'Add a commitment' action on the variety detail, pre-attached to that variety. - i18n en/es/pt/ast (human words: 'Plantare' + a plain explanation). Added the Plantares screen to the small-phone overflow guard (18 cases green). --- apps/app_seeds/lib/app.dart | 6 + apps/app_seeds/lib/i18n/ast.i18n.json | 25 +++ apps/app_seeds/lib/i18n/en.i18n.json | 25 +++ apps/app_seeds/lib/i18n/es.i18n.json | 25 +++ apps/app_seeds/lib/i18n/pt.i18n.json | 25 +++ apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 56 ++++++ apps/app_seeds/lib/i18n/strings_en.g.dart | 102 +++++++++++ apps/app_seeds/lib/i18n/strings_es.g.dart | 56 ++++++ apps/app_seeds/lib/i18n/strings_pt.g.dart | 56 ++++++ apps/app_seeds/lib/ui/app_drawer.dart | 9 + apps/app_seeds/lib/ui/plantare_sheet.dart | 145 ++++++++++++++++ apps/app_seeds/lib/ui/plantares_screen.dart | 160 ++++++++++++++++++ .../lib/ui/variety_detail_screen.dart | 16 ++ .../test/ui/small_screen_overflow_test.dart | 22 +++ 15 files changed, 730 insertions(+), 2 deletions(-) create mode 100644 apps/app_seeds/lib/ui/plantare_sheet.dart create mode 100644 apps/app_seeds/lib/ui/plantares_screen.dart diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 33b4afd..ae9fbb3 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -32,6 +32,7 @@ import 'ui/chat_screen.dart'; import 'ui/home_screen.dart'; import 'ui/intro_screen.dart'; import 'ui/inventory_list_screen.dart'; +import 'ui/plantares_screen.dart'; import 'ui/market_offer_detail_screen.dart'; import 'ui/market_screen.dart'; import 'ui/offline_banner.dart'; @@ -254,6 +255,11 @@ class TaneApp extends StatelessWidget { child: const InventoryListScreen(), ), ), + // Local reproduction commitments — inventory-adjacent, no network. + GoRoute( + path: '/plantares', + builder: (context, state) => const PlantaresScreen(), + ), GoRoute( path: '/variety/:id', builder: (context, state) => BlocProvider( diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index bddcffc..0eebba4 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -36,6 +36,7 @@ "chat": "Charra", "wishlist": "Llista de deseos", "following": "Siguiendo", + "plantares": "Plantares", "settings": "Axustes" }, "settings": { @@ -552,5 +553,29 @@ }, "notifications": { "newMessageFrom": "Mensaxe nuevu de {name}" + }, + "plantare": { + "title": "Plantares", + "help": "Un Plantare ye'l compromisu de reproducir una semilla y devolver una parte — asina una variedá sigue viaxando de mano en mano. Nun ye una venta.", + "add": "Amestar compromisu", + "empty": "Entá nun hai compromisos. Cuando compartas o recibas semilla col compromisu de reproducila y devolver daqué, anótalo equí.", + "iReturn": "Reprodúzola y devuélvola yo", + "owedToMe": "Devuélvenmela a min", + "direction": "Quién reproduz y devuelve", + "counterparty": "¿Con quién?", + "counterpartyHint": "Una persona o un coleutivu (opcional)", + "owed": "¿Qué se devuelve?", + "owedHint": "p. ex. un puñáu la próxima temporada (opcional)", + "note": "Nota (opcional)", + "save": "Guardar", + "markReturned": "Marcar devueltu", + "markForgiven": "Dar por saldáu", + "reopen": "Reabrir", + "delete": "Quitar", + "statusReturned": "Devueltu", + "statusForgiven": "Saldáu", + "openSection": "Pendientes", + "settledSection": "Fechos", + "removeConfirm": "¿Quitar esti compromisu?" } } diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 1a66028..e4462cf 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -37,6 +37,7 @@ "chat": "Chat", "wishlist": "Wishlist", "following": "Following", + "plantares": "Plantares", "settings": "Settings" }, "settings": { @@ -555,5 +556,29 @@ }, "notifications": { "newMessageFrom": "New message from {name}" + }, + "plantare": { + "title": "Plantares", + "help": "A Plantare is a promise to reproduce a seed and return some — how a variety keeps travelling from hand to hand. It's not a sale.", + "add": "Add a commitment", + "empty": "No commitments yet. When you share or receive seed with a promise to grow it out and return some, note it here.", + "iReturn": "I'll grow it out & return some", + "owedToMe": "They'll return some to me", + "direction": "Who reproduces & returns", + "counterparty": "With whom?", + "counterpartyHint": "A person or a collective (optional)", + "owed": "What comes back?", + "owedHint": "e.g. a handful next season (optional)", + "note": "Note (optional)", + "save": "Save", + "markReturned": "Mark returned", + "markForgiven": "Let it go", + "reopen": "Reopen", + "delete": "Remove", + "statusReturned": "Returned", + "statusForgiven": "Settled", + "openSection": "Open", + "settledSection": "Done", + "removeConfirm": "Remove this commitment?" } } diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 65c41b1..b627b0c 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -37,6 +37,7 @@ "chat": "Chat", "wishlist": "Lista de deseos", "following": "Siguiendo", + "plantares": "Plantares", "settings": "Ajustes" }, "settings": { @@ -554,5 +555,29 @@ }, "notifications": { "newMessageFrom": "Nuevo mensaje de {name}" + }, + "plantare": { + "title": "Plantares", + "help": "Un Plantare es el compromiso de reproducir una semilla y devolver una parte — así una variedad sigue viajando de mano en mano. No es una venta.", + "add": "Añadir compromiso", + "empty": "Aún no hay compromisos. Cuando compartas o recibas semilla con el compromiso de reproducirla y devolver algo, anótalo aquí.", + "iReturn": "La reproduzco y devuelvo yo", + "owedToMe": "Me la devuelven a mí", + "direction": "Quién reproduce y devuelve", + "counterparty": "¿Con quién?", + "counterpartyHint": "Una persona o un colectivo (opcional)", + "owed": "¿Qué se devuelve?", + "owedHint": "p. ej. un puñado la próxima temporada (opcional)", + "note": "Nota (opcional)", + "save": "Guardar", + "markReturned": "Marcar devuelto", + "markForgiven": "Dar por saldado", + "reopen": "Reabrir", + "delete": "Quitar", + "statusReturned": "Devuelto", + "statusForgiven": "Saldado", + "openSection": "Pendientes", + "settledSection": "Hechos", + "removeConfirm": "¿Quitar este compromiso?" } } diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index ba8c6e8..322f6fc 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -37,6 +37,7 @@ "chat": "Conversas", "wishlist": "Lista de desejos", "following": "A seguir", + "plantares": "Plantares", "settings": "Definições" }, "settings": { @@ -551,5 +552,29 @@ }, "notifications": { "newMessageFrom": "Nova mensagem de {name}" + }, + "plantare": { + "title": "Plantares", + "help": "Um Plantare é o compromisso de reproduzir uma semente e devolver uma parte — assim uma variedade continua a viajar de mão em mão. Não é uma venda.", + "add": "Adicionar compromisso", + "empty": "Ainda não há compromissos. Quando partilhares ou receberes semente com o compromisso de a reproduzir e devolver algo, anota-o aqui.", + "iReturn": "Reproduzo e devolvo eu", + "owedToMe": "Devolvem-me a mim", + "direction": "Quem reproduz e devolve", + "counterparty": "Com quem?", + "counterpartyHint": "Uma pessoa ou um coletivo (opcional)", + "owed": "O que é devolvido?", + "owedHint": "p. ex. um punhado na próxima temporada (opcional)", + "note": "Nota (opcional)", + "save": "Guardar", + "markReturned": "Marcar devolvido", + "markForgiven": "Dar por saldado", + "reopen": "Reabrir", + "delete": "Remover", + "statusReturned": "Devolvido", + "statusForgiven": "Saldado", + "openSection": "Pendentes", + "settledSection": "Feitos", + "removeConfirm": "Remover este compromisso?" } } diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index d9c532b..3c5dc97 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 1668 (417 per locale) +/// Strings: 1760 (440 per locale) /// -/// Built on 2026-07-10 at 19:42 UTC +/// Built on 2026-07-11 at 00:04 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index de571b8..930706d 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -77,6 +77,7 @@ class TranslationsAst extends Translations with BaseTranslations 'Charra'; @override String get wishlist => 'Llista de deseos'; @override String get following => 'Siguiendo'; + @override String get plantares => 'Plantares'; @override String get settings => 'Axustes'; } @@ -806,6 +808,37 @@ class _Translations$notifications$ast extends Translations$notifications$en { @override String newMessageFrom({required Object name}) => 'Mensaxe nuevu de ${name}'; } +// Path: plantare +class _Translations$plantare$ast extends Translations$plantare$en { + _Translations$plantare$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Plantares'; + @override String get help => 'Un Plantare ye\'l compromisu de reproducir una semilla y devolver una parte — asina una variedá sigue viaxando de mano en mano. Nun ye una venta.'; + @override String get add => 'Amestar compromisu'; + @override String get empty => 'Entá nun hai compromisos. Cuando compartas o recibas semilla col compromisu de reproducila y devolver daqué, anótalo equí.'; + @override String get iReturn => 'Reprodúzola y devuélvola yo'; + @override String get owedToMe => 'Devuélvenmela a min'; + @override String get direction => 'Quién reproduz y devuelve'; + @override String get counterparty => '¿Con quién?'; + @override String get counterpartyHint => 'Una persona o un coleutivu (opcional)'; + @override String get owed => '¿Qué se devuelve?'; + @override String get owedHint => 'p. ex. un puñáu la próxima temporada (opcional)'; + @override String get note => 'Nota (opcional)'; + @override String get save => 'Guardar'; + @override String get markReturned => 'Marcar devueltu'; + @override String get markForgiven => 'Dar por saldáu'; + @override String get reopen => 'Reabrir'; + @override String get delete => 'Quitar'; + @override String get statusReturned => 'Devueltu'; + @override String get statusForgiven => 'Saldáu'; + @override String get openSection => 'Pendientes'; + @override String get settledSection => 'Fechos'; + @override String get removeConfirm => '¿Quitar esti compromisu?'; +} + // Path: intro.slides class _Translations$intro$slides$ast extends Translations$intro$slides$en { _Translations$intro$slides$ast._(TranslationsAst root) : this._root = root, super.internal(root); @@ -1173,6 +1206,7 @@ extension on TranslationsAst { 'menu.chat' => 'Charra', 'menu.wishlist' => 'Llista de deseos', 'menu.following' => 'Siguiendo', + 'menu.plantares' => 'Plantares', 'menu.settings' => 'Axustes', 'settings.language' => 'Llingua', 'settings.systemLanguage' => 'Llingua del sistema', @@ -1563,6 +1597,28 @@ extension on TranslationsAst { 'wot.reset' => 'Reafitar a valores Duniter', 'wot.saved' => 'Guardáu', 'notifications.newMessageFrom' => ({required Object name}) => 'Mensaxe nuevu de ${name}', + 'plantare.title' => 'Plantares', + 'plantare.help' => 'Un Plantare ye\'l compromisu de reproducir una semilla y devolver una parte — asina una variedá sigue viaxando de mano en mano. Nun ye una venta.', + 'plantare.add' => 'Amestar compromisu', + 'plantare.empty' => 'Entá nun hai compromisos. Cuando compartas o recibas semilla col compromisu de reproducila y devolver daqué, anótalo equí.', + 'plantare.iReturn' => 'Reprodúzola y devuélvola yo', + 'plantare.owedToMe' => 'Devuélvenmela a min', + 'plantare.direction' => 'Quién reproduz y devuelve', + 'plantare.counterparty' => '¿Con quién?', + 'plantare.counterpartyHint' => 'Una persona o un coleutivu (opcional)', + 'plantare.owed' => '¿Qué se devuelve?', + 'plantare.owedHint' => 'p. ex. un puñáu la próxima temporada (opcional)', + 'plantare.note' => 'Nota (opcional)', + 'plantare.save' => 'Guardar', + 'plantare.markReturned' => 'Marcar devueltu', + 'plantare.markForgiven' => 'Dar por saldáu', + 'plantare.reopen' => 'Reabrir', + 'plantare.delete' => 'Quitar', + 'plantare.statusReturned' => 'Devueltu', + 'plantare.statusForgiven' => 'Saldáu', + 'plantare.openSection' => 'Pendientes', + 'plantare.settledSection' => 'Fechos', + 'plantare.removeConfirm' => '¿Quitar esti compromisu?', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 097d5d2..68ea9ef 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -78,6 +78,7 @@ class Translations with BaseTranslations { late final Translations$trust$en trust = Translations$trust$en.internal(_root); late final Translations$wot$en wot = Translations$wot$en.internal(_root); late final Translations$notifications$en notifications = Translations$notifications$en.internal(_root); + late final Translations$plantare$en plantare = Translations$plantare$en.internal(_root); } // Path: app @@ -214,6 +215,9 @@ class Translations$menu$en { /// en: 'Following' String get following => 'Following'; + /// en: 'Plantares' + String get plantares => 'Plantares'; + /// en: 'Settings' String get settings => 'Settings'; } @@ -1511,6 +1515,81 @@ class Translations$notifications$en { String newMessageFrom({required Object name}) => 'New message from ${name}'; } +// Path: plantare +class Translations$plantare$en { + Translations$plantare$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Plantares' + String get title => 'Plantares'; + + /// en: 'A Plantare is a promise to reproduce a seed and return some — how a variety keeps travelling from hand to hand. It's not a sale.' + String get help => 'A Plantare is a promise to reproduce a seed and return some — how a variety keeps travelling from hand to hand. It\'s not a sale.'; + + /// en: 'Add a commitment' + String get add => 'Add a commitment'; + + /// en: 'No commitments yet. When you share or receive seed with a promise to grow it out and return some, note it here.' + String get empty => 'No commitments yet. When you share or receive seed with a promise to grow it out and return some, note it here.'; + + /// en: 'I'll grow it out & return some' + String get iReturn => 'I\'ll grow it out & return some'; + + /// en: 'They'll return some to me' + String get owedToMe => 'They\'ll return some to me'; + + /// en: 'Who reproduces & returns' + String get direction => 'Who reproduces & returns'; + + /// en: 'With whom?' + String get counterparty => 'With whom?'; + + /// en: 'A person or a collective (optional)' + String get counterpartyHint => 'A person or a collective (optional)'; + + /// en: 'What comes back?' + String get owed => 'What comes back?'; + + /// en: 'e.g. a handful next season (optional)' + String get owedHint => 'e.g. a handful next season (optional)'; + + /// en: 'Note (optional)' + String get note => 'Note (optional)'; + + /// en: 'Save' + String get save => 'Save'; + + /// en: 'Mark returned' + String get markReturned => 'Mark returned'; + + /// en: 'Let it go' + String get markForgiven => 'Let it go'; + + /// en: 'Reopen' + String get reopen => 'Reopen'; + + /// en: 'Remove' + String get delete => 'Remove'; + + /// en: 'Returned' + String get statusReturned => 'Returned'; + + /// en: 'Settled' + String get statusForgiven => 'Settled'; + + /// en: 'Open' + String get openSection => 'Open'; + + /// en: 'Done' + String get settledSection => 'Done'; + + /// en: 'Remove this commitment?' + String get removeConfirm => 'Remove this commitment?'; +} + // Path: intro.slides class Translations$intro$slides$en { Translations$intro$slides$en.internal(this._root); @@ -1995,6 +2074,7 @@ extension on Translations { 'menu.chat' => 'Chat', 'menu.wishlist' => 'Wishlist', 'menu.following' => 'Following', + 'menu.plantares' => 'Plantares', 'menu.settings' => 'Settings', 'settings.language' => 'Language', 'settings.systemLanguage' => 'System language', @@ -2387,6 +2467,28 @@ extension on Translations { 'wot.reset' => 'Reset to Duniter defaults', 'wot.saved' => 'Saved', 'notifications.newMessageFrom' => ({required Object name}) => 'New message from ${name}', + 'plantare.title' => 'Plantares', + 'plantare.help' => 'A Plantare is a promise to reproduce a seed and return some — how a variety keeps travelling from hand to hand. It\'s not a sale.', + 'plantare.add' => 'Add a commitment', + 'plantare.empty' => 'No commitments yet. When you share or receive seed with a promise to grow it out and return some, note it here.', + 'plantare.iReturn' => 'I\'ll grow it out & return some', + 'plantare.owedToMe' => 'They\'ll return some to me', + 'plantare.direction' => 'Who reproduces & returns', + 'plantare.counterparty' => 'With whom?', + 'plantare.counterpartyHint' => 'A person or a collective (optional)', + 'plantare.owed' => 'What comes back?', + 'plantare.owedHint' => 'e.g. a handful next season (optional)', + 'plantare.note' => 'Note (optional)', + 'plantare.save' => 'Save', + 'plantare.markReturned' => 'Mark returned', + 'plantare.markForgiven' => 'Let it go', + 'plantare.reopen' => 'Reopen', + 'plantare.delete' => 'Remove', + 'plantare.statusReturned' => 'Returned', + 'plantare.statusForgiven' => 'Settled', + 'plantare.openSection' => 'Open', + 'plantare.settledSection' => 'Done', + 'plantare.removeConfirm' => 'Remove this commitment?', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 0e72da2..5c12cbc 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -77,6 +77,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Chat'; @override String get wishlist => 'Lista de deseos'; @override String get following => 'Siguiendo'; + @override String get plantares => 'Plantares'; @override String get settings => 'Ajustes'; } @@ -808,6 +810,37 @@ class _Translations$notifications$es extends Translations$notifications$en { @override String newMessageFrom({required Object name}) => 'Nuevo mensaje de ${name}'; } +// Path: plantare +class _Translations$plantare$es extends Translations$plantare$en { + _Translations$plantare$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Plantares'; + @override String get help => 'Un Plantare es el compromiso de reproducir una semilla y devolver una parte — así una variedad sigue viajando de mano en mano. No es una venta.'; + @override String get add => 'Añadir compromiso'; + @override String get empty => 'Aún no hay compromisos. Cuando compartas o recibas semilla con el compromiso de reproducirla y devolver algo, anótalo aquí.'; + @override String get iReturn => 'La reproduzco y devuelvo yo'; + @override String get owedToMe => 'Me la devuelven a mí'; + @override String get direction => 'Quién reproduce y devuelve'; + @override String get counterparty => '¿Con quién?'; + @override String get counterpartyHint => 'Una persona o un colectivo (opcional)'; + @override String get owed => '¿Qué se devuelve?'; + @override String get owedHint => 'p. ej. un puñado la próxima temporada (opcional)'; + @override String get note => 'Nota (opcional)'; + @override String get save => 'Guardar'; + @override String get markReturned => 'Marcar devuelto'; + @override String get markForgiven => 'Dar por saldado'; + @override String get reopen => 'Reabrir'; + @override String get delete => 'Quitar'; + @override String get statusReturned => 'Devuelto'; + @override String get statusForgiven => 'Saldado'; + @override String get openSection => 'Pendientes'; + @override String get settledSection => 'Hechos'; + @override String get removeConfirm => '¿Quitar este compromiso?'; +} + // Path: intro.slides class _Translations$intro$slides$es extends Translations$intro$slides$en { _Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -1176,6 +1209,7 @@ extension on TranslationsEs { 'menu.chat' => 'Chat', 'menu.wishlist' => 'Lista de deseos', 'menu.following' => 'Siguiendo', + 'menu.plantares' => 'Plantares', 'menu.settings' => 'Ajustes', 'settings.language' => 'Idioma', 'settings.systemLanguage' => 'Idioma del sistema', @@ -1567,6 +1601,28 @@ extension on TranslationsEs { 'wot.reset' => 'Restablecer a valores Duniter', 'wot.saved' => 'Guardado', 'notifications.newMessageFrom' => ({required Object name}) => 'Nuevo mensaje de ${name}', + 'plantare.title' => 'Plantares', + 'plantare.help' => 'Un Plantare es el compromiso de reproducir una semilla y devolver una parte — así una variedad sigue viajando de mano en mano. No es una venta.', + 'plantare.add' => 'Añadir compromiso', + 'plantare.empty' => 'Aún no hay compromisos. Cuando compartas o recibas semilla con el compromiso de reproducirla y devolver algo, anótalo aquí.', + 'plantare.iReturn' => 'La reproduzco y devuelvo yo', + 'plantare.owedToMe' => 'Me la devuelven a mí', + 'plantare.direction' => 'Quién reproduce y devuelve', + 'plantare.counterparty' => '¿Con quién?', + 'plantare.counterpartyHint' => 'Una persona o un colectivo (opcional)', + 'plantare.owed' => '¿Qué se devuelve?', + 'plantare.owedHint' => 'p. ej. un puñado la próxima temporada (opcional)', + 'plantare.note' => 'Nota (opcional)', + 'plantare.save' => 'Guardar', + 'plantare.markReturned' => 'Marcar devuelto', + 'plantare.markForgiven' => 'Dar por saldado', + 'plantare.reopen' => 'Reabrir', + 'plantare.delete' => 'Quitar', + 'plantare.statusReturned' => 'Devuelto', + 'plantare.statusForgiven' => 'Saldado', + 'plantare.openSection' => 'Pendientes', + 'plantare.settledSection' => 'Hechos', + 'plantare.removeConfirm' => '¿Quitar este compromiso?', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index f5663ac..26c0e68 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -77,6 +77,7 @@ class TranslationsPt extends Translations with BaseTranslations 'Conversas'; @override String get wishlist => 'Lista de desejos'; @override String get following => 'A seguir'; + @override String get plantares => 'Plantares'; @override String get settings => 'Definições'; } @@ -805,6 +807,37 @@ class _Translations$notifications$pt extends Translations$notifications$en { @override String newMessageFrom({required Object name}) => 'Nova mensagem de ${name}'; } +// Path: plantare +class _Translations$plantare$pt extends Translations$plantare$en { + _Translations$plantare$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Plantares'; + @override String get help => 'Um Plantare é o compromisso de reproduzir uma semente e devolver uma parte — assim uma variedade continua a viajar de mão em mão. Não é uma venda.'; + @override String get add => 'Adicionar compromisso'; + @override String get empty => 'Ainda não há compromissos. Quando partilhares ou receberes semente com o compromisso de a reproduzir e devolver algo, anota-o aqui.'; + @override String get iReturn => 'Reproduzo e devolvo eu'; + @override String get owedToMe => 'Devolvem-me a mim'; + @override String get direction => 'Quem reproduz e devolve'; + @override String get counterparty => 'Com quem?'; + @override String get counterpartyHint => 'Uma pessoa ou um coletivo (opcional)'; + @override String get owed => 'O que é devolvido?'; + @override String get owedHint => 'p. ex. um punhado na próxima temporada (opcional)'; + @override String get note => 'Nota (opcional)'; + @override String get save => 'Guardar'; + @override String get markReturned => 'Marcar devolvido'; + @override String get markForgiven => 'Dar por saldado'; + @override String get reopen => 'Reabrir'; + @override String get delete => 'Remover'; + @override String get statusReturned => 'Devolvido'; + @override String get statusForgiven => 'Saldado'; + @override String get openSection => 'Pendentes'; + @override String get settledSection => 'Feitos'; + @override String get removeConfirm => 'Remover este compromisso?'; +} + // Path: intro.slides class _Translations$intro$slides$pt extends Translations$intro$slides$en { _Translations$intro$slides$pt._(TranslationsPt root) : this._root = root, super.internal(root); @@ -1173,6 +1206,7 @@ extension on TranslationsPt { 'menu.chat' => 'Conversas', 'menu.wishlist' => 'Lista de desejos', 'menu.following' => 'A seguir', + 'menu.plantares' => 'Plantares', 'menu.settings' => 'Definições', 'settings.language' => 'Idioma', 'settings.systemLanguage' => 'Idioma do sistema', @@ -1561,6 +1595,28 @@ extension on TranslationsPt { 'wot.reset' => 'Repor para valores Duniter', 'wot.saved' => 'Guardado', 'notifications.newMessageFrom' => ({required Object name}) => 'Nova mensagem de ${name}', + 'plantare.title' => 'Plantares', + 'plantare.help' => 'Um Plantare é o compromisso de reproduzir uma semente e devolver uma parte — assim uma variedade continua a viajar de mão em mão. Não é uma venda.', + 'plantare.add' => 'Adicionar compromisso', + 'plantare.empty' => 'Ainda não há compromissos. Quando partilhares ou receberes semente com o compromisso de a reproduzir e devolver algo, anota-o aqui.', + 'plantare.iReturn' => 'Reproduzo e devolvo eu', + 'plantare.owedToMe' => 'Devolvem-me a mim', + 'plantare.direction' => 'Quem reproduz e devolve', + 'plantare.counterparty' => 'Com quem?', + 'plantare.counterpartyHint' => 'Uma pessoa ou um coletivo (opcional)', + 'plantare.owed' => 'O que é devolvido?', + 'plantare.owedHint' => 'p. ex. um punhado na próxima temporada (opcional)', + 'plantare.note' => 'Nota (opcional)', + 'plantare.save' => 'Guardar', + 'plantare.markReturned' => 'Marcar devolvido', + 'plantare.markForgiven' => 'Dar por saldado', + 'plantare.reopen' => 'Reabrir', + 'plantare.delete' => 'Remover', + 'plantare.statusReturned' => 'Devolvido', + 'plantare.statusForgiven' => 'Saldado', + 'plantare.openSection' => 'Pendentes', + 'plantare.settledSection' => 'Feitos', + 'plantare.removeConfirm' => 'Remover este compromisso?', _ => null, }; } diff --git a/apps/app_seeds/lib/ui/app_drawer.dart b/apps/app_seeds/lib/ui/app_drawer.dart index 799a972..0159156 100644 --- a/apps/app_seeds/lib/ui/app_drawer.dart +++ b/apps/app_seeds/lib/ui/app_drawer.dart @@ -35,6 +35,15 @@ class AppDrawer extends StatelessWidget { context.push('/inventory'); }, ), + // Local, works offline — a reproduction-commitment ledger. + _DrawerItem( + icon: const Icon(Icons.volunteer_activism_outlined), + label: t.menu.plantares, + onTap: () { + Navigator.of(context).pop(); + context.push('/plantares'); + }, + ), _DrawerItem( icon: const Icon(Symbols.storefront), label: t.menu.market, diff --git a/apps/app_seeds/lib/ui/plantare_sheet.dart b/apps/app_seeds/lib/ui/plantare_sheet.dart new file mode 100644 index 0000000..ef8d600 --- /dev/null +++ b/apps/app_seeds/lib/ui/plantare_sheet.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; + +import '../data/variety_repository.dart'; +import '../db/enums.dart'; +import '../i18n/strings.g.dart'; +import 'theme.dart'; + +/// Opens the "add a Plantare" sheet — a reproduction commitment. Optionally +/// pre-attached to [varietyId] (when opened from a variety's detail). +Future showPlantareSheet( + BuildContext context, { + required VarietyRepository repository, + String? varietyId, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _PlantareSheet(repository: repository, varietyId: varietyId), + ); +} + +class _PlantareSheet extends StatefulWidget { + const _PlantareSheet({required this.repository, this.varietyId}); + + final VarietyRepository repository; + final String? varietyId; + + @override + State<_PlantareSheet> createState() => _PlantareSheetState(); +} + +class _PlantareSheetState extends State<_PlantareSheet> { + PlantareDirection _direction = PlantareDirection.iReturn; + final _counterparty = TextEditingController(); + final _owed = TextEditingController(); + final _note = TextEditingController(); + bool _saving = false; + + @override + void dispose() { + _counterparty.dispose(); + _owed.dispose(); + _note.dispose(); + super.dispose(); + } + + String? _nullIfBlank(String s) => s.trim().isEmpty ? null : s.trim(); + + Future _save() async { + setState(() => _saving = true); + await widget.repository.createPlantare( + direction: _direction, + varietyId: widget.varietyId, + counterparty: _nullIfBlank(_counterparty.text), + owedDescription: _nullIfBlank(_owed.text), + note: _nullIfBlank(_note.text), + ); + if (mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(t.plantare.add, + style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 4), + Text(t.plantare.help, + style: const TextStyle(color: seedMuted, fontSize: 13)), + const SizedBox(height: 16), + Text(t.plantare.direction, + style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 6), + // Two big, human choices — no jargon. + for (final d in PlantareDirection.values) + ListTile( + key: Key('plantare.direction.${d.name}'), + contentPadding: EdgeInsets.zero, + leading: Icon( + _direction == d + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: _direction == d ? seedGreen : seedMuted, + ), + title: Text(d == PlantareDirection.iReturn + ? t.plantare.iReturn + : t.plantare.owedToMe), + onTap: () => setState(() => _direction = d), + ), + const SizedBox(height: 8), + TextField( + key: const Key('plantare.counterparty'), + controller: _counterparty, + textCapitalization: TextCapitalization.words, + decoration: InputDecoration( + labelText: t.plantare.counterparty, + helperText: t.plantare.counterpartyHint, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + key: const Key('plantare.owed'), + controller: _owed, + decoration: InputDecoration( + labelText: t.plantare.owed, + helperText: t.plantare.owedHint, + helperMaxLines: 2, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + key: const Key('plantare.note'), + controller: _note, + minLines: 1, + maxLines: 3, + decoration: InputDecoration( + labelText: t.plantare.note, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 20), + FilledButton( + key: const Key('plantare.save'), + onPressed: _saving ? null : _save, + child: Text(t.plantare.save), + ), + ], + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/plantares_screen.dart b/apps/app_seeds/lib/ui/plantares_screen.dart new file mode 100644 index 0000000..d2bb348 --- /dev/null +++ b/apps/app_seeds/lib/ui/plantares_screen.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../data/variety_repository.dart'; +import '../db/database.dart'; +import '../db/enums.dart'; +import '../i18n/strings.g.dart'; +import 'plantare_sheet.dart'; +import 'theme.dart'; + +/// The Plantares screen — your reproduction commitments (data-model §2.7). +/// Lists what's open (still to grow out & return) and what's done, and lets you +/// add or settle one. Local-first; reads the encrypted inventory DB. +class PlantaresScreen extends StatelessWidget { + const PlantaresScreen({super.key}); + + @override + Widget build(BuildContext context) { + final t = context.t; + final repo = context.read(); + return Scaffold( + appBar: AppBar(title: Text(t.plantare.title)), + floatingActionButton: FloatingActionButton.extended( + key: const Key('plantares.add'), + onPressed: () => showPlantareSheet(context, repository: repo), + icon: const Icon(Icons.add), + label: Text(t.plantare.add), + ), + body: StreamBuilder>( + stream: repo.watchPlantares(), + builder: (context, snapshot) { + final all = snapshot.data; + if (all == null) { + return const Center(child: CircularProgressIndicator()); + } + if (all.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + t.plantare.empty, + textAlign: TextAlign.center, + style: const TextStyle(color: seedMuted, fontSize: 15), + ), + ), + ); + } + final open = all.where((p) => p.status == PlantareStatus.open); + final settled = all.where((p) => p.status != PlantareStatus.open); + return ListView( + padding: const EdgeInsets.only(bottom: 88), + children: [ + if (open.isNotEmpty) _SectionHeader(t.plantare.openSection), + for (final p in open) _PlantareTile(p, repo), + if (settled.isNotEmpty) _SectionHeader(t.plantare.settledSection), + for (final p in settled) _PlantareTile(p, repo), + ], + ); + }, + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader(this.label); + final String label; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + label.toUpperCase(), + style: const TextStyle( + color: seedMuted, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ); +} + +/// One commitment row. An open one shows a big "mark returned"; a settled one +/// reads muted. The overflow menu carries the rest (let go / reopen / remove). +class _PlantareTile extends StatelessWidget { + const _PlantareTile(this.p, this.repo); + + final Plantare p; + final VarietyRepository repo; + + @override + Widget build(BuildContext context) { + final t = context.t; + final done = p.status != PlantareStatus.open; + final iReturn = p.direction == PlantareDirection.iReturn; + final title = (p.owedDescription?.trim().isNotEmpty ?? false) + ? p.owedDescription!.trim() + : (iReturn ? t.plantare.iReturn : t.plantare.owedToMe); + final subtitleParts = [ + iReturn ? t.plantare.iReturn : t.plantare.owedToMe, + if (p.counterparty?.trim().isNotEmpty ?? false) p.counterparty!.trim(), + if (done) + p.status == PlantareStatus.returned + ? t.plantare.statusReturned + : t.plantare.statusForgiven, + ]; + return ListTile( + leading: Icon( + iReturn ? Icons.volunteer_activism_outlined : Icons.eco_outlined, + color: done ? seedMuted : seedGreen, + ), + title: Text( + title, + style: TextStyle( + decoration: done ? TextDecoration.lineThrough : null, + color: done ? seedMuted : seedOnSurface, + ), + ), + subtitle: Text(subtitleParts.join(' · '), + maxLines: 2, overflow: TextOverflow.ellipsis), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!done) + IconButton( + key: Key('plantare.return.${p.id}'), + icon: const Icon(Icons.check_circle_outline), + tooltip: t.plantare.markReturned, + color: seedGreen, + onPressed: () => + repo.setPlantareStatus(p.id, PlantareStatus.returned), + ), + PopupMenuButton( + key: Key('plantare.menu.${p.id}'), + onSelected: (v) async { + switch (v) { + case 'forgiven': + await repo.setPlantareStatus(p.id, PlantareStatus.forgiven); + case 'reopen': + await repo.setPlantareStatus(p.id, PlantareStatus.open); + case 'delete': + await repo.deletePlantare(p.id); + } + }, + itemBuilder: (context) => [ + if (!done) + PopupMenuItem( + value: 'forgiven', child: Text(t.plantare.markForgiven)), + if (done) + PopupMenuItem( + value: 'reopen', child: Text(t.plantare.reopen)), + PopupMenuItem(value: 'delete', child: Text(t.plantare.delete)), + ], + ), + ], + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index f34df29..efae7f7 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -16,6 +16,7 @@ import '../i18n/strings.g.dart'; import '../state/variety_detail_cubit.dart'; import 'harvest_date_picker.dart'; import 'photo_pick.dart'; +import 'plantare_sheet.dart'; import 'quantity_kind_l10n.dart'; import 'quantity_picker.dart'; import 'seed_glyph.dart'; @@ -108,6 +109,21 @@ class _DetailView extends StatelessWidget { ), ], ), + const SizedBox(height: 16), + _SectionTitle(t.plantare.title), + Align( + alignment: AlignmentDirectional.centerStart, + child: OutlinedButton.icon( + key: const Key('detail.addPlantare'), + icon: const Icon(Icons.volunteer_activism_outlined, size: 18), + onPressed: () => showPlantareSheet( + context, + repository: context.read(), + varietyId: detail.id, + ), + label: Text(t.plantare.add), + ), + ), if (detail.notes != null && detail.notes!.trim().isNotEmpty) ...[ const SizedBox(height: 16), _SectionTitle(t.detail.notes), diff --git a/apps/app_seeds/test/ui/small_screen_overflow_test.dart b/apps/app_seeds/test/ui/small_screen_overflow_test.dart index a2f9217..4e9faf3 100644 --- a/apps/app_seeds/test/ui/small_screen_overflow_test.dart +++ b/apps/app_seeds/test/ui/small_screen_overflow_test.dart @@ -15,6 +15,7 @@ import 'package:tane/ui/home_screen.dart'; import 'package:tane/ui/intro_screen.dart'; import 'package:tane/ui/inventory_list_screen.dart'; import 'package:tane/ui/market_screen.dart'; +import 'package:tane/ui/plantares_screen.dart'; import 'package:tane/ui/profile_screen.dart'; import 'package:tane/ui/settings_screen.dart'; @@ -236,6 +237,27 @@ void main() { await disposeTree(tester); }); + testWidgets('the Plantares screen (with a commitment) fits a small screen', + (tester) async { + final db = newTestDatabase(); + addTearDown(db.close); + final repo = newTestRepository(db); + await repo.createPlantare( + direction: PlantareDirection.iReturn, + counterparty: 'Colectivo semillero de la comarca', + owedDescription: 'un puñado la próxima temporada, si germina bien', + ); + await pumpSmall( + tester, + wrapScreen( + repository: repo, + locale: AppLocale.es, + child: const PlantaresScreen(), + ), + ); + await disposeTree(tester); + }); + // NOTE: the variety-detail screen is deliberately NOT overflow-checked here. // It reports a ~2px overflow on a RenderFlex that is already DISPOSED/DEFUNCT // — a transient stale frame while its cubit's Drift stream rebuilds during