feat(ui): align inventory + detail with mockups (seed icons, green theme)

Visual pass toward docs/mockups (06 inventory, 07 item):
- Bundle the seedks icon font (chars a–k → seed pictograms: jar, packet, spoon…)
  and a SeedGlyph/QuantityKindIcon helper.
- Green app bar + pale-green canvas theme (buildTaneTheme).
- Inventory list: rounded-square photo thumbnail or green initial avatar, bold
  title, scientific-name subtitle, trailing edit pencil, styled category
  headers, rounded search field, seed-glyph empty state.
- Quantity selectors (quick-add + add-lot) show the seed pictogram + word;
  detail lot lines use the kind's glyph.
- Detail header restyled to mockup 07: category link + scientific name on the
  left, photo on the right.

watchInventory now also returns the linked species' scientific name (subtitle).
37 app tests green; seedks font verified in the Linux asset bundle.
This commit is contained in:
vjrj 2026-07-08 00:14:43 +02:00
parent e41ec9872b
commit 48e9d15772
12 changed files with 329 additions and 49 deletions

Binary file not shown.

View file

@ -9,6 +9,7 @@ import 'i18n/strings.g.dart';
import 'state/inventory_cubit.dart'; import 'state/inventory_cubit.dart';
import 'state/variety_detail_cubit.dart'; import 'state/variety_detail_cubit.dart';
import 'ui/inventory_list_screen.dart'; import 'ui/inventory_list_screen.dart';
import 'ui/theme.dart';
import 'ui/variety_detail_screen.dart'; import 'ui/variety_detail_screen.dart';
/// Root widget. Provides the repository + inventory cubit to the tree and wires /// Root widget. Provides the repository + inventory cubit to the tree and wires
@ -54,10 +55,7 @@ class TaneApp extends StatelessWidget {
child: MaterialApp.router( child: MaterialApp.router(
onGenerateTitle: (context) => context.t.app.title, onGenerateTitle: (context) => context.t.app.title,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: buildTaneTheme(),
colorSchemeSeed: const Color(0xFF4C7A34), // seed-green
useMaterial3: true,
),
locale: TranslationProvider.of(context).flutterLocale, locale: TranslationProvider.of(context).flutterLocale,
supportedLocales: AppLocaleUtils.supportedLocales, supportedLocales: AppLocaleUtils.supportedLocales,
localizationsDelegates: const [ localizationsDelegates: const [

View file

@ -12,6 +12,7 @@ class VarietyListItem extends Equatable {
required this.id, required this.id,
required this.label, required this.label,
this.category, this.category,
this.scientificName,
this.photo, this.photo,
}); });
@ -19,11 +20,14 @@ class VarietyListItem extends Equatable {
final String label; final String label;
final String? category; 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. /// First photo (encrypted BLOB) for the avatar, or null show an initial.
final Uint8List? photo; final Uint8List? photo;
@override @override
List<Object?> get props => [id, label, category, photo]; List<Object?> get props => [id, label, category, scientificName, photo];
} }
/// One germination test on a lot; [rate] is derived (0..1), null when it can't /// 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 { return query.watch().asyncMap((rows) async {
final photos = await _firstPhotosFor(rows.map((v) => v.id).toList()); final photos = await _firstPhotosFor(rows.map((v) => v.id).toList());
final sciNames = await _scientificNamesFor(
rows.map((v) => v.speciesId).whereType<String>().toSet(),
);
return rows return rows
.map( .map(
(v) => VarietyListItem( (v) => VarietyListItem(
id: v.id, id: v.id,
label: v.label, label: v.label,
category: v.category, category: v.category,
scientificName: v.speciesId == null
? null
: sciNames[v.speciesId],
photo: photos[v.id], photo: photos[v.id],
), ),
) )
@ -189,6 +199,17 @@ class VarietyRepository {
return byVariety; return byVariety;
} }
/// Maps each of [speciesIds] to its scientific name (one query).
Future<Map<String, String>> _scientificNamesFor(
Set<String> 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], /// The 20-second quick-add: a [label] (required) plus an optional [category],
/// an optional [quantity] (creates a Lot) and an optional [photoBytes] /// an optional [quantity] (creates a Lot) and an optional [photoBytes]
/// (stored as an encrypted BLOB Attachment). Returns the new Variety id. /// (stored as an encrypted BLOB Attachment). Returns the new Variety id.

View file

@ -6,6 +6,8 @@ import '../data/variety_repository.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../state/inventory_cubit.dart'; import '../state/inventory_cubit.dart';
import 'quick_add_sheet.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 /// The inventory home: a searchable list of seeds grouped by category, with a
/// quick-add FAB. Driven by [InventoryCubit] over the encrypted DB stream. /// quick-add FAB. Driven by [InventoryCubit] over the encrypted DB stream.
@ -34,13 +36,19 @@ class InventoryListScreen extends StatelessWidget {
return Column( return Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: TextField( child: TextField(
key: const Key('inventory.search'), key: const Key('inventory.search'),
decoration: InputDecoration( decoration: InputDecoration(
hintText: t.inventory.searchHint, hintText: t.inventory.searchHint,
prefixIcon: const Icon(Icons.search), 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<InventoryCubit>().search, onChanged: context.read<InventoryCubit>().search,
), ),
@ -66,10 +74,21 @@ class _InventoryBody extends StatelessWidget {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Text( child: Column(
t.inventory.empty, mainAxisSize: MainAxisSize.min,
textAlign: TextAlign.center, children: [
style: Theme.of(context).textTheme.bodyLarge, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), padding: const EdgeInsets.fromLTRB(16, 18, 16, 6),
child: Text( child: Text(
title, title,
style: Theme.of(context).textTheme.titleSmall?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600,
color: seedGreenDark,
), ),
), ),
); );
@ -117,16 +137,53 @@ class _VarietyTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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 trimmed = item.label.trim();
final initial = trimmed.isEmpty final initial = trimmed.isEmpty
? '?' ? '?'
: trimmed.substring(0, 1).toUpperCase(); : trimmed.substring(0, 1).toUpperCase();
return ListTile( return CircleAvatar(
leading: item.photo != null backgroundColor: seedGreen.withValues(alpha: 0.15),
? CircleAvatar(backgroundImage: MemoryImage(item.photo!)) foregroundColor: seedGreenDark,
: CircleAvatar(child: Text(initial)), child: Text(initial),
title: Text(item.label),
onTap: () => context.push('/variety/${item.id}'),
); );
} }
} }

