StoreManagement
Architecture
Three applications became three features of one. What that changed, and the one thing it broke.
The stack
| Piece | Choice | Why |
|---|---|---|
| UI | Flutter 3.44 | One 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. |
| Routing | go_router 14 | URL-shaped routes, which the web build needs and the native builds don't mind. Route guards handle the signed-out case in one place. |
| State | Riverpod 2.5 | Providers cache reads so moving between pages doesn't refetch. Invalidation is explicit, which matters when the same database is edited elsewhere. |
| Data | supabase_flutter 2.5 | Postgres with row-level auth and a generated REST layer. No server of my own to run or pay for. |
| Export | excel + file_saver | BookKeeper'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.
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.
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.
| Was | Is | Feature |
|---|---|---|
/entry/daily | /cash/entry/daily | DailyCash |
/reports/income | /cash/reports/income | DailyCash |
/reports/income | /books/reports/income | BookKeeper |
/stock/out | /stock/out | StockRoom (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:
/// 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:
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
refreshAllProvidersnow invalidates all three features’ providers rather than only the one on screen. - One deploy. One build at the site root, no
base-hrefjuggling between four of them. - Native became free. Four web apps on four paths can’t be an installable app. One can.