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/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 [

View file

@ -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<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
@ -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<String>().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<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],
/// an optional [quantity] (creates a Lot) and an optional [photoBytes]
/// (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 '../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<InventoryCubit>().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),
);
}
}

View file

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

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 '../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<void> _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);

View file

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

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;
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+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) 🚫
Ofertas, mensajería, relays, Nostr, red de confianza, Ğ1, sincronización entre

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB