feat(inventory): photo-first drafts + on-device OCR (digitization R2+R4)

Lower the bulk-digitization cliff with two more routes on top of the
already-landed CSV import and "save and add another":

- Photo-first drafts (capture now, catalogue later): burst-capture
  photos (camera or multi-gallery) into unnamed draft varieties, shown
  in a "to catalogue" tray, hidden from the main list until named.
  Adds Variety.isDraft (schema), addDraftVariety/watchDrafts/nameDraft,
  the triage sheet and the inventory banner.
- On-device OCR label suggestion (Tesseract, offline, no Google): a
  "Suggest name from photo" button in the naming dialog behind a
  LabelTextExtractor interface (Tesseract on Android/iOS, no-op
  elsewhere). Reads the largest print via hOCR bounding boxes, drops
  boilerplate/low-confidence noise, preprocesses (grayscale, contrast,
  upscale) and sweeps rotations (0-315 deg) so tilted packets still
  read. Bundles tessdata_fast eng+spa; validated on-device against real
  packets. The photo is written to a temp file deleted immediately in a
  finally block (the plugin needs a path) - a bounded, documented
  exception to no-plaintext-at-rest.

This commit also carries the co-developed schema evolution v5 to v8 that
shares these files (organic flag, species viability years, crop
calendar, lot provenance/abundance/preservation format, condition
checks) plus their exports/migrations and i18n.

Tests: CSV/draft/OCR unit + widget + migration green in isolation.
Note: the full widget suite currently hangs (>10 min) - under investigation.
This commit is contained in:
vjrj 2026-07-09 21:23:46 +02:00
parent 12a2ee2d64
commit 6809dc6143
89 changed files with 17141 additions and 228 deletions

View file

@ -47,6 +47,14 @@ class AppDrawer extends StatelessWidget {
_DrawerItem(icon: const Icon(Icons.group), label: t.menu.following),
const Spacer(),
const Divider(height: 1),
_DrawerItem(
icon: const Icon(Icons.auto_stories_outlined),
label: t.intro.menuEntry,
onTap: () {
Navigator.of(context).pop();
context.push('/intro');
},
),
_DrawerItem(
icon: const Icon(Icons.settings),
label: t.menu.settings,

View file

@ -4,9 +4,11 @@ import '../di/injector.dart';
import '../i18n/strings.g.dart';
import '../services/export_import_service.dart';
/// The three backup actions shown in Settings: export CSV, export JSON and
/// import JSON. Import asks for confirmation first (it merges into the live
/// inventory), then reports what happened in a SnackBar.
/// The backup actions shown in Settings. The primary pair save/restore a
/// full copy comes first; the spreadsheet list export/import is a secondary
/// convenience below. File formats (JSON/CSV) are an implementation detail and
/// never surface in the UI. Restore/import asks for confirmation first (it
/// merges into the live inventory), then reports what happened in a SnackBar.
class BackupSection extends StatelessWidget {
const BackupSection({this.service, super.key});
@ -22,23 +24,24 @@ class BackupSection extends StatelessWidget {
return Column(
children: [
ListTile(
leading: const Icon(Icons.table_view_outlined),
title: Text(t.backup.exportCsv),
subtitle: Text(t.backup.exportCsvSubtitle),
onTap: () => _runExport(context, () => _service.exportCsv()),
),
ListTile(
leading: const Icon(Icons.file_download_outlined),
leading: const Icon(Icons.save_alt_outlined),
title: Text(t.backup.exportJson),
subtitle: Text(t.backup.exportJsonSubtitle),
onTap: () => _runExport(context, () => _service.exportJson()),
),
ListTile(
leading: const Icon(Icons.file_upload_outlined),
leading: const Icon(Icons.settings_backup_restore_outlined),
title: Text(t.backup.importJson),
subtitle: Text(t.backup.importJsonSubtitle),
onTap: () => _runImport(context),
),
const Divider(),
ListTile(
leading: const Icon(Icons.table_view_outlined),
title: Text(t.backup.exportCsv),
subtitle: Text(t.backup.exportCsvSubtitle),
onTap: () => _runExport(context, () => _service.exportCsv()),
),
ListTile(
leading: const Icon(Icons.playlist_add_outlined),
title: Text(t.backup.importCsv),

View file

@ -0,0 +1,245 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../data/variety_repository.dart';
import '../i18n/strings.g.dart';
import '../services/ocr/label_text_extractor.dart';
import 'photo_pick.dart';
/// Rapid "capture now, catalogue later": shoot/pick a burst of photos and turn
/// each into a draft variety. Returns how many were captured.
Future<int> captureDraftBurst(
BuildContext context,
VarietyRepository repository,
) async {
final photos = await pickPhotos(context);
for (final bytes in photos) {
await repository.addDraftVariety(bytes);
}
return photos.length;
}
/// The "to catalogue" tray: lists photo-first drafts (live from the DB); tapping
/// one prompts for a name and promotes it into the inventory.
Future<void> showTriageSheet(
BuildContext context,
VarietyRepository repository,
) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => _TriageSheet(repository: repository),
);
}
class _TriageSheet extends StatelessWidget {
const _TriageSheet({required this.repository});
final VarietyRepository repository;
@override
Widget build(BuildContext context) {
final t = context.t;
return StreamBuilder<List<VarietyListItem>>(
stream: repository.watchDrafts(),
builder: (context, snapshot) {
final drafts = snapshot.data ?? const <VarietyListItem>[];
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.draft.triageTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
if (drafts.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
t.inventory.empty,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: drafts.length,
itemBuilder: (context, i) => _DraftTile(
draft: drafts[i],
repository: repository,
),
),
),
],
),
);
},
);
}
}
class _DraftTile extends StatelessWidget {
const _DraftTile({required this.draft, required this.repository});
final VarietyListItem draft;
final VarietyRepository repository;
@override
Widget build(BuildContext context) {
final t = context.t;
final photo = draft.photo;
return ListTile(
key: Key('draft.tile.${draft.id}'),
leading: photo == null
? const Icon(Icons.image_outlined)
: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(
photo,
width: 48,
height: 48,
fit: BoxFit.cover,
),
),
title: Text(t.draft.untitled),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: t.draft.discard,
onPressed: () => repository.softDeleteVariety(draft.id),
),
onTap: () async {
final name = await showDialog<String>(
context: context,
builder: (_) => NameDraftDialog(photo: photo),
);
if (name != null && name.trim().isNotEmpty) {
await repository.nameDraft(draft.id, name.trim());
}
},
);
}
}
/// Prompts for a draft's name, showing its photo. Where an on-device OCR engine
/// exists, a "Suggest name from photo" button pre-fills the field (always
/// editable, never auto-confirmed).
class NameDraftDialog extends StatefulWidget {
const NameDraftDialog({required this.photo, this.extractor, super.key});
final Uint8List? photo;
/// Injectable for tests; otherwise resolved from the service locator (or a
/// no-op when none is registered).
final LabelTextExtractor? extractor;
@override
State<NameDraftDialog> createState() => _NameDraftDialogState();
}
class _NameDraftDialogState extends State<NameDraftDialog> {
final _controller = TextEditingController();
bool _suggesting = false;
LabelTextExtractor get _extractor =>
widget.extractor ??
(GetIt.instance.isRegistered<LabelTextExtractor>()
? GetIt.instance<LabelTextExtractor>()
: const NoOpLabelTextExtractor());
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _suggest() async {
final photo = widget.photo;
if (photo == null) return;
setState(() => _suggesting = true);
final suggestion = await _extractor.suggestLabel(photo);
if (!mounted) return;
setState(() {
_suggesting = false;
if (suggestion != null && suggestion.trim().isNotEmpty) {
_controller.text = suggestion.trim();
}
});
}
@override
Widget build(BuildContext context) {
final t = context.t;
final canSuggest = widget.photo != null && _extractor.isSupported;
return AlertDialog(
title: Text(t.draft.nameField),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.photo != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(
widget.photo!,
height: 140,
fit: BoxFit.cover,
),
),
const SizedBox(height: 12),
TextField(
key: const Key('draft.nameField'),
controller: _controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: t.draft.nameHint,
border: const OutlineInputBorder(),
),
onSubmitted: (value) => Navigator.of(context).pop(value),
),
if (canSuggest) ...[
const SizedBox(height: 8),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
key: const Key('draft.suggestFromPhoto'),
onPressed: _suggesting ? null : _suggest,
icon: _suggesting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_fix_high_outlined),
label: Text(t.draft.suggestFromPhoto),
),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.common.cancel),
),
FilledButton(
key: const Key('draft.nameSave'),
onPressed: () => Navigator.of(context).pop(_controller.text),
child: Text(t.common.save),
),
],
);
}
}

