From f45c4526150632b00a03887b58390b8cb02027b0 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 22:12:24 +0200 Subject: [PATCH] fix(inventory): recover from a stuck loading spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inventory stream subscription had no onError handler, so a transient failure at startup (e.g. the encrypted DB not yet ready) went unhandled and left loading=true forever — the spinner that only a restart cleared. Handle stream errors: drop out of loading, surface an error state, and offer a retry that re-opens the stream. Add a _LoadError view (i18n en/ es/ast/pt) and cover both the failure and the retry-recovers paths. --- apps/app_seeds/lib/i18n/ast.i18n.json | 4 +- apps/app_seeds/lib/i18n/en.i18n.json | 4 +- apps/app_seeds/lib/i18n/es.i18n.json | 4 +- apps/app_seeds/lib/i18n/pt.i18n.json | 4 +- apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 4 ++ apps/app_seeds/lib/i18n/strings_en.g.dart | 8 +++ apps/app_seeds/lib/i18n/strings_es.g.dart | 4 ++ apps/app_seeds/lib/i18n/strings_pt.g.dart | 4 ++ apps/app_seeds/lib/state/inventory_cubit.dart | 54 +++++++++++++++---- .../lib/ui/inventory_list_screen.dart | 36 ++++++++++++- .../test/state/inventory_cubit_test.dart | 45 ++++++++++++++++ 12 files changed, 158 insertions(+), 17 deletions(-) diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index bddcffc..b87f21d 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -132,7 +132,9 @@ "noMatches": "Nenguna simiente concasa colos filtros.", "clearFilters": "Quitar filtros", "uncategorized": "Ensin categoría", - "needsReproductionFilter": "Por reproducir" + "needsReproductionFilter": "Por reproducir", + "loadError": "Nun se pudo abrir el to bancu de granes. Seique taba ocupáu: prueba otra vuelta.", + "retry": "Volver probar" }, "draft": { "capture": "Capturar semeyes", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 1a66028..a3e4cd6 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -133,7 +133,9 @@ "noMatches": "No seeds match your filters.", "clearFilters": "Clear filters", "uncategorized": "Uncategorized", - "needsReproductionFilter": "To regrow" + "needsReproductionFilter": "To regrow", + "loadError": "Couldn't open your seed bank. It may just have been busy — try again.", + "retry": "Try again" }, "draft": { "capture": "Capture photos", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 65c41b1..8766b2c 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -132,7 +132,9 @@ "noMatches": "Ninguna semilla coincide con los filtros.", "clearFilters": "Quitar filtros", "uncategorized": "Sin categoría", - "needsReproductionFilter": "Por reproducir" + "needsReproductionFilter": "Por reproducir", + "loadError": "No se pudo abrir tu banco de semillas. Quizá estaba ocupado: inténtalo de nuevo.", + "retry": "Reintentar" }, "draft": { "capture": "Capturar fotos", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index ba8c6e8..2a40cd5 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -133,7 +133,9 @@ "noMatches": "Nenhuma semente corresponde aos teus filtros.", "clearFilters": "Limpar filtros", "uncategorized": "Sem categoria", - "needsReproductionFilter": "Para reproduzir" + "needsReproductionFilter": "Para reproduzir", + "loadError": "Não foi possível abrir o teu banco de sementes. Talvez estivesse ocupado — tenta de novo.", + "retry": "Tentar de novo" }, "draft": { "capture": "Capturar fotos", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index d9c532b..346694f 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 1668 (417 per locale) +/// Strings: 1676 (419 per locale) /// -/// Built on 2026-07-10 at 19:42 UTC +/// Built on 2026-07-10 at 20:10 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index de571b8..1f0a750 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -268,6 +268,8 @@ class _Translations$inventory$ast extends Translations$inventory$en { @override String get clearFilters => 'Quitar filtros'; @override String get uncategorized => 'Ensin categoría'; @override String get needsReproductionFilter => 'Por reproducir'; + @override String get loadError => 'Nun se pudo abrir el to bancu de granes. Seique taba ocupáu: prueba otra vuelta.'; + @override String get retry => 'Volver probar'; } // Path: draft @@ -1248,6 +1250,8 @@ extension on TranslationsAst { 'inventory.clearFilters' => 'Quitar filtros', 'inventory.uncategorized' => 'Ensin categoría', 'inventory.needsReproductionFilter' => 'Por reproducir', + 'inventory.loadError' => 'Nun se pudo abrir el to bancu de granes. Seique taba ocupáu: prueba otra vuelta.', + 'inventory.retry' => 'Volver probar', 'draft.capture' => 'Capturar semeyes', 'draft.captured' => ({required Object n}) => '${n} capturaes por catalogar', 'draft.triageTitle' => 'Por catalogar', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 097d5d2..af003ea 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -455,6 +455,12 @@ class Translations$inventory$en { /// en: 'To regrow' String get needsReproductionFilter => 'To regrow'; + + /// en: 'Couldn't open your seed bank. It may just have been busy — try again.' + String get loadError => 'Couldn\'t open your seed bank. It may just have been busy — try again.'; + + /// en: 'Try again' + String get retry => 'Try again'; } // Path: draft @@ -2070,6 +2076,8 @@ extension on Translations { 'inventory.clearFilters' => 'Clear filters', 'inventory.uncategorized' => 'Uncategorized', 'inventory.needsReproductionFilter' => 'To regrow', + 'inventory.loadError' => 'Couldn\'t open your seed bank. It may just have been busy — try again.', + 'inventory.retry' => 'Try again', 'draft.capture' => 'Capture photos', 'draft.captured' => ({required Object n}) => '${n} captured to catalogue', 'draft.triageTitle' => 'To catalogue', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 0e72da2..146fcaa 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -268,6 +268,8 @@ class _Translations$inventory$es extends Translations$inventory$en { @override String get clearFilters => 'Quitar filtros'; @override String get uncategorized => 'Sin categoría'; @override String get needsReproductionFilter => 'Por reproducir'; + @override String get loadError => 'No se pudo abrir tu banco de semillas. Quizá estaba ocupado: inténtalo de nuevo.'; + @override String get retry => 'Reintentar'; } // Path: draft @@ -1250,6 +1252,8 @@ extension on TranslationsEs { 'inventory.clearFilters' => 'Quitar filtros', 'inventory.uncategorized' => 'Sin categoría', 'inventory.needsReproductionFilter' => 'Por reproducir', + 'inventory.loadError' => 'No se pudo abrir tu banco de semillas. Quizá estaba ocupado: inténtalo de nuevo.', + 'inventory.retry' => 'Reintentar', 'draft.capture' => 'Capturar fotos', 'draft.captured' => ({required Object n}) => '${n} capturadas por catalogar', 'draft.triageTitle' => 'Por catalogar', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index f5663ac..661df58 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -269,6 +269,8 @@ class _Translations$inventory$pt extends Translations$inventory$en { @override String get clearFilters => 'Limpar filtros'; @override String get uncategorized => 'Sem categoria'; @override String get needsReproductionFilter => 'Para reproduzir'; + @override String get loadError => 'Não foi possível abrir o teu banco de sementes. Talvez estivesse ocupado — tenta de novo.'; + @override String get retry => 'Tentar de novo'; } // Path: draft @@ -1248,6 +1250,8 @@ extension on TranslationsPt { 'inventory.clearFilters' => 'Limpar filtros', 'inventory.uncategorized' => 'Sem categoria', 'inventory.needsReproductionFilter' => 'Para reproduzir', + 'inventory.loadError' => 'Não foi possível abrir o teu banco de sementes. Talvez estivesse ocupado — tenta de novo.', + 'inventory.retry' => 'Tentar de novo', 'draft.capture' => 'Capturar fotos', 'draft.captured' => ({required Object n}) => '${n} capturadas para catalogar', 'draft.triageTitle' => 'Para catalogar', diff --git a/apps/app_seeds/lib/state/inventory_cubit.dart b/apps/app_seeds/lib/state/inventory_cubit.dart index 8d55ba5..ddc8c39 100644 --- a/apps/app_seeds/lib/state/inventory_cubit.dart +++ b/apps/app_seeds/lib/state/inventory_cubit.dart @@ -20,6 +20,7 @@ class InventoryState extends Equatable { this.needsReproductionOnly = false, this.sharingOnly = false, this.loading = true, + this.error, this.selectionMode = false, this.selectedIds = const {}, }); @@ -51,6 +52,11 @@ class InventoryState extends Equatable { final bool loading; + /// Set when the inventory stream fails (e.g. the encrypted DB wasn't ready). + /// The UI shows a retry affordance instead of an endless spinner; null when + /// fine. See [InventoryCubit.retry]. + final String? error; + /// Whether the list is in multi-select mode — used to pick a subset of the /// inventory to print labels for. final bool selectionMode; @@ -110,6 +116,7 @@ class InventoryState extends Equatable { bool? needsReproductionOnly, bool? sharingOnly, bool? loading, + String? Function()? error, bool? selectionMode, Set? selectedIds, }) { @@ -124,6 +131,7 @@ class InventoryState extends Equatable { needsReproductionOnly ?? this.needsReproductionOnly, sharingOnly: sharingOnly ?? this.sharingOnly, loading: loading ?? this.loading, + error: error != null ? error() : this.error, selectionMode: selectionMode ?? this.selectionMode, selectedIds: selectedIds ?? this.selectedIds, ); @@ -140,6 +148,7 @@ class InventoryState extends Equatable { needsReproductionOnly, sharingOnly, loading, + error, selectionMode, selectedIds, ]; @@ -149,21 +158,46 @@ class InventoryState extends Equatable { /// automatically after a quick-add — no manual refresh. class InventoryCubit extends Cubit { InventoryCubit(this._repo) : super(const InventoryState()) { - // One combined subscription (list + draft tray). Two separate StreamGroups - // here re-emit in a loop that hangs widget tests — see watchInventoryView. - _sub = _repo.watchInventoryView().listen( - (view) => emit( - state.copyWith(items: view.items, drafts: view.drafts, loading: false), - ), - ); + _subscribe(); } final VarietyRepository _repo; - late final StreamSubscription< + StreamSubscription< ({List items, List drafts}) - > + >? _sub; + /// (Re)opens the combined inventory subscription (list + draft tray). One + /// subscription only: two separate StreamGroups here re-emit in a loop that + /// hangs widget tests — see watchInventoryView. + /// + /// The onError handler is load-bearing: without it a transient stream failure + /// (e.g. the encrypted DB not yet ready at startup) would go unhandled and + /// leave [InventoryState.loading] true forever — the "stuck spinner" that a + /// restart clears. On error we drop out of loading and surface [error] so the + /// UI can offer [retry]. + void _subscribe() { + _sub?.cancel(); + _sub = _repo.watchInventoryView().listen( + (view) => emit( + state.copyWith( + items: view.items, + drafts: view.drafts, + loading: false, + error: () => null, + ), + ), + onError: (Object e) => + emit(state.copyWith(loading: false, error: () => '$e')), + ); + } + + /// Re-opens the inventory stream after a failure, back to the loading state. + void retry() { + emit(state.copyWith(loading: true, error: () => null)); + _subscribe(); + } + void search(String query) => emit(state.copyWith(query: query)); /// Toggles a category in the filter (add if absent, remove if present). @@ -235,7 +269,7 @@ class InventoryCubit extends Cubit { @override Future close() async { - await _sub.cancel(); + await _sub?.cancel(); return super.close(); } } diff --git a/apps/app_seeds/lib/ui/inventory_list_screen.dart b/apps/app_seeds/lib/ui/inventory_list_screen.dart index aaddcb4..1a5675c 100644 --- a/apps/app_seeds/lib/ui/inventory_list_screen.dart +++ b/apps/app_seeds/lib/ui/inventory_list_screen.dart @@ -46,7 +46,9 @@ class InventoryListScreen extends StatelessWidget { ), child: const Icon(Icons.add), ), - body: state.loading + body: state.error != null + ? _LoadError(onRetry: context.read().retry) + : state.loading ? const Center(child: CircularProgressIndicator()) : Column( children: [ @@ -237,6 +239,38 @@ class InventoryListScreen extends StatelessWidget { /// A tappable banner announcing how many photo-first captures are waiting to be /// named. Opens the "to catalogue" tray. +/// Shown when the inventory stream fails to open — instead of an endless +/// spinner, offer a clear message and a way to try again. +class _LoadError extends StatelessWidget { + const _LoadError({required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final t = context.t; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.cloud_off_outlined, size: 48, color: seedGreen), + const SizedBox(height: 16), + Text(t.inventory.loadError, textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton( + key: const Key('inventory.retry'), + onPressed: onRetry, + child: Text(t.inventory.retry), + ), + ], + ), + ), + ); + } +} + class _TriageBanner extends StatelessWidget { const _TriageBanner({required this.count}); diff --git a/apps/app_seeds/test/state/inventory_cubit_test.dart b/apps/app_seeds/test/state/inventory_cubit_test.dart index da74a88..6a4547d 100644 --- a/apps/app_seeds/test/state/inventory_cubit_test.dart +++ b/apps/app_seeds/test/state/inventory_cubit_test.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; +import 'package:commons_core/commons_core.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/data/variety_repository.dart'; import 'package:tane/db/database.dart'; @@ -8,6 +9,22 @@ import 'package:tane/state/inventory_cubit.dart'; import '../support/test_support.dart'; +/// A repository whose inventory stream errors until [healthy] is flipped — +/// mimics the transient DB-not-ready failure that used to leave the spinner +/// stuck forever. +class _FailingRepository extends VarietyRepository { + // ignore: use_super_parameters + _FailingRepository(AppDatabase db) + : super(db, idGen: IdGen(), nodeId: 'test-node'); + + bool healthy = false; + + @override + Stream<({List items, List drafts})> + watchInventoryView() => + healthy ? super.watchInventoryView() : Stream.error(StateError('boom')); +} + /// Waits until the cubit's state satisfies [predicate], whether it already /// does or a later stream emission gets it there. Future waitFor( @@ -39,6 +56,34 @@ void main() { expect(state.items.single.label, 'Maize'); }); + group('stream failure', () { + test('a failing stream drops loading and surfaces an error, not a ' + 'forever-spinner', () async { + final failing = _FailingRepository(db); + final failCubit = InventoryCubit(failing); + addTearDown(failCubit.close); + + final state = await waitFor(failCubit, (s) => s.error != null); + expect(state.loading, isFalse); + expect(state.error, contains('boom')); + }); + + test('retry re-opens the stream and recovers once the DB is healthy', + () async { + final failing = _FailingRepository(db); + final failCubit = InventoryCubit(failing); + addTearDown(failCubit.close); + await waitFor(failCubit, (s) => s.error != null); + + await failing.addQuickVariety(label: 'Maize'); + failing.healthy = true; + failCubit.retry(); + + final state = await waitFor(failCubit, (s) => s.error == null && !s.loading); + expect(state.items.single.label, 'Maize'); + }); + }); + test('drafts stay in the tray and never mix into the list', () async { await repo.addDraftVariety(Uint8List.fromList([1, 2, 3])); var state = await waitFor(cubit, (s) => s.drafts.isNotEmpty);