feat(block1): variety detail + edit screen with reactive lots
Tapping an inventory item now opens its detail (the /variety/:id route was a
placeholder). Read + edit one variety end-to-end:
- Detail view: photo, category, "also known as" names, notes and lots, driven
by a reactive VarietyDetailCubit.
- Edit core fields (label/category/notes, LWW) via a bottom sheet.
- Add a lot (harvest year + quantity) — the list refreshes reactively.
- Soft-delete (tombstone) with a confirm dialog.
Repository:
- watchVariety(id): merges Drift table-change streams (variety, lots, names,
attachments) via StreamGroup and reloads the full detail on any change, so a
lot added to a *different* table still refreshes the view.
- updateVariety (LWW), addLot, softDeleteVariety.
i18n: detail/edit/addLot strings (ES/EN); switched slang to {param} braces
interpolation (translator- and Weblate-friendly).
Tests: repository (reactive watchVariety, update, soft-delete) and widget tests
(render, edit updates title, add-lot appears, delete shows not-found). Full
suite green: 23 passing, 1 skipped (SQLCipher-only encryption guard).
This commit is contained in:
parent
040f15a898
commit
7ff4e38a15
15 changed files with 1093 additions and 18 deletions
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Object?> 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<Object?> 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<VarietyLot> lots;
|
||||
final List<String> vernacularNames;
|
||||
final Uint8List? photo;
|
||||
|
||||
@override
|
||||
List<Object?> 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<VarietyDetail?> watchVariety(String id) {
|
||||
final triggers = StreamGroup.merge<void>([
|
||||
(_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<VarietyDetail?> _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<void> 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<String> 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<void> 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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -41,8 +41,12 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
|||
|
||||
// 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',
|
||||
|
|
|
|||
|
|
@ -40,8 +40,12 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
|||
|
||||
// Translations
|
||||
@override late final _Translations$app$es app = _Translations$app$es._(_root);
|
||||
@override late final _Translations$common$es common = _Translations$common$es._(_root);
|
||||
@override late final _Translations$inventory$es inventory = _Translations$inventory$es._(_root);
|
||||
@override late final _Translations$quickAdd$es quickAdd = _Translations$quickAdd$es._(_root);
|
||||
@override late final _Translations$detail$es detail = _Translations$detail$es._(_root);
|
||||
@override late final _Translations$editVariety$es editVariety = _Translations$editVariety$es._(_root);
|
||||
@override late final _Translations$addLot$es addLot = _Translations$addLot$es._(_root);
|
||||
@override late final _Translations$quantityKind$es quantityKind = _Translations$quantityKind$es._(_root);
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +59,19 @@ class _Translations$app$es extends Translations$app$en {
|
|||
@override String get title => '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',
|
||||
|
|
|
|||
68
apps/app_seeds/lib/state/variety_detail_cubit.dart
Normal file
68
apps/app_seeds/lib/state/variety_detail_cubit.dart
Normal file
|
|
@ -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<Object?> get props => [detail, loading, deleted];
|
||||
}
|
||||
|
||||
/// Streams one variety's detail and edits it through the repository.
|
||||
class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
||||
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<VarietyDetail?> _sub;
|
||||
|
||||
Future<void> updateFields({String? label, String? category, String? notes}) =>
|
||||
_repo.updateVariety(
|
||||
id: varietyId,
|
||||
label: label,
|
||||
category: category,
|
||||
notes: notes,
|
||||
);
|
||||
|
||||
Future<void> addLot({int? year, Quantity? quantity}) =>
|
||||
_repo.addLot(varietyId: varietyId, harvestYear: year, quantity: quantity);
|
||||
|
||||
Future<void> deleteVariety() async {
|
||||
await _repo.softDeleteVariety(varietyId);
|
||||
emit(
|
||||
VarietyDetailState(detail: state.detail, loading: false, deleted: true),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _sub.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -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}'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
372
apps/app_seeds/lib/ui/variety_detail_screen.dart
Normal file
372
apps/app_seeds/lib/ui/variety_detail_screen.dart
Normal file
|
|
@ -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<VarietyDetailCubit, VarietyDetailState>(
|
||||
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<VarietyDetailCubit>();
|
||||
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 = <String>[
|
||||
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<void> _confirmDelete(
|
||||
BuildContext context,
|
||||
VarietyDetailCubit cubit,
|
||||
) async {
|
||||
final t = context.t;
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<void> _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<void>(
|
||||
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<void> _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) {
|
||||
final t = context.t;
|
||||
final yearController = TextEditingController();
|
||||
QuantityKind? selectedKind;
|
||||
return showModalBottomSheet<void>(
|
||||
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>[
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
63
apps/app_seeds/test/data/variety_detail_test.dart
Normal file
63
apps/app_seeds/test/data/variety_detail_test.dart
Normal file
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
92
apps/app_seeds/test/ui/variety_detail_screen_test.dart
Normal file
92
apps/app_seeds/test/ui/variety_detail_screen_test.dart
Normal file
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue