StoreManagement

State & data flow

Riverpod caches; Supabase serves. The interesting part is when to throw the cache away.

The layers

Page ──watch──▶ FutureProvider ──▶ Repository ──▶ Supabase ▲ │ │ │ │ └─ builds the query └── rebuilds when ────┘ (the only place that does) the value lands or is invalidated

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

app/lib/features/cash/daily_entry/entry_repository.dart
// 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.

packages/mblrc_shared/lib/auth_providers.dart
final supabaseProvider =
    Provider<SupabaseClient>((ref) => Supabase.instance.client);

final authStateProvider = StreamProvider<AuthState>(
    (ref) => ref.watch(supabaseProvider).auth.onAuthStateChange);
app/lib/app/router.dart
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.

app/lib/core/app_refresh.dart
/// 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.