Skip to content

Budgets - #748

Merged
sadespresso merged 8 commits into
internalfrom
feat/budgets
Jul 25, 2026
Merged

Budgets#748
sadespresso merged 8 commits into
internalfrom
feat/budgets

Conversation

@sadespresso

Copy link
Copy Markdown
Collaborator

Budgets

Brings the budgets feature to internal. 7 commits, budgets-only surface area.

What's in it

  • Period model reworked — periods are derived from an immutable anchor rather than a moving window, so budget history is free (every past period is just the anchor paged back) and nothing is rewritten on renewal.
  • Isolate-backed scanning — the transaction scan behind each budget moves to a background isolate (budget_spec.dart, computeAllProgressAsync / computeHistoryAsync), with a graceful fallback to the main isolate so a platform quirk degrades to jank, not an empty screen.
  • Read-only detail page split from the editor (budget_detail_page.dart) — opening a budget to check on it doesn't put a Delete button under your thumb.
  • Home-screen widgets, iOS + Android:
    • Two widgets each — Pinned (track one budget) and Rollup (how many need attention + the worst one).
    • iOS uses App Intents configuration; Android uses a lightweight framework-views config activity.
    • Both offer an "Automatic / any budget that needs attention" option resolved at render time, and both handle a deleted pinned budget gracefully.
    • Widget state is computed in Dart and published as display-ready strings (budget_widget_sync.dart) since the extensions can't reach ObjectBox or localize.
  • Insights & history strip on the detail page (budget_insight_row.dart, budget_history_strip.dart).
  • Tests: period math, progress, isolate path, and widget payload (test/unit/budget_*_test.dart).
  • Android: dropped the unused ObjectBox browser dependency.
  • Version bump to 0.24.0.

Notes

  • Diff excludes incidental tooling drift (a pubspec.lock transitive bump and an l10n key re-sort from a local flutter run) — those were discarded, not committed.

https://claude.ai/code/session_01FCFsJYn8tQ7Liofe7XCqPR

Debug builds pinned objectbox-android-objectbrowser 5.1.0 and excluded
objectbox-android, while the Dart objectbox package is 5.3.2 and requires
native >= 5.3.2. Every debug build therefore died in ObjectBox.initialize():

  Unsupported operation: ObjectBox platform-specific database library not
  compatible: is 5.1.0, expected 5.3.2 or newer

Removed rather than bumped, because nothing could ever have used it:
ObjectBox Admin only starts if the app constructs Admin(store), and there
is no such call anywhere in lib/. The gradle line was the artifact's only
mention in the repo.

Dropping the exclude lets objectbox_flutter_libs supply objectbox-android
5.4.2 for debug, exactly as it already did for release -- which is why
release builds were never affected.

Claude-Session: https://claude.ai/code/session_01J6iVLcwaKJ2xLf86pYRK3T
Budget.range is now an anchor that is never rewritten. currentPeriod()
derives the live period by paging the anchor to "now" with moment_dart's
PageableRange, so renewDueBudgets() -- which overwrote the period that had
just ended -- is gone, along with its test.

Every past period is therefore just currentPeriod(budget, asOf: earlier),
and the transactions behind it were never touched. That is what the new
computeHistory()/historyPeriods() are built on. History is clamped to
Budget.createdDate: spend in a watched category predates the budget, and
showing it as that budget's own past performance invents a history the
user never set.

A budget anchored to a future period stays there for the live period --
paging backward would silently start it early. An explicit asOf still
pages back, which is what history needs.

computeAllProgressAsync()/computeHistoryAsync() move the transaction scan
to Isolate.run, attaching to the existing store via Store.fromReference.
Only the scan moves; the entity graph, Intl, prefs and BuildContext all
stay on the main isolate.

Two things worth knowing:

  - The spawn must stay a top-level function. Isolate.run's closure
    captures its entire enclosing lexical scope, not just what it names,
    so an inline version dragged in a store-bound List<Budget>, the send
    threw, and the try/catch quietly degraded to a fallback that returns
    identical numbers. Six correctness tests passed against it. Only a
    test that calls the isolate directly caught it.

  - The isolate catches per-spec failures individually, so "every spec
    threw" arrives as a successful empty list. computeHistoryAsync treats
    that as a failure so the fallback actually fires.

ObjectBox.attachIsolate throws when a store is already open instead of
silently no-opping, and detachIsolate only closes a handle it opened --
otherwise the documented pairing could close the app's live store.

Claude-Session: https://claude.ai/code/session_01J6iVLcwaKJ2xLf86pYRK3T
/budgets/:id now opens BudgetDetailPage; the form moves to
/budgets/:id/edit. Opening a budget to check on it should not put a Delete
button under your thumb.

