From a69047326eb637732dd17f3939a2a1db0aa23318 Mon Sep 17 00:00:00 2001 From: vjrj Date: Wed, 8 Jul 2026 13:52:56 +0200 Subject: [PATCH] feat(inventory): seed/plant clarity + full-screen photo viewer - Each lot shows a Seeds/Plantel chip (green vs teal, with icon) so mixed seed-and-plant varieties read clearly at a glance. - Germination is hidden on plant lots (it only applies to seeds). - Tapping a photo in the detail carousel opens a full-screen, swipeable, pinch-to-zoom viewer. Tests: plant lot shows the Plants chip and hides germination. 53 green; Linux runs. --- .../lib/ui/variety_detail_screen.dart | 134 +++++++++++++++--- .../test/ui/variety_detail_screen_test.dart | 29 ++++ 2 files changed, 143 insertions(+), 20 deletions(-) diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index d43d1cb..6942d52 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:url_launcher/url_launcher.dart'; import '../data/species_repository.dart'; @@ -154,24 +155,33 @@ class _DetailView extends StatelessWidget { leading: lot.quantity == null ? const Icon(Icons.inventory_2_outlined) : QuantityKindIcon(lot.quantity!.kind, size: 28), - title: Text(_lotSubtitle(t, lot)), + title: Row( + children: [ + _LotTypeChip(type: lot.type), + const SizedBox(width: 8), + Expanded(child: Text(_lotSubtitle(t, lot))), + ], + ), subtitle: lot.storageLocation == null ? null : Text(lot.storageLocation!), - trailing: 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), - ), - ], - ), + // 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), + ), + ], + ), ), ], ), @@ -193,6 +203,45 @@ String _lotSubtitle(Translations t, VarietyLot lot) { String _germinationPercent(Translations t, double rate) => t.germination.result(percent: (rate * 100).round()); +/// 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 isPlant = type == LotType.plant; + final color = isPlant ? const Color(0xFF00796B) : seedGreenDark; + 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 (isPlant) + Icon(Symbols.potted_plant, size: 14, color: color) + else + SeedGlyph(SeedGlyphs.jars, size: 14, color: color), + const SizedBox(width: 4), + Text( + isPlant ? t.lotType.plant : t.lotType.seed, + 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}); @@ -854,11 +903,14 @@ class _PhotoGalleryState extends State<_PhotoGallery> { controller: _controller, itemCount: photos.length, onPageChanged: (i) => setState(() => _page = i), - itemBuilder: (_, i) => Image.memory( - photos[i].bytes, - width: 140, - height: 140, - fit: BoxFit.cover, + itemBuilder: (_, i) => GestureDetector( + onTap: () => _openPhotoViewer(context, photos, i), + child: Image.memory( + photos[i].bytes, + width: 140, + height: 140, + fit: BoxFit.cover, + ), ), ), ), @@ -941,6 +993,48 @@ class _Dots extends StatelessWidget { } } +void _openPhotoViewer( + BuildContext context, + List photos, + int initialIndex, +) { + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => _PhotoViewer(photos: photos, initialIndex: initialIndex), + ), + ); +} + +/// Full-screen, swipeable, pinch-to-zoom photo viewer. +class _PhotoViewer extends StatelessWidget { + const _PhotoViewer({required this.photos, required this.initialIndex}); + + final List photos; + final int initialIndex; + + @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) => InteractiveViewer( + minScale: 1, + maxScale: 5, + child: Center(child: Image.memory(photos[i].bytes)), + ), + ), + ); + } +} + class _SectionTitle extends StatelessWidget { const _SectionTitle(this.text); diff --git a/apps/app_seeds/test/ui/variety_detail_screen_test.dart b/apps/app_seeds/test/ui/variety_detail_screen_test.dart index d83d6de..0f740d1 100644 --- a/apps/app_seeds/test/ui/variety_detail_screen_test.dart +++ b/apps/app_seeds/test/ui/variety_detail_screen_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/data/species_repository.dart'; import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; import '../support/test_support.dart'; @@ -358,4 +359,32 @@ void main() { expect(find.text('Wiki'), findsNothing); await disposeTree(tester); }); + + testWidgets('plant lots show the Plants chip and hide germination', ( + tester, + ) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Tomato'); + await repo.addLot( + varietyId: id, + type: LotType.plant, + harvestYear: 2024, + quantity: const Quantity(kind: QuantityKind.plant, count: 3), + ); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Plants'), findsOneWidget); // lot-type chip + expect(find.text('Year 2024 ยท 3 plants'), findsOneWidget); + // Germination is seed-only. + expect(find.byIcon(Icons.grass_outlined), findsNothing); + await disposeTree(tester); + }); }