Merge branch 'main' into spike/block2-derisking
This commit is contained in:
commit
12f48ea318
24 changed files with 69558 additions and 341 deletions
|
|
@ -8,6 +8,19 @@ import 'theme.dart';
|
|||
/// The app's public website. Shown as a tappable row in [AboutScreen].
|
||||
const String _websiteUrl = 'https://tanemaki.app';
|
||||
|
||||
/// The year Tanemaki's copyright starts. Ğ1nkgo uses its own first year (2023);
|
||||
/// Tanemaki's is 2026.
|
||||
const int _copyrightStartYear = 2026;
|
||||
|
||||
/// A single year (`2026`) until the calendar rolls over, then a range
|
||||
/// (`2026-2027`), mirroring Ğ1nkgo's legalese line.
|
||||
String _copyrightYears() {
|
||||
final int now = DateTime.now().year;
|
||||
return now <= _copyrightStartYear
|
||||
? '$_copyrightStartYear'
|
||||
: '$_copyrightStartYear-$now';
|
||||
}
|
||||
|
||||
/// An "about" screen à la Ğ1nkgo: brand header, the human explanation of what
|
||||
/// Tanemaki is and where its name comes from, the app version, license, the
|
||||
/// website, and the bundled open-source licenses page.
|
||||
|
|
@ -62,12 +75,24 @@ class AboutScreen extends StatelessWidget {
|
|||
onTap: () => showLicensePage(
|
||||
context: context,
|
||||
applicationName: t.app.title,
|
||||
applicationLegalese: t.about.copyright(years: _copyrightYears()),
|
||||
applicationIcon: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Image.asset('assets/logo.png', width: 48),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Text(
|
||||
t.about.copyright(years: _copyrightYears()),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: seedOnSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
|
|
@ -605,6 +606,11 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
|
|||
|
||||
List<SpeciesMatch> _suggestions = const [];
|
||||
String? _pickedSpeciesId;
|
||||
|
||||
/// A species inferred from the typed name, offered as a one-tap suggestion.
|
||||
/// Only surfaced while the variety has no species and the grower has not
|
||||
/// picked one, so it never nags over a deliberate choice.
|
||||
SpeciesMatch? _suggested;
|
||||
late bool _isOrganic = widget.detail.isOrganic;
|
||||
late bool _needsReproduction = widget.detail.needsReproduction;
|
||||
late int? _sowMonths = widget.detail.sowMonths;
|
||||
|
|
@ -614,8 +620,20 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
|
|||
late int? _seedHarvestMonths = widget.detail.seedHarvestMonths;
|
||||
late bool _calendarOpen = widget.detail.hasCropCalendar;
|
||||
|
||||
// Typing stays responsive by not touching the DB on every keystroke: each
|
||||
// field waits until the grower pauses before running its (heavy) lookup, and
|
||||
// a per-field query counter drops any result that a later keystroke has
|
||||
// already superseded, so stale suggestions never flicker back in.
|
||||
static const _debounce = Duration(milliseconds: 250);
|
||||
Timer? _speciesDebounce;
|
||||
Timer? _nameDebounce;
|
||||
int _speciesQueryId = 0;
|
||||
int _nameQueryId = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_speciesDebounce?.cancel();
|
||||
_nameDebounce?.cancel();
|
||||
_name.dispose();
|
||||
_category.dispose();
|
||||
_notes.dispose();
|
||||
|
|
@ -623,10 +641,38 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _onSpeciesQuery(String query) async {
|
||||
void _onSpeciesQuery(String query) {
|
||||
_speciesDebounce?.cancel();
|
||||
_speciesDebounce = Timer(_debounce, () => _runSpeciesQuery(query));
|
||||
}
|
||||
|
||||
Future<void> _runSpeciesQuery(String query) async {
|
||||
final queryId = ++_speciesQueryId;
|
||||
final lang = Localizations.localeOf(context).languageCode;
|
||||
final results = await widget.species.search(query, languageCode: lang);
|
||||
if (mounted) setState(() => _suggestions = results);
|
||||
// A manual species search takes over from the name-based suggestion.
|
||||
if (mounted && queryId == _speciesQueryId) {
|
||||
setState(() => _suggestions = results);
|
||||
}
|
||||
}
|
||||
|
||||
void _onNameChanged(String name) {
|
||||
if (widget.detail.speciesId != null || _pickedSpeciesId != null) return;
|
||||
_nameDebounce?.cancel();
|
||||
_nameDebounce = Timer(_debounce, () => _runNameClassify(name));
|
||||
}
|
||||
|
||||
/// As the name is typed, offer the species it names — but only when the
|
||||
/// variety has no species yet and the grower hasn't picked one, so an
|
||||
/// existing link is never second-guessed.
|
||||
Future<void> _runNameClassify(String name) async {
|
||||
if (widget.detail.speciesId != null || _pickedSpeciesId != null) return;
|
||||
final queryId = ++_nameQueryId;
|
||||
final lang = Localizations.localeOf(context).languageCode;
|
||||
final match = await widget.species.classifyLabel(name, languageCode: lang);
|
||||
if (mounted && queryId == _nameQueryId) {
|
||||
setState(() => _suggested = match);
|
||||
}
|
||||
}
|
||||
|
||||
void _pick(SpeciesMatch match) {
|
||||
|
|
@ -634,6 +680,7 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
|
|||
_pickedSpeciesId = match.id;
|
||||
_speciesField.text = match.displayLabel;
|
||||
_suggestions = const [];
|
||||
_suggested = null;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -667,145 +714,172 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
|
|||
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)),
|
||||
),
|
||||
// Fields scroll; the Cancel/Save row is pinned as a fixed footer so Save
|
||||
// stays visible and tappable no matter how tall the sheet grows.
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Flexible(
|
||||
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)),
|
||||
),
|
||||
),
|
||||
onChanged: _onNameChanged,
|
||||
),
|
||||
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),
|
||||
),
|
||||
// One-tap suggestion inferred from the name (only when the
|
||||
// species field is idle, so it never competes with a manual
|
||||
// search).
|
||||
if (_suggested case final suggested?
|
||||
when _suggestions.isEmpty && _pickedSpeciesId == null)
|
||||
ListTile(
|
||||
dense: true,
|
||||
key: const Key('editVariety.speciesSuggestion'),
|
||||
leading: const Icon(Icons.auto_awesome_outlined),
|
||||
title: Text(suggested.displayLabel),
|
||||
subtitle: Text(t.editVariety.speciesSuggested),
|
||||
onTap: () => _pick(suggested),
|
||||
),
|
||||
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: 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)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
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 Spacer(),
|
||||
FilledButton(
|
||||
key: const Key('editVariety.save'),
|
||||
onPressed: _save,
|
||||
child: Text(t.common.save),
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue