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.
This commit is contained in:
vjrj 2026-07-08 13:52:56 +02:00
parent b23f4f1c77
commit a69047326e
2 changed files with 143 additions and 20 deletions

View file

@ -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,11 +155,20 @@ 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(
// Germination only applies to seed lots, not to plantel.
trailing: lot.type != LotType.seed
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (lot.latestGerminationRate != null)
@ -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,7 +903,9 @@ class _PhotoGalleryState extends State<_PhotoGallery> {
controller: _controller,
itemCount: photos.length,
onPageChanged: (i) => setState(() => _page = i),
itemBuilder: (_, i) => Image.memory(
itemBuilder: (_, i) => GestureDetector(
onTap: () => _openPhotoViewer(context, photos, i),
child: Image.memory(
photos[i].bytes,
width: 140,
height: 140,
@ -862,6 +913,7 @@ class _PhotoGalleryState extends State<_PhotoGallery> {
),
),
),
),
Positioned(
top: 2,
right: 2,
@ -941,6 +993,48 @@ class _Dots extends StatelessWidget {
}
}
void _openPhotoViewer(
BuildContext context,
List<VarietyPhoto> photos,
int initialIndex,
) {
Navigator.of(context).push(
MaterialPageRoute<void>(
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<VarietyPhoto> 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);

View file

@ -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);
});
}