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:
parent
f5c36f2369
commit
42c16c0e3f
24 changed files with 1960 additions and 416 deletions
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue