Repository navigation
Offline-first launch: open on saved data, sync in the background - #29
Merged
Merged
Conversation
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
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSJh8YFmryHU41unFvrxSu
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Changes
1. One launch wait (
lib/presentation/launch_hold.dart)main()callsLaunchHold.begin(), which uses Flutter's built-inWidgetsBinding.deferFirstFrame()(the same mechanismflutter_native_splash.preservewraps). Widgets still build underneath, so each screen's own queries run as usual. Only the pixels wait.LaunchPlaceholdermounted. Placeholders opt in for local data only: the group list's loading state, the blank balance line on group tiles, andSavedDataLoading(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.2. Web splash instead of skeleton
web/index.htmlnow draws only the brand mark onsurface, matching Android, and fades out onflutter-first-frame. The HTML skeleton (app bar, rail, nav bar, cards, FAB) is gone, along with theboot_hintlocalStorage hints andGroupListSkeleton. Net −1,000 lines.theme_test.dartstill checks the splash colours against the theme.2b. Android 12+ splash without the icon tile
flutter_native_splash.yamlno longer setsicon_background_color. Regenerated withdart run flutter_native_splash:create, which removedwindowSplashScreenIconBackgroundColorfrom bothvalues-v31styles. The mark now sits directly onsurface, matching Android 11 and older and the web. Before, the tile wasprimaryContaineronsurfacein 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
theme_test.dartmeasures the Android asset and checks the web size against it.2d. Profile screens
SavedDataLoadingwhile the local profile is read. Edit profile keeps a visible spinner only for a profile that hasn't arrived from the server yet.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 oneSelectionAreaan 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
SelectionAreaScreen text is no longer selectable, on Android or the web. On Android that is the platform's norm, and the
SelectionAreatripped a framework assertion in debug builds whenever predictive back revealed the previous page. Invite and group links areSelectableTextalready. 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.
FadeThroughTransitionfrom the Flutter team'sanimationspackage (3.0.0, onmaterial_ui). Branches stay built, so each keeps its scroll position. With system animations off it is still a cut.3. Empty list shows at once
LinearProgressIndicatorin the app bar's newbottomslot (DestinationScaffold.bottom) shows during this session's first full sync, only while the list is empty.4. Offline
ApiFailure.unreachableandisUnreachable()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.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.SyncStatusBannerstays silent. Real server errors still get the card with "Try again".SyncCoordinator: a network-up event after a failed attempt syncs immediately.Trade-offs
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 analyzeclean;flutter testpasses (526); biome is clean onweb/index.html.flutter-first-frameat 272ms,opensplit-launch-shownat 282ms, splash gone at 482ms. The signed-in, held path is covered by unit tests only.🤖 Generated with Claude Code
https://claude.ai/code/session_01YSJh8YFmryHU41unFvrxSu