View file

@ -51,12 +51,12 @@ class HarvestDateField extends StatelessWidget {
},
icon: const Icon(Icons.event_outlined, size: 20),
label: Align(
alignment: Alignment.centerLeft,
alignment: AlignmentDirectional.centerStart,
child: Text(harvestDateLabel(t, year: year, month: month)),
),
style: OutlinedButton.styleFrom(
foregroundColor: seedGreenDark,
alignment: Alignment.centerLeft,
alignment: AlignmentDirectional.centerStart,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
minimumSize: const Size.fromHeight(0),
),

View file

@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../i18n/strings.g.dart';
import 'seed_glyph.dart';
import 'theme.dart';
/// One onboarding page: a large emblem (a bundled seed glyph or a Material
/// symbol), a title and a short body. Illustrations use the app's own icon set
/// rather than photos no image licensing, and it keeps the bundle light.
class _Slide {
const _Slide({
this.glyph,
this.icon,
required this.title,
required this.body,
});
/// A `seedks` glyph char (see [SeedGlyphs]); mutually exclusive with [icon].
final String? glyph;
/// A Material symbol, used where no seed glyph fits (privacy, sprout).
final IconData? icon;
final String title;
final String body;
}
/// The first-run intro carousel (also reachable later from the drawer): a few
/// swipeable cards explaining what Tanemaki is, how privacy works, how sharing
/// works and the Plantare. [onDone] fires when the user skips or finishes, and
/// is where the caller marks the intro as seen and leaves the screen.
class IntroScreen extends StatefulWidget {
const IntroScreen({required this.onDone, super.key});
final VoidCallback onDone;
@override
State<IntroScreen> createState() => _IntroScreenState();
}
class _IntroScreenState extends State<IntroScreen> {
final _controller = PageController();
int _page = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
List<_Slide> _slides(Translations t) => [
_Slide(
glyph: SeedGlyphs.scattered,
title: t.intro.slides.welcome.title,
body: t.intro.slides.welcome.body,
),
_Slide(
glyph: SeedGlyphs.jars,
title: t.intro.slides.inventory.title,
body: t.intro.slides.inventory.body,
),
_Slide(
icon: Icons.lock_outline,
title: t.intro.slides.privacy.title,
body: t.intro.slides.privacy.body,
),
_Slide(
glyph: SeedGlyphs.share,
title: t.intro.slides.share.title,
body: t.intro.slides.share.body,
),
_Slide(
icon: Symbols.potted_plant,
title: t.intro.slides.plantare.title,
body: t.intro.slides.plantare.body,
),
];
void _next(int count) {
if (_page >= count - 1) {
widget.onDone();
return;
}
_controller.nextPage(
duration: const Duration(milliseconds: 280),
curve: Curves.easeInOut,
);
}
@override
Widget build(BuildContext context) {
final t = context.t;
final slides = _slides(t);
final isLast = _page == slides.length - 1;
return Scaffold(
backgroundColor: seedCanvas,
body: SafeArea(
child: Column(
children: [
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
onPressed: widget.onDone,
child: Text(t.intro.skip),
),
),
Expanded(
child: PageView.builder(
controller: _controller,
onPageChanged: (i) => setState(() => _page = i),
itemCount: slides.length,
itemBuilder: (context, i) => _SlideView(slide: slides[i]),
),
),
_Dots(count: slides.length, active: _page),
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => _next(slides.length),
child: Text(isLast ? t.intro.start : t.intro.next),
),
),
),
],
),
),
);
}
}
/// The centred emblem + title + body for a single [slide].
class _SlideView extends StatelessWidget {
const _SlideView({required this.slide});
final _Slide slide;
@override
Widget build(BuildContext context) {
// Centre when there's room, scroll when the viewport is short (small phones
// in landscape, split-screen) so the card never overflows.
return LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 160,
height: 160,
decoration: const BoxDecoration(
color: seedPrimaryContainer,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: slide.glyph != null
? SeedGlyph(slide.glyph!, size: 76, color: seedGreen)
: Icon(slide.icon, size: 72, color: seedGreen),
),
const SizedBox(height: 36),
Text(
slide.title,
textAlign: TextAlign.center,
style: const TextStyle(
color: seedTitle,
fontSize: 24,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
const SizedBox(height: 14),
Text(
slide.body,
textAlign: TextAlign.center,
style: const TextStyle(
color: seedOnSurfaceVariant,
fontSize: 16,
height: 1.45,
),
),
],
),
),
),
),
),
),
);
}
}
/// The page-position dots: the active one is a wider green pill.
class _Dots extends StatelessWidget {
const _Dots({required this.count, required this.active});
final int count;
final int active;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < count; i++)
AnimatedContainer(
duration: const Duration(milliseconds: 220),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: i == active ? 22 : 8,
height: 8,
decoration: BoxDecoration(
color: i == active ? seedGreen : seedOutline,
borderRadius: BorderRadius.circular(4),
),
),
],
);
}
}

