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.
This commit is contained in:
vjrj 2026-07-09 11:51:59 +02:00
parent f5c36f2369
commit 42c16c0e3f
24 changed files with 1960 additions and 416 deletions

View file

@ -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(

View file

@ -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" }
}

View file

@ -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" }
}

View file

@ -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

View file

@ -46,6 +46,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
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<AppLocale, Translations> {
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',

View file

@ -45,6 +45,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$photo$es photo = _Translations$photo$es._(_root);
@override late final _Translations$menu$es menu = _Translations$menu$es._(_root);
@override late final _Translations$settings$es settings = _Translations$settings$es._(_root);
@override late final _Translations$about$es about = _Translations$about$es._(_root);
@override late final _Translations$inventory$es inventory = _Translations$inventory$es._(_root);
@override late final _Translations$quickAdd$es quickAdd = _Translations$quickAdd$es._(_root);
@override late final _Translations$detail$es detail = _Translations$detail$es._(_root);
@ -53,6 +54,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$addLot$es addLot = _Translations$addLot$es._(_root);
@override late final _Translations$harvest$es harvest = _Translations$harvest$es._(_root);
@override late final _Translations$lotType$es lotType = _Translations$lotType$es._(_root);
@override late final _Translations$presentation$es presentation = _Translations$presentation$es._(_root);
@override late final _Translations$unit$es unit = _Translations$unit$es._(_root);
}
@ -88,8 +90,11 @@ class _Translations$home$es extends Translations$home$en {
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get tagline => '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',

View file

@ -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<PackageInfo>(
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);
},
);
}
}

View file

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

View file

@ -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,
),
),
),
),
],
);
},
),
);
}

View file

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

View file

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

View file

@ -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>[
QuantityKind.bunch,
];
/// Units for a plant/seedling lot.
const plantScaleKinds = <QuantityKind>[
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<QuantityKind> 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<QuantityKind> 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<LotType>(
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<Presentation?> 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 {

View file

@ -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),
),
],
),
],
),
),
);
},

View file

@ -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 {

View file

@ -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,
),
),
);

View file

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

View file

@ -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<void> _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<void> _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<void> _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<void>(
context: context,
@ -593,58 +607,84 @@ Future<void> _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<void> _showLotSheet(
year: selectedYear,
month: selectedMonth,
quantity: selectedQuantity,
presentation: selectedPresentation,
);
} else {
cubit.addLot(
@ -671,6 +712,7 @@ Future<void> _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<VarietyPhoto> photos,
VarietyDetailCubit cubit,
int initialIndex,
) {
Navigator.of(context).push(
MaterialPageRoute<void>(
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<VarietyPhoto> 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<void> _confirmDelete(
VarietyDetailCubit cubit,
VarietyPhoto photo,
) async {
final t = context.t;
final confirmed = await showDialog<bool>(
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<VarietyDetailCubit>();
return BlocBuilder<VarietyDetailCubit, VarietyDetailState>(
builder: (context, state) {
final photos = state.detail?.photos ?? const <VarietyPhoto>[];
// 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),
),
);
},
);
}
}

View file

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

View file

@ -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();

View file

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