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:
vjrj 2026-07-07 15:56:03 +02:00
parent 040f15a898
commit 7ff4e38a15
15 changed files with 1093 additions and 18 deletions

View file

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

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