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:
parent
b23f4f1c77
commit
a69047326e
2 changed files with 143 additions and 20 deletions
|
|
@ -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<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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue