Skip to content

Offline-first launch: open on saved data, sync in the background - #29

Merged
seenu-k merged 10 commits into
mainfrom
fix/empty-groups-offline-first
Oct 10, 2026
Merged

seenu-k merged 10 commits into
mainfrom
fix/empty-groups-offline-first

Conversation

@seenu-k

@seenu-k seenu-k commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • No groups: the list showed a spinner instead of "No groups yet" until a full sync finished. That flag is in memory only, so every cold start waited on the network, and offline users never saw the empty state.
  • Every cold start had several waiting phases: the splash (or the web HTML skeleton), then placeholder cards while the local database opened, then content, then balances filling in.
  • Offline: every launch ended with a "Could not refresh" card, and coming back online could wait up to 2 minutes (or the back-off) before syncing.

Changes

1. One launch wait (lib/presentation/launch_hold.dart)

  • main() calls LaunchHold.begin(), which uses Flutter's built-in WidgetsBinding.deferFirstFrame() (the same mechanism flutter_native_splash.preserve wraps). Widgets still build underneath, so each screen's own queries run as usual. Only the pixels wait.
  • The frame is released after two frames in a row with no LaunchPlaceholder mounted. Placeholders opt in for local data only: the group list's loading state, the blank balance line on group tiles, and SavedDataLoading (group detail opened from a notification or link, and the activity screen). The welcome screen, join links, and anything waiting on the network never hold launch.
  • A 10s safety net releases the frame if a local query never answers. Otherwise the user would be stuck on a splash that can't be told apart from a hang. It's longer than SQLite's own 5s busy timeout, so a locked database shows its own error first.
  • Local loading states now draw nothing instead of skeletons or spinners.

2. Web splash instead of skeleton

  • web/index.html now draws only the brand mark on surface, matching Android, and fades out on flutter-first-frame. The HTML skeleton (app bar, rail, nav bar, cards, FAB) is gone, along with the boot_hint localStorage hints and GroupListSkeleton. Net −1,000 lines.
  • theme_test.dart still checks the splash colours against the theme.

2b. Android 12+ splash without the icon tile

  • flutter_native_splash.yaml no longer sets icon_background_color. Regenerated with dart run flutter_native_splash:create, which removed windowSplashScreenIconBackgroundColor from both values-v31 styles. The mark now sits directly on surface, matching Android 11 and older and the web. Before, the tile was primaryContainer on surface in light mode (too faint to look deliberate, and shaped differently by each manufacturer) and nearly invisible in dark mode.

2c. One splash size on Android and the web

  • Android 12+ keeps the generator's recommended framing: a 1152px image with the mark inside the 768px safe circle. That shows as a 160dp ring in the 288dp icon.
  • The web splash SVG is sized to a matching 160px ring. theme_test.dart measures the Android asset and checks the web size against it.

2d. Profile screens

  • Edit profile and the picture picker use SavedDataLoading while the local profile is read. Edit profile keeps a visible spinner only for a profile that hasn't arrived from the server yet.
  • Fixed in passing: the picker drew initials immediately and kept them as the "unchanged" picture, even if the saved picture was different.

2e. Back closes the picker, not the page under it (lib/presentation/router.dart)

Every route was inside a ShellRoute, which existed only to give one SelectionArea an overlay. That shell is a second navigator. Pushed screens lived on it, while dialogs and the date and time pickers open on the root navigator. Android's predictive back gesture goes to the page that is top of its own navigator. Here that was the entry editor, under the picker, so swiping back popped the editor and then the group, and the picker stayed on screen. Pushed screens are now pages on the root navigator. A new test swipes back on Android with the date picker open, then again to the group; it fails on the old route table.

2g. No SelectionArea

Screen text is no longer selectable, on Android or the web. On Android that is the platform's norm, and the SelectionArea tripped a framework assertion in debug builds whenever predictive back revealed the previous page. Invite and group links are SelectableText already. The changelog has entries for this PR under Unreleased.

