StoreManagement

Architecture

Three applications became three features of one. What that changed, and the one thing it broke.

The stack

PieceChoiceWhy
UIFlutter 3.44One codebase for tablet, phone and web. The tablet is the target that matters and Flutter treats it as a first-class size rather than a stretched phone.
Routinggo_router 14URL-shaped routes, which the web build needs and the native builds don't mind. Route guards handle the signed-out case in one place.
StateRiverpod 2.5Providers cache reads so moving between pages doesn't refetch. Invalidation is explicit, which matters when the same database is edited elsewhere.
Datasupabase_flutter 2.5Postgres with row-level auth and a generated REST layer. No server of my own to run or pay for.
Exportexcel + file_saverBookKeeper's journal export only. Nothing else pulls them in.

One app, three features

Everything signed-in hangs off a single ShellRoute. The shell draws the feature rail and the section list; the router decides which page sits in the middle.

main.dart └── ProviderScope └── StoreManagementApp MaterialApp.router └── routerProvider go_router ├── /login LoginPage (no shell) └── ShellRoute └── LifecycleAwareRefresher └── AppShell ├── rail 3 features ├── sections the feature's menu └── page the route's widget

There is no launcher screen. There was one — a hub of three cards at / — but the rail lists all three features at every width, so it only stood between signing in and doing something. Signing in lands on DailyCash’s daily close, and / redirects there.

Features are described, not hard-coded

The rail, the section list, the landing route and the tests all read one list. Adding a page means adding a NavItem and a GoRoute, and every menu picks it up.

app/lib/app/nav.dart
class Feature {
  final String id;

  /// Every route in this feature starts with this, which is also how the shell
  /// works out which feature you're currently in.
  final String prefix;

  /// The wordmark splits in two so the tail can take the accent colour.
  final String head;
  final String tail;

  final List<NavGroup> groups;

  /// Where tapping the feature takes you when you aren't already inside it.
  NavItem get home => groups.first.items.first;
}

Routes carry their feature

This wasn’t a tidiness decision. All three apps defined /reports/*, and two of them defined /reports/income — DailyCash’s cash-basis income view and BookKeeper’s income statement are different pages with different meanings. Merging without prefixes would have silently lost one.

WasIsFeature
/entry/daily/cash/entry/dailyDailyCash
/reports/income/cash/reports/incomeDailyCash
/reports/income/books/reports/incomeBookKeeper
/stock/out/stock/outStockRoom (unchanged)

Old paths redirect where the answer is unambiguous. A bare /reports/* can’t be resolved to one feature, so it takes the one that had the most of them:

app/lib/app/router.dart
/// Every routing rule that doesn't need a live session, split out so it can be
/// tested without standing up Supabase.
String? redirectFor({required bool signedIn, required String path}) {
  if (!signedIn) return path == '/login' ? null : '/login';
  if (path == '/login') return homePath;

  // The root is not a screen. There used to be a hub here — three cards to
  // pick a feature from — but the rail lists all three at every width, so it
  // only stood between signing in and doing something.
  if (path == '/') return homePath;

  final moved = _legacy[path];
  if (moved != null) return moved;

  if (path == '/reports' || path.startsWith('/reports/')) {
    return '/cash/reports/monthly';
  }

  return null;
}

What the merge broke

The one real casualty

Every page asks isNarrow(context) whether it has room, and that reads MediaQuery — the size of the window. As separate apps that was the same thing as the page area. Inside a shell with a rail and a section list, it isn’t: a page on an 820pt tablet would believe it had 820pt while actually sitting in about 500pt, and lay out columns that don’t fit.

The fix is one widget, and it means no page had to be touched. The content pane publishes a MediaQuery describing itself:

app/lib/app/shell.dart
class _Pane extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(builder: (context, box) {
      final mq = MediaQuery.of(context);
      if (!box.hasBoundedWidth) return child;
      return MediaQuery(
        data: mq.copyWith(
            size: Size(box.maxWidth,
                box.hasBoundedHeight ? box.maxHeight : mq.size.height)),
        child: child,
      );
    });
  }
}

There are tests asserting the pane really is narrower than the window — see Tablet-first layout.

What the merge fixed

  • One sign-in. Three apps meant three sessions on three paths.
  • Refresh spans the whole app. The three features share a database — posting a day’s cash moves figures the ledger and the stock reconciliation both read — so refreshAllProviders now invalidates all three features’ providers rather than only the one on screen.
  • One deploy. One build at the site root, no base-href juggling between four of them.
  • Native became free. Four web apps on four paths can’t be an installable app. One can.