View file

@ -9,6 +9,7 @@ import '../data/variety_repository.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../state/quick_add_cubit.dart'; import '../state/quick_add_cubit.dart';
import 'quantity_kind_l10n.dart'; import 'quantity_kind_l10n.dart';
import 'seed_glyph.dart';
/// Picks a photo and returns its bytes (or null if cancelled). Injected so /// Picks a photo and returns its bytes (or null if cancelled). Injected so
/// widget tests can supply a fake instead of the camera plugin. /// widget tests can supply a fake instead of the camera plugin.
@ -103,6 +104,7 @@ class QuickAddSheet extends StatelessWidget {
children: [ children: [
for (final kind in _quickKinds) for (final kind in _quickKinds)
ChoiceChip( ChoiceChip(
avatar: QuantityKindIcon(kind, size: 18),
label: Text(quantityKindLabel(t, kind)), label: Text(quantityKindLabel(t, kind)),
selected: state.quantityKind == kind, selected: state.quantityKind == kind,
onSelected: (_) => cubit.selectQuantity(kind), onSelected: (_) => cubit.selectQuantity(kind),

View file

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

View file

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

View file

@ -6,6 +6,8 @@ import '../data/species_repository.dart';
import '../data/variety_repository.dart'; import '../data/variety_repository.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../state/variety_detail_cubit.dart'; import '../state/variety_detail_cubit.dart';
import 'seed_glyph.dart';
import 'theme.dart';
import 'quantity_kind_l10n.dart'; import 'quantity_kind_l10n.dart';
/// Read + edit view of a single variety: its photo, category, notes, other /// Read + edit view of a single variety: its photo, category, notes, other
@ -72,33 +74,7 @@ class _DetailView extends StatelessWidget {
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
if (detail.scientificName != null) ...[ _DetailHeader(detail: detail),
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!)),
),
],
if (detail.vernacularNames.isNotEmpty) ...[ if (detail.vernacularNames.isNotEmpty) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
_SectionTitle(t.detail.names), _SectionTitle(t.detail.names),
@ -134,7 +110,9 @@ class _DetailView extends StatelessWidget {
for (final lot in detail.lots) for (final lot in detail.lots)
ListTile( ListTile(
contentPadding: EdgeInsets.zero, 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)), title: Text(_lotSubtitle(t, lot)),
subtitle: lot.storageLocation == null subtitle: lot.storageLocation == null
? null ? null
@ -548,6 +526,7 @@ Future<void> _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) {
children: [ children: [
for (final kind in _addLotKinds) for (final kind in _addLotKinds)
ChoiceChip( ChoiceChip(
avatar: QuantityKindIcon(kind, size: 18),
label: Text(quantityKindLabel(t, kind)), label: Text(quantityKindLabel(t, kind)),
selected: selectedKind == kind, selected: selectedKind == kind,
onSelected: (_) => setState(() => selectedKind = kind), onSelected: (_) => setState(() => selectedKind = kind),
@ -598,6 +577,61 @@ String? _nullIfBlank(String value) {
return trimmed.isEmpty ? null : trimmed; 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 { class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.text); const _SectionTitle(this.text);

View file

@ -66,3 +66,8 @@ flutter:
# Seed strings for slang live under lib/i18n (not a Flutter asset; compiled). # Seed strings for slang live under lib/i18n (not a Flutter asset; compiled).
assets: assets:
- assets/catalog/species.json - assets/catalog/species.json
fonts:
# Custom seed-themed icon glyphs (chars ak). See docs/mockups/seedks-glyphs.png.
- family: seedks
fonts:
- asset: fonts/seedks.ttf

View file

@ -83,6 +83,10 @@ void main() {
final detail = await repo.watchVariety(id).first; final detail = await repo.watchVariety(id).first;
expect(detail!.scientificName, 'Solanum lycopersicum'); expect(detail!.scientificName, 'Solanum lycopersicum');
expect(detail.category, 'Solanaceae'); // prefilled from family 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');
}, },
); );

View file

@ -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; - **Editar nombres vernáculos** ("también conocida como": hoy solo lectura;
falta añadir/quitar — colección OR-Set). 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 - (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 fotos fuera de la BD (hoy van como BLOB cifrado dentro de la BD, que cumple la
regla). 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+0061U+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) 🚫 ## Fuera de alcance (Bloque 2, no empezar) 🚫
Ofertas, mensajería, relays, Nostr, red de confianza, Ğ1, sincronización entre Ofertas, mensajería, relays, Nostr, red de confianza, Ğ1, sincronización entre

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB