feat(block1): photo thumbnails in the inventory list
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.
This commit is contained in:
parent
37427fa738
commit
8a19a6c85a
4 changed files with 87 additions and 10 deletions
|
|
@ -8,14 +8,22 @@ import '../db/enums.dart';
|
||||||
|
|
||||||
/// A lightweight row for the inventory list (only what the list renders).
|
/// A lightweight row for the inventory list (only what the list renders).
|
||||||
class VarietyListItem extends Equatable {
|
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 id;
|
||||||
final String label;
|
final String label;
|
||||||
final String? category;
|
final String? category;
|
||||||
|
|
||||||
|
/// First photo (encrypted BLOB) for the avatar, or null → show an initial.
|
||||||
|
final Uint8List? photo;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [id, label, category];
|
List<Object?> get props => [id, label, category, photo];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One germination test on a lot; [rate] is derived (0..1), null when it can't
|
/// 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;
|
final int Function() _now;
|
||||||
Hlc _clock;
|
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<List<VarietyListItem>> watchInventory() {
|
Stream<List<VarietyListItem>> watchInventory() {
|
||||||
final query = _db.select(_db.varieties)
|
final query = _db.select(_db.varieties)
|
||||||
..where((v) => v.isDeleted.equals(false))
|
..where((v) => v.isDeleted.equals(false))
|
||||||
|
|
@ -143,14 +152,41 @@ class VarietyRepository {
|
||||||
(v) => OrderingTerm(expression: v.category),
|
(v) => OrderingTerm(expression: v.category),
|
||||||
(v) => OrderingTerm(expression: v.label),
|
(v) => OrderingTerm(expression: v.label),
|
||||||
]);
|
]);
|
||||||
return query.watch().map(
|
return query.watch().asyncMap((rows) async {
|
||||||
(rows) => rows
|
final photos = await _firstPhotosFor(rows.map((v) => v.id).toList());
|
||||||
|
return rows
|
||||||
.map(
|
.map(
|
||||||
(v) =>
|
(v) => VarietyListItem(
|
||||||
VarietyListItem(id: v.id, label: v.label, category: v.category),
|
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<Map<String, Uint8List>> _firstPhotosFor(
|
||||||
|
List<String> 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 = <String, Uint8List>{};
|
||||||
|
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],
|
/// The 20-second quick-add: a [label] (required) plus an optional [category],
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,9 @@ class _VarietyTile extends StatelessWidget {
|
||||||
? '?'
|
? '?'
|
||||||
: trimmed.substring(0, 1).toUpperCase();
|
: trimmed.substring(0, 1).toUpperCase();
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: CircleAvatar(child: Text(initial)),
|
leading: item.photo != null
|
||||||
|
? CircleAvatar(backgroundImage: MemoryImage(item.photo!))
|
||||||
|
: CircleAvatar(child: Text(initial)),
|
||||||
title: Text(item.label),
|
title: Text(item.label),
|
||||||
onTap: () => context.push('/variety/${item.id}'),
|
onTap: () => context.push('/variety/${item.id}'),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
test(
|
||||||
'stream is ordered by category then label and excludes soft-deleted rows',
|
'stream is ordered by category then label and excludes soft-deleted rows',
|
||||||
() async {
|
() async {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:tane/db/database.dart';
|
import 'package:tane/db/database.dart';
|
||||||
|
|
@ -6,6 +8,14 @@ import 'package:tane/ui/inventory_list_screen.dart';
|
||||||
|
|
||||||
import '../support/test_support.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() {
|
void main() {
|
||||||
late AppDatabase db;
|
late AppDatabase db;
|
||||||
|
|
||||||
|
|
@ -75,4 +85,21 @@ void main() {
|
||||||
expect(find.text('Inventario'), findsOneWidget);
|
expect(find.text('Inventario'), findsOneWidget);
|
||||||
await disposeTree(tester);
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue