From 42c16c0e3fece8f19ec98f4395a59f32930c3b51 Mon Sep 17 00:00:00 2001 From: vjrj Date: Thu, 9 Jul 2026 11:51:59 +0200 Subject: [PATCH] feat(ui): Material 3 redesign, cover-photo viewer, About screen Apply a Material 3 seed-green palette and rounded components across the home, inventory, quick-add, quantity picker and variety-detail screens. The full-screen photo viewer can now set any photo as the cover (via attachment sort order) and delete after confirmation. Lot forms and packaging surface as choice chips. Extract About out of Settings into its own screen (app version via package_info_plus, website link). Add es/en strings for the new lot types, presentation, home tagline and About. Update Seedees v2 mockups. --- apps/app_seeds/lib/app.dart | 5 + apps/app_seeds/lib/i18n/en.i18n.json | 45 +- apps/app_seeds/lib/i18n/es.i18n.json | 45 +- apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_en.g.dart | 200 ++++++++- apps/app_seeds/lib/i18n/strings_es.g.dart | 126 +++++- apps/app_seeds/lib/ui/about_screen.dart | 179 ++++++++ apps/app_seeds/lib/ui/app_drawer.dart | 165 ++++--- apps/app_seeds/lib/ui/home_screen.dart | 303 +++++++++---- .../lib/ui/inventory_list_screen.dart | 19 +- apps/app_seeds/lib/ui/quantity_kind_l10n.dart | 3 + apps/app_seeds/lib/ui/quantity_picker.dart | 108 +++-- apps/app_seeds/lib/ui/quick_add_sheet.dart | 138 +++--- apps/app_seeds/lib/ui/seed_glyph.dart | 38 +- apps/app_seeds/lib/ui/settings_screen.dart | 8 +- apps/app_seeds/lib/ui/theme.dart | 95 +++- .../lib/ui/variety_detail_screen.dart | 405 ++++++++++++------ apps/app_seeds/test/ui/about_screen_test.dart | 83 ++++ apps/app_seeds/test/ui/home_screen_test.dart | 32 +- .../test/ui/variety_detail_screen_test.dart | 119 ++++- docs/mockups/index.html | 2 +- docs/mockups/seedees_material3.html | 254 +++++++++++ .../mockups/seedees_material3_assets/logo.png | Bin 0 -> 17094 bytes .../seedees_material3_assets/seedks.ttf | Bin 0 -> 11016 bytes 24 files changed, 1960 insertions(+), 416 deletions(-) create mode 100644 apps/app_seeds/lib/ui/about_screen.dart create mode 100644 apps/app_seeds/test/ui/about_screen_test.dart create mode 100644 docs/mockups/seedees_material3.html create mode 100644 docs/mockups/seedees_material3_assets/logo.png create mode 100644 docs/mockups/seedees_material3_assets/seedks.ttf diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 57bae14..f0770df 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -9,6 +9,7 @@ import 'data/variety_repository.dart'; import 'i18n/strings.g.dart'; import 'state/inventory_cubit.dart'; import 'state/variety_detail_cubit.dart'; +import 'ui/about_screen.dart'; import 'ui/home_screen.dart'; import 'ui/inventory_list_screen.dart'; import 'ui/settings_screen.dart'; @@ -33,6 +34,10 @@ class TaneApp extends StatelessWidget { path: '/settings', builder: (context, state) => const SettingsScreen(), ), + GoRoute( + path: '/about', + builder: (context, state) => const AboutScreen(), + ), GoRoute( path: '/inventory', builder: (context, state) => BlocProvider( diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 1493db7..4ee1fa9 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -11,14 +11,21 @@ "comingSoon": "Coming soon" }, "home": { + "tagline": "Share and grow local seeds", "openMarket": "Open market", - "yourInventory": "Your inventory" + "openMarketSubtitle": "Browse and exchange", + "yourInventory": "Your inventory", + "yourInventorySubtitle": "Manage your seeds" }, "photo": { "camera": "Take a photo", - "gallery": "Choose from gallery" + "gallery": "Choose from gallery", + "setAsCover": "Set as cover", + "isCover": "Cover photo", + "deleteConfirm": "Delete this photo?" }, "menu": { + "tagline": "your seed bank", "inventory": "Inventory", "market": "Market", "profile": "Your profile", @@ -33,7 +40,21 @@ "langEs": "Español", "langEn": "English", "about": "About", - "aboutText": "Local-first, encrypted inventory for traditional seeds. AGPL-3.0." + "aboutText": "Local-first, encrypted inventory for traditional seeds. AGPL-3.0.", + "aboutOpen": "About Tanemaki" + }, + "about": { + "title": "About", + "kanji": "種まき", + "tagline": "A local-first, decentralized app for managing and sharing traditional seeds and seedlings.", + "intro": "Tanemaki (種まき, \"to sow / scatter seeds\" in Japanese; short name Tane, 種 \"seed\") helps people and collectives keep a friendly inventory of their seed bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. The goal is both practical and political: to support traditional seed varieties and push back against the seed monopoly.", + "heritage": "The name honors the old Japanese mutual-aid traditions around rice — yui (shared community labour) and tanomoshi (reciprocity funds) — that inspired the paper Plantare, the \"community currency for seed exchange\" (BAH-Semillero, 2009, CC-BY-SA). Tanemaki is the digital Plantare.", + "version": "Version", + "license": "License", + "licenseValue": "AGPL-3.0", + "website": "Website", + "openSourceLicenses": "Open-source licenses", + "openSourceLicensesSubtitle": "Third-party libraries and their licenses" }, "inventory": { "title": "Inventory", @@ -101,7 +122,20 @@ }, "lotType": { "seed": "Seeds", - "plant": "Plants" + "plant": "Plant", + "seedling": "Seedling", + "tree": "Tree / shrub", + "bulb": "Bulb / tuber", + "cutting": "Cutting" + }, + "presentation": { + "title": "Packaging", + "none": "Unspecified", + "pot": "Pot", + "tray": "Tray", + "plug": "Plug", + "bareRoot": "Bare-root", + "rootBall": "Root-ball" }, "unit": { "aFew": "a few", @@ -127,6 +161,9 @@ "plant": { "singular": "plant", "plural": "plants" }, "pot": { "singular": "pot", "plural": "pots" }, "tray": { "singular": "tray", "plural": "trays" }, + "seedling": { "singular": "seedling", "plural": "seedlings" }, + "tree": { "singular": "tree", "plural": "trees" }, + "cutting": { "singular": "cutting", "plural": "cuttings" }, "grams": { "singular": "gram", "plural": "grams" }, "count": { "singular": "seed", "plural": "seeds" } } diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 69e0d2e..316a4e2 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -11,14 +11,21 @@ "comingSoon": "Pronto" }, "home": { + "tagline": "Comparte y cultiva semillas locales", "openMarket": "Mercado abierto", - "yourInventory": "Tu inventario" + "openMarketSubtitle": "Explora e intercambia", + "yourInventory": "Tu inventario", + "yourInventorySubtitle": "Gestiona tus semillas" }, "photo": { "camera": "Hacer una foto", - "gallery": "Elegir de la galería" + "gallery": "Elegir de la galería", + "setAsCover": "Poner de portada", + "isCover": "Foto de portada", + "deleteConfirm": "¿Borrar esta foto?" }, "menu": { + "tagline": "tu banco de semillas", "inventory": "Inventario", "market": "Mercado", "profile": "Tu perfil", @@ -33,7 +40,21 @@ "langEs": "Español", "langEn": "English", "about": "Acerca de", - "aboutText": "Inventario local y cifrado para semillas tradicionales. AGPL-3.0." + "aboutText": "Inventario local y cifrado para semillas tradicionales. AGPL-3.0.", + "aboutOpen": "Acerca de Tanemaki" + }, + "about": { + "title": "Acerca de", + "kanji": "種まき", + "tagline": "Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.", + "intro": "Tanemaki (種まき, «sembrar / esparcir semillas» en japonés; nombre corto Tane, 種 «semilla») ayuda a personas y colectivos a llevar un inventario amable de su banco de semillas, decidir qué ofrecen y compartirlo localmente — sin un intermediario central que pueda controlarlo, censurarlo o ser multado por ello. El objetivo es a la vez práctico y político: apoyar las variedades tradicionales de semillas y plantar cara al monopolio semillero.", + "heritage": "El nombre honra las viejas tradiciones japonesas de ayuda mutua en torno al arroz — yui (trabajo comunitario compartido) y tanomoshi (fondos de reciprocidad) — que inspiraron el papel Plantare, la «moneda comunitaria para el intercambio de semillas» (BAH-Semillero, 2009, CC-BY-SA). Tanemaki es el Plantare digital.", + "version": "Versión", + "license": "Licencia", + "licenseValue": "AGPL-3.0", + "website": "Sitio web", + "openSourceLicenses": "Licencias de código abierto", + "openSourceLicensesSubtitle": "Bibliotecas de terceros y sus licencias" }, "inventory": { "title": "Inventario", @@ -101,7 +122,20 @@ }, "lotType": { "seed": "Semillas", - "plant": "Plantel" + "plant": "Planta", + "seedling": "Plantón", + "tree": "Árbol / arbusto", + "bulb": "Bulbo / tubérculo", + "cutting": "Esqueje" + }, + "presentation": { + "title": "Envase", + "none": "Sin especificar", + "pot": "Maceta", + "tray": "Bandeja", + "plug": "Alvéolo", + "bareRoot": "Raíz desnuda", + "rootBall": "Cepellón" }, "unit": { "aFew": "unas pocas", @@ -127,6 +161,9 @@ "plant": { "singular": "planta", "plural": "plantas" }, "pot": { "singular": "maceta", "plural": "macetas" }, "tray": { "singular": "bandeja", "plural": "bandejas" }, + "seedling": { "singular": "plántula", "plural": "plántulas" }, + "tree": { "singular": "árbol", "plural": "árboles" }, + "cutting": { "singular": "esqueje", "plural": "esquejes" }, "grams": { "singular": "gramo", "plural": "gramos" }, "count": { "singular": "semilla", "plural": "semillas" } } diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 095cc16..1e189bb 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: 2 -/// Strings: 260 (130 per locale) +/// Strings: 332 (166 per locale) /// -/// Built on 2026-07-08 at 12:10 UTC +/// Built on 2026-07-09 at 09:25 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index e11c75a..344c748 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -46,6 +46,7 @@ class Translations with BaseTranslations { late final Translations$photo$en photo = Translations$photo$en.internal(_root); late final Translations$menu$en menu = Translations$menu$en.internal(_root); late final Translations$settings$en settings = Translations$settings$en.internal(_root); + late final Translations$about$en about = Translations$about$en.internal(_root); late final Translations$inventory$en inventory = Translations$inventory$en.internal(_root); late final Translations$quickAdd$en quickAdd = Translations$quickAdd$en.internal(_root); late final Translations$detail$en detail = Translations$detail$en.internal(_root); @@ -54,6 +55,7 @@ class Translations with BaseTranslations { late final Translations$addLot$en addLot = Translations$addLot$en.internal(_root); late final Translations$harvest$en harvest = Translations$harvest$en.internal(_root); late final Translations$lotType$en lotType = Translations$lotType$en.internal(_root); + late final Translations$presentation$en presentation = Translations$presentation$en.internal(_root); late final Translations$unit$en unit = Translations$unit$en.internal(_root); } @@ -104,11 +106,20 @@ class Translations$home$en { // Translations + /// en: 'Share and grow local seeds' + String get tagline => 'Share and grow local seeds'; + /// en: 'Open market' String get openMarket => 'Open market'; + /// en: 'Browse and exchange' + String get openMarketSubtitle => 'Browse and exchange'; + /// en: 'Your inventory' String get yourInventory => 'Your inventory'; + + /// en: 'Manage your seeds' + String get yourInventorySubtitle => 'Manage your seeds'; } // Path: photo @@ -124,6 +135,15 @@ class Translations$photo$en { /// en: 'Choose from gallery' String get gallery => 'Choose from gallery'; + + /// en: 'Set as cover' + String get setAsCover => 'Set as cover'; + + /// en: 'Cover photo' + String get isCover => 'Cover photo'; + + /// en: 'Delete this photo?' + String get deleteConfirm => 'Delete this photo?'; } // Path: menu @@ -134,6 +154,9 @@ class Translations$menu$en { // Translations + /// en: 'your seed bank' + String get tagline => 'your seed bank'; + /// en: 'Inventory' String get inventory => 'Inventory'; @@ -181,6 +204,51 @@ class Translations$settings$en { /// en: 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.' String get aboutText => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.'; + + /// en: 'About Tanemaki' + String get aboutOpen => 'About Tanemaki'; +} + +// Path: about +class Translations$about$en { + Translations$about$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'About' + String get title => 'About'; + + /// en: '種まき' + String get kanji => '種まき'; + + /// en: 'A local-first, decentralized app for managing and sharing traditional seeds and seedlings.' + String get tagline => 'A local-first, decentralized app for managing and sharing traditional seeds and seedlings.'; + + /// en: 'Tanemaki (種まき, "to sow / scatter seeds" in Japanese; short name Tane, 種 "seed") helps people and collectives keep a friendly inventory of their seed bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. The goal is both practical and political: to support traditional seed varieties and push back against the seed monopoly.' + String get intro => 'Tanemaki (種まき, "to sow / scatter seeds" in Japanese; short name Tane, 種 "seed") helps people and collectives keep a friendly inventory of their seed bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. The goal is both practical and political: to support traditional seed varieties and push back against the seed monopoly.'; + + /// en: 'The name honors the old Japanese mutual-aid traditions around rice — yui (shared community labour) and tanomoshi (reciprocity funds) — that inspired the paper Plantare, the "community currency for seed exchange" (BAH-Semillero, 2009, CC-BY-SA). Tanemaki is the digital Plantare.' + String get heritage => 'The name honors the old Japanese mutual-aid traditions around rice — yui (shared community labour) and tanomoshi (reciprocity funds) — that inspired the paper Plantare, the "community currency for seed exchange" (BAH-Semillero, 2009, CC-BY-SA). Tanemaki is the digital Plantare.'; + + /// en: 'Version' + String get version => 'Version'; + + /// en: 'License' + String get license => 'License'; + + /// en: 'AGPL-3.0' + String get licenseValue => 'AGPL-3.0'; + + /// en: 'Website' + String get website => 'Website'; + + /// en: 'Open-source licenses' + String get openSourceLicenses => 'Open-source licenses'; + + /// en: 'Third-party libraries and their licenses' + String get openSourceLicensesSubtitle => 'Third-party libraries and their licenses'; } // Path: inventory @@ -410,8 +478,50 @@ class Translations$lotType$en { /// en: 'Seeds' String get seed => 'Seeds'; - /// en: 'Plants' - String get plant => 'Plants'; + /// en: 'Plant' + String get plant => 'Plant'; + + /// en: 'Seedling' + String get seedling => 'Seedling'; + + /// en: 'Tree / shrub' + String get tree => 'Tree / shrub'; + + /// en: 'Bulb / tuber' + String get bulb => 'Bulb / tuber'; + + /// en: 'Cutting' + String get cutting => 'Cutting'; +} + +// Path: presentation +class Translations$presentation$en { + Translations$presentation$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Packaging' + String get title => 'Packaging'; + + /// en: 'Unspecified' + String get none => 'Unspecified'; + + /// en: 'Pot' + String get pot => 'Pot'; + + /// en: 'Tray' + String get tray => 'Tray'; + + /// en: 'Plug' + String get plug => 'Plug'; + + /// en: 'Bare-root' + String get bareRoot => 'Bare-root'; + + /// en: 'Root-ball' + String get rootBall => 'Root-ball'; } // Path: unit @@ -453,6 +563,9 @@ class Translations$unit$en { late final Translations$unit$plant$en plant = Translations$unit$plant$en.internal(_root); late final Translations$unit$pot$en pot = Translations$unit$pot$en.internal(_root); late final Translations$unit$tray$en tray = Translations$unit$tray$en.internal(_root); + late final Translations$unit$seedling$en seedling = Translations$unit$seedling$en.internal(_root); + late final Translations$unit$tree$en tree = Translations$unit$tree$en.internal(_root); + late final Translations$unit$cutting$en cutting = Translations$unit$cutting$en.internal(_root); late final Translations$unit$grams$en grams = Translations$unit$grams$en.internal(_root); late final Translations$unit$count$en count = Translations$unit$count$en.internal(_root); } @@ -742,6 +855,51 @@ class Translations$unit$tray$en { String get plural => 'trays'; } +// Path: unit.seedling +class Translations$unit$seedling$en { + Translations$unit$seedling$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'seedling' + String get singular => 'seedling'; + + /// en: 'seedlings' + String get plural => 'seedlings'; +} + +// Path: unit.tree +class Translations$unit$tree$en { + Translations$unit$tree$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'tree' + String get singular => 'tree'; + + /// en: 'trees' + String get plural => 'trees'; +} + +// Path: unit.cutting +class Translations$unit$cutting$en { + Translations$unit$cutting$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'cutting' + String get singular => 'cutting'; + + /// en: 'cuttings' + String get plural => 'cuttings'; +} + // Path: unit.grams class Translations$unit$grams$en { Translations$unit$grams$en.internal(this._root); @@ -787,10 +945,17 @@ extension on Translations { 'common.edit' => 'Edit', 'common.type' => 'Type', 'common.comingSoon' => 'Coming soon', + 'home.tagline' => 'Share and grow local seeds', 'home.openMarket' => 'Open market', + 'home.openMarketSubtitle' => 'Browse and exchange', 'home.yourInventory' => 'Your inventory', + 'home.yourInventorySubtitle' => 'Manage your seeds', 'photo.camera' => 'Take a photo', 'photo.gallery' => 'Choose from gallery', + 'photo.setAsCover' => 'Set as cover', + 'photo.isCover' => 'Cover photo', + 'photo.deleteConfirm' => 'Delete this photo?', + 'menu.tagline' => 'your seed bank', 'menu.inventory' => 'Inventory', 'menu.market' => 'Market', 'menu.profile' => 'Your profile', @@ -804,6 +969,18 @@ extension on Translations { 'settings.langEn' => 'English', 'settings.about' => 'About', 'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.', + 'settings.aboutOpen' => 'About Tanemaki', + 'about.title' => 'About', + 'about.kanji' => '種まき', + 'about.tagline' => 'A local-first, decentralized app for managing and sharing traditional seeds and seedlings.', + 'about.intro' => 'Tanemaki (種まき, "to sow / scatter seeds" in Japanese; short name Tane, 種 "seed") helps people and collectives keep a friendly inventory of their seed bank, decide what they offer, and share it locally — without a central intermediary that could control, censor, or be fined for it. The goal is both practical and political: to support traditional seed varieties and push back against the seed monopoly.', + 'about.heritage' => 'The name honors the old Japanese mutual-aid traditions around rice — yui (shared community labour) and tanomoshi (reciprocity funds) — that inspired the paper Plantare, the "community currency for seed exchange" (BAH-Semillero, 2009, CC-BY-SA). Tanemaki is the digital Plantare.', + 'about.version' => 'Version', + 'about.license' => 'License', + 'about.licenseValue' => 'AGPL-3.0', + 'about.website' => 'Website', + 'about.openSourceLicenses' => 'Open-source licenses', + 'about.openSourceLicensesSubtitle' => 'Third-party libraries and their licenses', 'inventory.title' => 'Inventory', 'inventory.searchHint' => 'Search seeds', 'inventory.empty' => 'No seeds yet. Tap + to add your first.', @@ -863,7 +1040,18 @@ extension on Translations { 'harvest.monthNames.10' => 'November', 'harvest.monthNames.11' => 'December', 'lotType.seed' => 'Seeds', - 'lotType.plant' => 'Plants', + 'lotType.plant' => 'Plant', + 'lotType.seedling' => 'Seedling', + 'lotType.tree' => 'Tree / shrub', + 'lotType.bulb' => 'Bulb / tuber', + 'lotType.cutting' => 'Cutting', + 'presentation.title' => 'Packaging', + 'presentation.none' => 'Unspecified', + 'presentation.pot' => 'Pot', + 'presentation.tray' => 'Tray', + 'presentation.plug' => 'Plug', + 'presentation.bareRoot' => 'Bare-root', + 'presentation.rootBall' => 'Root-ball', 'unit.aFew' => 'a few', 'unit.some' => 'some', 'unit.plenty' => 'plenty', @@ -906,6 +1094,12 @@ extension on Translations { 'unit.pot.plural' => 'pots', 'unit.tray.singular' => 'tray', 'unit.tray.plural' => 'trays', + 'unit.seedling.singular' => 'seedling', + 'unit.seedling.plural' => 'seedlings', + 'unit.tree.singular' => 'tree', + 'unit.tree.plural' => 'trees', + 'unit.cutting.singular' => 'cutting', + 'unit.cutting.plural' => 'cuttings', 'unit.grams.singular' => 'gram', 'unit.grams.plural' => 'grams', 'unit.count.singular' => 'seed', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 602737b..75e3581 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -45,6 +45,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Comparte y cultiva semillas locales'; @override String get openMarket => 'Mercado abierto'; + @override String get openMarketSubtitle => 'Explora e intercambia'; @override String get yourInventory => 'Tu inventario'; + @override String get yourInventorySubtitle => 'Gestiona tus semillas'; } // Path: photo @@ -101,6 +106,9 @@ class _Translations$photo$es extends Translations$photo$en { // Translations @override String get camera => 'Hacer una foto'; @override String get gallery => 'Elegir de la galería'; + @override String get setAsCover => 'Poner de portada'; + @override String get isCover => 'Foto de portada'; + @override String get deleteConfirm => '¿Borrar esta foto?'; } // Path: menu @@ -110,6 +118,7 @@ class _Translations$menu$es extends Translations$menu$en { final TranslationsEs _root; // ignore: unused_field // Translations + @override String get tagline => 'tu banco de semillas'; @override String get inventory => 'Inventario'; @override String get market => 'Mercado'; @override String get profile => 'Tu perfil'; @@ -132,6 +141,27 @@ class _Translations$settings$es extends Translations$settings$en { @override String get langEn => 'English'; @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'; +} + +// Path: about +class _Translations$about$es extends Translations$about$en { + _Translations$about$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Acerca de'; + @override String get kanji => '種まき'; + @override String get tagline => 'Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.'; + @override String get intro => 'Tanemaki (種まき, «sembrar / esparcir semillas» en japonés; nombre corto Tane, 種 «semilla») ayuda a personas y colectivos a llevar un inventario amable de su banco de semillas, decidir qué ofrecen y compartirlo localmente — sin un intermediario central que pueda controlarlo, censurarlo o ser multado por ello. El objetivo es a la vez práctico y político: apoyar las variedades tradicionales de semillas y plantar cara al monopolio semillero.'; + @override String get heritage => 'El nombre honra las viejas tradiciones japonesas de ayuda mutua en torno al arroz — yui (trabajo comunitario compartido) y tanomoshi (fondos de reciprocidad) — que inspiraron el papel Plantare, la «moneda comunitaria para el intercambio de semillas» (BAH-Semillero, 2009, CC-BY-SA). Tanemaki es el Plantare digital.'; + @override String get version => 'Versión'; + @override String get license => 'Licencia'; + @override String get licenseValue => 'AGPL-3.0'; + @override String get website => 'Sitio web'; + @override String get openSourceLicenses => 'Licencias de código abierto'; + @override String get openSourceLicensesSubtitle => 'Bibliotecas de terceros y sus licencias'; } // Path: inventory @@ -265,7 +295,27 @@ class _Translations$lotType$es extends Translations$lotType$en { // Translations @override String get seed => 'Semillas'; - @override String get plant => 'Plantel'; + @override String get plant => 'Planta'; + @override String get seedling => 'Plantón'; + @override String get tree => 'Árbol / arbusto'; + @override String get bulb => 'Bulbo / tubérculo'; + @override String get cutting => 'Esqueje'; +} + +// Path: presentation +class _Translations$presentation$es extends Translations$presentation$en { + _Translations$presentation$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Envase'; + @override String get none => 'Sin especificar'; + @override String get pot => 'Maceta'; + @override String get tray => 'Bandeja'; + @override String get plug => 'Alvéolo'; + @override String get bareRoot => 'Raíz desnuda'; + @override String get rootBall => 'Cepellón'; } // Path: unit @@ -298,6 +348,9 @@ class _Translations$unit$es extends Translations$unit$en { @override late final _Translations$unit$plant$es plant = _Translations$unit$plant$es._(_root); @override late final _Translations$unit$pot$es pot = _Translations$unit$pot$es._(_root); @override late final _Translations$unit$tray$es tray = _Translations$unit$tray$es._(_root); + @override late final _Translations$unit$seedling$es seedling = _Translations$unit$seedling$es._(_root); + @override late final _Translations$unit$tree$es tree = _Translations$unit$tree$es._(_root); + @override late final _Translations$unit$cutting$es cutting = _Translations$unit$cutting$es._(_root); @override late final _Translations$unit$grams$es grams = _Translations$unit$grams$es._(_root); @override late final _Translations$unit$count$es count = _Translations$unit$count$es._(_root); } @@ -511,6 +564,39 @@ class _Translations$unit$tray$es extends Translations$unit$tray$en { @override String get plural => 'bandejas'; } +// Path: unit.seedling +class _Translations$unit$seedling$es extends Translations$unit$seedling$en { + _Translations$unit$seedling$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get singular => 'plántula'; + @override String get plural => 'plántulas'; +} + +// Path: unit.tree +class _Translations$unit$tree$es extends Translations$unit$tree$en { + _Translations$unit$tree$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get singular => 'árbol'; + @override String get plural => 'árboles'; +} + +// Path: unit.cutting +class _Translations$unit$cutting$es extends Translations$unit$cutting$en { + _Translations$unit$cutting$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get singular => 'esqueje'; + @override String get plural => 'esquejes'; +} + // Path: unit.grams class _Translations$unit$grams$es extends Translations$unit$grams$en { _Translations$unit$grams$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -548,10 +634,17 @@ extension on TranslationsEs { 'common.edit' => 'Editar', 'common.type' => 'Tipo', 'common.comingSoon' => 'Pronto', + 'home.tagline' => 'Comparte y cultiva semillas locales', 'home.openMarket' => 'Mercado abierto', + 'home.openMarketSubtitle' => 'Explora e intercambia', 'home.yourInventory' => 'Tu inventario', + 'home.yourInventorySubtitle' => 'Gestiona tus semillas', 'photo.camera' => 'Hacer una foto', 'photo.gallery' => 'Elegir de la galería', + 'photo.setAsCover' => 'Poner de portada', + 'photo.isCover' => 'Foto de portada', + 'photo.deleteConfirm' => '¿Borrar esta foto?', + 'menu.tagline' => 'tu banco de semillas', 'menu.inventory' => 'Inventario', 'menu.market' => 'Mercado', 'menu.profile' => 'Tu perfil', @@ -565,6 +658,18 @@ extension on TranslationsEs { 'settings.langEn' => 'English', 'settings.about' => 'Acerca de', 'settings.aboutText' => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.', + 'settings.aboutOpen' => 'Acerca de Tanemaki', + 'about.title' => 'Acerca de', + 'about.kanji' => '種まき', + 'about.tagline' => 'Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.', + 'about.intro' => 'Tanemaki (種まき, «sembrar / esparcir semillas» en japonés; nombre corto Tane, 種 «semilla») ayuda a personas y colectivos a llevar un inventario amable de su banco de semillas, decidir qué ofrecen y compartirlo localmente — sin un intermediario central que pueda controlarlo, censurarlo o ser multado por ello. El objetivo es a la vez práctico y político: apoyar las variedades tradicionales de semillas y plantar cara al monopolio semillero.', + 'about.heritage' => 'El nombre honra las viejas tradiciones japonesas de ayuda mutua en torno al arroz — yui (trabajo comunitario compartido) y tanomoshi (fondos de reciprocidad) — que inspiraron el papel Plantare, la «moneda comunitaria para el intercambio de semillas» (BAH-Semillero, 2009, CC-BY-SA). Tanemaki es el Plantare digital.', + 'about.version' => 'Versión', + 'about.license' => 'Licencia', + 'about.licenseValue' => 'AGPL-3.0', + 'about.website' => 'Sitio web', + 'about.openSourceLicenses' => 'Licencias de código abierto', + 'about.openSourceLicensesSubtitle' => 'Bibliotecas de terceros y sus licencias', 'inventory.title' => 'Inventario', 'inventory.searchHint' => 'Buscar semillas', 'inventory.empty' => 'Aún no hay semillas. Toca + para añadir la primera.', @@ -624,7 +729,18 @@ extension on TranslationsEs { 'harvest.monthNames.10' => 'noviembre', 'harvest.monthNames.11' => 'diciembre', 'lotType.seed' => 'Semillas', - 'lotType.plant' => 'Plantel', + 'lotType.plant' => 'Planta', + 'lotType.seedling' => 'Plantón', + 'lotType.tree' => 'Árbol / arbusto', + 'lotType.bulb' => 'Bulbo / tubérculo', + 'lotType.cutting' => 'Esqueje', + 'presentation.title' => 'Envase', + 'presentation.none' => 'Sin especificar', + 'presentation.pot' => 'Maceta', + 'presentation.tray' => 'Bandeja', + 'presentation.plug' => 'Alvéolo', + 'presentation.bareRoot' => 'Raíz desnuda', + 'presentation.rootBall' => 'Cepellón', 'unit.aFew' => 'unas pocas', 'unit.some' => 'algunas', 'unit.plenty' => 'muchas', @@ -667,6 +783,12 @@ extension on TranslationsEs { 'unit.pot.plural' => 'macetas', 'unit.tray.singular' => 'bandeja', 'unit.tray.plural' => 'bandejas', + 'unit.seedling.singular' => 'plántula', + 'unit.seedling.plural' => 'plántulas', + 'unit.tree.singular' => 'árbol', + 'unit.tree.plural' => 'árboles', + 'unit.cutting.singular' => 'esqueje', + 'unit.cutting.plural' => 'esquejes', 'unit.grams.singular' => 'gramo', 'unit.grams.plural' => 'gramos', 'unit.count.singular' => 'semilla', diff --git a/apps/app_seeds/lib/ui/about_screen.dart b/apps/app_seeds/lib/ui/about_screen.dart new file mode 100644 index 0000000..fa72ac6 --- /dev/null +++ b/apps/app_seeds/lib/ui/about_screen.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../i18n/strings.g.dart'; +import 'theme.dart'; + +/// The app's public website. Shown as a tappable row in [AboutScreen]. +const String _websiteUrl = 'https://tanemaki.app'; + +/// An "about" screen à la Ğ1nkgo: brand header, the human explanation of what +/// Tanemaki is and where its name comes from, the app version, license, the +/// website, and the bundled open-source licenses page. +class AboutScreen extends StatelessWidget { + const AboutScreen({super.key}); + + @override + Widget build(BuildContext context) { + final t = context.t; + return Scaffold( + appBar: AppBar(title: Text(t.about.title)), + body: ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + const _Header(), + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 8), + child: Text( + t.about.tagline, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: seedOnSurface, + height: 1.35, + ), + ), + ), + const _Paragraph(), + const Divider(height: 32), + _InfoTile( + icon: Icons.info_outline, + label: t.about.version, + valueBuilder: (context) => const _VersionText(), + ), + _InfoTile( + icon: Icons.balance_outlined, + label: t.about.license, + value: t.about.licenseValue, + ), + ListTile( + leading: const Icon(Icons.public, color: seedGreen), + title: Text(t.about.website), + subtitle: const Text(_websiteUrl), + onTap: () => launchUrl( + Uri.parse(_websiteUrl), + mode: LaunchMode.externalApplication, + ), + ), + ListTile( + leading: const Icon(Icons.description_outlined, color: seedGreen), + title: Text(t.about.openSourceLicenses), + subtitle: Text(t.about.openSourceLicensesSubtitle), + onTap: () => showLicensePage( + context: context, + applicationName: t.app.title, + applicationIcon: Padding( + padding: const EdgeInsets.all(8), + child: Image.asset('assets/logo.png', width: 48), + ), + ), + ), + ], + ), + ); + } +} + +/// Brand block: the logo, the app name and its 種まき kanji. +class _Header extends StatelessWidget { + const _Header(); + + @override + Widget build(BuildContext context) { + final t = context.t; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 4), + child: Column( + children: [ + Image.asset('assets/logo.png', width: 88), + const SizedBox(height: 12), + Text( + t.app.title, + style: const TextStyle( + color: seedGreen, + fontSize: 28, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + Text( + t.about.kanji, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: seedGreen.withValues(alpha: 0.7), + ), + ), + ], + ), + ); + } +} + +/// The two explanatory paragraphs: what Tanemaki is, and the heritage of its +/// name (yui / tanomoshi / the paper Plantare). +class _Paragraph extends StatelessWidget { + const _Paragraph(); + + @override + Widget build(BuildContext context) { + final t = context.t; + final style = Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: seedOnSurface, height: 1.5); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.about.intro, style: style), + const SizedBox(height: 14), + Text(t.about.heritage, style: style), + ], + ), + ); + } +} + +/// A leading-icon row showing a static [value] or a [valueBuilder] widget. +class _InfoTile extends StatelessWidget { + const _InfoTile({ + required this.icon, + required this.label, + this.value, + this.valueBuilder, + }); + + final IconData icon; + final String label; + final String? value; + final WidgetBuilder? valueBuilder; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(icon, color: seedGreen), + title: Text(label), + trailing: value != null + ? Text(value!, style: Theme.of(context).textTheme.bodyMedium) + : valueBuilder?.call(context), + ); + } +} + +/// Reads the app version from the platform and renders it as `x.y.z (build)`. +class _VersionText extends StatelessWidget { + const _VersionText(); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) { + final info = snapshot.data; + final text = info == null + ? '—' + : '${info.version} (${info.buildNumber})'; + return Text(text, style: Theme.of(context).textTheme.bodyMedium); + }, + ); + } +} diff --git a/apps/app_seeds/lib/ui/app_drawer.dart b/apps/app_seeds/lib/ui/app_drawer.dart index 172cb46..d8f6102 100644 --- a/apps/app_seeds/lib/ui/app_drawer.dart +++ b/apps/app_seeds/lib/ui/app_drawer.dart @@ -6,9 +6,10 @@ import '../i18n/strings.g.dart'; import 'seed_glyph.dart'; import 'theme.dart'; -/// The app's navigation drawer (mockup 03). Inventory is live; the social items -/// (market, profile, chat…) belong to Block 2 and are shown disabled with a -/// "coming soon" tag so the roadmap is visible. +/// The app's navigation drawer (redesign screen 05). A white sheet: Inventory is +/// the live destination (green seed glyph), the social items (market, profile, +/// chat…) belong to Block 2 and are greyed with a "soon" tag, and Settings sits +/// pinned at the bottom. class AppDrawer extends StatelessWidget { const AppDrawer({super.key}); @@ -16,34 +17,42 @@ class AppDrawer extends StatelessWidget { Widget build(BuildContext context) { final t = context.t; return Drawer( - backgroundColor: Colors.white, child: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const _DrawerHeader(), - _ActiveItem( - icon: const SeedGlyph(SeedGlyphs.jars, size: 22), + _DrawerItem( + icon: const SeedGlyph(SeedGlyphs.jars, size: 23), label: t.menu.inventory, onTap: () { Navigator.of(context).pop(); context.go('/inventory'); }, ), - _SoonItem(icon: Symbols.storefront, label: t.menu.market), - const Divider(), - _SoonItem(icon: Icons.person_outline, label: t.menu.profile), - _SoonItem(icon: Icons.chat_bubble_outline, label: t.menu.chat), - _SoonItem(icon: Icons.favorite_border, label: t.menu.wishlist), - _SoonItem(icon: Icons.group_outlined, label: t.menu.following), + _DrawerItem( + icon: const Icon(Symbols.storefront), + label: t.menu.market, + divider: true, + ), + _DrawerItem(icon: const Icon(Icons.person), label: t.menu.profile), + _DrawerItem( + icon: const Icon(Icons.chat_bubble), + label: t.menu.chat, + ), + _DrawerItem( + icon: const Icon(Icons.favorite), + label: t.menu.wishlist, + ), + _DrawerItem(icon: const Icon(Icons.group), label: t.menu.following), const Spacer(), - const Divider(), - _ActiveItem( - icon: const Icon(Icons.settings_outlined), + const Divider(height: 1), + _DrawerItem( + icon: const Icon(Icons.settings), label: t.menu.settings, onTap: () { Navigator.of(context).pop(); - context.go('/settings'); + context.push('/settings'); }, ), ], @@ -53,70 +62,110 @@ class AppDrawer extends StatelessWidget { } } +/// The drawer's brand header. Tapping it closes the drawer and returns to the +/// home screen. class _DrawerHeader extends StatelessWidget { const _DrawerHeader(); @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( - children: [ - const Icon(Symbols.eco, color: seedGreen, size: 32), - const SizedBox(width: 12), - Text( - context.t.app.title, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: seedGreenDark, - fontWeight: FontWeight.bold, + return InkWell( + onTap: () { + Navigator.of(context).pop(); + context.go('/'); + }, + child: Container( + padding: const EdgeInsets.fromLTRB(22, 18, 22, 16), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: seedDivider)), + ), + child: Row( + children: [ + Image.asset('assets/logo.png', height: 34), + const SizedBox(width: 12), + Text( + context.t.app.title, + style: const TextStyle( + color: seedGreen, + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), ), - ), - ], + ], + ), ), ); } } -class _ActiveItem extends StatelessWidget { - const _ActiveItem({ +/// A 52-tall drawer row. A live destination has an [onTap] (dark text, green +/// icon); a disabled Block-2 destination is greyed with a "soon" tag. [divider] +/// draws a hairline under the row (separating live from upcoming items). +class _DrawerItem extends StatelessWidget { + const _DrawerItem({ required this.icon, required this.label, - required this.onTap, + this.onTap, + this.divider = false, }); final Widget icon; final String label; - final VoidCallback onTap; + final VoidCallback? onTap; + final bool divider; @override Widget build(BuildContext context) { - return ListTile( - leading: SizedBox(width: 24, child: Center(child: icon)), - title: Text(label), + final enabled = onTap != null; + final fg = enabled ? seedOnSurface : const Color(0xFF9AA88F); + final iconColor = enabled ? seedGreen : const Color(0xFF9AA88F); + final row = InkWell( onTap: onTap, - ); - } -} - -/// A disabled Block-2 destination with a subtle "coming soon" tag. -class _SoonItem extends StatelessWidget { - const _SoonItem({required this.icon, required this.label}); - - final IconData icon; - final String label; - - @override - Widget build(BuildContext context) { - return ListTile( - enabled: false, - leading: Icon(icon), - title: Text(label), - trailing: Text( - context.t.common.comingSoon, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: Colors.black38), + child: Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 22), + decoration: divider + ? const BoxDecoration( + border: Border(bottom: BorderSide(color: seedDivider)), + ) + : null, + child: Row( + children: [ + SizedBox( + width: 24, + child: Center( + child: IconTheme.merge( + data: IconThemeData(size: 23, color: iconColor), + child: icon, + ), + ), + ), + const SizedBox(width: 18), + Expanded( + child: Text( + label, + style: TextStyle( + color: fg, + fontSize: 15, + fontWeight: enabled ? FontWeight.w500 : FontWeight.w400, + ), + ), + ), + if (!enabled) + Text( + context.t.common.comingSoon.toUpperCase(), + style: const TextStyle( + color: Color(0xFFB3BDA8), + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + ), + ), + ], + ), ), ); + return row; } } diff --git a/apps/app_seeds/lib/ui/home_screen.dart b/apps/app_seeds/lib/ui/home_screen.dart index 97e51db..42cb960 100644 --- a/apps/app_seeds/lib/ui/home_screen.dart +++ b/apps/app_seeds/lib/ui/home_screen.dart @@ -6,9 +6,10 @@ import 'app_drawer.dart'; import 'seed_glyph.dart'; import 'theme.dart'; -/// The main menu (mockup 02): a sprout logo over a faint seed-glyph watermark, -/// with the big "open market" (Block 2, coming soon) and "your inventory" -/// destinations. The hamburger opens [AppDrawer]. +/// The main menu (redesign screen 00): a sprout logo in a soft green disc over a +/// faint seed-glyph watermark, with "Your inventory" as the primary green call +/// to action and "Open market" (Block 2) as a disabled outlined card. The +/// hamburger opens [AppDrawer]. class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @@ -25,33 +26,61 @@ class HomeScreen extends StatelessWidget { child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 440), child: Padding( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Image.asset( - 'assets/logo.png', - height: 140, - fit: BoxFit.contain, - ), - const SizedBox(height: 40), - _MenuButton( - icon: const SeedGlyph( - SeedGlyphs.share, - size: 26, - color: Colors.white, + Center( + child: Container( + width: 128, + height: 128, + decoration: const BoxDecoration( + color: Color(0xFFDDF3CE), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Image.asset( + 'assets/logo.png', + height: 84, + fit: BoxFit.contain, + ), ), - label: t.home.openMarket, - tag: t.common.comingSoon, - onTap: null, + ), + const SizedBox(height: 10), + Text( + t.app.title, + textAlign: TextAlign.center, + style: const TextStyle( + color: Color(0xFF1A2A13), + fontSize: 26, + fontWeight: FontWeight.w500, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 2), + Text( + t.home.tagline, + textAlign: TextAlign.center, + style: const TextStyle( + color: Color(0xFF55624A), + fontSize: 14, + ), + ), + const SizedBox(height: 34), + _PrimaryMenuCard( + key: const Key('home.inventory'), + icon: Icons.inventory_2_outlined, + label: t.home.yourInventory, + subtitle: t.home.yourInventorySubtitle, + onTap: () => context.go('/inventory'), ), const SizedBox(height: 16), - _MenuButton( - key: const Key('home.inventory'), - icon: const Icon(Icons.list, color: Colors.white), - label: t.home.yourInventory, - onTap: () => context.go('/inventory'), + _OutlinedMenuCard( + icon: Icons.storefront_outlined, + label: t.home.openMarket, + subtitle: t.home.openMarketSubtitle, + tag: t.common.comingSoon, ), ], ), @@ -64,50 +93,49 @@ class HomeScreen extends StatelessWidget { } } -class _MenuButton extends StatelessWidget { - const _MenuButton({ +/// The primary green call-to-action: white icon in a translucent disc, a title +/// and a subtitle, with a soft green shadow. +class _PrimaryMenuCard extends StatelessWidget { + const _PrimaryMenuCard({ required this.icon, required this.label, - this.onTap, - this.tag, + required this.subtitle, + required this.onTap, super.key, }); - final Widget icon; + final IconData icon; final String label; - final VoidCallback? onTap; - final String? tag; + final String subtitle; + final VoidCallback onTap; @override Widget build(BuildContext context) { - final enabled = onTap != null; return Material( - color: enabled ? seedGreen : seedGreen.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(10), + color: seedGreen, + borderRadius: BorderRadius.circular(18), + elevation: 3, + shadowColor: seedGreen.withValues(alpha: 0.4), child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(18), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20), + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 22), child: Row( children: [ - icon, + _IconDisc( + background: Colors.white.withValues(alpha: 0.18), + child: Icon(icon, color: Colors.white, size: 26), + ), const SizedBox(width: 16), Expanded( - child: Text( - label, - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, - ), + child: _CardText( + label: label, + subtitle: subtitle, + color: Colors.white, + subtitleColor: Colors.white.withValues(alpha: 0.85), ), ), - if (tag != null) - Text( - tag!, - style: const TextStyle(color: Colors.white70, fontSize: 12), - ), ], ), ), @@ -116,41 +144,168 @@ class _MenuButton extends StatelessWidget { } } -/// A faint scatter of seed pictograms behind the menu (like mockup 02). +/// A disabled Block-2 destination: an outlined white card with a green-disc +/// icon and a "soon" tag. +class _OutlinedMenuCard extends StatelessWidget { + const _OutlinedMenuCard({ + required this.icon, + required this.label, + required this.subtitle, + required this.tag, + }); + + final IconData icon; + final String label; + final String subtitle; + final String tag; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFB8C4AC)), + ), + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 22), + child: Row( + children: [ + _IconDisc( + background: const Color(0xFFDDF3CE), + child: Icon(icon, color: seedGreen, size: 26), + ), + const SizedBox(width: 16), + Expanded( + child: _CardText( + label: label, + subtitle: subtitle, + color: seedOnSurface, + subtitleColor: seedMuted, + ), + ), + Text( + tag.toUpperCase(), + style: const TextStyle( + color: Color(0xFFB3BDA8), + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + ), + ), + ], + ), + ); + } +} + +class _IconDisc extends StatelessWidget { + const _IconDisc({required this.background, required this.child}); + + final Color background; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + width: 44, + height: 44, + decoration: BoxDecoration(shape: BoxShape.circle, color: background), + alignment: Alignment.center, + child: child, + ); + } +} + +class _CardText extends StatelessWidget { + const _CardText({ + required this.label, + required this.subtitle, + required this.color, + required this.subtitleColor, + }); + + final String label; + final String subtitle; + final Color color; + final Color subtitleColor; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: color, + fontSize: 19, + fontWeight: FontWeight.w500, + ), + ), + Text(subtitle, style: TextStyle(color: subtitleColor, fontSize: 13)), + ], + ); + } +} + +/// A single seed pictogram scattered onto the watermark: [ax]/[ay] are +/// fractional positions (0..1) within the layer, [angle] a slight tilt so the +/// scatter feels hand-strewn rather than gridded (like screen 00). +class _ScatteredGlyph { + const _ScatteredGlyph(this.char, this.ax, this.ay, this.size, this.angle); + + final String char; + final double ax; + final double ay; + final double size; + final double angle; +} + +/// A faint, disordered scatter of seed pictograms behind the menu (like screen +/// 00): glyphs strewn around the edges at staggered positions and tilts, +/// positioned by fraction so it holds up at any screen size. class _SeedWatermark extends StatelessWidget { const _SeedWatermark(); static const _glyphs = [ - SeedGlyphs.jars, - SeedGlyphs.envelope, - SeedGlyphs.bigSpoon, - SeedGlyphs.jar, - SeedGlyphs.sack, - SeedGlyphs.scattered, - SeedGlyphs.mug, - SeedGlyphs.smallSpoon, + _ScatteredGlyph(SeedGlyphs.sack, 0.06, 0.14, 62, -0.18), + _ScatteredGlyph(SeedGlyphs.envelope, 0.82, 0.09, 48, 0.22), + _ScatteredGlyph(SeedGlyphs.scattered, 0.70, 0.30, 44, -0.12), + _ScatteredGlyph(SeedGlyphs.jar, 0.16, 0.44, 40, 0.15), + _ScatteredGlyph(SeedGlyphs.jars, 0.85, 0.66, 44, 0.18), + _ScatteredGlyph(SeedGlyphs.smallSpoon, 0.10, 0.74, 48, -0.22), + _ScatteredGlyph(SeedGlyphs.pouring, 0.40, 0.90, 52, 0.10), + _ScatteredGlyph(SeedGlyphs.mug, 0.72, 0.88, 60, -0.14), ]; @override Widget build(BuildContext context) { return IgnorePointer( - child: Opacity( - opacity: 0.06, - child: Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.only(bottom: 24), - child: Wrap( - spacing: 28, - runSpacing: 18, - alignment: WrapAlignment.center, - children: [ - for (final g in _glyphs) - SeedGlyph(g, size: 64, color: seedGreenDark), - ], - ), - ), - ), + child: LayoutBuilder( + builder: (context, constraints) { + final w = constraints.maxWidth; + final h = constraints.maxHeight; + return Stack( + children: [ + for (final g in _glyphs) + Positioned( + left: w * g.ax, + top: h * g.ay, + child: Opacity( + opacity: 0.08, + child: Transform.rotate( + angle: g.angle, + child: SeedGlyph( + g.char, + size: g.size, + color: seedGreenDark, + ), + ), + ), + ), + ], + ); + }, ), ); } diff --git a/apps/app_seeds/lib/ui/inventory_list_screen.dart b/apps/app_seeds/lib/ui/inventory_list_screen.dart index 3301000..1cdf090 100644 --- a/apps/app_seeds/lib/ui/inventory_list_screen.dart +++ b/apps/app_seeds/lib/ui/inventory_list_screen.dart @@ -43,12 +43,14 @@ class InventoryListScreen extends StatelessWidget { key: const Key('inventory.search'), decoration: InputDecoration( hintText: t.inventory.searchHint, - prefixIcon: const Icon(Icons.search), + prefixIcon: const Icon(Icons.search, color: seedMuted), + hintStyle: const TextStyle(color: seedMuted), filled: true, fillColor: Colors.white, isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 14), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(28), borderSide: BorderSide.none, ), ), @@ -123,9 +125,10 @@ class _CategoryHeader extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 18, 16, 6), child: Text( title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: seedGreenDark, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: seedGreen, ), ), ); @@ -154,7 +157,7 @@ class _VarietyTile extends StatelessWidget { ), trailing: IconButton( icon: const Icon(Icons.edit_outlined), - color: Colors.black38, + color: seedMuted, tooltip: context.t.common.edit, onPressed: open, ), @@ -183,8 +186,8 @@ class _Avatar extends StatelessWidget { ? '?' : trimmed.substring(0, 1).toUpperCase(); return CircleAvatar( - backgroundColor: seedGreen.withValues(alpha: 0.15), - foregroundColor: seedGreenDark, + backgroundColor: seedAvatar, + foregroundColor: seedOnAvatar, child: Text(initial), ); } diff --git a/apps/app_seeds/lib/ui/quantity_kind_l10n.dart b/apps/app_seeds/lib/ui/quantity_kind_l10n.dart index 8eb26db..10cd8a9 100644 --- a/apps/app_seeds/lib/ui/quantity_kind_l10n.dart +++ b/apps/app_seeds/lib/ui/quantity_kind_l10n.dart @@ -28,6 +28,9 @@ import '../i18n/strings.g.dart'; QuantityKind.plant => (t.unit.plant.singular, t.unit.plant.plural), QuantityKind.pot => (t.unit.pot.singular, t.unit.pot.plural), QuantityKind.tray => (t.unit.tray.singular, t.unit.tray.plural), + QuantityKind.seedling => (t.unit.seedling.singular, t.unit.seedling.plural), + QuantityKind.tree => (t.unit.tree.singular, t.unit.tree.plural), + QuantityKind.cutting => (t.unit.cutting.singular, t.unit.cutting.plural), QuantityKind.grams => (t.unit.grams.singular, t.unit.grams.plural), QuantityKind.count => (t.unit.count.singular, t.unit.count.plural), }; diff --git a/apps/app_seeds/lib/ui/quantity_picker.dart b/apps/app_seeds/lib/ui/quantity_picker.dart index a139c90..7a88cc9 100644 --- a/apps/app_seeds/lib/ui/quantity_picker.dart +++ b/apps/app_seeds/lib/ui/quantity_picker.dart @@ -1,6 +1,5 @@ import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; import '../db/enums.dart'; import '../i18n/strings.g.dart'; @@ -28,17 +27,37 @@ const seedScaleKinds = [ QuantityKind.bunch, ]; -/// Units for a plant/seedling lot. -const plantScaleKinds = [ - QuantityKind.plant, - QuantityKind.pot, - QuantityKind.tray, -]; +/// The amount units offered for each lot form. Living forms are counted as +/// whole individuals (their packaging is a separate [Presentation]); [bulb] also +/// allows grams. [pot]/[tray] are intentionally not offered anymore. +List kindsForType(LotType type) => switch (type) { + LotType.seed => seedScaleKinds, + LotType.seedling => const [QuantityKind.seedling], + LotType.plant => const [QuantityKind.plant], + LotType.tree => const [QuantityKind.tree], + LotType.bulb => const [ + QuantityKind.bulb, + QuantityKind.tuber, + QuantityKind.grams, + ], + LotType.cutting => const [QuantityKind.cutting], +}; -List kindsForType(LotType type) => - type == LotType.seed ? seedScaleKinds : plantScaleKinds; +/// The human label for a lot form. +String lotTypeLabel(Translations t, LotType type) => switch (type) { + LotType.seed => t.lotType.seed, + LotType.seedling => t.lotType.seedling, + LotType.plant => t.lotType.plant, + LotType.tree => t.lotType.tree, + LotType.bulb => t.lotType.bulb, + LotType.cutting => t.lotType.cutting, +}; -/// Seed vs plant/seedling segmented selector. +/// Living forms whose packaging ([Presentation]) is worth asking about. +bool typeHasPresentation(LotType type) => + type == LotType.seedling || type == LotType.plant || type == LotType.tree; + +/// Form selector: a wrap of choice chips, one per [LotType]. class LotTypeSelector extends StatelessWidget { const LotTypeSelector({ required this.value, @@ -52,26 +71,65 @@ class LotTypeSelector extends StatelessWidget { @override Widget build(BuildContext context) { final t = context.t; - return SegmentedButton( - segments: [ - ButtonSegment( - value: LotType.seed, - label: Text(t.lotType.seed), - icon: const SeedGlyph(SeedGlyphs.jars, size: 18), - ), - ButtonSegment( - value: LotType.plant, - label: Text(t.lotType.plant), - icon: const Icon(Symbols.potted_plant, size: 18), - ), + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final type in LotType.values) + ChoiceChip( + key: Key('lotType.${type.name}'), + selected: value == type, + onSelected: (_) => onChanged(type), + avatar: type == LotType.seed + ? const SeedGlyph(SeedGlyphs.jars, size: 18) + : Icon(iconForLotType(type), size: 18), + label: Text(lotTypeLabel(t, type)), + ), ], - selected: {value}, - showSelectedIcon: false, - onSelectionChanged: (s) => onChanged(s.first), ); } } +/// Optional packaging selector for living lots. Tapping the selected chip again +/// clears it (packaging is optional). +class PresentationSelector extends StatelessWidget { + const PresentationSelector({ + required this.value, + required this.onChanged, + super.key, + }); + + final Presentation? value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final t = context.t; + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final p in Presentation.values) + ChoiceChip( + key: Key('presentation.${p.name}'), + selected: value == p, + onSelected: (sel) => onChanged(sel ? p : null), + label: Text(presentationLabel(t, p)), + ), + ], + ); + } +} + +/// The human label for a packaging [Presentation]. +String presentationLabel(Translations t, Presentation p) => switch (p) { + Presentation.pot => t.presentation.pot, + Presentation.tray => t.presentation.tray, + Presentation.plug => t.presentation.plug, + Presentation.bareRoot => t.presentation.bareRoot, + Presentation.rootBall => t.presentation.rootBall, +}; + /// A horizontal scale of large unit icons + an optional number stepper. Emits a /// [Quantity] (or null while nothing is picked) via [onChanged]. class QuantityPicker extends StatefulWidget { diff --git a/apps/app_seeds/lib/ui/quick_add_sheet.dart b/apps/app_seeds/lib/ui/quick_add_sheet.dart index 4d27494..7e78182 100644 --- a/apps/app_seeds/lib/ui/quick_add_sheet.dart +++ b/apps/app_seeds/lib/ui/quick_add_sheet.dart @@ -39,77 +39,81 @@ class QuickAddSheet extends StatelessWidget { top: 16, bottom: MediaQuery.of(context).viewInsets.bottom + 16, ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - t.quickAdd.title, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 12), - TextField( - key: const Key('quickAdd.labelField'), - autofocus: true, - textInputAction: TextInputAction.done, - decoration: InputDecoration( - labelText: t.quickAdd.labelField, - errorText: state.showLabelError - ? t.quickAdd.labelRequired - : null, - border: const OutlineInputBorder(), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + t.quickAdd.title, + style: Theme.of(context).textTheme.titleLarge, ), - onChanged: cubit.labelChanged, - onSubmitted: (_) => cubit.submit(), - ), - const SizedBox(height: 16), - LotTypeSelector( - value: state.lotType, - onChanged: (type) { - cubit - ..setLotType(type) - ..setQuantity(null); - }, - ), - const SizedBox(height: 12), - Text( - t.quickAdd.quantity, - style: Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(height: 8), - QuantityPicker( - type: state.lotType, - value: state.quantity, - onChanged: cubit.setQuantity, - ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - onPressed: cubit.toggleExpanded, - icon: Icon( - state.expanded ? Icons.expand_less : Icons.expand_more, + const SizedBox(height: 12), + TextField( + key: const Key('quickAdd.labelField'), + autofocus: true, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: t.quickAdd.labelField, + errorText: state.showLabelError + ? t.quickAdd.labelRequired + : null, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), ), - label: Text(t.quickAdd.more), + onChanged: cubit.labelChanged, + onSubmitted: (_) => cubit.submit(), ), - ), - if (state.expanded) const _MoreSection(), - const SizedBox(height: 8), - Row( - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(t.quickAdd.cancel), + const SizedBox(height: 16), + LotTypeSelector( + value: state.lotType, + onChanged: (type) { + cubit + ..setLotType(type) + ..setQuantity(null); + }, + ), + const SizedBox(height: 12), + Text( + t.quickAdd.quantity, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + QuantityPicker( + type: state.lotType, + value: state.quantity, + onChanged: cubit.setQuantity, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: cubit.toggleExpanded, + icon: Icon( + state.expanded ? Icons.expand_less : Icons.expand_more, + ), + label: Text(t.quickAdd.more), ), - const Spacer(), - FilledButton( - key: const Key('quickAdd.save'), - onPressed: state.submitting ? null : cubit.submit, - child: Text(t.quickAdd.save), - ), - ], - ), - ], + ), + if (state.expanded) const _MoreSection(), + const SizedBox(height: 8), + Row( + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(t.quickAdd.cancel), + ), + const Spacer(), + FilledButton( + key: const Key('quickAdd.save'), + onPressed: state.submitting ? null : cubit.submit, + child: Text(t.quickAdd.save), + ), + ], + ), + ], + ), ), ); }, diff --git a/apps/app_seeds/lib/ui/seed_glyph.dart b/apps/app_seeds/lib/ui/seed_glyph.dart index 4489967..8283e8f 100644 --- a/apps/app_seeds/lib/ui/seed_glyph.dart +++ b/apps/app_seeds/lib/ui/seed_glyph.dart @@ -2,6 +2,8 @@ import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../db/enums.dart'; + /// Renders a glyph from the bundled `seedks` icon font, whose characters `a`–`k` /// map to seed-themed pictograms (jar, packet, spoon…). See /// docs/mockups/seedks-glyphs.png. @@ -61,18 +63,36 @@ String? seedGlyphForKind(QuantityKind kind) => switch (kind) { IconData _materialIconForKind(QuantityKind kind) => switch (kind) { QuantityKind.grams => Icons.scale_outlined, QuantityKind.count => Icons.tag, - QuantityKind.cob || QuantityKind.ear => Icons.grass, - QuantityKind.head || QuantityKind.seedHead => Icons.filter_vintage_outlined, - QuantityKind.fruit => Icons.local_florist_outlined, - QuantityKind.plant => Symbols.potted_plant, - QuantityKind.pot => Icons.yard_outlined, - QuantityKind.tray => Icons.grid_on_outlined, - QuantityKind.bulb || - QuantityKind.tuber || - QuantityKind.bunch => Icons.spa_outlined, + // Seed/fruit forms — each a distinct icon so they're told apart at a glance. + QuantityKind.cob => Symbols.grass, // mazorca + QuantityKind.ear => Symbols.grain, // espiga + QuantityKind.head => Symbols.filter_vintage, // cabezuela + QuantityKind.seedHead => Symbols.spa, + QuantityKind.fruit => Symbols.nutrition, // fruto (apple) + QuantityKind.bulb => Symbols.spa, // bulbo + QuantityKind.tuber => Symbols.egg, // tubérculo + QuantityKind.bunch => Symbols.local_florist, // manojo + // Living material counted as individuals. + QuantityKind.seedling => Symbols.potted_plant, + QuantityKind.plant => Symbols.yard, + QuantityKind.tree => Symbols.park, + QuantityKind.cutting => Symbols.content_cut, + QuantityKind.pot => Symbols.yard, + QuantityKind.tray => Symbols.grid_on, _ => Icons.eco_outlined, }; +/// The icon for a lot's [LotType] form (used in the form selector and lot chip), +/// distinct across all forms so the selector reads clearly. +IconData iconForLotType(LotType type) => switch (type) { + LotType.seed => Symbols.eco, // overridden by a seed glyph where shown + LotType.seedling => Symbols.potted_plant, + LotType.plant => Symbols.local_florist, + LotType.tree => Symbols.park, + LotType.bulb => Symbols.spa, + LotType.cutting => Symbols.content_cut, +}; + /// A consistent icon for a [QuantityKind]: the seed glyph where one exists, /// otherwise a Material fallback. class QuantityKindIcon extends StatelessWidget { diff --git a/apps/app_seeds/lib/ui/settings_screen.dart b/apps/app_seeds/lib/ui/settings_screen.dart index bba7b6a..775f1d5 100644 --- a/apps/app_seeds/lib/ui/settings_screen.dart +++ b/apps/app_seeds/lib/ui/settings_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../i18n/strings.g.dart'; import 'theme.dart'; @@ -35,8 +36,10 @@ class SettingsScreen extends StatelessWidget { _SectionHeader(t.settings.about), ListTile( leading: Image.asset('assets/logo.png', width: 32), - title: Text(t.app.title), + title: Text(t.settings.aboutOpen), subtitle: Text(t.settings.aboutText), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/about'), ), ], ), @@ -56,8 +59,9 @@ class _SectionHeader extends StatelessWidget { child: Text( text, style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: seedGreenDark, + color: seedGreen, fontWeight: FontWeight.w600, + letterSpacing: 0.4, ), ), ); diff --git a/apps/app_seeds/lib/ui/theme.dart b/apps/app_seeds/lib/ui/theme.dart index 1126683..1af7383 100644 --- a/apps/app_seeds/lib/ui/theme.dart +++ b/apps/app_seeds/lib/ui/theme.dart @@ -1,35 +1,104 @@ import 'package:flutter/material.dart'; -/// Seed-green palette drawn from the mockups (docs/mockups): a green app bar and -/// a pale-green canvas. -const seedGreen = Color(0xFF3C8B2E); -const seedGreenDark = Color(0xFF1B5E20); -const seedCanvas = Color(0xFFEAF4E2); -const seedLink = Color(0xFF1E88C0); +/// Material 3 palette from the Seedees v2 redesign +/// (docs/mockups/seedees_material3.html): green app bars over a pale-green +/// canvas, a saturated green for primary actions (FAB, buttons, links, category +/// headers) and a soft green container for selected/tonal surfaces. +const seedGreen = Color(0xFF2F7D34); // primary (actions, links, headers) +const seedGreenDark = Color(0xFF22521E); // status bar / darker green +const seedAppBar = Color(0xFF3E8C38); // app-bar green +const seedCanvas = Color(0xFFE7F1E0); // surface / scaffold +const seedPrimaryContainer = Color(0xFFD3E8C8); // tonal green container +const seedOnPrimaryContainer = Color(0xFF2F6D2A); +const seedField = Color(0xFFF3F7EE); // outlined field fill +const seedAvatar = Color(0xFFCFE7C3); // list avatar disc +const seedOnAvatar = Color(0xFF3B7A34); +const seedOutline = Color(0xFFC3CBB6); +const seedDivider = Color(0xFFE4E9DD); +const seedTitle = Color(0xFF1B2416); // heading text on canvas +const seedOnSurface = Color(0xFF1B2416); +const seedOnSurfaceVariant = Color(0xFF5C6B52); // scientific names, secondary +const seedMuted = Color(0xFF8A9880); // hints, list edit icon +const seedRating = Color(0xFFE8A200); // stars ThemeData buildTaneTheme() { - final scheme = ColorScheme.fromSeed( - seedColor: seedGreen, - brightness: Brightness.light, - ); + final scheme = + ColorScheme.fromSeed( + seedColor: seedGreen, + brightness: Brightness.light, + ).copyWith( + primary: seedGreen, + onPrimary: Colors.white, + primaryContainer: seedPrimaryContainer, + onPrimaryContainer: seedOnPrimaryContainer, + secondaryContainer: seedPrimaryContainer, + onSecondaryContainer: seedOnPrimaryContainer, + tertiary: seedRating, + onTertiary: Colors.white, + surface: seedCanvas, + onSurface: seedOnSurface, + surfaceContainerHighest: seedField, + onSurfaceVariant: seedOnSurfaceVariant, + outline: seedMuted, + outlineVariant: seedOutline, + ); return ThemeData( useMaterial3: true, colorScheme: scheme, scaffoldBackgroundColor: seedCanvas, + // Green app bars — aligned with the v2 mockups and the real app. appBarTheme: const AppBarTheme( - backgroundColor: seedGreen, + backgroundColor: seedAppBar, foregroundColor: Colors.white, + surfaceTintColor: Colors.transparent, elevation: 0, + scrolledUnderElevation: 0, centerTitle: false, + iconTheme: IconThemeData(color: Colors.white), + titleTextStyle: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w500, + ), ), - floatingActionButtonTheme: const FloatingActionButtonThemeData( + // Squircle FAB (20px radius) as in the mockups, in the saturated primary. + floatingActionButtonTheme: FloatingActionButtonThemeData( backgroundColor: seedGreen, foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + drawerTheme: const DrawerThemeData( + backgroundColor: Colors.white, + surfaceTintColor: Colors.transparent, ), bottomSheetTheme: const BottomSheetThemeData( backgroundColor: seedCanvas, surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(26)), + ), + ), + // Pill "Save" actions across sheets and dialogs, as in the v3 mockups. + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: seedGreen, + foregroundColor: Colors.white, + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 13), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom(foregroundColor: seedGreen), + ), + // Green floating labels on the edit-seed / add-batch text fields. (Borders + // are left to each field so the inventory search keeps its borderless pill.) + inputDecorationTheme: const InputDecorationTheme( + floatingLabelStyle: TextStyle(color: seedGreen), + ), + dividerTheme: const DividerThemeData( + space: 1, + thickness: 1, + color: seedDivider, ), - dividerTheme: const DividerThemeData(space: 1, thickness: 1), ); } diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index 8dc95e9..41d941d 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -2,7 +2,6 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:material_symbols_icons/symbols.dart'; import 'package:url_launcher/url_launcher.dart'; import '../data/species_repository.dart'; @@ -198,6 +197,7 @@ String _lotSubtitle(Translations t, VarietyLot lot) { else t.detail.noYear, if (lot.quantity != null) quantityDisplay(t, lot.quantity!), + if (lot.presentation != null) presentationLabel(t, lot.presentation!), ]; return parts.join(' · '); } @@ -214,8 +214,9 @@ class _LotTypeChip extends StatelessWidget { @override Widget build(BuildContext context) { final t = context.t; - final isPlant = type == LotType.plant; - final color = isPlant ? const Color(0xFF00796B) : seedGreenDark; + final isSeed = type == LotType.seed; + // Seed lots read green; living/vegetative forms read teal. + final color = isSeed ? seedGreenDark : const Color(0xFF00796B); return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( @@ -225,13 +226,13 @@ class _LotTypeChip extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - if (isPlant) - Icon(Symbols.potted_plant, size: 14, color: color) + if (isSeed) + SeedGlyph(SeedGlyphs.jars, size: 14, color: color) else - SeedGlyph(SeedGlyphs.jars, size: 14, color: color), + Icon(iconForLotType(type), size: 14, color: color), const SizedBox(width: 4), Text( - isPlant ? t.lotType.plant : t.lotType.seed, + lotTypeLabel(t, type), style: TextStyle( fontSize: 11, color: color, @@ -323,7 +324,9 @@ Future _showGerminationSheet( keyboardType: TextInputType.number, decoration: InputDecoration( labelText: t.germination.germinated, - border: const OutlineInputBorder(), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), ), ), ), @@ -335,7 +338,9 @@ Future _showGerminationSheet( keyboardType: TextInputType.number, decoration: InputDecoration( labelText: t.germination.sampleSize, - border: const OutlineInputBorder(), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), ), ), ), @@ -504,7 +509,9 @@ class _EditVarietySheetState extends State<_EditVarietySheet> { controller: _name, decoration: InputDecoration( labelText: t.editVariety.name, - border: const OutlineInputBorder(), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), ), ), const SizedBox(height: 12), @@ -515,7 +522,9 @@ class _EditVarietySheetState extends State<_EditVarietySheet> { labelText: t.editVariety.species, hintText: t.editVariety.speciesHint, prefixIcon: const Icon(Icons.eco_outlined), - border: const OutlineInputBorder(), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), ), onChanged: _onSpeciesQuery, ), @@ -532,7 +541,9 @@ class _EditVarietySheetState extends State<_EditVarietySheet> { controller: _category, decoration: InputDecoration( labelText: t.editVariety.category, - border: const OutlineInputBorder(), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), ), ), const SizedBox(height: 12), @@ -542,7 +553,9 @@ class _EditVarietySheetState extends State<_EditVarietySheet> { maxLines: 5, decoration: InputDecoration( labelText: t.editVariety.notes, - border: const OutlineInputBorder(), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), ), ), const SizedBox(height: 16), @@ -577,6 +590,7 @@ Future _showLotSheet( var selectedMonth = existing?.harvestMonth; var selectedType = existing?.type ?? LotType.seed; var selectedQuantity = existing?.quantity; + var selectedPresentation = existing?.presentation; final editing = existing != null; return showModalBottomSheet( context: context, @@ -593,58 +607,84 @@ Future _showLotSheet( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - Expanded( - child: Text( - editing ? t.detail.editLot : t.addLot.title, - style: Theme.of(sheetContext).textTheme.titleLarge, - ), + Flexible( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + editing ? t.detail.editLot : t.addLot.title, + style: Theme.of(sheetContext).textTheme.titleLarge, + ), + ), + if (editing) + IconButton( + key: const Key('lot.delete'), + icon: const Icon(Icons.delete_outline), + tooltip: t.common.delete, + onPressed: () { + cubit.deleteLot(existing.id); + Navigator.of(sheetContext).pop(); + }, + ), + ], + ), + const SizedBox(height: 12), + LotTypeSelector( + value: selectedType, + onChanged: (type) => setState(() { + selectedType = type; + selectedQuantity = null; + if (!typeHasPresentation(type)) { + selectedPresentation = null; + } + }), + ), + if (typeHasPresentation(selectedType)) ...[ + const SizedBox(height: 16), + Text( + t.presentation.title, + style: Theme.of(sheetContext).textTheme.labelLarge, + ), + const SizedBox(height: 8), + PresentationSelector( + value: selectedPresentation, + onChanged: (p) => + setState(() => selectedPresentation = p), + ), + ], + const SizedBox(height: 12), + Text( + t.addLot.year, + style: Theme.of(sheetContext).textTheme.labelLarge, + ), + const SizedBox(height: 8), + HarvestDateField( + year: selectedYear, + month: selectedMonth, + onChanged: (year, month) => setState(() { + selectedYear = year; + selectedMonth = month; + }), + ), + const SizedBox(height: 16), + Text( + t.addLot.quantity, + style: Theme.of(sheetContext).textTheme.labelLarge, + ), + const SizedBox(height: 8), + QuantityPicker( + type: selectedType, + value: selectedQuantity, + onChanged: (q) => selectedQuantity = q, + ), + ], ), - if (editing) - IconButton( - key: const Key('lot.delete'), - icon: const Icon(Icons.delete_outline), - tooltip: t.common.delete, - onPressed: () { - cubit.deleteLot(existing.id); - Navigator.of(sheetContext).pop(); - }, - ), - ], - ), - const SizedBox(height: 12), - LotTypeSelector( - value: selectedType, - onChanged: (type) => setState(() { - selectedType = type; - selectedQuantity = null; - }), - ), - const SizedBox(height: 12), - Text( - t.addLot.year, - style: Theme.of(sheetContext).textTheme.labelLarge, - ), - const SizedBox(height: 8), - HarvestDateField( - year: selectedYear, - month: selectedMonth, - onChanged: (year, month) => setState(() { - selectedYear = year; - selectedMonth = month; - }), - ), - const SizedBox(height: 16), - Text( - t.addLot.quantity, - style: Theme.of(sheetContext).textTheme.labelLarge, - ), - const SizedBox(height: 8), - QuantityPicker( - type: selectedType, - value: selectedQuantity, - onChanged: (q) => selectedQuantity = q, + ), ), const SizedBox(height: 16), Row( @@ -664,6 +704,7 @@ Future _showLotSheet( year: selectedYear, month: selectedMonth, quantity: selectedQuantity, + presentation: selectedPresentation, ); } else { cubit.addLot( @@ -671,6 +712,7 @@ Future _showLotSheet( year: selectedYear, month: selectedMonth, quantity: selectedQuantity, + presentation: selectedPresentation, ); } Navigator.of(sheetContext).pop(); @@ -807,12 +849,25 @@ class _DetailHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (detail.category != null) - Text( - detail.category!, - style: const TextStyle( - color: seedLink, - fontWeight: FontWeight.w600, - ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.category_outlined, + size: 17, + color: seedGreen, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + detail.category!, + style: const TextStyle( + color: seedGreen, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), if (detail.scientificName != null) ...[ const SizedBox(height: 2), @@ -890,37 +945,27 @@ class _PhotoGalleryState extends State<_PhotoGallery> { child: Column( mainAxisSize: MainAxisSize.min, children: [ + // Tap a photo to open the full-screen viewer, where you can set the + // cover or delete it. The first photo is the cover. SizedBox( height: 140, - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: PageView.builder( - controller: _controller, - itemCount: photos.length, - onPageChanged: (i) => setState(() => _page = i), - itemBuilder: (_, i) => GestureDetector( - onTap: () => _openPhotoViewer(context, photos, i), - child: Image.memory( - photos[i].bytes, - width: 140, - height: 140, - fit: BoxFit.cover, - ), - ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: PageView.builder( + controller: _controller, + itemCount: photos.length, + onPageChanged: (i) => setState(() => _page = i), + itemBuilder: (_, i) => GestureDetector( + key: Key('photo.thumb.$i'), + onTap: () => _openPhotoViewer(context, cubit, i), + child: Image.memory( + photos[i].bytes, + width: 140, + height: 140, + fit: BoxFit.cover, ), ), - Positioned( - top: 2, - right: 2, - child: _CircleIconButton( - icon: Icons.close, - tooltip: context.t.common.delete, - onPressed: () => cubit.removePhoto(photos[page].id), - ), - ), - ], + ), ), ), const SizedBox(height: 6), @@ -946,33 +991,6 @@ class _PhotoGalleryState extends State<_PhotoGallery> { } } -class _CircleIconButton extends StatelessWidget { - const _CircleIconButton({ - required this.icon, - required this.onPressed, - this.tooltip, - }); - - final IconData icon; - final VoidCallback onPressed; - final String? tooltip; - - @override - Widget build(BuildContext context) { - return Material( - color: Colors.black45, - shape: const CircleBorder(), - child: IconButton( - key: const Key('photo.delete'), - icon: Icon(icon, size: 18, color: Colors.white), - tooltip: tooltip, - visualDensity: VisualDensity.compact, - onPressed: onPressed, - ), - ); - } -} - class _Dots extends StatelessWidget { const _Dots({required this.count, required this.active, this.onTap}); @@ -1009,38 +1027,141 @@ class _Dots extends StatelessWidget { void _openPhotoViewer( BuildContext context, - List photos, + VarietyDetailCubit cubit, int initialIndex, ) { Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, - builder: (_) => _PhotoViewer(photos: photos, initialIndex: initialIndex), + // Re-provide the cubit so the pushed route can set the cover / delete and + // rebuild live as the photo list reorders or shrinks. + builder: (_) => BlocProvider.value( + value: cubit, + child: _PhotoViewer(initialIndex: initialIndex), + ), ), ); } -/// Full-screen, swipeable, pinch-to-zoom photo viewer. -class _PhotoViewer extends StatelessWidget { - const _PhotoViewer({required this.photos, required this.initialIndex}); +/// Full-screen, swipeable, pinch-to-zoom photo viewer. Its app bar hosts the +/// per-photo actions (set as cover, delete) — the thumbnail gallery only opens +/// it. Stays in sync with the cubit, so deleting the last photo closes it. +class _PhotoViewer extends StatefulWidget { + const _PhotoViewer({required this.initialIndex}); - final List photos; final int initialIndex; + @override + State<_PhotoViewer> createState() => _PhotoViewerState(); +} + +class _PhotoViewerState extends State<_PhotoViewer> { + late final PageController _controller = PageController( + initialPage: widget.initialIndex, + ); + late int _page = widget.initialIndex; + bool _popped = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _confirmDelete( + VarietyDetailCubit cubit, + VarietyPhoto photo, + ) async { + final t = context.t; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + content: Text(t.photo.deleteConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(t.common.cancel), + ), + FilledButton( + key: const Key('photo.deleteConfirm'), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(t.common.delete), + ), + ], + ), + ); + if (confirmed ?? false) await cubit.removePhoto(photo.id); + } + @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - appBar: AppBar( - backgroundColor: Colors.black, - foregroundColor: Colors.white, - elevation: 0, - ), - body: PageView.builder( - controller: PageController(initialPage: initialIndex), - itemCount: photos.length, - itemBuilder: (_, i) => _ZoomablePhoto(bytes: photos[i].bytes), - ), + final cubit = context.read(); + return BlocBuilder( + builder: (context, state) { + final photos = state.detail?.photos ?? const []; + // Nothing left to show (last photo deleted / variety gone): close once. + // The guard stops the post-frame callback re-arming every rebuild. + if (photos.isEmpty) { + if (!_popped) { + _popped = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) Navigator.of(context).maybePop(); + }); + } + return const Scaffold(backgroundColor: Colors.black); + } + final page = _page.clamp(0, photos.length - 1); + // A delete can leave the pager past the end: snap it back. jumpToPage + // fires onPageChanged, which updates _page — so no setState here (that + // would re-arm the callback and spin the frame loop). + if (page != _page && _controller.hasClients) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _controller.hasClients) _controller.jumpToPage(page); + }); + } + final isCover = page == 0; + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + elevation: 0, + actions: [ + if (photos.length > 1) + IconButton( + key: const Key('photo.cover'), + icon: Icon(isCover ? Icons.star : Icons.star_border), + tooltip: isCover + ? context.t.photo.isCover + : context.t.photo.setAsCover, + onPressed: isCover + ? null + : () async { + await cubit.setPreferredPhoto(photos[page].id); + // The chosen photo is now the cover (index 0): follow + // it there so the filled star reflects the change. + if (mounted && _controller.hasClients) { + _controller.jumpToPage(0); + setState(() => _page = 0); + } + }, + ), + IconButton( + key: const Key('photo.delete'), + icon: const Icon(Icons.delete_outline), + tooltip: context.t.common.delete, + onPressed: () => _confirmDelete(cubit, photos[page]), + ), + ], + ), + body: PageView.builder( + controller: _controller, + itemCount: photos.length, + onPageChanged: (i) => setState(() => _page = i), + itemBuilder: (_, i) => _ZoomablePhoto(bytes: photos[i].bytes), + ), + ); + }, ); } } diff --git a/apps/app_seeds/test/ui/about_screen_test.dart b/apps/app_seeds/test/ui/about_screen_test.dart new file mode 100644 index 0000000..f778fae --- /dev/null +++ b/apps/app_seeds/test/ui/about_screen_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:tane/app.dart'; +import 'package:tane/i18n/strings.g.dart'; + +import '../support/test_support.dart'; + +void main() { + Widget app(db) => TranslationProvider( + child: TaneApp( + repository: newTestRepository(db), + species: newTestSpeciesRepository(db), + ), + ); + + setUp(() { + PackageInfo.setMockInitialValues( + appName: 'Tanemaki', + packageName: 'org.comunes.tane', + version: '0.1.0', + buildNumber: '1', + buildSignature: '', + ); + }); + + testWidgets('Settings About tile opens the About screen', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final db = newTestDatabase(); + addTearDown(db.close); + + await tester.pumpWidget(app(db)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Settings')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('About Tanemaki')); + await tester.pumpAndSettle(); + + // Explanation text and kanji are shown above the fold. + expect(find.text('種まき'), findsOneWidget); + expect( + find.textContaining('helps people and collectives'), + findsOneWidget, + ); + expect(find.textContaining('digital Plantare'), findsOneWidget); + + // License and version live below the fold; scroll them into view. + await tester.scrollUntilVisible(find.text('AGPL-3.0'), 200); + expect(find.text('AGPL-3.0'), findsOneWidget); + expect(find.text('0.1.0 (1)'), findsOneWidget); + await disposeTree(tester); + }); + + testWidgets('About screen opens the open-source licenses page', ( + tester, + ) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final db = newTestDatabase(); + addTearDown(db.close); + + await tester.pumpWidget(app(db)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Settings')); + await tester.pumpAndSettle(); + await tester.tap(find.text('About Tanemaki')); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible(find.text('Open-source licenses'), 200); + await tester.tap(find.text('Open-source licenses')); + await tester.pumpAndSettle(); + + // Flutter's built-in LicensePage renders the app name. + expect(find.byType(LicensePage), findsOneWidget); + await disposeTree(tester); + }); +} diff --git a/apps/app_seeds/test/ui/home_screen_test.dart b/apps/app_seeds/test/ui/home_screen_test.dart index 5ad9805..3603b7f 100644 --- a/apps/app_seeds/test/ui/home_screen_test.dart +++ b/apps/app_seeds/test/ui/home_screen_test.dart @@ -23,7 +23,12 @@ void main() { expect(find.text('Your inventory'), findsOneWidget); expect(find.text('Open market'), findsOneWidget); - expect(find.text('Coming soon'), findsOneWidget); // market is Block 2 + expect(find.text('COMING SOON'), findsOneWidget); // market is Block 2 + + // Redesign copy: tagline + the two destination subtitles. + expect(find.text('Share and grow local seeds'), findsOneWidget); + expect(find.text('Manage your seeds'), findsOneWidget); + expect(find.text('Browse and exchange'), findsOneWidget); await tester.tap(find.byKey(const Key('home.inventory'))); await tester.pumpAndSettle(); @@ -53,6 +58,31 @@ void main() { await disposeTree(tester); }); + testWidgets('drawer header returns to home', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + final db = newTestDatabase(); + addTearDown(db.close); + + await tester.pumpWidget(app(db)); + await tester.pumpAndSettle(); + + // Navigate away from home first. + await tester.tap(find.byKey(const Key('home.inventory'))); + await tester.pumpAndSettle(); + expect(find.text('No seeds yet. Tap + to add your first.'), findsOneWidget); + + // Open the drawer and tap the Tanemaki brand header. + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Tanemaki')); + await tester.pumpAndSettle(); + + // Back on the home menu. + expect(find.text('Your inventory'), findsOneWidget); + expect(find.text('Open market'), findsOneWidget); + await disposeTree(tester); + }); + testWidgets('drawer Settings opens the settings screen', (tester) async { LocaleSettings.setLocaleSync(AppLocale.en); final db = newTestDatabase(); diff --git a/apps/app_seeds/test/ui/variety_detail_screen_test.dart b/apps/app_seeds/test/ui/variety_detail_screen_test.dart index 0f740d1..a85bc63 100644 --- a/apps/app_seeds/test/ui/variety_detail_screen_test.dart +++ b/apps/app_seeds/test/ui/variety_detail_screen_test.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -7,6 +9,14 @@ import 'package:tane/db/enums.dart'; import '../support/test_support.dart'; +/// A valid 1x1 transparent PNG so photos decode cleanly in widget tests. +final _tinyPng = Uint8List.fromList(const [ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, // + 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, // + 120, 156, 99, 250, 207, 0, 0, 0, 2, 0, 1, 226, 33, 188, 51, 0, 0, 0, 0, // + 73, 69, 78, 68, 174, 66, 96, 130, +]); + void main() { late AppDatabase db; @@ -381,10 +391,117 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Plants'), findsOneWidget); // lot-type chip + expect(find.text('Plant'), findsOneWidget); // lot-type chip expect(find.text('Year 2024 · 3 plants'), findsOneWidget); // Germination is seed-only. expect(find.byIcon(Icons.grass_outlined), findsNothing); await disposeTree(tester); }); + + testWidgets('adds a seedling lot with a packaging chip', (tester) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Basil'); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('detail.addLot'))); + await tester.pumpAndSettle(); + // Pick the seedling form, then its packaging. + await tester.tap(find.byKey(const Key('lotType.seedling'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('presentation.pot'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('addLot.save'))); + await tester.pumpAndSettle(); + + expect(find.text('Seedling'), findsOneWidget); // form chip on the lot + expect(find.textContaining('Pot'), findsOneWidget); // packaging in subtitle + // Germination is seed-only, so a seedling lot hides it. + expect(find.byIcon(Icons.grass_outlined), findsNothing); + await disposeTree(tester); + }); + + testWidgets( + 'the full-screen viewer deletes a photo only after confirmation', + (tester) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Maize'); + await repo.addPhoto(id, _tinyPng); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + + // Open the viewer from the thumbnail. + await tester.tap(find.byKey(const Key('photo.thumb.0'))); + await tester.pumpAndSettle(); + + // Cancelling the confirmation keeps the photo (still in the viewer). + await tester.tap(find.byKey(const Key('photo.delete'))); + await tester.pumpAndSettle(); + expect(find.text('Delete this photo?'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.text('Delete this photo?'), findsNothing); // dialog gone + expect(find.byKey(const Key('photo.delete')), findsOneWidget); // open + + // Confirming removes it; with none left the viewer closes onto detail. + await tester.tap(find.byKey(const Key('photo.delete'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('photo.deleteConfirm'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('photo.thumb.0')), findsNothing); // no photos + expect(find.byKey(const Key('detail.edit')), findsOneWidget); // on detail + await disposeTree(tester); + }, + ); + + testWidgets('the full-screen viewer sets a non-cover photo as the cover', ( + tester, + ) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Maize'); + await repo.addPhoto(id, _tinyPng); + await repo.addPhoto(id, _tinyPng); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('photo.thumb.0'))); + await tester.pumpAndSettle(); + // On the cover (page 0) the star is filled and disabled. + expect(find.byIcon(Icons.star), findsOneWidget); + expect(find.byIcon(Icons.star_border), findsNothing); + + // Swipe to the second (non-cover) photo: the star turns to an outline. + await tester.fling(find.byType(PageView).last, const Offset(-400, 0), 1000); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.star_border), findsOneWidget); + + // Setting it as cover snaps the view back to the (new) cover: filled again. + await tester.tap(find.byKey(const Key('photo.cover'))); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.star), findsOneWidget); + expect(find.byIcon(Icons.star_border), findsNothing); + await disposeTree(tester); + }); } diff --git a/docs/mockups/index.html b/docs/mockups/index.html index f528c98..f9cc685 100644 --- a/docs/mockups/index.html +++ b/docs/mockups/index.html @@ -1,2 +1,2 @@ -mockups

