diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 488fae8..1d2feba 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -168,7 +168,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 8862366..3d4f76a 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -169,7 +169,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 95f4f61..a7529de 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -168,7 +168,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 df16e7e..1bd4334 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -169,7 +169,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 daf1e26..14f6883 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: 2000 (500 per locale) +/// Strings: 2008 (502 per locale) /// -/// Built on 2026-07-11 at 11:22 UTC +/// Built on 2026-07-11 at 11:29 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 ed86fb1..8f6b5b8 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -324,6 +324,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 @@ -1423,6 +1425,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 b17323b..66c4933 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -575,6 +575,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 @@ -2411,6 +2417,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 b3baf10..3fa19c7 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -324,6 +324,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 @@ -1425,6 +1427,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 6198d88..7ce472d 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -325,6 +325,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 @@ -1423,6 +1425,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 210af20..5dedbf3 100644 --- a/apps/app_seeds/lib/state/inventory_cubit.dart +++ b/apps/app_seeds/lib/state/inventory_cubit.dart @@ -23,6 +23,7 @@ class InventoryState extends Equatable { this.sowThisMonthOnly = false, this.filterMonth = 0, this.loading = true, + this.error, this.selectionMode = false, this.selectedIds = const {}, }); @@ -63,6 +64,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; @@ -131,6 +137,7 @@ class InventoryState extends Equatable { bool? sowThisMonthOnly, int? filterMonth, bool? loading, + String? Function()? error, bool? selectionMode, Set? selectedIds, }) { @@ -147,6 +154,7 @@ class InventoryState extends Equatable { sowThisMonthOnly: sowThisMonthOnly ?? this.sowThisMonthOnly, filterMonth: filterMonth ?? this.filterMonth, loading: loading ?? this.loading, + error: error != null ? error() : this.error, selectionMode: selectionMode ?? this.selectionMode, selectedIds: selectedIds ?? this.selectedIds, ); @@ -165,6 +173,7 @@ class InventoryState extends Equatable { sowThisMonthOnly, filterMonth, loading, + error, selectionMode, selectedIds, ]; @@ -176,22 +185,81 @@ class InventoryCubit extends Cubit { InventoryCubit(this._repo, {int Function()? nowMonth}) : _nowMonth = nowMonth ?? (() => DateTime.now().month), 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; final int Function() _nowMonth; - late final StreamSubscription< + StreamSubscription< ({List items, List drafts}) - > + >? _sub; + /// Pending auto-retry, cancelled on a fresh (re)subscribe or on close. + Timer? _retryTimer; + + /// Consecutive stream failures since the last good emission. Drives the + /// backoff and, once [_maxAutoRetries] is hit, the switch to a manual retry. + int _failures = 0; + + /// How many times we silently re-open the stream before giving up and asking + /// the user. The startup DB-not-ready race clears in well under a second, so + /// a handful of backed-off attempts recovers it without the user noticing. + static const _maxAutoRetries = 6; + + /// (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 auto-retry with backoff (staying in [loading] + /// so the user just sees the spinner briefly), and only surface [error] for a + /// manual [retry] once the transient window has clearly passed. + void _subscribe() { + _retryTimer?.cancel(); + _sub?.cancel(); + _sub = _repo.watchInventoryView().listen( + (view) { + _failures = 0; + emit( + state.copyWith( + items: view.items, + drafts: view.drafts, + loading: false, + error: () => null, + ), + ); + }, + onError: _onStreamError, + ); + } + + void _onStreamError(Object e) { + if (isClosed) return; + _failures++; + if (_failures <= _maxAutoRetries) { + // Exponential backoff capped at ~4s: 250ms, 500ms, 1s, 2s, 4s, 4s. + final delayMs = (250 * (1 << (_failures - 1))).clamp(250, 4000); + // Stay in loading — an auto-recovering spinner, not an error screen. + _retryTimer = Timer(Duration(milliseconds: delayMs), () { + if (!isClosed) _subscribe(); + }); + } else { + // Transient window has passed; hand it to the user. + emit(state.copyWith(loading: false, error: () => '$e')); + } + } + + /// Re-opens the inventory stream on demand (from the manual retry button), + /// resetting the auto-retry budget and returning to the loading state. + void retry() { + _failures = 0; + 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). @@ -270,7 +338,8 @@ class InventoryCubit extends Cubit { @override Future close() async { - await _sub.cancel(); + _retryTimer?.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 aee28d1..dddb729 100644 --- a/apps/app_seeds/lib/ui/inventory_list_screen.dart +++ b/apps/app_seeds/lib/ui/inventory_list_screen.dart @@ -49,7 +49,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: [ @@ -240,6 +242,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/pubspec.yaml b/apps/app_seeds/pubspec.yaml index 00d79fd..1b66d56 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -86,6 +86,8 @@ dev_dependencies: sdk: flutter flutter_lints: ^6.0.0 + # Virtual clock to fast-forward the inventory auto-retry backoff in tests. + fake_async: ^1.3.1 build_runner: ^2.4.13 drift_dev: ^2.28.0 slang_build_runner: ^4.7.0 diff --git a/apps/app_seeds/test/state/inventory_cubit_test.dart b/apps/app_seeds/test/state/inventory_cubit_test.dart index da74a88..fbabece 100644 --- a/apps/app_seeds/test/state/inventory_cubit_test.dart +++ b/apps/app_seeds/test/state/inventory_cubit_test.dart @@ -1,5 +1,7 @@ import 'dart:typed_data'; +import 'package:commons_core/commons_core.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/data/variety_repository.dart'; import 'package:tane/db/database.dart'; @@ -8,6 +10,25 @@ import 'package:tane/state/inventory_cubit.dart'; import '../support/test_support.dart'; +/// A repository whose inventory stream errors for the first +/// [failuresBeforeHealthy] subscriptions, then behaves normally — mimics the +/// transient DB-not-ready failure that used to leave the spinner stuck forever. +/// The default never recovers, for the "give up" path. +class _FailingRepository extends VarietyRepository { + // ignore: use_super_parameters + _FailingRepository(AppDatabase db, {this.failuresBeforeHealthy = 1 << 30}) + : super(db, idGen: IdGen(), nodeId: 'test-node'); + + final int failuresBeforeHealthy; + int subscriptions = 0; + + @override + Stream<({List items, List drafts})> + watchInventoryView() => subscriptions++ < failuresBeforeHealthy + ? Stream.error(StateError('boom')) + : super.watchInventoryView(); +} + /// Waits until the cubit's state satisfies [predicate], whether it already /// does or a later stream emission gets it there. Future waitFor( @@ -39,6 +60,53 @@ void main() { expect(state.items.single.label, 'Maize'); }); + group('stream failure', () { + test('a transient failure recovers on its own — no error, no manual retry', + () async { + // Fails the first two subscribes, then the DB is ready. + final failing = _FailingRepository(db, failuresBeforeHealthy: 2); + await failing.addQuickVariety(label: 'Maize'); + final failCubit = InventoryCubit(failing); + addTearDown(failCubit.close); + + // Auto-retry with backoff brings it back without ever showing an error. + final state = await waitFor(failCubit, (s) => !s.loading && s.error == null); + expect(state.items.single.label, 'Maize'); + expect(failing.subscriptions, greaterThan(1)); + }); + + test('stays in loading (spinner), never flips to error, while auto-retrying', + () async { + final failing = _FailingRepository(db, failuresBeforeHealthy: 2); + await failing.addQuickVariety(label: 'Maize'); + final failCubit = InventoryCubit(failing); + addTearDown(failCubit.close); + + final errors = []; + final sub = failCubit.stream.listen((s) => errors.add(s.error)); + addTearDown(sub.cancel); + + await waitFor(failCubit, (s) => !s.loading && s.error == null); + expect(errors.every((e) => e == null), isTrue, + reason: 'no error state should be emitted during auto-recovery'); + }); + + test('gives up after the retry budget and surfaces a manual-retry error', + () { + fakeAsync((async) { + final failing = _FailingRepository(db); // never recovers + final failCubit = InventoryCubit(failing); + addTearDown(failCubit.close); + + // Fast-forward past all backoff windows (~12s of retries). + async.elapse(const Duration(seconds: 30)); + + expect(failCubit.state.loading, isFalse); + expect(failCubit.state.error, contains('boom')); + }); + }); + }); + 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);