2f. Moving between destinations (lib/presentation/router.dart, account_screen.dart, app.dart)

Settings' account card switched to Account with a hard cut. Account was also built on first visit with its profile not yet read, so it drew "Add the name your groups see" for a frame before the saved name replaced it.

  • Destinations fade through (Material's motion for top-level destinations), using FadeThroughTransition from the Flutter team's animations package (3.0.0, on material_ui). Branches stay built, so each keeps its scroll position. With system animations off it is still a cut.
  • The app root listens to your profile from launch (Riverpod eager initialization), so Account has it on its first frame. Building the branch early would not help: Riverpod 3 pauses providers that only offstage widgets listen to.
  • Account draws nothing until it has read the session and profile.

3. Empty list shows at once

  • "No groups yet" renders as soon as the local database answers. A thin LinearProgressIndicator in the app bar's new bottom slot (DestinationScaffold.bottom) shows during this session's first full sync, only while the list is empty.

4. Offline

  • ApiFailure.unreachable and isUnreachable() tell "no server answered" apart from "the server refused". This is judged by the request itself, not by connectivity_plus, which reports captive portals as connected.
  • When unreachable, an OfflineIndicator (cloud-off icon) appears in the group list and group app bars. Tapping or hovering it explains, and the text mentions waiting changes if there are any. SyncStatusBanner stays silent. Real server errors still get the card with "Try again".
  • SyncCoordinator: a network-up event after a failed attempt syncs immediately.

Trade-offs

  • A slow disk now means a longer splash rather than a skeleton.
  • On a brand-new device, "No groups yet" shows briefly (with the bar) before groups arrive.
  • Browsers that used the old web build keep two unused localStorage keys (opensplit.signedIn, opensplit.hasGroups). Harmless.

Testing

  • launch_hold_test.dart, the unreachable classification, reconnect retry, the offline icon appearing and clearing, the empty-list cases, and the splash palette.
  • flutter analyze clean; flutter test passes (526); biome is clean on web/index.html.
  • Web, in headless Chrome against the local Worker, signed out: flutter-first-frame at 272ms, opensplit-launch-shown at 282ms, splash gone at 482ms. The signed-in, held path is covered by unit tests only.
  • Not yet checked on an Android device or the web build. Worth a cold start with groups, with none, and in airplane mode, on both, before merging.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YSJh8YFmryHU41unFvrxSu

seenu-k and others added 2 commits October 10, 2026 10:10
With no saved groups, the list hid its empty state behind a spinner until
the first full sync finished. That flag lives only in memory, so every cold
start waited on the network, and offline users never saw the empty state.

The empty state now renders as soon as the local database answers. A thin
progress bar under the app bar shows while this session's first full sync
runs, and the sync status banner now appears for an empty list too.
DestinationScaffold gains a `bottom` slot for the bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YSJh8YFmryHU41unFvrxSu
Launch: the first frame is deferred (WidgetsBinding.deferFirstFrame) until
the landing screen has no local-data placeholder on it, capped at 800ms.
The Android splash and the web loader stay up meanwhile, so a cold start
goes straight to the saved groups and their balances. Placeholders opt in
via LaunchPlaceholder; anything waiting on the network never holds launch.

Offline: a sync that failed because no server answered now shows a single
quiet line instead of the "Could not refresh" card, and a failed sync is
retried as soon as the network comes back rather than after its back-off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YSJh8YFmryHU41unFvrxSu
@seenu-k seenu-k changed the title fix: show the empty group list at once instead of a sync spinner Offline-first launch: open on saved data, sync in the background Oct 10, 2026
seenu-k and others added 8 commits October 10, 2026 10:51
The splash (Android, and now a matching one on the web) stays up until the
landing screen has real data, so a launch is one wait instead of a loader,
then placeholders, then content.

- web/index.html draws only the brand mark, replacing the HTML skeleton
  of app bar, rail, navigation bar and cards; the boot hints it read and
  GroupListSkeleton go with it.
- Local loading states draw nothing. LaunchPlaceholder stays as the
  signal that holds the splash.
- The launch limit is now a 10s safety net against a hung query, not an
  800ms tuning value.
- Offline is a cloud-off icon in the group list and group app bars, with
  a tooltip, instead of a line in the content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YSJh8YFmryHU41unFvrxSu
The tile was primaryContainer on surface in light mode, too close to read
as deliberate, and drawn in each manufacturer's icon shape; in dark mode it
was all but invisible. The mark now sits directly on the surface, as it
does below Android 12 and on the web. Regenerated with
flutter_native_splash:create from the updated config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YSJh8YFmryHU41unFvrxSu
Flutter's flutter-first-frame event fires after the first frame is built,
even while LaunchHold defers it, so the web splash faded onto a blank page
before the app had read its saved data. LaunchHold now takes an onShown
callback, run after the released frame is drawn; on the web it dispatches
opensplit-launch-shown, which index.html waits for instead.

The web splash mark is also sized to match Android 12+: a 160px ring, the
size the platform draws assets/splash/splash-icon.png at (288dp icon, mark
640/1152 of it), pinned by a test that measures the asset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YSJh8YFmryHU41unFvrxSu
Edit profile and the picture picker now use SavedDataLoading while the
local profile is read, like every other screen, so they hold the launch
splash instead of flashing a spinner. Edit profile keeps a visible spinner
for the other case, a profile this device has not received from the
server yet.

The picker also waits for the saved profile before starting: it used to
draw initials at once and keep them as the "unchanged" picture, whatever
the saved one turned out to be.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YSJh8YFmryHU41unFvrxSu
The whole route table sat inside a ShellRoute whose only job was to give
one SelectionArea an overlay. A ShellRoute is a second navigator, so every
pushed screen lived on it while dialogs, the date picker and the time picker
open on the root navigator by default. Each navigator has its own top, and
Android's predictive back gesture is claimed by the page that is top of its
own stack: the entry editor, under the picker. Swiping back popped the
editor and then the group out from under the picker, which stayed on screen.

Pushed screens are now pages on the root navigator, the same stack as
dialogs and pickers, so there is one answer to what back closes. The
destination branches remain the only nested navigators, and each holds just
its destination. SelectionArea moves to each route, which is where Flutter
documents it and where it has the route's overlay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YSJh8YFmryHU41unFvrxSu
Settings' account card switches to the Account destination, and that was a
hard cut onto a page that was still wrong: the branch was built on first
visit, its profile not yet read, so it drew "Add the name your groups see"
and the UPI prompt for a frame before the saved name and handle replaced
them and the header grew a line.

- Destinations now fade through, Material's motion for moving between
  top-level destinations, using FadeThroughTransition from the Flutter
  team's animations package. Every branch stays built, as before, so each
  keeps its scroll position and stack. With animations turned off in the
  system settings the switch is still a cut.
- The profile is read at launch from the app root, Riverpod's eager
  initialization, so Account has it on the frame it is first built.
  Building the branch early would not have done it: Riverpod pauses
  providers that only offstage widgets listen to.
- Account draws nothing until it has read the session and the profile,
  like the other screens that show saved data, rather than drawing a saved
  name as unset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YSJh8YFmryHU41unFvrxSu
Screen text was selectable everywhere, for the web's sake, through a
SelectionArea on every route. On Android that is not what an app does:
long-press belongs to the control under the finger. It also tripped a
framework assertion in debug builds whenever predictive back drew the
previous page back in, as the swipe from an expense back to its group does.

The values worth copying, the invite and group links, are SelectableText
already. The swipe-back test now swipes twice, the second being the one
that asserted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YSJh8YFmryHU41unFvrxSu
@seenu-k
seenu-k merged commit 3599e61 into main Oct 10, 2026
4 checks passed
@seenu-k
seenu-k deleted the fix/empty-groups-offline-first branch October 10, 2026 10:55
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