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:
parent
12a2ee2d64
commit
6809dc6143
89 changed files with 17141 additions and 228 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue