diff --git a/apps/app_seeds/fonts/seedks.ttf b/apps/app_seeds/fonts/seedks.ttf new file mode 100644 index 0000000..47a1bb3 Binary files /dev/null and b/apps/app_seeds/fonts/seedks.ttf differ diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 2d415ee..1579674 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -9,6 +9,7 @@ import 'i18n/strings.g.dart'; import 'state/inventory_cubit.dart'; import 'state/variety_detail_cubit.dart'; import 'ui/inventory_list_screen.dart'; +import 'ui/theme.dart'; import 'ui/variety_detail_screen.dart'; /// Root widget. Provides the repository + inventory cubit to the tree and wires @@ -54,10 +55,7 @@ class TaneApp extends StatelessWidget { child: MaterialApp.router( onGenerateTitle: (context) => context.t.app.title, debugShowCheckedModeBanner: false, - theme: ThemeData( - colorSchemeSeed: const Color(0xFF4C7A34), // seed-green - useMaterial3: true, - ), + theme: buildTaneTheme(), locale: TranslationProvider.of(context).flutterLocale, supportedLocales: AppLocaleUtils.supportedLocales, localizationsDelegates: const [ diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 307fca3..34e1d81 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -12,6 +12,7 @@ class VarietyListItem extends Equatable { required this.id, required this.label, this.category, + this.scientificName, this.photo, }); @@ -19,11 +20,14 @@ class VarietyListItem extends Equatable { final String label; final String? category; + /// Scientific name of the linked species, shown as the tile subtitle. + final String? scientificName; + /// First photo (encrypted BLOB) for the avatar, or null → show an initial. final Uint8List? photo; @override - List get props => [id, label, category, photo]; + List get props => [id, label, category, scientificName, photo]; } /// One germination test on a lot; [rate] is derived (0..1), null when it can't @@ -154,12 +158,18 @@ class VarietyRepository { ]); return query.watch().asyncMap((rows) async { final photos = await _firstPhotosFor(rows.map((v) => v.id).toList()); + final sciNames = await _scientificNamesFor( + rows.map((v) => v.speciesId).whereType().toSet(), + ); return rows .map( (v) => VarietyListItem( id: v.id, label: v.label, category: v.category, + scientificName: v.speciesId == null + ? null + : sciNames[v.speciesId], photo: photos[v.id], ), ) @@ -189,6 +199,17 @@ class VarietyRepository { return byVariety; } + /// Maps each of [speciesIds] to its scientific name (one query). + Future> _scientificNamesFor( + Set speciesIds, + ) async { + if (speciesIds.isEmpty) return const {}; + final rows = await (_db.select( + _db.species, + )..where((s) => s.id.isIn(speciesIds))).get(); + return {for (final s in rows) s.id: s.scientificName}; + } + /// The 20-second quick-add: a [label] (required) plus an optional [category], /// an optional [quantity] (creates a Lot) and an optional [photoBytes] /// (stored as an encrypted BLOB Attachment). Returns the new Variety id. diff --git a/apps/app_seeds/lib/ui/inventory_list_screen.dart b/apps/app_seeds/lib/ui/inventory_list_screen.dart index f4a9f4c..9c2e52e 100644 --- a/apps/app_seeds/lib/ui/inventory_list_screen.dart +++ b/apps/app_seeds/lib/ui/inventory_list_screen.dart @@ -6,6 +6,8 @@ import '../data/variety_repository.dart'; import '../i18n/strings.g.dart'; import '../state/inventory_cubit.dart'; import 'quick_add_sheet.dart'; +import 'seed_glyph.dart'; +import 'theme.dart'; /// The inventory home: a searchable list of seeds grouped by category, with a /// quick-add FAB. Driven by [InventoryCubit] over the encrypted DB stream. @@ -34,13 +36,19 @@ class InventoryListScreen extends StatelessWidget { return Column( children: [ Padding( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), child: TextField( key: const Key('inventory.search'), decoration: InputDecoration( hintText: t.inventory.searchHint, prefixIcon: const Icon(Icons.search), - border: const OutlineInputBorder(), + filled: true, + fillColor: Colors.white, + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), ), onChanged: context.read().search, ), @@ -66,10 +74,21 @@ class _InventoryBody extends StatelessWidget { return Center( child: Padding( padding: const EdgeInsets.all(24), - child: Text( - t.inventory.empty, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SeedGlyph( + SeedGlyphs.jars, + size: 72, + color: seedGreen.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + t.inventory.empty, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], ), ), ); @@ -99,11 +118,12 @@ class _CategoryHeader extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + padding: const EdgeInsets.fromLTRB(16, 18, 16, 6), child: Text( title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: seedGreenDark, ), ), ); @@ -117,16 +137,53 @@ class _VarietyTile extends StatelessWidget { @override Widget build(BuildContext context) { + void open() => context.push('/variety/${item.id}'); + return ListTile( + leading: _Avatar(item: item), + title: Text( + item.label, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: item.scientificName == null + ? null + : Text( + item.scientificName!, + style: const TextStyle(fontStyle: FontStyle.italic), + ), + trailing: IconButton( + icon: const Icon(Icons.edit_outlined), + color: Colors.black38, + tooltip: context.t.common.edit, + onPressed: open, + ), + onTap: open, + ); + } +} + +/// Rounded-square photo thumbnail, or a green initial circle when there is none. +class _Avatar extends StatelessWidget { + const _Avatar({required this.item}); + + final VarietyListItem item; + + @override + Widget build(BuildContext context) { + final photo = item.photo; + if (photo != null) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory(photo, width: 48, height: 48, fit: BoxFit.cover), + ); + } final trimmed = item.label.trim(); final initial = trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase(); - return ListTile( - leading: item.photo != null - ? CircleAvatar(backgroundImage: MemoryImage(item.photo!)) - : CircleAvatar(child: Text(initial)), - title: Text(item.label), - onTap: () => context.push('/variety/${item.id}'), + return CircleAvatar( + backgroundColor: seedGreen.withValues(alpha: 0.15), + foregroundColor: seedGreenDark, + child: Text(initial), ); } } diff --git a/apps/app_seeds/lib/ui/quick_add_sheet.dart b/apps/app_seeds/lib/ui/quick_add_sheet.dart index 405d2ef..0921011 100644 --- a/apps/app_seeds/lib/ui/quick_add_sheet.dart +++ b/apps/app_seeds/lib/ui/quick_add_sheet.dart @@ -9,6 +9,7 @@ import '../data/variety_repository.dart'; import '../i18n/strings.g.dart'; import '../state/quick_add_cubit.dart'; import 'quantity_kind_l10n.dart'; +import 'seed_glyph.dart'; /// Picks a photo and returns its bytes (or null if cancelled). Injected so /// widget tests can supply a fake instead of the camera plugin. @@ -103,6 +104,7 @@ class QuickAddSheet extends StatelessWidget { children: [ for (final kind in _quickKinds) ChoiceChip( + avatar: QuantityKindIcon(kind, size: 18), label: Text(quantityKindLabel(t, kind)), selected: state.quantityKind == kind, onSelected: (_) => cubit.selectQuantity(kind), diff --git a/apps/app_seeds/lib/ui/seed_glyph.dart b/apps/app_seeds/lib/ui/seed_glyph.dart new file mode 100644 index 0000000..086b62a --- /dev/null +++ b/apps/app_seeds/lib/ui/seed_glyph.dart @@ -0,0 +1,87 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.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. +class SeedGlyph extends StatelessWidget { + const SeedGlyph(this.char, {this.size = 24, this.color, super.key}); + + final String char; + final double size; + final Color? color; + + @override + Widget build(BuildContext context) { + return Text( + char, + style: TextStyle( + fontFamily: 'seedks', + fontSize: size, + height: 1, + color: color ?? Theme.of(context).colorScheme.primary, + ), + ); + } +} + +/// Named seedks glyphs (the character each pictogram is mapped to). +abstract final class SeedGlyphs { + static const jar = 'a'; + static const jars = 'b'; + static const search = 'c'; + static const share = 'd'; + static const sack = 'e'; + static const smallSpoon = 'f'; + static const bigSpoon = 'g'; + static const mug = 'h'; + static const envelope = 'i'; + static const pouring = 'j'; + static const scattered = 'k'; +} + +/// The seedks character for a quantity [kind], or null for plant-form/precise +/// kinds that have no seed pictogram (they fall back to a Material icon). +String? seedGlyphForKind(QuantityKind kind) => switch (kind) { + QuantityKind.aFew => SeedGlyphs.scattered, + QuantityKind.some => SeedGlyphs.smallSpoon, + QuantityKind.plenty => SeedGlyphs.jars, + QuantityKind.handful => SeedGlyphs.bigSpoon, + QuantityKind.pinch => SeedGlyphs.scattered, + QuantityKind.jar => SeedGlyphs.jar, + QuantityKind.packet => SeedGlyphs.envelope, + _ => null, +}; + +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.bulb || + QuantityKind.tuber || + QuantityKind.bunch => Icons.spa_outlined, + _ => Icons.eco_outlined, +}; + +/// A consistent icon for a [QuantityKind]: the seed glyph where one exists, +/// otherwise a Material fallback. +class QuantityKindIcon extends StatelessWidget { + const QuantityKindIcon(this.kind, {this.size = 24, this.color, super.key}); + + final QuantityKind kind; + final double size; + final Color? color; + + @override + Widget build(BuildContext context) { + final glyph = seedGlyphForKind(kind); + if (glyph != null) return SeedGlyph(glyph, size: size, color: color); + return Icon( + _materialIconForKind(kind), + size: size, + color: color ?? Theme.of(context).colorScheme.primary, + ); + } +} diff --git a/apps/app_seeds/lib/ui/theme.dart b/apps/app_seeds/lib/ui/theme.dart new file mode 100644 index 0000000..1126683 --- /dev/null +++ b/apps/app_seeds/lib/ui/theme.dart @@ -0,0 +1,35 @@ +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); + +ThemeData buildTaneTheme() { + final scheme = ColorScheme.fromSeed( + seedColor: seedGreen, + brightness: Brightness.light, + ); + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + scaffoldBackgroundColor: seedCanvas, + appBarTheme: const AppBarTheme( + backgroundColor: seedGreen, + foregroundColor: Colors.white, + elevation: 0, + centerTitle: false, + ), + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: seedGreen, + foregroundColor: Colors.white, + ), + bottomSheetTheme: const BottomSheetThemeData( + backgroundColor: seedCanvas, + surfaceTintColor: Colors.transparent, + ), + 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 e0e2abc..1ef55a9 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -6,6 +6,8 @@ import '../data/species_repository.dart'; import '../data/variety_repository.dart'; import '../i18n/strings.g.dart'; import '../state/variety_detail_cubit.dart'; +import 'seed_glyph.dart'; +import 'theme.dart'; import 'quantity_kind_l10n.dart'; /// Read + edit view of a single variety: its photo, category, notes, other @@ -72,33 +74,7 @@ class _DetailView extends StatelessWidget { body: ListView( padding: const EdgeInsets.all(16), children: [ - if (detail.scientificName != null) ...[ - Text( - detail.scientificName!, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontStyle: FontStyle.italic, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 12), - ], - if (detail.photo != null) - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.memory( - detail.photo!, - height: 200, - width: double.infinity, - fit: BoxFit.cover, - ), - ), - if (detail.category != null) ...[ - const SizedBox(height: 12), - Align( - alignment: Alignment.centerLeft, - child: Chip(label: Text(detail.category!)), - ), - ], + _DetailHeader(detail: detail), if (detail.vernacularNames.isNotEmpty) ...[ const SizedBox(height: 16), _SectionTitle(t.detail.names), @@ -134,7 +110,9 @@ class _DetailView extends StatelessWidget { for (final lot in detail.lots) ListTile( contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.inventory_2_outlined), + leading: lot.quantity == null + ? const Icon(Icons.inventory_2_outlined) + : QuantityKindIcon(lot.quantity!.kind, size: 28), title: Text(_lotSubtitle(t, lot)), subtitle: lot.storageLocation == null ? null @@ -548,6 +526,7 @@ Future _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) { children: [ for (final kind in _addLotKinds) ChoiceChip( + avatar: QuantityKindIcon(kind, size: 18), label: Text(quantityKindLabel(t, kind)), selected: selectedKind == kind, onSelected: (_) => setState(() => selectedKind = kind), @@ -598,6 +577,61 @@ String? _nullIfBlank(String value) { return trimmed.isEmpty ? null : trimmed; } +/// Mockup-07 header: category link + scientific name on the left, the photo on +/// the right. +class _DetailHeader extends StatelessWidget { + const _DetailHeader({required this.detail}); + + final VarietyDetail detail; + + @override + Widget build(BuildContext context) { + final hasText = detail.category != null || detail.scientificName != null; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (detail.category != null) + Text( + detail.category!, + style: const TextStyle( + color: seedLink, + fontWeight: FontWeight.w600, + ), + ), + if (detail.scientificName != null) ...[ + const SizedBox(height: 2), + Text( + detail.scientificName!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + if (detail.photo != null) ...[ + if (hasText) const SizedBox(width: 12), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.memory( + detail.photo!, + width: 140, + height: 140, + fit: BoxFit.cover, + ), + ), + ], + ], + ); + } +} + class _SectionTitle extends StatelessWidget { const _SectionTitle(this.text); diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index 8d4e6c3..f2cf588 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -66,3 +66,8 @@ flutter: # Seed strings for slang live under lib/i18n (not a Flutter asset; compiled). assets: - assets/catalog/species.json + fonts: + # Custom seed-themed icon glyphs (chars a–k). See docs/mockups/seedks-glyphs.png. + - family: seedks + fonts: + - asset: fonts/seedks.ttf diff --git a/apps/app_seeds/test/data/variety_detail_test.dart b/apps/app_seeds/test/data/variety_detail_test.dart index f3cea69..435f222 100644 --- a/apps/app_seeds/test/data/variety_detail_test.dart +++ b/apps/app_seeds/test/data/variety_detail_test.dart @@ -83,6 +83,10 @@ void main() { final detail = await repo.watchVariety(id).first; expect(detail!.scientificName, 'Solanum lycopersicum'); expect(detail.category, 'Solanaceae'); // prefilled from family + + // The inventory list row also surfaces the scientific name (subtitle). + final row = (await repo.watchInventory().first).single; + expect(row.scientificName, 'Solanum lycopersicum'); }, ); diff --git a/docs/design/block1-status.md b/docs/design/block1-status.md index 2ac311c..54683d6 100644 --- a/docs/design/block1-status.md +++ b/docs/design/block1-status.md @@ -37,11 +37,48 @@ JSON por locale, compatible Weblate). Ver [`../../apps/app_seeds`](../../apps/ap - **Editar nombres vernáculos** ("también conocida como": hoy solo lectura; falta añadir/quitar — colección OR-Set). -- **Editar/borrar lotes** (se pueden añadir pero no modificar/eliminar). +- **Editar/borrar lotes** (se pueden añadir pero no modificar/eliminar). Falta + `updateLot`/`softDeleteLot` en el repo (hoy solo `addLot` en + [`variety_repository.dart`](../../apps/app_seeds/lib/data/variety_repository.dart)) + y la afordancia en las tiles de lote de + [`variety_detail_screen.dart`](../../apps/app_seeds/lib/ui/variety_detail_screen.dart). + El soft-delete ya tiene infraestructura (`isDeleted`/tombstone vía `SyncColumns`). +- **Editar cantidad del lote**: la UI de alta solo ofrece 6 `QuantityKind` como + chips; falta campo para cantidad numérica (`precise`) y etiqueta libre (`label`), + que el modelo (`Quantity`) ya soporta. - (Opcional) QR de recuperación de la semilla raíz; almacenamiento cifrado de fotos fuera de la BD (hoy van como BLOB cifrado dentro de la BD, que cumple la regla). +### Feedback de prueba (vjrj, 2026-07-07) + +Tras probar la app: no se pueden editar unidades/cantidad ni editar/borrar lotes +(solo añadir) — **esperado**, cubierto por los pendientes de arriba. Echa en falta +la iconografía de los mockups (ver nota siguiente). + +## Iconografía de los mockups (diseño futuro) 🎨 + +La app usa hoy **Material Icons genéricos**. Los iconos de los mockups **existen** ya +como font custom: **[`seedks.ttf`](../../seedks.ttf)** (raíz del repo, trackeado en +git), familia interna **"Seedks"**, **11 glifos** en `a`–`k` (U+0061–U+006B), de la +misma época que [`../icons.svg`](../icons.svg) (2018). Preview renderizado: +[`../mockups/seedks-glyphs.png`](../mockups/seedks-glyphs.png). + +Mapeo de glifos: + +| Glifo | Icono | Glifo | Icono | Glifo | Icono | +|---|---|---|---|---|---| +| `a` | tarro | `e` | saco/bolsa | `i` | sobre | +| `b` | tarros (varios) | `f` | cucharilla | `j` | sobre vertiendo | +| `c` | búsqueda (lupa) | `g` | cuchara | `k` | semillas sueltas | +| `d` | mano compartiendo | `h` | taza | | | + +**No** está cableado: falta declarar `fonts:` en +[`../../apps/app_seeds/pubspec.yaml`](../../apps/app_seeds/pubspec.yaml) y crear un +mapa codepoint→`IconData` (`fontFamily: 'Seedks'`). Recuperar la iconografía **no** +requiere SVGs nuevos. Iconos por familia/categoría y por unidad de cantidad = +trabajo de diseño para después del pulido del Bloque 1. + ## Fuera de alcance (Bloque 2, no empezar) 🚫 Ofertas, mensajería, relays, Nostr, red de confianza, Ğ1, sincronización entre diff --git a/docs/mockups/seedks-glyphs.png b/docs/mockups/seedks-glyphs.png new file mode 100644 index 0000000..e290861 Binary files /dev/null and b/docs/mockups/seedks-glyphs.png differ