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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
245
apps/app_seeds/lib/ui/draft_triage.dart
Normal file
245
apps/app_seeds/lib/ui/draft_triage.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
228
apps/app_seeds/lib/ui/intro_screen.dart
Normal file
228
apps/app_seeds/lib/ui/intro_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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