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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../data/species_repository.dart';
|
import '../data/species_repository.dart';
|
||||||
|
|
@ -154,24 +155,33 @@ class _DetailView extends StatelessWidget {
|
||||||
leading: lot.quantity == null
|
leading: lot.quantity == null
|
||||||
? const Icon(Icons.inventory_2_outlined)
|
? const Icon(Icons.inventory_2_outlined)
|
||||||
: QuantityKindIcon(lot.quantity!.kind, size: 28),
|
: 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
|
subtitle: lot.storageLocation == null
|
||||||
? null
|
? null
|
||||||
: Text(lot.storageLocation!),
|
: Text(lot.storageLocation!),
|
||||||
trailing: Row(
|
// Germination only applies to seed lots, not to plantel.
|
||||||
mainAxisSize: MainAxisSize.min,
|
trailing: lot.type != LotType.seed
|
||||||
children: [
|
? null
|
||||||
if (lot.latestGerminationRate != null)
|
: Row(
|
||||||
_GerminationBadge(rate: lot.latestGerminationRate!),
|
mainAxisSize: MainAxisSize.min,
|
||||||
IconButton(
|
children: [
|
||||||
key: Key('lot.germination.${lot.id}'),
|
if (lot.latestGerminationRate != null)
|
||||||
icon: const Icon(Icons.grass_outlined),
|
_GerminationBadge(rate: lot.latestGerminationRate!),
|
||||||
tooltip: t.germination.title,
|
IconButton(
|
||||||
onPressed: () =>
|
key: Key('lot.germination.${lot.id}'),
|
||||||
_showGerminationSheet(context, cubit, lot),
|
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) =>
|
String _germinationPercent(Translations t, double rate) =>
|
||||||
t.germination.result(percent: (rate * 100).round());
|
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.
|
/// A compact pill showing the latest germination rate on a lot tile.
|
||||||
class _GerminationBadge extends StatelessWidget {
|
class _GerminationBadge extends StatelessWidget {
|
||||||
const _GerminationBadge({required this.rate});
|
const _GerminationBadge({required this.rate});
|
||||||
|
|
@ -854,11 +903,14 @@ class _PhotoGalleryState extends State<_PhotoGallery> {
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
itemCount: photos.length,
|
itemCount: photos.length,
|
||||||
onPageChanged: (i) => setState(() => _page = i),
|
onPageChanged: (i) => setState(() => _page = i),
|
||||||
itemBuilder: (_, i) => Image.memory(
|
itemBuilder: (_, i) => GestureDetector(
|
||||||
photos[i].bytes,
|
onTap: () => _openPhotoViewer(context, photos, i),
|
||||||
width: 140,
|
child: Image.memory(
|
||||||
height: 140,
|
photos[i].bytes,
|
||||||
fit: BoxFit.cover,
|
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 {
|
class _SectionTitle extends StatelessWidget {
|
||||||
const _SectionTitle(this.text);
|
const _SectionTitle(this.text);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:tane/data/species_repository.dart';
|
import 'package:tane/data/species_repository.dart';
|
||||||
import 'package:tane/db/database.dart';
|
import 'package:tane/db/database.dart';
|
||||||
|
import 'package:tane/db/enums.dart';
|
||||||
|
|
||||||
import '../support/test_support.dart';
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
|
@ -358,4 +359,32 @@ void main() {
|
||||||
expect(find.text('Wiki'), findsNothing);
|
expect(find.text('Wiki'), findsNothing);
|
||||||
await disposeTree(tester);
|
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