View file

@ -4,9 +4,11 @@ import 'package:go_router/go_router.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart';
import '../domain/seed_viability.dart';
import '../i18n/strings.g.dart';
import '../state/inventory_cubit.dart';
import 'app_drawer.dart';
import 'draft_triage.dart';
import 'quantity_picker.dart';
import 'quick_add_sheet.dart';
import 'seed_glyph.dart';
@ -21,7 +23,17 @@ class InventoryListScreen extends StatelessWidget {
Widget build(BuildContext context) {
final t = context.t;
return Scaffold(
appBar: AppBar(title: Text(t.inventory.title)),
appBar: AppBar(
title: Text(t.inventory.title),
actions: [
IconButton(
key: const Key('inventory.captureBurst'),
icon: const Icon(Icons.add_a_photo_outlined),
tooltip: t.draft.capture,
onPressed: () => _captureBurst(context),
),
],
),
drawer: const AppDrawer(),
floatingActionButton: FloatingActionButton(
key: const Key('inventory.addFab'),
@ -39,6 +51,8 @@ class InventoryListScreen extends StatelessWidget {
}
return Column(
children: [
if (state.drafts.isNotEmpty)
_TriageBanner(count: state.drafts.length),
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: TextField(
@ -75,6 +89,45 @@ class InventoryListScreen extends StatelessWidget {
),
);
}
Future<void> _captureBurst(BuildContext context) async {
final t = context.t;
final repository = context.read<VarietyRepository>();
final messenger = ScaffoldMessenger.of(context);
final count = await captureDraftBurst(context, repository);
if (count > 0) {
messenger.showSnackBar(
SnackBar(content: Text(t.draft.captured(n: count))),
);
}
}
}
/// A tappable banner announcing how many photo-first captures are waiting to be
/// named. Opens the "to catalogue" tray.
class _TriageBanner extends StatelessWidget {
const _TriageBanner({required this.count});
final int count;
@override
Widget build(BuildContext context) {
final t = context.t;
return Material(
color: seedAvatar,
child: ListTile(
key: const Key('inventory.triageBanner'),
leading: const Icon(Icons.inventory_2_outlined, color: seedGreen),
title: Text(
t.draft.triageCount(n: count),
style: const TextStyle(fontWeight: FontWeight.w500),
),
trailing: const Icon(Icons.chevron_right),
onTap: () =>
showTriageSheet(context, context.read<VarietyRepository>()),
),
);
}
}
/// A horizontally scrolling row of filter chips: one per category in use plus
@ -94,9 +147,42 @@ class _FilterBar extends StatelessWidget {
for (final type in LotType.values)
if (state.items.any((i) => i.lotTypes.contains(type))) type,
];
if (categories.isEmpty && forms.isEmpty) return const SizedBox.shrink();
// Only offer the eco chip when some variety is flagged organic.
final hasOrganic = state.hasOrganic;
// Only offer the "to regrow" chip when something is flagged for it.
final hasNeedsReproduction = state.hasNeedsReproduction;
if (categories.isEmpty &&
forms.isEmpty &&
!hasOrganic &&
!hasNeedsReproduction) {
return const SizedBox.shrink();
}
final chips = <Widget>[
if (hasOrganic)
FilterChip(
key: const Key('inventory.filter.organic'),
avatar: Icon(
Icons.eco,
size: 18,
color: state.organicOnly ? null : seedGreen,
),
label: Text(t.editVariety.organic),
selected: state.organicOnly,
onSelected: (_) => cubit.toggleOrganicOnly(),
),
if (hasNeedsReproduction)
FilterChip(
key: const Key('inventory.filter.needsReproduction'),
avatar: Icon(
Icons.autorenew,
size: 18,
color: state.needsReproductionOnly ? null : seedGreen,
),
label: Text(t.inventory.needsReproductionFilter),
selected: state.needsReproductionOnly,
onSelected: (_) => cubit.toggleNeedsReproductionOnly(),
),
for (final category in categories)
FilterChip(
key: Key('inventory.filter.category.$category'),
@ -113,7 +199,10 @@ class _FilterBar extends StatelessWidget {
),
];
final hasActiveFilter =
state.categoryFilter.isNotEmpty || state.typeFilter.isNotEmpty;
state.categoryFilter.isNotEmpty ||
state.typeFilter.isNotEmpty ||
state.organicOnly ||
state.needsReproductionOnly;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
@ -122,7 +211,7 @@ class _FilterBar extends StatelessWidget {
children: [
for (final chip in chips)
Padding(
padding: const EdgeInsets.only(right: 8),
padding: const EdgeInsetsDirectional.only(end: 8),
child: chip,
),
if (hasActiveFilter)
@ -231,18 +320,61 @@ class _VarietyTile extends StatelessWidget {
item.scientificName!,
style: const TextStyle(fontStyle: FontStyle.italic),
),
trailing: IconButton(
icon: const Icon(Icons.edit_outlined),
// Action colour: this is a tap target, not secondary text.
color: seedGreen,
tooltip: context.t.common.edit,
onPressed: open,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.isOrganic)
Padding(
padding: const EdgeInsetsDirectional.only(end: 4),
child: Tooltip(
key: Key('inventory.organic.${item.id}'),
message: context.t.editVariety.organic,
child: const Icon(Icons.eco, size: 20, color: seedGreen),
),
),
_ViabilityDot(item.viability),
IconButton(
icon: const Icon(Icons.edit_outlined),
// Action colour: this is a tap target, not secondary text.
color: seedGreen,
tooltip: context.t.common.edit,
onPressed: open,
),
],
),
onTap: open,
);
}
}
/// A small aging indicator on a list tile: an amber clock when some seed lot is
/// in its last viable year, a red alert when one is past typical viability.
/// Renders nothing for fresh or unknown lots.
class _ViabilityDot extends StatelessWidget {
const _ViabilityDot(this.status);
final SeedViability status;
@override
Widget build(BuildContext context) {
final t = context.t;
final expired = status == SeedViability.expired;
if (status != SeedViability.expiringSoon && !expired) {
return const SizedBox.shrink();
}
final scheme = Theme.of(context).colorScheme;
return Tooltip(
key: Key('inventory.viability.${expired ? 'expired' : 'soon'}'),
message: expired ? t.viability.expired : t.viability.expiringSoon,
child: Icon(
expired ? Icons.error_outline : Icons.schedule,
size: 20,
color: expired ? scheme.error : const Color(0xFFB26A00),
),
);
}
}
/// Rounded-square photo thumbnail, or a green initial circle when there is none.
class _Avatar extends StatelessWidget {
const _Avatar({required this.item});

View file

@ -44,3 +44,58 @@ Future<Uint8List?> pickPhoto(BuildContext context) async {
return null;
}
}
/// Rapid multi-capture for the "capture now, catalogue later" flow: pick many
/// photos from the gallery at once, or shoot a burst with the camera (it
/// reopens after each shot until the user cancels). Returns every captured
/// image's bytes, or an empty list if cancelled/unavailable.
Future<List<Uint8List>> pickPhotos(BuildContext context) async {
final t = context.t;
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
key: const Key('photos.source.camera'),
leading: const Icon(Icons.photo_camera_outlined),
title: Text(t.photo.camera),
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
),
ListTile(
key: const Key('photos.source.gallery'),
leading: const Icon(Icons.photo_library_outlined),
title: Text(t.photo.gallery),
onTap: () => Navigator.of(sheetContext).pop(ImageSource.gallery),
),
],
),
),
);
if (source == null) return const [];
final picker = ImagePicker();
try {
if (source == ImageSource.gallery) {
final files = await picker.pickMultiImage(
maxWidth: 1280,
imageQuality: 80,
);
return [for (final file in files) await file.readAsBytes()];
}
// Camera burst: keep reopening the camera until the user cancels.
final shots = <Uint8List>[];
while (true) {
final file = await picker.pickImage(
source: ImageSource.camera,
maxWidth: 1280,
imageQuality: 80,
);
if (file == null) break;
shots.add(await file.readAsBytes());
}
return shots;
} on Object {
return const [];
}
}

