import 'dart:async'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../data/variety_repository.dart'; import '../db/enums.dart'; import '../domain/crop_calendar.dart'; /// Inventory list state: all items from the DB plus the current search query /// and active filters. [visibleItems] applies query ∧ category ∧ form; grouping /// by category is done in the UI. Empty filter sets mean "show everything". class InventoryState extends Equatable { const InventoryState({ this.items = const [], this.drafts = const [], this.query = '', this.categoryFilter = const {}, this.typeFilter = const {}, this.organicOnly = false, this.needsReproductionOnly = false, this.sharingOnly = false, this.sowThisMonthOnly = false, this.filterMonth = 0, this.loading = true, this.error, this.selectionMode = false, this.selectedIds = const {}, }); final List items; /// Photo-first captures still awaiting a name — the "to catalogue" tray, /// shown above the list and never mixed into [items]. final List drafts; final String query; /// Categories to keep; empty = all categories. final Set categoryFilter; /// Lot forms to keep; empty = all forms. An item matches if it holds at /// least one lot of a selected form. final Set typeFilter; /// When true, keep only varieties flagged organic ("eco"). final bool organicOnly; /// When true, keep only varieties flagged "needs reproducing this season". final bool needsReproductionOnly; /// When true, keep only varieties with some lot offered to others — the /// "what I share" view. final bool sharingOnly; /// When true, keep only varieties whose recorded sow-months include /// [filterMonth] — the inventory "to sow this month" view. final bool sowThisMonthOnly; /// The month (1..12) the "to sow this month" filter matches against — set to /// the current month by the cubit when the filter is turned on. Only read /// while [sowThisMonthOnly] is true. final int filterMonth; 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; /// Ids of the varieties currently selected in [selectionMode]. final Set selectedIds; /// Categories present across all items, in display order (deduped), so the /// UI can offer one chip per category actually in use. List get categories { final seen = {}; final result = []; for (final item in items) { final category = item.category; if (category != null && seen.add(category)) result.add(category); } return result; } List get visibleItems { final q = query.trim().toLowerCase(); return items.where((i) { if (q.isNotEmpty && !i.label.toLowerCase().contains(q)) return false; if (categoryFilter.isNotEmpty && !categoryFilter.contains(i.category)) { return false; } if (typeFilter.isNotEmpty && i.lotTypes.intersection(typeFilter).isEmpty) { return false; } if (organicOnly && !i.isOrganic) return false; if (needsReproductionOnly && !i.needsReproduction) return false; if (sharingOnly && !i.isShared) return false; if (sowThisMonthOnly && !maskHasMonth(i.sowMonths, filterMonth)) { return false; } return true; }).toList(); } /// Whether any variety has recorded sow-months, so the UI only offers the /// "to sow this month" chip when it would match something. bool get hasSowCalendar => items.any((i) => i.sowMonths != null); /// Whether any item is flagged organic, so the UI only offers the eco filter /// chip when it would actually match something. bool get hasOrganic => items.any((i) => i.isOrganic); /// Whether any item is flagged "needs reproducing", so the UI only offers /// that filter chip when it would match something. bool get hasNeedsReproduction => items.any((i) => i.needsReproduction); /// Whether any variety has a lot offered to others, so the UI only offers /// the "sharing" chip and the printable catalog when they'd show something. bool get hasShared => items.any((i) => i.isShared); InventoryState copyWith({ List? items, List? drafts, String? query, Set? categoryFilter, Set? typeFilter, bool? organicOnly, bool? needsReproductionOnly, bool? sharingOnly, bool? sowThisMonthOnly, int? filterMonth, bool? loading, String? Function()? error, bool? selectionMode, Set? selectedIds, }) { return InventoryState( items: items ?? this.items, drafts: drafts ?? this.drafts, query: query ?? this.query, categoryFilter: categoryFilter ?? this.categoryFilter, typeFilter: typeFilter ?? this.typeFilter, organicOnly: organicOnly ?? this.organicOnly, needsReproductionOnly: needsReproductionOnly ?? this.needsReproductionOnly, sharingOnly: sharingOnly ?? this.sharingOnly, 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, ); } @override List get props => [ items, drafts, query, categoryFilter, typeFilter, organicOnly, needsReproductionOnly, sharingOnly, sowThisMonthOnly, filterMonth, loading, error, selectionMode, selectedIds, ]; } /// Subscribes to the repository's reactive inventory stream. The list updates /// automatically after a quick-add — no manual refresh. class InventoryCubit extends Cubit { InventoryCubit(this._repo, {int Function()? nowMonth}) : _nowMonth = nowMonth ?? (() => DateTime.now().month), super(const InventoryState()) { _subscribe(); } final VarietyRepository _repo; final int Function() _nowMonth; 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). void toggleCategory(String category) { final next = Set.of(state.categoryFilter); if (!next.remove(category)) next.add(category); emit(state.copyWith(categoryFilter: next)); } /// Toggles a lot form in the filter (add if absent, remove if present). void toggleType(LotType type) { final next = Set.of(state.typeFilter); if (!next.remove(type)) next.add(type); emit(state.copyWith(typeFilter: next)); } /// Toggles the "organic only" filter. void toggleOrganicOnly() => emit(state.copyWith(organicOnly: !state.organicOnly)); /// Toggles the "needs reproducing only" filter. void toggleNeedsReproductionOnly() => emit(state.copyWith(needsReproductionOnly: !state.needsReproductionOnly)); /// Toggles the "what I share" filter. void toggleSharingOnly() => emit(state.copyWith(sharingOnly: !state.sharingOnly)); /// Toggles the "to sow this month" filter, pinned to the current month. void toggleSowThisMonth() => emit(state.copyWith( sowThisMonthOnly: !state.sowThisMonthOnly, filterMonth: _nowMonth(), )); /// Enters multi-select mode with an empty selection (from the toolbar). void startSelection() => emit(state.copyWith(selectionMode: true, selectedIds: const {})); /// Enters multi-select mode with [id] as the first selected variety /// (triggered by a long-press on a tile). void enterSelection(String id) => emit(state.copyWith(selectionMode: true, selectedIds: {id})); /// Toggles a variety in the selection (add if absent, remove if present). /// An empty selection stays in selection mode; the user exits explicitly. void toggleSelection(String id) { final next = Set.of(state.selectedIds); if (!next.remove(id)) next.add(id); emit(state.copyWith(selectionMode: true, selectedIds: next)); } /// Selects every currently visible variety, so "filter to a category, then /// select all" is the way to pick a subset of the inventory to print. void selectAllVisible() => emit( state.copyWith( selectionMode: true, selectedIds: state.visibleItems.map((i) => i.id).toSet(), ), ); /// Leaves selection mode and clears the selection. void exitSelection() => emit(state.copyWith(selectionMode: false, selectedIds: const {})); /// Clears all filters (search is left untouched). void clearFilters() => emit( state.copyWith( categoryFilter: const {}, typeFilter: const {}, organicOnly: false, needsReproductionOnly: false, sharingOnly: false, sowThisMonthOnly: false, ), ); @override Future close() async { _retryTimer?.cancel(); await _sub?.cancel(); return super.close(); } }