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'; 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'; import 'photo_pick.dart'; import 'quantity_kind_l10n.dart'; import 'quantity_picker.dart'; import 'seed_glyph.dart'; import 'theme.dart'; /// Read + edit view of a single variety: its photo, category, notes, other /// names and lots. Edits go through [VarietyDetailCubit]; the view is reactive. class VarietyDetailScreen extends StatelessWidget { const VarietyDetailScreen({super.key}); @override Widget build(BuildContext context) { return BlocConsumer( listenWhen: (prev, curr) => !prev.deleted && curr.deleted, listener: (context, state) => Navigator.of(context).maybePop(), builder: (context, state) { if (state.loading) { return const Scaffold( body: Center(child: CircularProgressIndicator()), ); } final detail = state.detail; if (detail == null) { return Scaffold( appBar: AppBar(), body: Center(child: Text(context.t.detail.notFound)), ); } return _DetailView(detail: detail); }, ); } } class _DetailView extends StatelessWidget { const _DetailView({required this.detail}); final VarietyDetail detail; @override Widget build(BuildContext context) { final t = context.t; final cubit = context.read(); return Scaffold( appBar: AppBar( title: Text(detail.label), actions: [ IconButton( key: const Key('detail.edit'), icon: const Icon(Icons.edit_outlined), tooltip: t.common.edit, onPressed: () => _showEditSheet( context, cubit, detail, context.read(), ), ), IconButton( key: const Key('detail.delete'), icon: const Icon(Icons.delete_outline), tooltip: t.common.delete, onPressed: () => _confirmDelete(context, cubit), ), ], ), body: ListView( padding: const EdgeInsets.all(16), children: [ _DetailHeader(detail: detail), const SizedBox(height: 16), _SectionTitle(t.detail.names), Wrap( spacing: 8, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ for (final name in detail.vernacularNames) InputChip( key: Key('name.${name.id}'), label: Text(name.name), deleteIcon: const Icon(Icons.close, size: 18), onDeleted: () => cubit.removeVernacularName(name.id), ), ActionChip( key: const Key('name.add'), avatar: const Icon(Icons.add, size: 18), label: Text(t.detail.addName), onPressed: () => _showAddNameDialog(context, cubit), ), ], ), if (detail.notes != null && detail.notes!.trim().isNotEmpty) ...[ const SizedBox(height: 16), _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( spacing: 8, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ for (final link in detail.links) InputChip( key: Key('link.${link.id}'), avatar: const Icon(Icons.link, size: 18), label: Text(link.display), deleteIcon: const Icon(Icons.close, size: 18), onDeleted: () => cubit.removeExternalLink(link.id), onPressed: () => _openUrl(link.url), ), ActionChip( key: const Key('link.add'), avatar: const Icon(Icons.add, size: 18), label: Text(t.detail.addLink), onPressed: () => _showAddLinkDialog(context, cubit), ), ], ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _SectionTitle(t.detail.lots), TextButton.icon( key: const Key('detail.addLot'), onPressed: () => _showLotSheet(context, cubit), icon: const Icon(Icons.add), label: Text(t.detail.addLot), ), ], ), if (detail.lots.isEmpty) Text(t.detail.noLots) else for (final lot in detail.lots) _LotTile( cubit: cubit, lot: lot, viabilityYears: detail.viabilityYears, ), ], ), ); } } String _lotSubtitle(Translations t, VarietyLot lot) { final parts = [ if (lot.harvestYear != null) harvestDateLabel(t, year: lot.harvestYear!, month: lot.harvestMonth) else t.detail.noYear, 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!), if (lot.offerStatus != OfferStatus.private) shareStatusLabel(t, lot.offerStatus), ]; return parts.join(' · '); } /// The provenance line for a lot ("from · place"), or null when neither is set. String? _lotOrigin(VarietyLot lot) { final parts = [?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}); final LotType type; @override Widget build(BuildContext context) { final t = context.t; final isSeed = type == LotType.seed; // Seed lots read green; living/vegetative forms read teal. final color = isSeed ? seedGreenDark : const Color(0xFF00796B); return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (isSeed) SeedGlyph(SeedGlyphs.jars, size: 14, color: color) else Icon(iconForLotType(type), size: 14, color: color), const SizedBox(width: 4), Text( lotTypeLabel(t, type), style: TextStyle( fontSize: 11, color: color, fontWeight: FontWeight.w600, ), ), ], ), ); } } /// A compact pill showing the latest germination rate on a lot tile. class _GerminationBadge extends StatelessWidget { const _GerminationBadge({required this.rate}); final double rate; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: scheme.secondaryContainer, borderRadius: BorderRadius.circular(12), ), child: Text( _germinationPercent(context.t, rate), style: TextStyle( color: scheme.onSecondaryContainer, fontWeight: FontWeight.w600, ), ), ); } } Future _showGerminationSheet( BuildContext context, VarietyDetailCubit cubit, VarietyLot lot, ) { final t = context.t; final sample = TextEditingController(); final germinated = TextEditingController(); return showModalBottomSheet( context: context, isScrollControlled: true, builder: (sheetContext) => Padding( padding: EdgeInsets.only( left: 16, right: 16, top: 16, bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 16, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( t.germination.title, style: Theme.of(sheetContext).textTheme.titleLarge, ), const SizedBox(height: 8), if (lot.germinationTests.isEmpty) Text(t.germination.none) else for (final test in lot.germinationTests) ListTile( dense: true, contentPadding: EdgeInsets.zero, leading: const Icon(Icons.grass_outlined), title: Text( test.rate == null ? '—' : _germinationPercent(t, test.rate!), ), subtitle: (test.sampleSize != null && test.germinatedCount != null) ? Text('${test.germinatedCount}/${test.sampleSize}') : null, ), const Divider(), Row( children: [ Expanded( child: TextField( key: const Key('germination.germinated'), controller: germinated, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: t.germination.germinated, border: const OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(8)), ), ), ), ), const SizedBox(width: 12), Expanded( child: TextField( key: const Key('germination.sampleSize'), controller: sample, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: t.germination.sampleSize, border: const OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(8)), ), ), ), ), ], ), const SizedBox(height: 16), Row( children: [ TextButton( onPressed: () => Navigator.of(sheetContext).pop(), child: Text(t.common.cancel), ), const Spacer(), FilledButton( key: const Key('germination.save'), onPressed: () { cubit.addGerminationTest( lotId: lot.id, testedOn: DateTime.now().millisecondsSinceEpoch, sampleSize: int.tryParse(sample.text.trim()), germinatedCount: int.tryParse(germinated.text.trim()), ); Navigator.of(sheetContext).pop(); }, child: Text(t.germination.add), ), ], ), ], ), ), ); } Future _confirmDelete( BuildContext context, VarietyDetailCubit cubit, ) async { final t = context.t; final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( content: Text(t.detail.deleteConfirm), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(t.common.cancel), ), FilledButton( key: const Key('detail.deleteConfirm'), onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(t.common.delete), ), ], ), ); if (confirmed ?? false) await cubit.deleteVariety(); } Future _showEditSheet( BuildContext context, VarietyDetailCubit cubit, VarietyDetail detail, SpeciesRepository species, ) { return showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _EditVarietySheet(cubit: cubit, detail: detail, species: species), ); } /// The edit sheet, stateful so it can search the species catalog live and link /// the picked species on save. class _EditVarietySheet extends StatefulWidget { const _EditVarietySheet({ required this.cubit, required this.detail, required this.species, }); final VarietyDetailCubit cubit; final VarietyDetail detail; final SpeciesRepository species; @override State<_EditVarietySheet> createState() => _EditVarietySheetState(); } class _EditVarietySheetState extends State<_EditVarietySheet> { late final TextEditingController _name = TextEditingController( text: widget.detail.label, ); late final TextEditingController _category = TextEditingController( text: widget.detail.category ?? '', ); late final TextEditingController _notes = TextEditingController( text: widget.detail.notes ?? '', ); late final TextEditingController _speciesField = TextEditingController( text: widget.detail.scientificName ?? '', ); List _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() { _name.dispose(); _category.dispose(); _notes.dispose(); _speciesField.dispose(); super.dispose(); } Future _onSpeciesQuery(String query) async { final lang = Localizations.localeOf(context).languageCode; final results = await widget.species.search(query, languageCode: lang); if (mounted) setState(() => _suggestions = results); } void _pick(SpeciesMatch match) { setState(() { _pickedSpeciesId = match.id; _speciesField.text = match.displayLabel; _suggestions = const []; }); } void _save() { final name = _name.text.trim(); widget.cubit.updateFields( 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!); } Navigator.of(context).pop(); } @override Widget build(BuildContext context) { final t = context.t; return Padding( padding: EdgeInsets.only( left: 16, right: 16, top: 16, bottom: MediaQuery.of(context).viewInsets.bottom + 16, ), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( t.editVariety.title, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 12), TextField( key: const Key('editVariety.name'), controller: _name, textCapitalization: TextCapitalization.sentences, decoration: InputDecoration( labelText: t.editVariety.name, border: const OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(8)), ), ), ), const SizedBox(height: 12), TextField( key: const Key('editVariety.species'), controller: _speciesField, decoration: InputDecoration( labelText: t.editVariety.species, hintText: t.editVariety.speciesHint, prefixIcon: const Icon(Icons.eco_outlined), border: const OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(8)), ), ), onChanged: _onSpeciesQuery, ), for (final match in _suggestions) ListTile( dense: true, key: Key('species.option.${match.id}'), title: Text(match.displayLabel), subtitle: match.family == null ? null : Text(match.family!), onTap: () => _pick(match), ), const SizedBox(height: 12), TextField( controller: _category, textCapitalization: TextCapitalization.sentences, decoration: InputDecoration( labelText: t.editVariety.category, border: const OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(8)), ), ), ), const SizedBox(height: 12), TextField( controller: _notes, minLines: 2, maxLines: 5, decoration: InputDecoration( labelText: t.editVariety.notes, border: const OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(8)), ), ), ), 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: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(t.common.cancel), ), const Spacer(), FilledButton( key: const Key('editVariety.save'), onPressed: _save, child: Text(t.common.save), ), ], ), ], ), ), ); } } /// 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 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 how (whether) a lot is offered to others. String shareStatusLabel(Translations t, OfferStatus s) => switch (s) { OfferStatus.private => t.share.private, OfferStatus.shared => t.share.gift, OfferStatus.exchange => t.share.exchange, OfferStatus.sell => t.share.sell, }; /// 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 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 share terms. Gift and swap come first and sale /// last, never preselected — the gift is first-class, the sale a choice /// (sharing-model §4.1). There is always a selection ("just for me" default). class _ShareSelector extends StatelessWidget { const _ShareSelector({required this.value, required this.onChanged}); final OfferStatus value; final ValueChanged onChanged; @override Widget build(BuildContext context) { final t = context.t; return Wrap( spacing: 8, runSpacing: 4, children: [ for (final s in const [ OfferStatus.private, OfferStatus.shared, OfferStatus.exchange, OfferStatus.sell, ]) ChoiceChip( key: Key('share.${s.name}'), label: Text(shareStatusLabel(t, s)), selected: value == s, onSelected: (sel) { if (sel) onChanged(s); }, ), ], ); } } /// 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 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 _showConditionSheet( BuildContext context, VarietyDetailCubit cubit, VarietyLot lot, ) { final t = context.t; final containers = TextEditingController(); DesiccantState? state; return showModalBottomSheet( 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 _showLotSheet( BuildContext context, VarietyDetailCubit cubit, { VarietyLot? existing, }) { final t = context.t; var selectedYear = existing?.harvestYear ?? DateTime.now().year; var selectedMonth = existing?.harvestMonth; var selectedType = existing?.type ?? LotType.seed; 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 selectedShare = existing?.offerStatus ?? OfferStatus.private; var showOrigin = existing?.originName != null || existing?.originPlace != null; var showAbundance = existing?.abundance != null; var showShare = selectedShare != OfferStatus.private; return showModalBottomSheet( 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: [ Flexible( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Expanded( child: Text( editing ? t.detail.editLot : t.addLot.title, style: Theme.of(sheetContext).textTheme.titleLarge, ), ), if (editing) IconButton( key: const Key('lot.delete'), icon: const Icon(Icons.delete_outline), tooltip: t.common.delete, onPressed: () { cubit.deleteLot(existing.id); Navigator.of(sheetContext).pop(); }, ), ], ), const SizedBox(height: 12), LotTypeSelector( value: selectedType, onChanged: (type) => setState(() { selectedType = type; selectedQuantity = null; if (!typeHasPresentation(type)) { selectedPresentation = null; } }), ), if (typeHasPresentation(selectedType)) ...[ const SizedBox(height: 16), Text( t.presentation.title, style: Theme.of(sheetContext).textTheme.labelLarge, ), const SizedBox(height: 8), PresentationSelector( value: selectedPresentation, onChanged: (p) => setState(() => selectedPresentation = p), ), ], const SizedBox(height: 12), Text( t.addLot.year, style: Theme.of(sheetContext).textTheme.labelLarge, ), const SizedBox(height: 8), HarvestDateField( year: selectedYear, month: selectedMonth, onChanged: (year, month) => setState(() { selectedYear = year; selectedMonth = month; }), ), const SizedBox(height: 16), Text( t.addLot.quantity, style: Theme.of(sheetContext).textTheme.labelLarge, ), const SizedBox(height: 8), QuantityPicker( type: selectedType, 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 (!showShare) ActionChip( key: const Key('lot.addShare'), avatar: const Icon( Icons.volunteer_activism_outlined, size: 18, ), label: Text(t.share.add), onPressed: () => setState(() => showShare = 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; // The bridge from "how much" to "do you share it": // declaring plenty reveals the sharing choice. A // nudge, never a change of the choice itself. if (a == Abundance.plentyToShare || a == Abundance.enoughToShare) { showShare = true; } }), ), ], if (showShare) ...[ const SizedBox(height: 16), Text( t.share.title, style: Theme.of(sheetContext).textTheme.labelLarge, ), const SizedBox(height: 8), _ShareSelector( value: selectedShare, onChanged: (s) => setState(() => selectedShare = s), ), if (selectedShare == OfferStatus.private && (selectedAbundance == Abundance.plentyToShare || selectedAbundance == Abundance.enoughToShare)) Padding( padding: const EdgeInsets.only(top: 6), child: Text( t.share.nudge, key: const Key('share.nudge'), style: Theme.of(sheetContext).textTheme.bodySmall, ), ), ], // 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), ], ], ), ], ), ), ), const SizedBox(height: 16), Row( children: [ TextButton( onPressed: () => Navigator.of(sheetContext).pop(), child: Text(t.common.cancel), ), const Spacer(), FilledButton( key: const Key('addLot.save'), onPressed: () { final originName = _nullIfBlank(originNameCtrl.text); final originPlace = _nullIfBlank(originPlaceCtrl.text); if (editing) { cubit.updateLot( lotId: existing.id, type: selectedType, year: selectedYear, month: selectedMonth, quantity: selectedQuantity, presentation: selectedPresentation, originName: originName, originPlace: originPlace, abundance: selectedAbundance, preservationFormat: selectedPreservation, offerStatus: selectedShare, ); } else { cubit.addLot( type: selectedType, year: selectedYear, month: selectedMonth, quantity: selectedQuantity, presentation: selectedPresentation, originName: originName, originPlace: originPlace, abundance: selectedAbundance, preservationFormat: selectedPreservation, offerStatus: selectedShare, ); } Navigator.of(sheetContext).pop(); }, child: Text(t.common.save), ), ], ), ], ), ), ), ); } Future _showAddNameDialog( BuildContext context, VarietyDetailCubit cubit, ) { final t = context.t; final controller = TextEditingController(); void submit(BuildContext dialogContext) { final name = controller.text.trim(); if (name.isNotEmpty) cubit.addVernacularName(name); Navigator.of(dialogContext).pop(); } return showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(t.detail.addName), content: TextField( key: const Key('name.field'), controller: controller, autofocus: true, textCapitalization: TextCapitalization.sentences, decoration: InputDecoration(labelText: t.editVariety.name), onSubmitted: (_) => submit(dialogContext), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: Text(t.common.cancel), ), FilledButton( key: const Key('name.save'), onPressed: () => submit(dialogContext), child: Text(t.common.save), ), ], ), ); } Future _openUrl(String url) async { final normalized = url.contains('://') ? url : 'https://$url'; final uri = Uri.tryParse(normalized); if (uri != null) { await launchUrl(uri, mode: LaunchMode.externalApplication); } } Future _showAddLinkDialog( BuildContext context, VarietyDetailCubit cubit, ) { final t = context.t; final urlController = TextEditingController(); final titleController = TextEditingController(); void submit(BuildContext dialogContext) { final url = urlController.text.trim(); if (url.isNotEmpty) { cubit.addExternalLink(url, title: _nullIfBlank(titleController.text)); } Navigator.of(dialogContext).pop(); } return showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(t.detail.addLink), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( key: const Key('link.url'), controller: urlController, autofocus: true, keyboardType: TextInputType.url, decoration: InputDecoration(labelText: t.detail.linkUrl), ), const SizedBox(height: 8), TextField( key: const Key('link.title'), controller: titleController, decoration: InputDecoration(labelText: t.detail.linkTitle), ), ], ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: Text(t.common.cancel), ), FilledButton( key: const Key('link.save'), onPressed: () => submit(dialogContext), child: Text(t.common.save), ), ], ), ); } String? _nullIfBlank(String value) { final trimmed = value.trim(); return trimmed.isEmpty ? null : trimmed; } /// Mockup-07 header: category link + scientific name on the left, the photo on /// the right. class _DetailHeader extends StatelessWidget { const _DetailHeader({required this.detail}); final VarietyDetail detail; @override Widget build(BuildContext context) { final hasText = detail.category != null || detail.scientificName != null || detail.isOrganic || detail.needsReproduction; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( 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, children: [ const Icon( Icons.category_outlined, size: 17, color: seedGreen, ), const SizedBox(width: 6), Flexible( child: Text( detail.category!, style: const TextStyle( color: seedGreen, fontWeight: FontWeight.w600, ), ), ), ], ), if (detail.scientificName != null) ...[ const SizedBox(height: 2), Text( detail.scientificName!, style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontStyle: FontStyle.italic, color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ], ), ), if (hasText) const SizedBox(width: 12), _PhotoGallery(detail: detail), ], ); } } /// 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 { const _PhotoGallery({required this.detail}); final VarietyDetail detail; @override State<_PhotoGallery> createState() => _PhotoGalleryState(); } class _PhotoGalleryState extends State<_PhotoGallery> { final _controller = PageController(); int _page = 0; @override void dispose() { _controller.dispose(); super.dispose(); } Future _addPhoto(VarietyDetailCubit cubit) async { final bytes = await pickPhoto(context); if (bytes != null) await cubit.addPhoto(bytes); } @override Widget build(BuildContext context) { final cubit = context.read(); final photos = widget.detail.photos; if (photos.isEmpty) { return SizedBox( width: 140, height: 140, child: OutlinedButton( key: const Key('photo.add'), onPressed: () => _addPhoto(cubit), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.add_a_photo_outlined), const SizedBox(height: 8), Text(context.t.quickAdd.addPhoto, textAlign: TextAlign.center), ], ), ), ); } final page = _page.clamp(0, photos.length - 1); return SizedBox( width: 140, child: Column( mainAxisSize: MainAxisSize.min, children: [ // Tap a photo to open the full-screen viewer, where you can set the // cover or delete it. The first photo is the cover. SizedBox( height: 140, child: ClipRRect( borderRadius: BorderRadius.circular(12), child: PageView.builder( controller: _controller, itemCount: photos.length, onPageChanged: (i) => setState(() => _page = i), itemBuilder: (_, i) => GestureDetector( key: Key('photo.thumb.$i'), onTap: () => _openPhotoViewer(context, cubit, i), child: Image.memory( photos[i].bytes, width: 140, height: 140, fit: BoxFit.cover, ), ), ), ), ), const SizedBox(height: 6), if (photos.length > 1) _Dots( count: photos.length, active: page, onTap: (i) => _controller.animateToPage( i, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut, ), ), TextButton.icon( key: const Key('photo.add'), onPressed: () => _addPhoto(cubit), icon: const Icon(Icons.add_a_photo_outlined, size: 18), label: Text(context.t.quickAdd.addPhoto), ), ], ), ); } } class _Dots extends StatelessWidget { const _Dots({required this.count, required this.active, this.onTap}); final int count; final int active; final ValueChanged? onTap; @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ for (var i = 0; i < count; i++) GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap == null ? null : () => onTap!(i), child: Padding( // Bigger, tappable hit area around each dot. padding: const EdgeInsets.all(6), child: Container( width: 8, height: 8, decoration: BoxDecoration( shape: BoxShape.circle, color: i == active ? seedGreen : Colors.black26, ), ), ), ), ], ); } } void _openPhotoViewer( BuildContext context, VarietyDetailCubit cubit, int initialIndex, ) { Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, // Re-provide the cubit so the pushed route can set the cover / delete and // rebuild live as the photo list reorders or shrinks. builder: (_) => BlocProvider.value( value: cubit, child: _PhotoViewer(initialIndex: initialIndex), ), ), ); } /// Full-screen, swipeable, pinch-to-zoom photo viewer. Its app bar hosts the /// per-photo actions (set as cover, delete) — the thumbnail gallery only opens /// it. Stays in sync with the cubit, so deleting the last photo closes it. class _PhotoViewer extends StatefulWidget { const _PhotoViewer({required this.initialIndex}); final int initialIndex; @override State<_PhotoViewer> createState() => _PhotoViewerState(); } class _PhotoViewerState extends State<_PhotoViewer> { late final PageController _controller = PageController( initialPage: widget.initialIndex, ); late int _page = widget.initialIndex; bool _popped = false; @override void dispose() { _controller.dispose(); super.dispose(); } Future _confirmDelete( VarietyDetailCubit cubit, VarietyPhoto photo, ) async { final t = context.t; final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( content: Text(t.photo.deleteConfirm), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(t.common.cancel), ), FilledButton( key: const Key('photo.deleteConfirm'), onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(t.common.delete), ), ], ), ); if (confirmed ?? false) await cubit.removePhoto(photo.id); } @override Widget build(BuildContext context) { final cubit = context.read(); return BlocBuilder( builder: (context, state) { final photos = state.detail?.photos ?? const []; // Nothing left to show (last photo deleted / variety gone): close once. // The guard stops the post-frame callback re-arming every rebuild. if (photos.isEmpty) { if (!_popped) { _popped = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) Navigator.of(context).maybePop(); }); } return const Scaffold(backgroundColor: Colors.black); } final page = _page.clamp(0, photos.length - 1); // A delete can leave the pager past the end: snap it back. jumpToPage // fires onPageChanged, which updates _page — so no setState here (that // would re-arm the callback and spin the frame loop). if (page != _page && _controller.hasClients) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _controller.hasClients) _controller.jumpToPage(page); }); } final isCover = page == 0; return Scaffold( backgroundColor: Colors.black, appBar: AppBar( backgroundColor: Colors.black, foregroundColor: Colors.white, elevation: 0, actions: [ if (photos.length > 1) 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, onPressed: isCover ? null : () async { await cubit.setPreferredPhoto(photos[page].id); // The chosen photo is now the cover (index 0): follow // it there so the filled star reflects the change. if (mounted && _controller.hasClients) { _controller.jumpToPage(0); setState(() => _page = 0); } }, ), IconButton( key: const Key('photo.delete'), icon: const Icon(Icons.delete_outline), tooltip: context.t.common.delete, onPressed: () => _confirmDelete(cubit, photos[page]), ), ], ), body: PageView.builder( controller: _controller, itemCount: photos.length, onPageChanged: (i) => setState(() => _page = i), itemBuilder: (_, i) => _ZoomablePhoto(bytes: photos[i].bytes), ), ); }, ); } } /// Pinch-to-zoom photo that only pans while zoomed, so a plain horizontal /// swipe still pages the surrounding [PageView]. class _ZoomablePhoto extends StatefulWidget { const _ZoomablePhoto({required this.bytes}); final Uint8List bytes; @override State<_ZoomablePhoto> createState() => _ZoomablePhotoState(); } class _ZoomablePhotoState extends State<_ZoomablePhoto> { final _controller = TransformationController(); bool _zoomed = false; @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return InteractiveViewer( transformationController: _controller, minScale: 1, maxScale: 5, panEnabled: _zoomed, onInteractionEnd: (_) { final zoomed = _controller.value.getMaxScaleOnAxis() > 1.01; if (zoomed != _zoomed) setState(() => _zoomed = zoomed); }, child: Center(child: Image.memory(widget.bytes)), ); } } class _SectionTitle extends StatelessWidget { const _SectionTitle(this.text); final String text; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( text, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: Theme.of(context).colorScheme.primary, ), ), ); } }