From 8a19a6c85aec0c689ba1cda2fa9139ee9b93081d Mon Sep 17 00:00:00 2001 From: vjrj Date: Tue, 7 Jul 2026 22:03:36 +0200 Subject: [PATCH] feat(block1): photo thumbnails in the inventory list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list showed only initials even when a variety had a photo (quick-add stores one as an encrypted BLOB). Now the avatar shows the first photo, falling back to the initial — matching the "foto/inicial" list spec. - VarietyListItem carries the first photo bytes; watchInventory loads them in a single query per emission. - List tile renders a MemoryImage avatar when present. Tests: repository (photo bytes surfaced, null when none) and a widget test (avatar replaces the initial). Full suite: 37 passing, 0 skipped. --- .../lib/data/variety_repository.dart | 54 +++++++++++++++---- .../lib/ui/inventory_list_screen.dart | 4 +- .../test/data/variety_repository_test.dart | 12 +++++ .../test/ui/inventory_list_screen_test.dart | 27 ++++++++++ 4 files changed, 87 insertions(+), 10 deletions(-) diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 94a4326..307fca3 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -8,14 +8,22 @@ import '../db/enums.dart'; /// A lightweight row for the inventory list (only what the list renders). class VarietyListItem extends Equatable { - const VarietyListItem({required this.id, required this.label, this.category}); + const VarietyListItem({ + required this.id, + required this.label, + this.category, + this.photo, + }); final String id; final String label; final String? category; + /// First photo (encrypted BLOB) for the avatar, or null → show an initial. + final Uint8List? photo; + @override - List get props => [id, label, category]; + List get props => [id, label, category, photo]; } /// One germination test on a lot; [rate] is derived (0..1), null when it can't @@ -135,7 +143,8 @@ class VarietyRepository { final int Function() _now; Hlc _clock; - /// Emits the non-deleted inventory, ordered by category then label. + /// Emits the non-deleted inventory, ordered by category then label, each with + /// its first photo for the avatar. Stream> watchInventory() { final query = _db.select(_db.varieties) ..where((v) => v.isDeleted.equals(false)) @@ -143,14 +152,41 @@ class VarietyRepository { (v) => OrderingTerm(expression: v.category), (v) => OrderingTerm(expression: v.label), ]); - return query.watch().map( - (rows) => rows + return query.watch().asyncMap((rows) async { + final photos = await _firstPhotosFor(rows.map((v) => v.id).toList()); + return rows .map( - (v) => - VarietyListItem(id: v.id, label: v.label, category: v.category), + (v) => VarietyListItem( + id: v.id, + label: v.label, + category: v.category, + photo: photos[v.id], + ), ) - .toList(), - ); + .toList(); + }); + } + + /// Loads the first photo BLOB for each of [varietyIds] (one query). + Future> _firstPhotosFor( + List varietyIds, + ) async { + if (varietyIds.isEmpty) return const {}; + final rows = + await (_db.select(_db.attachments)..where( + (a) => + a.parentId.isIn(varietyIds) & + a.parentType.equalsValue(ParentType.variety) & + a.kind.equalsValue(AttachmentKind.photo) & + a.isDeleted.equals(false), + )) + .get(); + final byVariety = {}; + for (final row in rows) { + final bytes = row.bytes; + if (bytes != null) byVariety.putIfAbsent(row.parentId, () => bytes); + } + return byVariety; } /// The 20-second quick-add: a [label] (required) plus an optional [category], diff --git a/apps/app_seeds/lib/ui/inventory_list_screen.dart b/apps/app_seeds/lib/ui/inventory_list_screen.dart index 87db76e..f4a9f4c 100644 --- a/apps/app_seeds/lib/ui/inventory_list_screen.dart +++ b/apps/app_seeds/lib/ui/inventory_list_screen.dart @@ -122,7 +122,9 @@ class _VarietyTile extends StatelessWidget { ? '?' : trimmed.substring(0, 1).toUpperCase(); return ListTile( - leading: CircleAvatar(child: Text(initial)), + leading: item.photo != null + ? CircleAvatar(backgroundImage: MemoryImage(item.photo!)) + : CircleAvatar(child: Text(initial)), title: Text(item.label), onTap: () => context.push('/variety/${item.id}'), ); diff --git a/apps/app_seeds/test/data/variety_repository_test.dart b/apps/app_seeds/test/data/variety_repository_test.dart index 3fc2375..d9903ce 100644 --- a/apps/app_seeds/test/data/variety_repository_test.dart +++ b/apps/app_seeds/test/data/variety_repository_test.dart @@ -52,6 +52,18 @@ void main() { }, ); + test('watchInventory carries the first photo bytes for the avatar', () async { + await repo.addQuickVariety( + label: 'With photo', + photoBytes: Uint8List.fromList([9, 8, 7]), + ); + await repo.addQuickVariety(label: 'No photo'); + + final items = await repo.watchInventory().first; + expect(items.firstWhere((i) => i.label == 'With photo').photo, [9, 8, 7]); + expect(items.firstWhere((i) => i.label == 'No photo').photo, isNull); + }); + test( 'stream is ordered by category then label and excludes soft-deleted rows', () async { diff --git a/apps/app_seeds/test/ui/inventory_list_screen_test.dart b/apps/app_seeds/test/ui/inventory_list_screen_test.dart index 19e662e..432a02b 100644 --- a/apps/app_seeds/test/ui/inventory_list_screen_test.dart +++ b/apps/app_seeds/test/ui/inventory_list_screen_test.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/db/database.dart'; @@ -6,6 +8,14 @@ import 'package:tane/ui/inventory_list_screen.dart'; import '../support/test_support.dart'; +/// A valid 1x1 transparent PNG so the avatar image decodes cleanly in tests. +final _tinyPng = Uint8List.fromList(const [ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, // + 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, // + 120, 156, 99, 250, 207, 0, 0, 0, 2, 0, 1, 226, 33, 188, 51, 0, 0, 0, 0, // + 73, 69, 78, 68, 174, 66, 96, 130, +]); + void main() { late AppDatabase db; @@ -75,4 +85,21 @@ void main() { expect(find.text('Inventario'), findsOneWidget); await disposeTree(tester); }); + + testWidgets( + 'shows a photo avatar instead of the initial when a photo exists', + (tester) async { + final repo = newTestRepository(db); + await repo.addQuickVariety(label: 'Photographed', photoBytes: _tinyPng); + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const InventoryListScreen()), + ); + await tester.pumpAndSettle(); + + expect(find.text('Photographed'), findsOneWidget); + expect(find.text('P'), findsNothing); // initial replaced by the photo + await disposeTree(tester); + }, + ); }