View file

@ -72,7 +72,7 @@ class QuickAddSheet extends StatelessWidget {
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
onPressed: cubit.toggleExpanded,
icon: Icon(
@ -197,7 +197,7 @@ class _MoreSection extends StatelessWidget {
),
),
Align(
alignment: Alignment.centerLeft,
alignment: AlignmentDirectional.centerStart,
child: OutlinedButton.icon(
onPressed: () async {
final bytes = await pickPhoto(context);

View file

@ -56,7 +56,7 @@ String? seedGlyphForKind(QuantityKind kind) => switch (kind) {
QuantityKind.cup => SeedGlyphs.mug,
QuantityKind.jar => SeedGlyphs.jar,
QuantityKind.sack => SeedGlyphs.sack,
QuantityKind.packet => SeedGlyphs.envelope,
QuantityKind.packet => SeedGlyphs.pouring,
_ => null,
};

View file

@ -1,5 +1,6 @@
import 'dart:typed_data';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
@ -7,6 +8,8 @@ import 'package:url_launcher/url_launcher.dart';
import '../data/species_repository.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart';
import '../domain/crop_calendar.dart';
import '../domain/seed_viability.dart';
import '../i18n/strings.g.dart';
import '../state/variety_detail_cubit.dart';
import 'harvest_date_picker.dart';
@ -108,6 +111,11 @@ class _DetailView extends StatelessWidget {
_SectionTitle(t.detail.notes),
Text(detail.notes!),
],
if (detail.hasCropCalendar) ...[
const SizedBox(height: 16),
_SectionTitle(t.cropCalendar.title),
_CropCalendarView(detail: detail),
],
const SizedBox(height: 16),
_SectionTitle(t.detail.links),
Wrap(
@ -149,40 +157,10 @@ class _DetailView extends StatelessWidget {
Text(t.detail.noLots)
else
for (final lot in detail.lots)
ListTile(
key: Key('lot.${lot.id}'),
contentPadding: EdgeInsets.zero,
onTap: () => _showLotSheet(context, cubit, existing: lot),
leading: lot.quantity == null
? const Icon(Icons.inventory_2_outlined)
: QuantityKindIcon(lot.quantity!.kind, size: 28),
title: Row(
children: [
_LotTypeChip(type: lot.type),
const SizedBox(width: 8),
Expanded(child: Text(_lotSubtitle(t, lot))),
],
),
subtitle: lot.storageLocation == null
? null
: Text(lot.storageLocation!),
// Germination only applies to seed lots, not to plantel.
trailing: lot.type != LotType.seed
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (lot.latestGerminationRate != null)
_GerminationBadge(rate: lot.latestGerminationRate!),
IconButton(
key: Key('lot.germination.${lot.id}'),
icon: const Icon(Icons.grass_outlined),
tooltip: t.germination.title,
onPressed: () =>
_showGerminationSheet(context, cubit, lot),
),
],
),
_LotTile(
cubit: cubit,
lot: lot,
viabilityYears: detail.viabilityYears,
),
],
),
@ -196,15 +174,167 @@ String _lotSubtitle(Translations t, VarietyLot lot) {
harvestDateLabel(t, year: lot.harvestYear!, month: lot.harvestMonth)
else
t.detail.noYear,
if (lot.quantity != null) quantityDisplay(t, lot.quantity!),
if (lot.quantity != null)
quantityDisplay(t, lot.quantity!)
else if (lot.abundance != null)
abundanceLabel(t, lot.abundance!),
if (lot.presentation != null) presentationLabel(t, lot.presentation!),
];
return parts.join(' · ');
}
/// The provenance line for a lot ("from · place"), or null when neither is set.
String? _lotOrigin(VarietyLot lot) {
final parts = <String>[?lot.originName, ?lot.originPlace];
return parts.isEmpty ? null : parts.join(' · ');
}
String _germinationPercent(Translations t, double rate) =>
t.germination.result(percent: (rate * 100).round());
/// One lot row: kind, harvest/quantity summary, storage location, an aging
/// (viability) warning for seed lots, and the germination test affordance.
class _LotTile extends StatelessWidget {
const _LotTile({
required this.cubit,
required this.lot,
required this.viabilityYears,
});
final VarietyDetailCubit cubit;
final VarietyLot lot;
/// Typical seed longevity of the variety's species (years), or null.
final int? viabilityYears;
@override
Widget build(BuildContext context) {
final t = context.t;
// Aging only makes sense for seed lots (living material is not stored dry).
final viability = lot.type == LotType.seed
? seedViability(
harvestYear: lot.harvestYear,
viabilityYears: viabilityYears,
currentYear: DateTime.now().year,
)
: SeedViability.unknown;
final warning = _ViabilityWarning.forStatus(viability, viabilityYears);
return ListTile(
key: Key('lot.${lot.id}'),
contentPadding: EdgeInsets.zero,
onTap: () => _showLotSheet(context, cubit, existing: lot),
leading: lot.quantity == null
? const Icon(Icons.inventory_2_outlined)
: QuantityKindIcon(lot.quantity!.kind, size: 28),
title: Row(
children: [
_LotTypeChip(type: lot.type),
const SizedBox(width: 8),
Expanded(child: Text(_lotSubtitle(t, lot))),
],
),
subtitle:
(lot.storageLocation == null &&
warning == null &&
_lotOrigin(lot) == null)
? null
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_lotOrigin(lot) case final origin?)
Row(
children: [
const Icon(Icons.place_outlined, size: 14),
const SizedBox(width: 4),
Expanded(child: Text(origin)),
],
),
if (lot.storageLocation case final loc?) Text(loc),
?warning,
],
),
// Germination only applies to seed lots, not to plantel.
trailing: lot.type != LotType.seed
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (lot.latestGerminationRate != null)
_GerminationBadge(rate: lot.latestGerminationRate!),
IconButton(
key: Key('lot.germination.${lot.id}'),
icon: const Icon(Icons.grass_outlined),
tooltip: t.germination.title,
onPressed: () => _showGerminationSheet(context, cubit, lot),
),
],
),
);
}
}
/// An inline "sow/reproduce this seed soon" line for an aging seed lot. Renders
/// nothing (returns null) when the lot is fresh or its age can't be judged.
class _ViabilityWarning extends StatelessWidget {
const _ViabilityWarning._({
required this.status,
required this.viabilityYears,
});
static _ViabilityWarning? forStatus(SeedViability status, int? years) {
if (status != SeedViability.expiringSoon &&
status != SeedViability.expired) {
return null;
}
return _ViabilityWarning._(status: status, viabilityYears: years);
}
final SeedViability status;
final int? viabilityYears;
@override
Widget build(BuildContext context) {
final t = context.t;
final scheme = Theme.of(context).colorScheme;
final expired = status == SeedViability.expired;
final color = expired ? scheme.error : const Color(0xFFB26A00);
final years = viabilityYears;
final text = expired
? (years == null
? t.viability.expired
: t.viability.expiredYears(years: years))
: (years == null
? t.viability.expiringSoon
: t.viability.expiringSoonYears(years: years));
return Padding(
key: Key('lot.viability.${expired ? 'expired' : 'soon'}'),
padding: const EdgeInsets.only(top: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
expired ? Icons.error_outline : Icons.schedule,
size: 14,
color: color,
),
const SizedBox(width: 4),
Flexible(
child: Text(
text,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
/// A small pill making a lot's kind (seeds vs plants/plantel) unmistakable.
class _LotTypeChip extends StatelessWidget {
const _LotTypeChip({required this.type});
@ -447,6 +577,14 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
List<SpeciesMatch> _suggestions = const [];
String? _pickedSpeciesId;
late bool _isOrganic = widget.detail.isOrganic;
late bool _needsReproduction = widget.detail.needsReproduction;
late int? _sowMonths = widget.detail.sowMonths;
late int? _transplantMonths = widget.detail.transplantMonths;
late int? _floweringMonths = widget.detail.floweringMonths;
late int? _fruitingMonths = widget.detail.fruitingMonths;
late int? _seedHarvestMonths = widget.detail.seedHarvestMonths;
late bool _calendarOpen = widget.detail.hasCropCalendar;
@override
void dispose() {
@ -477,6 +615,13 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
label: name.isEmpty ? null : name,
category: _nullIfBlank(_category.text),
notes: _nullIfBlank(_notes.text),
isOrganic: _isOrganic,
needsReproduction: _needsReproduction,
sowMonths: Value(_sowMonths),
transplantMonths: Value(_transplantMonths),
floweringMonths: Value(_floweringMonths),
fruitingMonths: Value(_fruitingMonths),
seedHarvestMonths: Value(_seedHarvestMonths),
);
if (_pickedSpeciesId != null) {
widget.cubit.linkSpecies(_pickedSpeciesId!);
@ -507,6 +652,7 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
TextField(
key: const Key('editVariety.name'),
controller: _name,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: t.editVariety.name,
border: const OutlineInputBorder(
@ -539,6 +685,7 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
const SizedBox(height: 12),
TextField(
controller: _category,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: t.editVariety.category,
border: const OutlineInputBorder(
@ -558,6 +705,62 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
),
),
),
const SizedBox(height: 4),
SwitchListTile(
key: const Key('editVariety.organic'),
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.eco_outlined, color: seedGreen),
title: Text(t.editVariety.organic),
subtitle: Text(t.editVariety.organicHint),
value: _isOrganic,
onChanged: (v) => setState(() => _isOrganic = v),
),
SwitchListTile(
key: const Key('editVariety.needsReproduction'),
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.autorenew, color: seedGreen),
title: Text(t.needsReproduction.label),
subtitle: Text(t.needsReproduction.hint),
value: _needsReproduction,
onChanged: (v) => setState(() => _needsReproduction = v),
),
// Crop calendar: one collapsed reveal, not five dropdowns up front.
ExpansionTile(
key: const Key('editVariety.cropCalendar'),
initiallyExpanded: _calendarOpen,
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
leading: const Icon(Icons.calendar_month_outlined),
title: Text(t.cropCalendar.title),
onExpansionChanged: (v) => _calendarOpen = v,
children: [
_MonthMultiSelect(
label: t.cropCalendar.sow,
mask: _sowMonths,
onChanged: (m) => setState(() => _sowMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.transplant,
mask: _transplantMonths,
onChanged: (m) => setState(() => _transplantMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.flowering,
mask: _floweringMonths,
onChanged: (m) => setState(() => _floweringMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.fruiting,
mask: _fruitingMonths,
onChanged: (m) => setState(() => _fruitingMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.seedHarvest,
mask: _seedHarvestMonths,
onChanged: (m) => setState(() => _seedHarvestMonths = m),
),
],
),
const SizedBox(height: 16),
Row(
children: [
@ -580,6 +783,322 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
}
}
/// A labelled multi-select of the 12 months for one crop-calendar phase a
/// phase can span several months. [mask] is a 12-bit month mask; tapping a
/// month toggles its bit.
class _MonthMultiSelect extends StatelessWidget {
const _MonthMultiSelect({
required this.label,
required this.mask,
required this.onChanged,
});
final String label;
final int? mask;
final ValueChanged<int?> onChanged;
@override
Widget build(BuildContext context) {
final t = context.t;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
for (var m = 1; m <= 12; m++)
FilterChip(
key: Key('calendar.$label.$m'),
visualDensity: VisualDensity.compact,
labelPadding: const EdgeInsets.symmetric(horizontal: 2),
label: Text(_monthAbbrev(t.harvest.monthNames[m - 1])),
selected: maskHasMonth(mask, m),
onSelected: (_) => onChanged(toggleMonth(mask, m)),
),
],
),
],
),
);
}
}
/// A short (3-char) label for a month name, for the compact calendar chips.
String _monthAbbrev(String name) =>
name.length <= 3 ? name : name.substring(0, 3);
/// Read-only crop calendar: one line per recorded phase ("Sow · Mar, Apr, Sep").
/// Renders only the phases that have months set.
class _CropCalendarView extends StatelessWidget {
const _CropCalendarView({required this.detail});
final VarietyDetail detail;
@override
Widget build(BuildContext context) {
final t = context.t;
final phases = <(String, int?)>[
(t.cropCalendar.sow, detail.sowMonths),
(t.cropCalendar.transplant, detail.transplantMonths),
(t.cropCalendar.flowering, detail.floweringMonths),
(t.cropCalendar.fruiting, detail.fruitingMonths),
(t.cropCalendar.seedHarvest, detail.seedHarvestMonths),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (label, mask) in phases)
if (maskToMonths(mask).isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: '$label · ',
style: const TextStyle(fontWeight: FontWeight.w600),
),
TextSpan(
text: maskToMonths(mask)
.map((m) => _monthAbbrev(t.harvest.monthNames[m - 1]))
.join(', '),
),
],
),
),
),
],
);
}
}
/// Human label for a coarse-abundance level.
String abundanceLabel(Translations t, Abundance a) => switch (a) {
Abundance.plentyToShare => t.abundance.plentyToShare,
Abundance.enoughToShare => t.abundance.enoughToShare,
Abundance.enoughForMe => t.abundance.enoughForMe,
Abundance.runningLow => t.abundance.runningLow,
};
/// Human label for a seed preservation format.
String preservationLabel(Translations t, PreservationFormat p) => switch (p) {
PreservationFormat.jarWithDesiccant => t.preservation.jarWithDesiccant,
PreservationFormat.glassJar => t.preservation.glassJar,
PreservationFormat.paperEnvelope => t.preservation.paperEnvelope,
PreservationFormat.paperBag => t.preservation.paperBag,
PreservationFormat.plasticBag => t.preservation.plasticBag,
};
/// Human label for a drying-agent (silica) state.
String desiccantLabel(Translations t, DesiccantState d) => switch (d) {
DesiccantState.none => t.desiccant.none,
DesiccantState.add => t.desiccant.add,
DesiccantState.replace => t.desiccant.replace,
DesiccantState.dry => t.desiccant.dry,
DesiccantState.fresh => t.desiccant.fresh,
};
/// Single-select chips for the coarse-abundance level; tapping the current one
/// clears it (null).
class _AbundanceSelector extends StatelessWidget {
const _AbundanceSelector({required this.value, required this.onChanged});
final Abundance? value;
final ValueChanged<Abundance?> onChanged;
@override
Widget build(BuildContext context) {
final t = context.t;
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final a in Abundance.values)
ChoiceChip(
key: Key('abundance.${a.name}'),
label: Text(abundanceLabel(t, a)),
selected: value == a,
onSelected: (sel) => onChanged(sel ? a : null),
),
],
);
}
}
/// Single-select chips for the preservation format; tapping the current one
/// clears it (null).
class _PreservationSelector extends StatelessWidget {
const _PreservationSelector({required this.value, required this.onChanged});
final PreservationFormat? value;
final ValueChanged<PreservationFormat?> onChanged;
@override
Widget build(BuildContext context) {
final t = context.t;
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final p in PreservationFormat.values)
ChoiceChip(
key: Key('preservation.${p.name}'),
label: Text(preservationLabel(t, p)),
selected: value == p,
onSelected: (sel) => onChanged(sel ? p : null),
),
],
);
}
}
/// Lists a lot's storage-condition checks (newest first) and offers to add one.
class _ConditionChecksBlock extends StatelessWidget {
const _ConditionChecksBlock({required this.cubit, required this.lot});
final VarietyDetailCubit cubit;
final VarietyLot lot;
@override
Widget build(BuildContext context) {
final t = context.t;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
t.conditionCheck.title,
style: Theme.of(context).textTheme.labelLarge,
),
),
if (lot.conditionChecks.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(t.conditionCheck.none),
)
else
for (final check in lot.conditionChecks)
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.inventory_2_outlined),
title: Text(
t.conditionCheck.summary(
count: check.containerCount?.toString() ?? '',
state: check.desiccantState == null
? ''
: desiccantLabel(t, check.desiccantState!),
),
),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
key: const Key('conditionCheck.open'),
icon: const Icon(Icons.add),
label: Text(t.conditionCheck.add),
onPressed: () => _showConditionSheet(context, cubit, lot),
),
),
],
);
}
}
Future<void> _showConditionSheet(
BuildContext context,
VarietyDetailCubit cubit,
VarietyLot lot,
) {
final t = context.t;
final containers = TextEditingController();
DesiccantState? state;
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.conditionCheck.title,
style: Theme.of(sheetContext).textTheme.titleLarge,
),
const SizedBox(height: 12),
TextField(
key: const Key('conditionCheck.containers'),
controller: containers,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.conditionCheck.containers,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
const SizedBox(height: 16),
Text(
t.conditionCheck.desiccant,
style: Theme.of(sheetContext).textTheme.labelLarge,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final d in DesiccantState.values)
ChoiceChip(
key: Key('desiccant.${d.name}'),
label: Text(desiccantLabel(t, d)),
selected: state == d,
onSelected: (sel) => setState(() => state = sel ? d : null),
),
],
),
const SizedBox(height: 16),
Row(
children: [
TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(t.common.cancel),
),
const Spacer(),
FilledButton(
key: const Key('conditionCheck.save'),
onPressed: () {
cubit.addConditionCheck(
lotId: lot.id,
checkedOn: DateTime.now().millisecondsSinceEpoch,
containerCount: int.tryParse(containers.text.trim()),
desiccantState: state,
);
Navigator.of(sheetContext).pop();
},
child: Text(t.conditionCheck.add),
),
],
),
],
),
),
),
);
}
Future<void> _showLotSheet(
BuildContext context,
VarietyDetailCubit cubit, {
@ -592,6 +1111,17 @@ Future<void> _showLotSheet(
var selectedQuantity = existing?.quantity;
var selectedPresentation = existing?.presentation;
final editing = existing != null;
// Provenance + abundance + preservation: hidden behind reveal-on-tap chips so
// the default form stays small (progressive disclosure).
final originNameCtrl = TextEditingController(text: existing?.originName ?? '');
final originPlaceCtrl = TextEditingController(
text: existing?.originPlace ?? '',
);
var selectedAbundance = existing?.abundance;
var selectedPreservation = existing?.preservationFormat;
var showOrigin =
existing?.originName != null || existing?.originPlace != null;
var showAbundance = existing?.abundance != null;
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
@ -682,6 +1212,99 @@ Future<void> _showLotSheet(
value: selectedQuantity,
onChanged: (q) => selectedQuantity = q,
),
const SizedBox(height: 12),
// Layer 1: reveal-on-tap extras, one field per chip.
Wrap(
spacing: 8,
children: [
if (!showOrigin)
ActionChip(
key: const Key('lot.addOrigin'),
avatar: const Icon(Icons.place_outlined, size: 18),
label: Text(t.provenance.section),
onPressed: () => setState(() => showOrigin = true),
),
if (!showAbundance)
ActionChip(
key: const Key('lot.addAbundance'),
avatar: const Icon(Icons.inventory_2_outlined, size: 18),
label: Text(t.abundance.add),
onPressed: () =>
setState(() => showAbundance = true),
),
],
),
if (showOrigin) ...[
const SizedBox(height: 12),
TextField(
key: const Key('lot.originName'),
controller: originNameCtrl,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: t.provenance.seedsFrom,
helperText: t.provenance.seedsFromHint,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
const SizedBox(height: 12),
TextField(
key: const Key('lot.originPlace'),
controller: originPlaceCtrl,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: t.provenance.place,
helperText: t.provenance.placeHint,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
],
if (showAbundance) ...[
const SizedBox(height: 16),
Text(
t.abundance.title,
style: Theme.of(sheetContext).textTheme.labelLarge,
),
const SizedBox(height: 8),
_AbundanceSelector(
value: selectedAbundance,
onChanged: (a) => setState(() => selectedAbundance = a),
),
],
// Layer 2: advanced seed-bank details, collapsed by default.
// Only for seed lots, and (condition checks) an existing lot.
if (selectedType == LotType.seed)
ExpansionTile(
key: const Key('lot.advanced'),
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
leading: const Icon(Icons.inventory_outlined),
title: Text(t.conditionCheck.advanced),
children: [
const SizedBox(height: 4),
Align(
alignment: Alignment.centerLeft,
child: Text(
t.preservation.title,
style:
Theme.of(sheetContext).textTheme.labelLarge,
),
),
const SizedBox(height: 8),
_PreservationSelector(
value: selectedPreservation,
onChanged: (p) =>
setState(() => selectedPreservation = p),
),
if (editing) ...[
const SizedBox(height: 8),
_ConditionChecksBlock(cubit: cubit, lot: existing),
],
],
),
],
),
),
@ -697,6 +1320,8 @@ Future<void> _showLotSheet(
FilledButton(
key: const Key('addLot.save'),
onPressed: () {
final originName = _nullIfBlank(originNameCtrl.text);
final originPlace = _nullIfBlank(originPlaceCtrl.text);
if (editing) {
cubit.updateLot(
lotId: existing.id,
@ -705,6 +1330,10 @@ Future<void> _showLotSheet(
month: selectedMonth,
quantity: selectedQuantity,
presentation: selectedPresentation,
originName: originName,
originPlace: originPlace,
abundance: selectedAbundance,
preservationFormat: selectedPreservation,
);
} else {
cubit.addLot(
@ -713,6 +1342,10 @@ Future<void> _showLotSheet(
month: selectedMonth,
quantity: selectedQuantity,
presentation: selectedPresentation,
originName: originName,
originPlace: originPlace,
abundance: selectedAbundance,
preservationFormat: selectedPreservation,
);
}
Navigator.of(sheetContext).pop();
@ -748,6 +1381,7 @@ Future<void> _showAddNameDialog(
key: const Key('name.field'),
controller: controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: t.editVariety.name),
onSubmitted: (_) => submit(dialogContext),
),
@ -840,7 +1474,11 @@ class _DetailHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasText = detail.category != null || detail.scientificName != null;
final hasText =
detail.category != null ||
detail.scientificName != null ||
detail.isOrganic ||
detail.needsReproduction;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -848,6 +1486,14 @@ class _DetailHeader extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (detail.isOrganic) ...[
_OrganicChip(),
const SizedBox(height: 6),
],
if (detail.needsReproduction) ...[
_NeedsReproductionChip(),
const SizedBox(height: 6),
],
if (detail.category != null)
Row(
mainAxisSize: MainAxisSize.min,
@ -889,6 +1535,66 @@ class _DetailHeader extends StatelessWidget {
}
}
/// A small green "Organic" pill shown on an organic variety's detail header.
class _OrganicChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
key: const Key('detail.organic'),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: seedGreen.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.eco, size: 15, color: seedGreen),
const SizedBox(width: 4),
Text(
context.t.editVariety.organic,
style: const TextStyle(
color: seedGreen,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
);
}
}
/// A small "to regrow" pill shown on a variety flagged as needing reproduction.
class _NeedsReproductionChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
key: const Key('detail.needsReproduction'),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: seedGreen.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.autorenew, size: 15, color: seedGreen),
const SizedBox(width: 4),
Text(
context.t.needsReproduction.badge,
style: const TextStyle(
color: seedGreen,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
);
}
}
/// A 140×140 photo carousel (mockup 07): swipe between photos, page dots, add a
/// photo, and delete the current one. Shows just an "add" button when empty.
class _PhotoGallery extends StatefulWidget {
@ -1131,6 +1837,11 @@ class _PhotoViewerState extends State<_PhotoViewer> {
IconButton(
key: const Key('photo.cover'),
icon: Icon(isCover ? Icons.star : Icons.star_border),
// The cover star is disabled; without an explicit disabled
// color it renders faded/dark on the black bar. Amber both
// keeps the contrast and reads as "selected".
color: Colors.white,
disabledColor: Colors.amber,
tooltip: isCover
? context.t.photo.isCover
: context.t.photo.setAsCover,