Apply a Material 3 seed-green palette and rounded components across the home, inventory, quick-add, quantity picker and variety-detail screens. The full-screen photo viewer can now set any photo as the cover (via attachment sort order) and delete after confirmation. Lot forms and packaging surface as choice chips. Extract About out of Settings into its own screen (app version via package_info_plus, website link). Add es/en strings for the new lot types, presentation, home tagline and About. Update Seedees v2 mockups.
312 lines
8.8 KiB
Dart
312 lines
8.8 KiB
Dart
import 'package:commons_core/commons_core.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../db/enums.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import 'quantity_kind_l10n.dart';
|
|
import 'seed_glyph.dart';
|
|
import 'theme.dart';
|
|
|
|
/// Informal seed amounts, ascending: vibes → containers → seed/fruit forms.
|
|
/// ([QuantityKind.handful] is intentionally omitted — it overlaps with spoon.)
|
|
const seedScaleKinds = <QuantityKind>[
|
|
QuantityKind.aFew,
|
|
QuantityKind.teaspoon,
|
|
QuantityKind.spoon,
|
|
QuantityKind.packet,
|
|
QuantityKind.cup,
|
|
QuantityKind.jar,
|
|
QuantityKind.sack,
|
|
QuantityKind.cob,
|
|
QuantityKind.pod,
|
|
QuantityKind.ear,
|
|
QuantityKind.head,
|
|
QuantityKind.fruit,
|
|
QuantityKind.bulb,
|
|
QuantityKind.tuber,
|
|
QuantityKind.bunch,
|
|
];
|
|
|
|
/// The amount units offered for each lot form. Living forms are counted as
|
|
/// whole individuals (their packaging is a separate [Presentation]); [bulb] also
|
|
/// allows grams. [pot]/[tray] are intentionally not offered anymore.
|
|
List<QuantityKind> kindsForType(LotType type) => switch (type) {
|
|
LotType.seed => seedScaleKinds,
|
|
LotType.seedling => const [QuantityKind.seedling],
|
|
LotType.plant => const [QuantityKind.plant],
|
|
LotType.tree => const [QuantityKind.tree],
|
|
LotType.bulb => const [
|
|
QuantityKind.bulb,
|
|
QuantityKind.tuber,
|
|
QuantityKind.grams,
|
|
],
|
|
LotType.cutting => const [QuantityKind.cutting],
|
|
};
|
|
|
|
/// The human label for a lot form.
|
|
String lotTypeLabel(Translations t, LotType type) => switch (type) {
|
|
LotType.seed => t.lotType.seed,
|
|
LotType.seedling => t.lotType.seedling,
|
|
LotType.plant => t.lotType.plant,
|
|
LotType.tree => t.lotType.tree,
|
|
LotType.bulb => t.lotType.bulb,
|
|
LotType.cutting => t.lotType.cutting,
|
|
};
|
|
|
|
/// Living forms whose packaging ([Presentation]) is worth asking about.
|
|
bool typeHasPresentation(LotType type) =>
|
|
type == LotType.seedling || type == LotType.plant || type == LotType.tree;
|
|
|
|
/// Form selector: a wrap of choice chips, one per [LotType].
|
|
class LotTypeSelector extends StatelessWidget {
|
|
const LotTypeSelector({
|
|
required this.value,
|
|
required this.onChanged,
|
|
super.key,
|
|
});
|
|
|
|
final LotType value;
|
|
final ValueChanged<LotType> onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
return Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
for (final type in LotType.values)
|
|
ChoiceChip(
|
|
key: Key('lotType.${type.name}'),
|
|
selected: value == type,
|
|
onSelected: (_) => onChanged(type),
|
|
avatar: type == LotType.seed
|
|
? const SeedGlyph(SeedGlyphs.jars, size: 18)
|
|
: Icon(iconForLotType(type), size: 18),
|
|
label: Text(lotTypeLabel(t, type)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Optional packaging selector for living lots. Tapping the selected chip again
|
|
/// clears it (packaging is optional).
|
|
class PresentationSelector extends StatelessWidget {
|
|
const PresentationSelector({
|
|
required this.value,
|
|
required this.onChanged,
|
|
super.key,
|
|
});
|
|
|
|
final Presentation? value;
|
|
final ValueChanged<Presentation?> onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
return Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
for (final p in Presentation.values)
|
|
ChoiceChip(
|
|
key: Key('presentation.${p.name}'),
|
|
selected: value == p,
|
|
onSelected: (sel) => onChanged(sel ? p : null),
|
|
label: Text(presentationLabel(t, p)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The human label for a packaging [Presentation].
|
|
String presentationLabel(Translations t, Presentation p) => switch (p) {
|
|
Presentation.pot => t.presentation.pot,
|
|
Presentation.tray => t.presentation.tray,
|
|
Presentation.plug => t.presentation.plug,
|
|
Presentation.bareRoot => t.presentation.bareRoot,
|
|
Presentation.rootBall => t.presentation.rootBall,
|
|
};
|
|
|
|
/// A horizontal scale of large unit icons + an optional number stepper. Emits a
|
|
/// [Quantity] (or null while nothing is picked) via [onChanged].
|
|
class QuantityPicker extends StatefulWidget {
|
|
const QuantityPicker({
|
|
required this.type,
|
|
required this.onChanged,
|
|
this.value,
|
|
super.key,
|
|
});
|
|
|
|
final LotType type;
|
|
final Quantity? value;
|
|
final ValueChanged<Quantity?> onChanged;
|
|
|
|
@override
|
|
State<QuantityPicker> createState() => _QuantityPickerState();
|
|
}
|
|
|
|
class _QuantityPickerState extends State<QuantityPicker> {
|
|
QuantityKind? _kind;
|
|
final _countController = TextEditingController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_kind = widget.value?.kind;
|
|
final c = widget.value?.count;
|
|
if (c != null) _countController.text = _fmt(c);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(QuantityPicker old) {
|
|
super.didUpdateWidget(old);
|
|
// Switching seed/plant, or the owner clearing the value, drops a stale
|
|
// selection. We only update local fields here (no onChanged during build);
|
|
// the owner clears its own value alongside the type change.
|
|
if (widget.value == null && _kind != null) {
|
|
_kind = null;
|
|
_countController.clear();
|
|
} else if (widget.type != old.type &&
|
|
_kind != null &&
|
|
!kindsForType(widget.type).contains(_kind)) {
|
|
_kind = null;
|
|
_countController.clear();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_countController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _emit() {
|
|
final kind = _kind;
|
|
if (kind == null) {
|
|
widget.onChanged(null);
|
|
return;
|
|
}
|
|
final n = num.tryParse(_countController.text.trim().replaceAll(',', '.'));
|
|
widget.onChanged(Quantity(kind: kind, count: kind.countable ? n : null));
|
|
}
|
|
|
|
void _select(QuantityKind kind) {
|
|
setState(() => _kind = kind);
|
|
_emit();
|
|
}
|
|
|
|
void _bump(int delta) {
|
|
final current = int.tryParse(_countController.text.trim()) ?? 0;
|
|
final next = (current + delta).clamp(0, 9999);
|
|
_countController.text = next == 0 ? '' : '$next';
|
|
_emit();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
final kinds = kindsForType(widget.type);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Wrap (not a horizontal scroll) so every unit stays visible.
|
|
Wrap(
|
|
spacing: 4,
|
|
runSpacing: 4,
|
|
children: [
|
|
for (final kind in kinds)
|
|
_ScaleItem(
|
|
kind: kind,
|
|
label: unitLabel(t, kind),
|
|
selected: _kind == kind,
|
|
onTap: () => _select(kind),
|
|
),
|
|
],
|
|
),
|
|
if (_kind?.countable ?? false) ...[
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
IconButton.filledTonal(
|
|
onPressed: () => _bump(-1),
|
|
icon: const Icon(Icons.remove),
|
|
),
|
|
SizedBox(
|
|
width: 64,
|
|
child: TextField(
|
|
key: const Key('quantity.count'),
|
|
controller: _countController,
|
|
textAlign: TextAlign.center,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(isDense: true),
|
|
onChanged: (_) => _emit(),
|
|
),
|
|
),
|
|
IconButton.filledTonal(
|
|
onPressed: () => _bump(1),
|
|
icon: const Icon(Icons.add),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
unitLabel(t, _kind!),
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
String _fmt(num n) =>
|
|
n == n.roundToDouble() ? n.toInt().toString() : n.toString();
|
|
|
|
class _ScaleItem extends StatelessWidget {
|
|
const _ScaleItem({
|
|
required this.kind,
|
|
required this.label,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
final QuantityKind kind;
|
|
final String label;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color = selected ? seedGreenDark : Colors.black54;
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: Container(
|
|
width: 68,
|
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
|
decoration: BoxDecoration(
|
|
color: selected ? seedGreen.withValues(alpha: 0.15) : null,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
QuantityKindIcon(kind, size: 30, color: color),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontSize: 11, color: color),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|