StoreManagement
State & data flow
Riverpod caches; Supabase serves. The interesting part is when to throw the cache away.
The layers
Pages never build a query. They watch a provider; the provider calls a repository; the repository is the only thing holding a SupabaseClient. That keeps the data layer swappable and, more usefully, keeps it testable — a widget test overrides the provider and never touches a network.
The provider shapes in use
// The repository itself: one per client, no state of its own.
final entryRepositoryProvider =
Provider((ref) => EntryRepository(ref.watch(supabaseProvider)));
// Something the person chose, held in memory.
final selectedDateProvider = StateProvider<DateTime>((ref) {
final now = DateTime.now();
return DateTime(now.year, now.month, now.day);
});
// A read, keyed by the thing it's a read of. .family means one cache entry
// per date rather than one that thrashes as you move between days.
final daySummaryProvider = FutureProvider.family<DailyCashSummary?, DateTime>(
(ref, date) => ref.watch(entryRepositoryProvider).fetchDay(date));
// A read that depends on another provider: change the filter and this
// refetches on its own, with the filtering done by the database.
final advancesProvider = FutureProvider<List<AdvanceWithBalance>>((ref) {
final f = ref.watch(recordsFilterProvider(kAdvancesPage));
return ref.watch(entryRepositoryProvider).fetchAdvances(
from: f.from, to: f.to, staffId: f.person);
});Auth is a stream, and the router listens
Sign-in state isn’t a variable anyone sets. Supabase emits it, a provider exposes it, and go_router refreshes on it — so signing out from any page redirects to the login screen without a single call site knowing that’s the rule.
final supabaseProvider =
Provider<SupabaseClient>((ref) => Supabase.instance.client);
final authStateProvider = StreamProvider<AuthState>(
(ref) => ref.watch(supabaseProvider).auth.onAuthStateChange);return GoRouter(
initialLocation: '/',
refreshListenable: _StreamListenable(auth.onAuthStateChange),
redirect: (context, state) {
final signedIn = auth.currentSession != null;
final path = state.uri.path;
if (!signedIn && path != '/login') return '/login';
if (signedIn && path == '/login') return '/';
…
},
);Throwing the cache away
The problem caching creates
Riverpod holds a provider’s result until something invalidates it. That’s what makes the app quick to move around — and it means a change made anywhere else (the Supabase SQL editor, another tab, the tablet at the counter while you’re on a laptop) stays invisible on screen indefinitely.
So invalidation is explicit and covers all three features at once. They share a database: posting a day’s cash moves figures that BookKeeper’s ledger and StockRoom’s reconciliation both read, so refreshing only the feature on screen would leave the other two quietly stale.
/// Invalidates every data-fetching provider across all three features,
/// forcing a fresh read from Supabase.
///
/// It covers the whole app rather than just the feature on screen because the
/// three share a database: posting a day's cash in DailyCash moves figures
/// that BookKeeper's ledger and StockRoom's reconcile both read.
void refreshAllProviders(WidgetRef ref) {
ref.invalidate(staffListProvider);
ref.invalidate(daySummaryProvider);
…
ref.invalidate(productsProvider);
ref.invalidate(trialBalanceProvider);
}It fires from three places:
- a manual refresh button, and pull-to-refresh;
LifecycleAwareRefresher, which wraps the shell and refreshes whenever the app is foregrounded or the browser tab regains focus;- after a write, so the page you just edited reflects what you did.
A browser hard-refresh gets this for free by restarting the app. The widget covers the case a hard refresh doesn’t: staying on one page while the data changes underneath you.
Money never travels as a double by accident
Figures are formatted in one place — mblrc_shared/money.dart — in a monospaced style so columns of pesos line up on their digits. The arithmetic that decides what those figures are lives in core/cash/computations.dart, deliberately free of Flutter and Supabase so it can be tested as plain functions.