99 Main page


01 Start page


02 Main menu


03 Sidebar menu


04 Search page


05 Search item


06 Inventory


07 Inventory item


071 Inventory item edit


08 Your profile


09 Public profile


10 Chat


mockups

Tue May 29 2018 18:43:18 GMT+0200 (CEST)

\ No newline at end of file +mockups
Rediseño Material 3 · v3 (2026): las pantallas modernizadas viven en seedees_material3.html (HTML self-contained, 10 pantallas incl. Home, app bars verdes alineadas con la app real). Las imágenes de abajo son el set original.

99 Main page


01 Start page


02 Main menu


03 Sidebar menu


04 Search page


05 Search item


06 Inventory


07 Inventory item


071 Inventory item edit


08 Your profile


09 Public profile


10 Chat


mockups

Tue May 29 2018 18:43:18 GMT+0200 (CEST)

\ No newline at end of file diff --git a/docs/mockups/seedees_material3.html b/docs/mockups/seedees_material3.html new file mode 100644 index 0000000..e367969 --- /dev/null +++ b/docs/mockups/seedees_material3.html @@ -0,0 +1,254 @@ + + + + + + +Tanemaki — Material mockups (v3) + + + + + + +
+
+
+ Tanemaki +
+
Tanemaki · Material mockups
+
Aligned with the real app — green app bars, botanical content, search, batches and action sheets.
+
+
+
+
+ + +
+
+
9:41network_wifisignal_cellular_altbattery_full
+
menuTanemakisearch
+
+
+ a + j + e + b +
+
+
Tanemaki
+
Tanemaki
+
Share and grow local seeds
+ + +
+
+
+
00 · Home
+
+ + +
+
+
9:41network_wifisignal_cellular_altbattery_full
+
menuInventory
+
+
searchSearch seeds
+
Poaceae
+
M
Golden Bantam Corn
Zea mays
edit
+
Solanaceae
+
T
Chestnut Tomato
Solanum lycopersicum
edit
+
add
+
+
+
01 · Inventory
+
+ +
+
+
9:41network_wifisignal_cellular_altbattery_full
+
arrow_backChestnut Tomatoeditdelete
+
+
+
Solanaceae
Solanum lycopersicum
+
add_a_photoAdd photo
+
+
Also known as
+ +
Links
+ +
BatchesaddAdd batch
+
kbSeeds
Unknown year · a few
grass
+
+
+
02 · Seed detail
+
+ + +
+
+
9:41network_wifisignal_cellular_altbattery_full
+
arrow_backChestnut Tomato
+
+
+
+
Add batch
+
+
bSeeds
+
potted_plantSeedling
+
+
Harvest date
+
calendar_monthYear 2026
+
How much?
+
+
ka few
+
fteaspoon
+
gspoon
+
ipacket
+
hcup
+
ajar
+
esack
+
grasscob
+
ecopod
+
grassspike
+
local_floristflower head
+
local_floristfruit
+
spabulb
+
spatuber
+
grassbunch
+
+
Cancel
+
+
+
+
03 · Add batch
+
+ +
+
+
9:41network_wifisignal_cellular_altbattery_full
+
arrow_backChestnut Tomatoedit
+
+
+
+
Edit seed
+
NameChestnut Tomato
+
Species (from catalog)ecoSolanum lycopersicum
+
CategorySolanaceae
+
Notes
+
Cancel
+
+
+
+
04 · Edit seed
+
+ + +
+
+
9:41network_wifisignal_cellular_altbattery_full
+
+
+
+
Tanemaki
+ +
settingsSettings
+
+
+
+
05 · Side menu
+
+ +
+
+
9:41network_wifisignal_cellular_altbattery_full
+
menuMarketnear_mesearch
+
+
near_meNearby
Exchange
Family
+
+
tomate.jpg
Chestnut Tomato
Solanum lycopersicum
swap_horizBarterb
location_on2 km
+
maíz.jpg
Golden Bantam Corn
Zea mays
1.20 €grass
location_on5 km
+
girasol
Sunflower
Helianthus annuus
0.40 €a
location_on800 m
+
calabaza
Squash
Cucurbita maxima
swap_horizBarteri
location_on3 km
+
+
+
+
06 · Market (coming soon)
+
+ + +
+
+
9:41network_wifisignal_cellular_altbattery_full
+
arrow_backChestnut Tomatosharefavorite
+
+
tomate_castañal.jpg
avatar
+
+
Chestnut Tomato
+
Solanum lycopersicum · Solanaceae
+
swap_horizExchangelocation_on2 kmstar5.0
+

