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
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue