diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 0eb5c0e..a260645 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -6,7 +6,9 @@ import 'package:go_router/go_router.dart'; import 'data/variety_repository.dart'; import 'i18n/strings.g.dart'; import 'state/inventory_cubit.dart'; +import 'state/variety_detail_cubit.dart'; import 'ui/inventory_list_screen.dart'; +import 'ui/variety_detail_screen.dart'; /// Root widget. Provides the repository + inventory cubit to the tree and wires /// go_router. The list is `/`; `/variety/:id` is a placeholder detail route @@ -30,8 +32,11 @@ class TaneApp extends StatelessWidget { ), GoRoute( path: '/variety/:id', - builder: (context, state) => - _VarietyDetailPlaceholder(id: state.pathParameters['id']!), + builder: (context, state) => BlocProvider( + create: (_) => + VarietyDetailCubit(repository, state.pathParameters['id']!), + child: const VarietyDetailScreen(), + ), ), ], ); @@ -60,17 +65,3 @@ class TaneApp extends StatelessWidget { ); } } - -class _VarietyDetailPlaceholder extends StatelessWidget { - const _VarietyDetailPlaceholder({required this.id}); - - final String id; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(), - body: Center(child: Text(id)), - ); - } -} diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index a0e2656..f2ecd0e 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -1,3 +1,4 @@ +import 'package:async/async.dart'; import 'package:commons_core/commons_core.dart'; import 'package:drift/drift.dart'; import 'package:equatable/equatable.dart'; @@ -17,6 +18,56 @@ class VarietyListItem extends Equatable { List get props => [id, label, category]; } +/// One held batch of a variety, for the detail view. +class VarietyLot extends Equatable { + const VarietyLot({ + required this.id, + this.harvestYear, + this.quantity, + this.storageLocation, + }); + + final String id; + final int? harvestYear; + final Quantity? quantity; + final String? storageLocation; + + @override + List get props => [id, harvestYear, quantity, storageLocation]; +} + +/// The full detail of one variety (identity + its lots, names and first photo). +class VarietyDetail extends Equatable { + const VarietyDetail({ + required this.id, + required this.label, + this.category, + this.notes, + this.lots = const [], + this.vernacularNames = const [], + this.photo, + }); + + final String id; + final String label; + final String? category; + final String? notes; + final List lots; + final List vernacularNames; + final Uint8List? photo; + + @override + List get props => [ + id, + label, + category, + notes, + lots, + vernacularNames, + photo, + ]; +} + /// Reads and writes the inventory. The encrypted Drift DB is the single source /// of truth; [watchInventory] exposes a reactive stream the UI subscribes to. /// @@ -120,6 +171,151 @@ class VarietyRepository { return varietyId; } + /// Reactively watches one variety with its lots, vernacular names and first + /// photo. Emits `null` if the variety does not exist or is soft-deleted. + /// + /// Re-emits whenever the variety or any related row changes: a merged trigger + /// stream over the four tables drives a full reload, so adding a lot (a change + /// to a *different* table) still refreshes the view. + Stream watchVariety(String id) { + final triggers = StreamGroup.merge([ + (_db.select( + _db.varieties, + )..where((v) => v.id.equals(id))).watch().map((_) {}), + (_db.select( + _db.lots, + )..where((l) => l.varietyId.equals(id))).watch().map((_) {}), + (_db.select( + _db.varietyVernacularNames, + )..where((n) => n.varietyId.equals(id))).watch().map((_) {}), + (_db.select( + _db.attachments, + )..where((a) => a.parentId.equals(id))).watch().map((_) {}), + ]); + return triggers.asyncMap((_) => _loadVariety(id)); + } + + Future _loadVariety(String id) async { + final v = + await (_db.select(_db.varieties) + ..where((t) => t.id.equals(id) & t.isDeleted.equals(false))) + .getSingleOrNull(); + if (v == null) return null; + + final lots = + await (_db.select(_db.lots) + ..where((l) => l.varietyId.equals(id) & l.isDeleted.equals(false)) + ..orderBy([(l) => OrderingTerm.desc(l.harvestYear)])) + .get(); + final names = await (_db.select( + _db.varietyVernacularNames, + )..where((n) => n.varietyId.equals(id) & n.isDeleted.equals(false))).get(); + final photos = + await (_db.select(_db.attachments) + ..where( + (a) => + a.parentId.equals(id) & + a.parentType.equalsValue(ParentType.variety) & + a.kind.equalsValue(AttachmentKind.photo) & + a.isDeleted.equals(false), + ) + ..limit(1)) + .get(); + + return VarietyDetail( + id: v.id, + label: v.label, + category: v.category, + notes: v.notes, + lots: lots.map(_toLot).toList(), + vernacularNames: names.map((n) => n.name).toList(), + photo: photos.isEmpty ? null : photos.first.bytes, + ); + } + + /// Updates a variety's scalar fields (LWW). Passing null clears [category] + /// and [notes]; a null [label] leaves the label unchanged. + Future updateVariety({ + required String id, + String? label, + String? category, + String? notes, + }) async { + final (_, updated) = _stamp(); + await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write( + VarietiesCompanion( + label: label == null ? const Value.absent() : Value(label), + category: Value(category), + notes: Value(notes), + updatedAt: Value(updated), + lastAuthor: Value(nodeId), + ), + ); + } + + /// Adds a lot (a held batch) to a variety. Returns the new lot id. + Future addLot({ + required String varietyId, + int? harvestYear, + Quantity? quantity, + String? storageLocation, + }) async { + final (created, updated) = _stamp(); + final id = idGen.newId(); + await _db + .into(_db.lots) + .insert( + LotsCompanion.insert( + id: id, + varietyId: varietyId, + createdAt: created, + updatedAt: updated, + lastAuthor: nodeId, + harvestYear: Value(harvestYear), + quantityKind: Value(quantity?.kind.name), + quantityPrecise: Value(quantity?.precise), + quantityLabel: Value(quantity?.label), + storageLocation: Value(storageLocation), + ), + ); + return id; + } + + /// Soft-deletes a variety (tombstone); it disappears from the inventory but + /// the row survives for correct CRDT merges later. + Future softDeleteVariety(String id) async { + final (_, updated) = _stamp(); + await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write( + VarietiesCompanion( + isDeleted: const Value(true), + updatedAt: Value(updated), + lastAuthor: Value(nodeId), + ), + ); + } + + VarietyLot _toLot(Lot l) { + final hasQuantity = + l.quantityKind != null || + l.quantityPrecise != null || + l.quantityLabel != null; + return VarietyLot( + id: l.id, + harvestYear: l.harvestYear, + storageLocation: l.storageLocation, + quantity: hasQuantity + ? Quantity( + kind: _parseKind(l.quantityKind), + precise: l.quantityPrecise, + label: l.quantityLabel, + ) + : null, + ); + } + + QuantityKind _parseKind(String? name) => + QuantityKind.values.asNameMap()[name] ?? QuantityKind.aFew; + /// Advances the local clock and returns `(createdAtMillis, packedHlc)`. (int, String) _stamp() { final now = _now(); diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 14b3f9f..be99d1b 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -2,6 +2,12 @@ "app": { "title": "Tanemaki" }, + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit" + }, "inventory": { "title": "Inventory", "searchHint": "Search seeds", @@ -18,6 +24,28 @@ "save": "Save", "cancel": "Cancel" }, + "detail": { + "notFound": "This seed is no longer here.", + "lots": "Lots", + "noLots": "No lots yet.", + "names": "Also known as", + "notes": "Notes", + "addLot": "Add lot", + "deleteConfirm": "Delete this seed?", + "year": "Year {year}", + "noYear": "Year unknown" + }, + "editVariety": { + "title": "Edit seed", + "name": "Name", + "category": "Category", + "notes": "Notes" + }, + "addLot": { + "title": "Add lot", + "year": "Harvest year", + "quantity": "Quantity" + }, "quantityKind": { "aFew": "a few", "some": "some", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 5197803..6240ba1 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -2,6 +2,12 @@ "app": { "title": "Tanemaki" }, + "common": { + "save": "Guardar", + "cancel": "Cancelar", + "delete": "Eliminar", + "edit": "Editar" + }, "inventory": { "title": "Inventario", "searchHint": "Buscar semillas", @@ -18,6 +24,28 @@ "save": "Guardar", "cancel": "Cancelar" }, + "detail": { + "notFound": "Esta semilla ya no está aquí.", + "lots": "Lotes", + "noLots": "Aún no hay lotes.", + "names": "También conocida como", + "notes": "Notas", + "addLot": "Añadir lote", + "deleteConfirm": "¿Eliminar esta semilla?", + "year": "Año {year}", + "noYear": "Año desconocido" + }, + "editVariety": { + "title": "Editar semilla", + "name": "Nombre", + "category": "Categoría", + "notes": "Notas" + }, + "addLot": { + "title": "Añadir lote", + "year": "Año de cosecha", + "quantity": "Cantidad" + }, "quantityKind": { "aFew": "unas pocas", "some": "algunas", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 9e225f8..bc3caac 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 2 -/// Strings: 62 (31 per locale) +/// Strings: 102 (51 per locale) /// -/// Built on 2026-07-07 at 13:12 UTC +/// Built on 2026-07-07 at 13:25 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index ed1891f..e2354fe 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -41,8 +41,12 @@ class Translations with BaseTranslations { // Translations late final Translations$app$en app = Translations$app$en.internal(_root); + late final Translations$common$en common = Translations$common$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); + late final Translations$editVariety$en editVariety = Translations$editVariety$en.internal(_root); + late final Translations$addLot$en addLot = Translations$addLot$en.internal(_root); late final Translations$quantityKind$en quantityKind = Translations$quantityKind$en.internal(_root); } @@ -58,6 +62,27 @@ class Translations$app$en { String get title => 'Tanemaki'; } +// Path: common +class Translations$common$en { + Translations$common$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Save' + String get save => 'Save'; + + /// en: 'Cancel' + String get cancel => 'Cancel'; + + /// en: 'Delete' + String get delete => 'Delete'; + + /// en: 'Edit' + String get edit => 'Edit'; +} + // Path: inventory class Translations$inventory$en { Translations$inventory$en.internal(this._root); @@ -112,6 +137,81 @@ class Translations$quickAdd$en { String get cancel => 'Cancel'; } +// Path: detail +class Translations$detail$en { + Translations$detail$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'This seed is no longer here.' + String get notFound => 'This seed is no longer here.'; + + /// en: 'Lots' + String get lots => 'Lots'; + + /// en: 'No lots yet.' + String get noLots => 'No lots yet.'; + + /// en: 'Also known as' + String get names => 'Also known as'; + + /// en: 'Notes' + String get notes => 'Notes'; + + /// en: 'Add lot' + String get addLot => 'Add lot'; + + /// en: 'Delete this seed?' + String get deleteConfirm => 'Delete this seed?'; + + /// en: 'Year {year}' + String year({required Object year}) => 'Year ${year}'; + + /// en: 'Year unknown' + String get noYear => 'Year unknown'; +} + +// Path: editVariety +class Translations$editVariety$en { + Translations$editVariety$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Edit seed' + String get title => 'Edit seed'; + + /// en: 'Name' + String get name => 'Name'; + + /// en: 'Category' + String get category => 'Category'; + + /// en: 'Notes' + String get notes => 'Notes'; +} + +// Path: addLot +class Translations$addLot$en { + Translations$addLot$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Add lot' + String get title => 'Add lot'; + + /// en: 'Harvest year' + String get year => 'Harvest year'; + + /// en: 'Quantity' + String get quantity => 'Quantity'; +} + // Path: quantityKind class Translations$quantityKind$en { Translations$quantityKind$en.internal(this._root); @@ -184,6 +284,10 @@ extension on Translations { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Tanemaki', + 'common.save' => 'Save', + 'common.cancel' => 'Cancel', + 'common.delete' => 'Delete', + 'common.edit' => 'Edit', 'inventory.title' => 'Inventory', 'inventory.searchHint' => 'Search seeds', 'inventory.empty' => 'No seeds yet. Tap + to add your first.', @@ -196,6 +300,22 @@ extension on Translations { 'quickAdd.more' => 'Add more…', 'quickAdd.save' => 'Save', 'quickAdd.cancel' => 'Cancel', + 'detail.notFound' => 'This seed is no longer here.', + 'detail.lots' => 'Lots', + 'detail.noLots' => 'No lots yet.', + 'detail.names' => 'Also known as', + 'detail.notes' => 'Notes', + 'detail.addLot' => 'Add lot', + 'detail.deleteConfirm' => 'Delete this seed?', + 'detail.year' => ({required Object year}) => 'Year ${year}', + 'detail.noYear' => 'Year unknown', + 'editVariety.title' => 'Edit seed', + 'editVariety.name' => 'Name', + 'editVariety.category' => 'Category', + 'editVariety.notes' => 'Notes', + 'addLot.title' => 'Add lot', + 'addLot.year' => 'Harvest year', + 'addLot.quantity' => 'Quantity', 'quantityKind.aFew' => 'a few', 'quantityKind.some' => 'some', 'quantityKind.plenty' => 'plenty', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index f2e0dfe..9a4bb70 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -40,8 +40,12 @@ class TranslationsEs extends Translations with BaseTranslations 'Tanemaki'; } +// Path: common +class _Translations$common$es extends Translations$common$en { + _Translations$common$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get save => 'Guardar'; + @override String get cancel => 'Cancelar'; + @override String get delete => 'Eliminar'; + @override String get edit => 'Editar'; +} + // Path: inventory class _Translations$inventory$es extends Translations$inventory$en { _Translations$inventory$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -85,6 +102,49 @@ class _Translations$quickAdd$es extends Translations$quickAdd$en { @override String get cancel => 'Cancelar'; } +// Path: detail +class _Translations$detail$es extends Translations$detail$en { + _Translations$detail$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get notFound => 'Esta semilla ya no está aquí.'; + @override String get lots => 'Lotes'; + @override String get noLots => 'Aún no hay lotes.'; + @override String get names => 'También conocida como'; + @override String get notes => 'Notas'; + @override String get addLot => 'Añadir lote'; + @override String get deleteConfirm => '¿Eliminar esta semilla?'; + @override String year({required Object year}) => 'Año ${year}'; + @override String get noYear => 'Año desconocido'; +} + +// Path: editVariety +class _Translations$editVariety$es extends Translations$editVariety$en { + _Translations$editVariety$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Editar semilla'; + @override String get name => 'Nombre'; + @override String get category => 'Categoría'; + @override String get notes => 'Notas'; +} + +// Path: addLot +class _Translations$addLot$es extends Translations$addLot$en { + _Translations$addLot$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Añadir lote'; + @override String get year => 'Año de cosecha'; + @override String get quantity => 'Cantidad'; +} + // Path: quantityKind class _Translations$quantityKind$es extends Translations$quantityKind$en { _Translations$quantityKind$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -121,6 +181,10 @@ extension on TranslationsEs { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Tanemaki', + 'common.save' => 'Guardar', + 'common.cancel' => 'Cancelar', + 'common.delete' => 'Eliminar', + 'common.edit' => 'Editar', 'inventory.title' => 'Inventario', 'inventory.searchHint' => 'Buscar semillas', 'inventory.empty' => 'Aún no hay semillas. Toca + para añadir la primera.', @@ -133,6 +197,22 @@ extension on TranslationsEs { 'quickAdd.more' => 'Añadir más…', 'quickAdd.save' => 'Guardar', 'quickAdd.cancel' => 'Cancelar', + 'detail.notFound' => 'Esta semilla ya no está aquí.', + 'detail.lots' => 'Lotes', + 'detail.noLots' => 'Aún no hay lotes.', + 'detail.names' => 'También conocida como', + 'detail.notes' => 'Notas', + 'detail.addLot' => 'Añadir lote', + 'detail.deleteConfirm' => '¿Eliminar esta semilla?', + 'detail.year' => ({required Object year}) => 'Año ${year}', + 'detail.noYear' => 'Año desconocido', + 'editVariety.title' => 'Editar semilla', + 'editVariety.name' => 'Nombre', + 'editVariety.category' => 'Categoría', + 'editVariety.notes' => 'Notas', + 'addLot.title' => 'Añadir lote', + 'addLot.year' => 'Año de cosecha', + 'addLot.quantity' => 'Cantidad', 'quantityKind.aFew' => 'unas pocas', 'quantityKind.some' => 'algunas', 'quantityKind.plenty' => 'muchas', diff --git a/apps/app_seeds/lib/state/variety_detail_cubit.dart b/apps/app_seeds/lib/state/variety_detail_cubit.dart new file mode 100644 index 0000000..0419cec --- /dev/null +++ b/apps/app_seeds/lib/state/variety_detail_cubit.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../data/variety_repository.dart'; + +class VarietyDetailState extends Equatable { + const VarietyDetailState({ + this.detail, + this.loading = true, + this.deleted = false, + }); + + final VarietyDetail? detail; + final bool loading; + final bool deleted; + + @override + List get props => [detail, loading, deleted]; +} + +/// Streams one variety's detail and edits it through the repository. +class VarietyDetailCubit extends Cubit { + VarietyDetailCubit(this._repo, this.varietyId) + : super(const VarietyDetailState()) { + _sub = _repo + .watchVariety(varietyId) + .listen( + (d) => emit( + VarietyDetailState( + detail: d, + loading: false, + deleted: state.deleted, + ), + ), + ); + } + + final VarietyRepository _repo; + final String varietyId; + late final StreamSubscription _sub; + + Future updateFields({String? label, String? category, String? notes}) => + _repo.updateVariety( + id: varietyId, + label: label, + category: category, + notes: notes, + ); + + Future addLot({int? year, Quantity? quantity}) => + _repo.addLot(varietyId: varietyId, harvestYear: year, quantity: quantity); + + Future deleteVariety() async { + await _repo.softDeleteVariety(varietyId); + emit( + VarietyDetailState(detail: state.detail, loading: false, deleted: true), + ); + } + + @override + Future close() async { + await _sub.cancel(); + return super.close(); + } +} diff --git a/apps/app_seeds/lib/ui/inventory_list_screen.dart b/apps/app_seeds/lib/ui/inventory_list_screen.dart index 86c26d2..87db76e 100644 --- a/apps/app_seeds/lib/ui/inventory_list_screen.dart +++ b/apps/app_seeds/lib/ui/inventory_list_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import '../data/variety_repository.dart'; import '../i18n/strings.g.dart'; @@ -123,6 +124,7 @@ class _VarietyTile extends StatelessWidget { return ListTile( leading: CircleAvatar(child: Text(initial)), title: Text(item.label), + onTap: () => context.push('/variety/${item.id}'), ); } } diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart new file mode 100644 index 0000000..e10ce65 --- /dev/null +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -0,0 +1,372 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../data/variety_repository.dart'; +import '../i18n/strings.g.dart'; +import '../state/variety_detail_cubit.dart'; +import 'quantity_kind_l10n.dart'; + +/// Read + edit view of a single variety: its photo, category, notes, other +/// names and lots. Edits go through [VarietyDetailCubit]; the view is reactive. +class VarietyDetailScreen extends StatelessWidget { + const VarietyDetailScreen({super.key}); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (prev, curr) => !prev.deleted && curr.deleted, + listener: (context, state) => Navigator.of(context).maybePop(), + builder: (context, state) { + if (state.loading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + final detail = state.detail; + if (detail == null) { + return Scaffold( + appBar: AppBar(), + body: Center(child: Text(context.t.detail.notFound)), + ); + } + return _DetailView(detail: detail); + }, + ); + } +} + +class _DetailView extends StatelessWidget { + const _DetailView({required this.detail}); + + final VarietyDetail detail; + + @override + Widget build(BuildContext context) { + final t = context.t; + final cubit = context.read(); + return Scaffold( + appBar: AppBar( + title: Text(detail.label), + actions: [ + IconButton( + key: const Key('detail.edit'), + icon: const Icon(Icons.edit_outlined), + tooltip: t.common.edit, + onPressed: () => _showEditSheet(context, cubit, detail), + ), + IconButton( + key: const Key('detail.delete'), + icon: const Icon(Icons.delete_outline), + tooltip: t.common.delete, + onPressed: () => _confirmDelete(context, cubit), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + 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) ...[ + const SizedBox(height: 16), + _SectionTitle(t.detail.names), + Wrap( + spacing: 8, + children: [ + for (final name in detail.vernacularNames) + Chip(label: Text(name)), + ], + ), + ], + if (detail.notes != null && detail.notes!.trim().isNotEmpty) ...[ + const SizedBox(height: 16), + _SectionTitle(t.detail.notes), + Text(detail.notes!), + ], + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _SectionTitle(t.detail.lots), + TextButton.icon( + key: const Key('detail.addLot'), + onPressed: () => _showAddLotSheet(context, cubit), + icon: const Icon(Icons.add), + label: Text(t.detail.addLot), + ), + ], + ), + if (detail.lots.isEmpty) + Text(t.detail.noLots) + else + for (final lot in detail.lots) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.inventory_2_outlined), + title: Text(_lotSubtitle(t, lot)), + subtitle: lot.storageLocation == null + ? null + : Text(lot.storageLocation!), + ), + ], + ), + ); + } +} + +String _lotSubtitle(Translations t, VarietyLot lot) { + final parts = [ + if (lot.harvestYear != null) + t.detail.year(year: lot.harvestYear!) + else + t.detail.noYear, + if (lot.quantity != null) _quantityLabel(t, lot.quantity!), + ]; + return parts.join(' · '); +} + +String _quantityLabel(Translations t, Quantity q) { + final kind = quantityKindLabel(t, q.kind); + if (q.precise != null) return '${_trimDouble(q.precise!)} $kind'; + return kind; +} + +String _trimDouble(double v) => + v == v.roundToDouble() ? v.toStringAsFixed(0) : v.toString(); + +Future _confirmDelete( + BuildContext context, + VarietyDetailCubit cubit, +) async { + final t = context.t; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + content: Text(t.detail.deleteConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(t.common.cancel), + ), + FilledButton( + key: const Key('detail.deleteConfirm'), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(t.common.delete), + ), + ], + ), + ); + if (confirmed ?? false) await cubit.deleteVariety(); +} + +Future _showEditSheet( + BuildContext context, + VarietyDetailCubit cubit, + VarietyDetail detail, +) { + final t = context.t; + final nameController = TextEditingController(text: detail.label); + final categoryController = TextEditingController(text: detail.category ?? ''); + final notesController = TextEditingController(text: detail.notes ?? ''); + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetContext) => Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + t.editVariety.title, + style: Theme.of(sheetContext).textTheme.titleLarge, + ), + const SizedBox(height: 12), + TextField( + key: const Key('editVariety.name'), + controller: nameController, + decoration: InputDecoration( + labelText: t.editVariety.name, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: categoryController, + decoration: InputDecoration( + labelText: t.editVariety.category, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: notesController, + minLines: 2, + maxLines: 5, + decoration: InputDecoration( + labelText: t.editVariety.notes, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + TextButton( + onPressed: () => Navigator.of(sheetContext).pop(), + child: Text(t.common.cancel), + ), + const Spacer(), + FilledButton( + key: const Key('editVariety.save'), + onPressed: () { + final name = nameController.text.trim(); + cubit.updateFields( + label: name.isEmpty ? null : name, + category: _nullIfBlank(categoryController.text), + notes: _nullIfBlank(notesController.text), + ); + Navigator.of(sheetContext).pop(); + }, + child: Text(t.common.save), + ), + ], + ), + ], + ), + ), + ); +} + +Future _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) { + final t = context.t; + final yearController = TextEditingController(); + QuantityKind? selectedKind; + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetContext) => StatefulBuilder( + builder: (sheetContext, setState) => Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + t.addLot.title, + style: Theme.of(sheetContext).textTheme.titleLarge, + ), + const SizedBox(height: 12), + TextField( + key: const Key('addLot.year'), + controller: yearController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: t.addLot.year, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + Text( + t.addLot.quantity, + style: Theme.of(sheetContext).textTheme.labelLarge, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + for (final kind in _addLotKinds) + ChoiceChip( + label: Text(quantityKindLabel(t, kind)), + selected: selectedKind == kind, + onSelected: (_) => setState(() => selectedKind = kind), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + TextButton( + onPressed: () => Navigator.of(sheetContext).pop(), + child: Text(t.common.cancel), + ), + const Spacer(), + FilledButton( + key: const Key('addLot.save'), + onPressed: () { + cubit.addLot( + year: int.tryParse(yearController.text.trim()), + quantity: selectedKind == null + ? null + : Quantity(kind: selectedKind!), + ); + Navigator.of(sheetContext).pop(); + }, + child: Text(t.common.save), + ), + ], + ), + ], + ), + ), + ), + ); +} + +const _addLotKinds = [ + QuantityKind.aFew, + QuantityKind.handful, + QuantityKind.packet, + QuantityKind.pod, + QuantityKind.cob, + QuantityKind.grams, +]; + +String? _nullIfBlank(String value) { + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; +} + +class _SectionTitle extends StatelessWidget { + const _SectionTitle(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + text, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index e46f378..7824e38 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -21,6 +21,9 @@ dependencies: flutter_bloc: ^9.1.1 equatable: ^2.0.5 + # Stream combinators (StreamGroup) for reactive multi-table reads. + async: ^2.11.0 + # Encrypted local database. drift: ^2.28.0 drift_flutter: ^0.2.4 diff --git a/apps/app_seeds/slang.yaml b/apps/app_seeds/slang.yaml index 6f6331d..a21971b 100644 --- a/apps/app_seeds/slang.yaml +++ b/apps/app_seeds/slang.yaml @@ -7,6 +7,9 @@ output_file_name: strings.g.dart translate_var: t enum_name: AppLocale class_name: Translations +# Use {param} placeholders (translator-friendly, Weblate-compatible) instead of +# slang's default Dart-style $param. +string_interpolation: braces # Missing keys in a non-base locale fall back to the base locale at runtime. fallback_strategy: base_locale # Compile all locales in (not deferred), so setLocaleSync / useDeviceLocaleSync diff --git a/apps/app_seeds/test/data/variety_detail_test.dart b/apps/app_seeds/test/data/variety_detail_test.dart new file mode 100644 index 0000000..16d3167 --- /dev/null +++ b/apps/app_seeds/test/data/variety_detail_test.dart @@ -0,0 +1,63 @@ +import 'package:async/async.dart'; +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/variety_repository.dart'; +import 'package:tane/db/database.dart'; + +import '../support/test_support.dart'; + +void main() { + late AppDatabase db; + late VarietyRepository repo; + + setUp(() { + db = newTestDatabase(); + repo = newTestRepository(db); + }); + tearDown(() => db.close()); + + test('watchVariety emits null for a missing variety', () async { + expect(await repo.watchVariety('does-not-exist').first, isNull); + }); + + test('watchVariety reacts to a lot being added', () async { + final id = await repo.addQuickVariety(label: 'Maize', category: 'Poaceae'); + final queue = StreamQueue(repo.watchVariety(id)); + + final initial = await queue.next; + expect(initial!.label, 'Maize'); + expect(initial.lots, isEmpty); + + await repo.addLot( + varietyId: id, + harvestYear: 2024, + quantity: const Quantity(kind: QuantityKind.cob), + ); + + final updated = await queue.next; + expect(updated!.lots.single.harvestYear, 2024); + expect(updated.lots.single.quantity?.kind, QuantityKind.cob); + + await queue.cancel(); + }); + + test('updateVariety changes scalar fields (last-writer-wins)', () async { + final id = await repo.addQuickVariety(label: 'Old name'); + await repo.updateVariety(id: id, label: 'New name', notes: 'a note'); + + final detail = await repo.watchVariety(id).first; + expect(detail!.label, 'New name'); + expect(detail.notes, 'a note'); + }); + + test( + 'softDeleteVariety hides it from watchVariety and the inventory', + () async { + final id = await repo.addQuickVariety(label: 'Doomed'); + await repo.softDeleteVariety(id); + + expect(await repo.watchVariety(id).first, isNull); + expect(await repo.watchInventory().first, isEmpty); + }, + ); +} diff --git a/apps/app_seeds/test/support/test_support.dart b/apps/app_seeds/test/support/test_support.dart index e3fe7ee..c889c5a 100644 --- a/apps/app_seeds/test/support/test_support.dart +++ b/apps/app_seeds/test/support/test_support.dart @@ -8,6 +8,8 @@ import 'package:tane/data/variety_repository.dart'; import 'package:tane/db/database.dart'; import 'package:tane/i18n/strings.g.dart'; import 'package:tane/state/inventory_cubit.dart'; +import 'package:tane/state/variety_detail_cubit.dart'; +import 'package:tane/ui/variety_detail_screen.dart'; /// A fresh in-memory database for host tests (unencrypted; encryption is /// verified separately in the SQLCipher-only security test). @@ -54,3 +56,30 @@ Widget wrapScreen({ ), ); } + +/// Wraps the [VarietyDetailScreen] for [varietyId] with its cubit + i18n. +Widget wrapDetail({ + required VarietyRepository repository, + required String varietyId, + AppLocale locale = AppLocale.en, +}) { + LocaleSettings.setLocaleSync(locale); + return TranslationProvider( + child: RepositoryProvider.value( + value: repository, + child: BlocProvider( + create: (_) => VarietyDetailCubit(repository, varietyId), + child: MaterialApp( + locale: locale.flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: const VarietyDetailScreen(), + ), + ), + ), + ); +} diff --git a/apps/app_seeds/test/ui/variety_detail_screen_test.dart b/apps/app_seeds/test/ui/variety_detail_screen_test.dart new file mode 100644 index 0000000..13dae99 --- /dev/null +++ b/apps/app_seeds/test/ui/variety_detail_screen_test.dart @@ -0,0 +1,92 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/database.dart'; + +import '../support/test_support.dart'; + +void main() { + late AppDatabase db; + + setUp(() => db = newTestDatabase()); + tearDown(() => db.close()); + + testWidgets('renders the variety label, category and its lots', ( + tester, + ) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Maize', category: 'Poaceae'); + await repo.addLot( + varietyId: id, + harvestYear: 2024, + quantity: const Quantity(kind: QuantityKind.cob), + ); + + await tester.pumpWidget(wrapDetail(repository: repo, varietyId: id)); + await tester.pumpAndSettle(); + + expect(find.text('Maize'), findsOneWidget); // app bar title + expect(find.text('Poaceae'), findsOneWidget); // category chip + expect(find.text('Year 2024 · a cob'), findsOneWidget); // lot line + await disposeTree(tester); + }); + + testWidgets('editing the name updates the title', (tester) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Old name'); + + await tester.pumpWidget(wrapDetail(repository: repo, varietyId: id)); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('detail.edit'))); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('editVariety.name')), + 'New name', + ); + await tester.tap(find.byKey(const Key('editVariety.save'))); + await tester.pumpAndSettle(); + + expect(find.text('New name'), findsOneWidget); + await disposeTree(tester); + }); + + testWidgets('adding a lot shows it in the list', (tester) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Bean'); + + await tester.pumpWidget(wrapDetail(repository: repo, varietyId: id)); + await tester.pumpAndSettle(); + + expect(find.text('No lots yet.'), findsOneWidget); + + await tester.tap(find.byKey(const Key('detail.addLot'))); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('addLot.year')), '2023'); + await tester.tap(find.text('a handful')); + await tester.tap(find.byKey(const Key('addLot.save'))); + await tester.pumpAndSettle(); + + expect(find.text('Year 2023 · a handful'), findsOneWidget); + await disposeTree(tester); + }); + + testWidgets('deleting the variety shows the not-found state', (tester) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Doomed'); + + await tester.pumpWidget(wrapDetail(repository: repo, varietyId: id)); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('detail.delete'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail.deleteConfirm'))); + await tester.pumpAndSettle(); + + // The variety is soft-deleted: the reactive view re-emits null. + expect(find.text('This seed is no longer here.'), findsOneWidget); + await disposeTree(tester); + }); +}