tane/apps/app_seeds/lib/state/inventory_cubit.dart
vjrj 248b239c48 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	apps/app_seeds/lib/i18n/strings.g.dart
#	apps/app_seeds/lib/state/inventory_cubit.dart
2026-07-11 13:31:04 +02:00

345 lines
12 KiB
Dart

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<VarietyListItem> items;
/// Photo-first captures still awaiting a name — the "to catalogue" tray,
/// shown above the list and never mixed into [items].
final List<VarietyListItem> drafts;
final String query;
/// Categories to keep; empty = all categories.
final Set<String> categoryFilter;
/// Lot forms to keep; empty = all forms. An item matches if it holds at
/// least one lot of a selected form.
final Set<LotType> 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<String> selectedIds;
/// Categories present across all items, in display order (deduped), so the
/// UI can offer one chip per category actually in use.
List<String> get categories {
final seen = <String>{};
final result = <String>[];
for (final item in items) {
final category = item.category;
if (category != null && seen.add(category)) result.add(category);
}
return result;
}
List<VarietyListItem> 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<VarietyListItem>? items,
List<VarietyListItem>? drafts,
String? query,
Set<String>? categoryFilter,
Set<LotType>? typeFilter,
bool? organicOnly,
bool? needsReproductionOnly,
bool? sharingOnly,
bool? sowThisMonthOnly,
int? filterMonth,
bool? loading,
String? Function()? error,
bool? selectionMode,
Set<String>? 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<Object?> 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<InventoryState> {
InventoryCubit(this._repo, {int Function()? nowMonth})
: _nowMonth = nowMonth ?? (() => DateTime.now().month),
super(const InventoryState()) {
_subscribe();
}
final VarietyRepository _repo;
final int Function() _nowMonth;
StreamSubscription<
({List<VarietyListItem> items, List<VarietyListItem> 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<String>.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<LotType>.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<String>.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<void> close() async {
_retryTimer?.cancel();
await _sub?.cancel();
return super.close();
}
}