feat(ui): Material 3 redesign, cover-photo viewer, About screen

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.
This commit is contained in:
vjrj 2026-07-09 11:51:59 +02:00
parent f5c36f2369
commit 42c16c0e3f
24 changed files with 1960 additions and 416 deletions

View file

@ -2,7 +2,6 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:url_launcher/url_launcher.dart';
import '../data/species_repository.dart';
@ -198,6 +197,7 @@ String _lotSubtitle(Translations t, VarietyLot lot) {
else
t.detail.noYear,
if (lot.quantity != null) quantityDisplay(t, lot.quantity!),
if (lot.presentation != null) presentationLabel(t, lot.presentation!),
];
return parts.join(' · ');
}
@ -214,8 +214,9 @@ class _LotTypeChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = context.t;
final isPlant = type == LotType.plant;
final color = isPlant ? const Color(0xFF00796B) : seedGreenDark;
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(
@ -225,13 +226,13 @@ class _LotTypeChip extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isPlant)
Icon(Symbols.potted_plant, size: 14, color: color)
if (isSeed)
SeedGlyph(SeedGlyphs.jars, size: 14, color: color)
else
SeedGlyph(SeedGlyphs.jars, size: 14, color: color),
Icon(iconForLotType(type), size: 14, color: color),
const SizedBox(width: 4),
Text(
isPlant ? t.lotType.plant : t.lotType.seed,
lotTypeLabel(t, type),
style: TextStyle(
fontSize: 11,
color: color,
@ -323,7 +324,9 @@ Future<void> _showGerminationSheet(
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.germination.germinated,
border: const OutlineInputBorder(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
),
@ -335,7 +338,9 @@ Future<void> _showGerminationSheet(
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.germination.sampleSize,
border: const OutlineInputBorder(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
),
@ -504,7 +509,9 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
controller: _name,
decoration: InputDecoration(
labelText: t.editVariety.name,
border: const OutlineInputBorder(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
const SizedBox(height: 12),
@ -515,7 +522,9 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
labelText: t.editVariety.species,
hintText: t.editVariety.speciesHint,
prefixIcon: const Icon(Icons.eco_outlined),
border: const OutlineInputBorder(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
onChanged: _onSpeciesQuery,
),
@ -532,7 +541,9 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
controller: _category,
decoration: InputDecoration(
labelText: t.editVariety.category,
border: const OutlineInputBorder(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
const SizedBox(height: 12),
@ -542,7 +553,9 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
maxLines: 5,
decoration: InputDecoration(
labelText: t.editVariety.notes,
border: const OutlineInputBorder(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
const SizedBox(height: 16),
@ -577,6 +590,7 @@ Future<void> _showLotSheet(
var selectedMonth = existing?.harvestMonth;
var selectedType = existing?.type ?? LotType.seed;
var selectedQuantity = existing?.quantity;
var selectedPresentation = existing?.presentation;
final editing = existing != null;
return showModalBottomSheet<void>(
context: context,
@ -593,58 +607,84 @@ Future<void> _showLotSheet(
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,
),
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,
),
],
),
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;
}),
),
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: 16),
Row(
@ -664,6 +704,7 @@ Future<void> _showLotSheet(
year: selectedYear,
month: selectedMonth,
quantity: selectedQuantity,
presentation: selectedPresentation,
);
} else {
cubit.addLot(
@ -671,6 +712,7 @@ Future<void> _showLotSheet(
year: selectedYear,
month: selectedMonth,
quantity: selectedQuantity,
presentation: selectedPresentation,
);
}
Navigator.of(sheetContext).pop();
@ -807,12 +849,25 @@ class _DetailHeader extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (detail.category != null)
Text(
detail.category!,
style: const TextStyle(
color: seedLink,
fontWeight: FontWeight.w600,
),
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),
@ -890,37 +945,27 @@ class _PhotoGalleryState extends State<_PhotoGallery> {
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: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: PageView.builder(
controller: _controller,
itemCount: photos.length,
onPageChanged: (i) => setState(() => _page = i),
itemBuilder: (_, i) => GestureDetector(
onTap: () => _openPhotoViewer(context, photos, i),
child: Image.memory(
photos[i].bytes,
width: 140,
height: 140,
fit: BoxFit.cover,
),
),
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,
),
),
Positioned(
top: 2,
right: 2,
child: _CircleIconButton(
icon: Icons.close,
tooltip: context.t.common.delete,
onPressed: () => cubit.removePhoto(photos[page].id),
),
),
],
),
),
),
const SizedBox(height: 6),
@ -946,33 +991,6 @@ class _PhotoGalleryState extends State<_PhotoGallery> {
}
}
class _CircleIconButton extends StatelessWidget {
const _CircleIconButton({
required this.icon,
required this.onPressed,
this.tooltip,
});
final IconData icon;
final VoidCallback onPressed;
final String? tooltip;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black45,
shape: const CircleBorder(),
child: IconButton(
key: const Key('photo.delete'),
icon: Icon(icon, size: 18, color: Colors.white),
tooltip: tooltip,
visualDensity: VisualDensity.compact,
onPressed: onPressed,
),
);
}
}
class _Dots extends StatelessWidget {
const _Dots({required this.count, required this.active, this.onTap});
@ -1009,38 +1027,141 @@ class _Dots extends StatelessWidget {
void _openPhotoViewer(
BuildContext context,
List<VarietyPhoto> photos,
VarietyDetailCubit cubit,
int initialIndex,
) {
Navigator.of(context).push(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (_) => _PhotoViewer(photos: photos, initialIndex: initialIndex),
// 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.
class _PhotoViewer extends StatelessWidget {
const _PhotoViewer({required this.photos, required this.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 List<VarietyPhoto> photos;
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<void> _confirmDelete(
VarietyDetailCubit cubit,
VarietyPhoto photo,
) async {
final t = context.t;
final confirmed = await showDialog<bool>(
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) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
elevation: 0,
),
body: PageView.builder(
controller: PageController(initialPage: initialIndex),
itemCount: photos.length,
itemBuilder: (_, i) => _ZoomablePhoto(bytes: photos[i].bytes),
),
final cubit = context.read<VarietyDetailCubit>();
return BlocBuilder<VarietyDetailCubit, VarietyDetailState>(
builder: (context, state) {
final photos = state.detail?.photos ?? const <VarietyPhoto>[];
// 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),
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),
),
);
},
);
}
}