Local seasonal tomato variety. Open-pollinated seeds, 2025 harvest. Looking to trade for legumes or herbs.

+ +
Similar
+
+
+
+
+
07 · Exchange detail (coming soon)
+
+ +
+
+
9:41network_wifisignal_cellular_altbattery_full
+
arrow_backYour profileedit
+
+
+
avatar
+
Orto del Sole
Perugia, Italy
starstarstarstarstar5.0 · 5 reviews
+
+
12
seeds
34
exchanges
8
following
+
map · approx. locationlocation_on
+
mailhuerta@ejemplo.com
+
verified_userVerified member since 2024
+
+
+
08 · Profile (coming soon)
+
+ + +
+
+
9:41network_wifisignal_cellular_altbattery_full
+
arrow_backhort
Orto del Sole
online
search
+
+
Apr 4
+
Glad you're interested in these tomato seeds!
17:22
+
Can we meet this Saturday at your garden?
17:24done_all
+
Sure! I'll bring a few packets.
17:25
+
lockEnd-to-end encrypted conversation
+
+
moodWrite a messageattach_file
send
+
+
09 · Chat (coming soon)
+
+ +
+
+ + diff --git a/docs/mockups/seedees_material3_assets/logo.png b/docs/mockups/seedees_material3_assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a2db48227a6ff852eb50b622fcde71cbc42a8f91 GIT binary patch literal 17094 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 T6}trf<^%+5Vr|@n@w)auzrzhg literal 0 HcmV?d00001 diff --git a/docs/mockups/seedees_material3_assets/seedks.ttf b/docs/mockups/seedees_material3_assets/seedks.ttf new file mode 100644 index 0000000000000000000000000000000000000000..47a1bb3c0271203ea8805b5839ab01e8616f5bed GIT binary patch literal 11016 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 literal 0 HcmV?d00001