The page shows the live period's progress, the insight sentence, the
category chips, a strip of the last 6 periods -- free, now that renewal no
longer overwrites them -- and every transaction behind the number.

progress is null both while the first compute is in flight and when it
failed (fetch swallows its errors so the rest of the page still renders),
so both degrade to a message rather than asserting non-null. Asserting
would red-screen a budget with unparseable data on the very page meant to
explain it.

Claude-Session: https://claude.ai/code/session_01J6iVLcwaKJ2xLf86pYRK3T
A widget extension runs in its own process: it cannot reach ObjectBox,
call Flutter localization, or format money. So BudgetWidgetSync computes
everything here and hands over display-ready strings -- including the
labels, resolved in the app's locale, because the extension has no access
to Flow's translations.

One JSON blob rather than flat keys per field: the widgets render a
variable-length list (the pinned widget resolves an arbitrary id, and its
configuration screen enumerates them all), which flat saveWidgetData keys
cannot express without inventing an index convention on both platforms.
It carries a version so an extension updated independently by the store
falls back to its placeholder rather than mis-rendering fields it does not
understand.

Both amounts and percentages always ship. Whether a given widget shows
amounts is a per-widget setting the user picks on the platform side, so
the decision -- and the state -- belongs there. This is shoulder-surfing
privacy, not security: the amounts sit in the shared container either way.

A sync can fire before FlowLocalizations has loaded, since the transaction
listener is wired ahead of runApp, and an unloaded lookup returns "".
Publishing that would paint a blank where a label belongs, so empty labels
are omitted and the widget falls back to its own default instead.

HomeWidgets.syncAll() replaces four scattered WidgetSummarySync.sync()
call sites, and watchForChanges() adds a Budget box listener: editing a
budget touches no transaction, so nothing else would have triggered a sync.

buildPayload() is split out from sync() so the contract two native
implementations depend on can actually be tested -- a shape regression is
otherwise invisible from Dart, surfacing only as a widget rendering "---"
on someone's home screen.

Claude-Session: https://claude.ai/code/session_01J6iVLcwaKJ2xLf86pYRK3T
Two widgets against the payload contract: a systemMedium roll-up (how many
budgets need attention, plus the worst one) and a systemSmall pinned widget
for one budget, or whichever needs attention most.

The pinned widget uses AppIntentConfiguration so the budget is picked from
a live EntityQuery over the payload rather than baked in at compile time,
and "Any budget that needs attention" resolves to summary.worstId at render
time. Both offer a Hide amounts toggle, which removes every monetary value
while leaving name, status, period, percentage and days-left in place.

Status is never carried by colour alone: iOS 18 tinted rendering flattens
every hue to one, so the widgets render the status as a word.

No pbxproj edit -- "Flow Widgets" is a PBXFileSystemSynchronizedRootGroup,
so files in the folder join the target automatically.

Claude-Session: https://claude.ai/code/session_01J6iVLcwaKJ2xLf86pYRK3T
Glance implementations of the same two widgets, against the same payload
contract: a 4x2 roll-up and a 2x2 pinned widget, each with a configuration
activity carrying the Hide amounts toggle, and a budget picker on the
pinned one whose first option resolves to summary.worstId at render time.

Status is never carried by colour alone -- themed icons flatten it -- so
the status ships as a word, like on iOS.

Two things found by actually placing the widgets on a device:

  - The configuration activities have to be exported for the launcher to
    start them, which means any app on the device can start them too, with
    an id it chose. That got the caller the budget picker -- the user's
    budget names, on screen, on demand -- and a Save would rewrite a real
    widget's privacy setting. They now check the id belongs to one of our
    own providers first.

  - targetSdk 36 forces edge-to-edge, so Theme.DeviceDefault's action bar
    drew over the un-inset ScrollView: the screen's own title was hidden
    behind it and the first section header sat under its bottom edge, with
    no way to scroll it into view. The activity draws its own title, so the
    action bar is now off and the content insets for the system bars.

Claude-Session: https://claude.ai/code/session_01J6iVLcwaKJ2xLf86pYRK3T
A /budgets/:id route reached with an id that no longer resolves — a
deleted budget, or an id that drifted after a data reset — rendered the
bare "page not found" error page. That id arrives from three surfaces
that can outlive the budget: the in-app budget alert, home-screen widget
deep links (flow-mn:///budgets/:id), and donated Siri shortcuts. Fall
back to the budgets list so these entry points degrade gracefully rather
than dead-ending. Applies to the edit route too.

Claude-Session: https://claude.ai/code/session_01FCFsJYn8tQ7Liofe7XCqPR
@sadespresso
sadespresso merged commit 2b82eca into internal Jul 25, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant