diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2d75e29..4fe408f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,11 @@ What changed in each version of the app, newest first. The format is
- Exchange-rate estimates only use a rate from within a week of the expense's day, and the server keeps a full year of rates to draw on.
- Two people editing the same expense at once no longer quietly lose one edit: whichever arrives second is set aside, with the newer version shown, to be applied again on top of it.
- Saving something syncs that group alone, and all your groups are read in one request, so syncing uses far less data and battery.
+- OpenSplit opens straight onto what is saved on this device, behind one launch screen, with a thin bar while it checks for changes.
+- When you are offline, a cloud icon at the top of the screen says so.
+- Moving between Groups, Account and Settings fades from one to the next.
+- The Android launch screen shows the logo without a tile behind it.
+- Text on screen is no longer selectable, as in other apps. Invite links can still be copied.
### Fixed
- Settling up with somebody and then removing them while offline no longer leaves the removal refused once you are back online.
@@ -52,6 +57,9 @@ What changed in each version of the app, newest first. The format is
- Nobody can add a new expense that charges somebody who has left a group.
- An expense split among 26 or more people now saves; before, the server refused it and the app kept retrying.
- In Settle up, making the person being paid the payer now clears "Who is being paid" on screen too, not just behind it.
+- Swiping back with the date or time picker open closes the picker, not the screen behind it.
+- The Account page opens with your name and UPI ID already showing, instead of filling them in a moment later.
+- Syncing tries again as soon as you are back online, rather than waiting for its next turn.
## [2.0.0] - 2026-10-01
### Changed
diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml
index d34236c..90bcb41 100644
--- a/android/app/src/main/res/values-night-v31/styles.xml
+++ b/android/app/src/main/res/values-night-v31/styles.xml
@@ -16,9 +16,7 @@
shortEdges#131318@drawable/android12splash
- #1F1F25
-
diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml
index 792dbf6..7b20625 100644
--- a/android/app/src/main/res/values-v31/styles.xml
+++ b/android/app/src/main/res/values-v31/styles.xml
@@ -16,9 +16,7 @@
shortEdges#FCF8FF@drawable/android12splash
- #E3DFFF
-
diff --git a/flutter_native_splash.yaml b/flutter_native_splash.yaml
index 1e04b99..2ff25b3 100644
--- a/flutter_native_splash.yaml
+++ b/flutter_native_splash.yaml
@@ -1,8 +1,8 @@
# Regenerate with: dart run flutter_native_splash:create
#
# Colours here are ColorScheme values written out as literals, because none of
-# this runs Dart — the same duplication the web loading skeleton has, and pinned
-# the same way, by a test.
+# this runs Dart — the same duplication the web splash has, and pinned the same
+# way, by a test.
#
# #FCF8FF is ColorScheme.surface in the light scheme, #131318 in the dark.
# The brand kit quotes the light one as #FBF8FF; this is what
@@ -17,11 +17,14 @@ flutter_native_splash:
# Android 12 replaced the full-bleed splash image with an icon in a masked
# circle. splash-icon.png is 1152px with the mark at 640px specifically to
# survive that crop.
+ #
+ # No icon_background_color: the mark sits directly on the surface, as it does
+ # below Android 12 and on the web. A background is drawn in the launcher's
+ # icon shape, which varies by manufacturer, and in a container colour too
+ # close to the surface to read as deliberate.
android_12:
image: assets/splash/splash-icon.png
image_dark: assets/splash/splash-icon-dark.png
- icon_background_color: "#E3DFFF"
- icon_background_color_dark: "#1F1F25"
android: true
android_gravity: center
@@ -29,7 +32,6 @@ flutter_native_splash:
# No iOS target in this repo.
ios: false
- # The web has its own loading state, written by hand in web/index.html: a
- # skeleton of the screen that is about to appear, drawn in the theme's own
- # colours. This generator would inject a centred logo above it and fight it.
+ # The web's splash is written by hand in web/index.html, as inline SVG so it
+ # paints on the first round trip and fades out on Flutter's first frame.
web: false
diff --git a/lib/application/sync_coordinator.dart b/lib/application/sync_coordinator.dart
index 7dc64c1..c86f53d 100644
--- a/lib/application/sync_coordinator.dart
+++ b/lib/application/sync_coordinator.dart
@@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'package:flutter/foundation.dart';
+import '../data/sync/api_client.dart';
import '../data/sync/sync_engine.dart';
@immutable
@@ -25,6 +26,10 @@ class SyncStatus {
final DateTime? retryAt;
Object? get error => lastReport?.error;
+
+ /// Whether the last attempt failed because no server could be reached,
+ /// which is the ordinary state of an offline device rather than a fault.
+ bool get isOffline => isUnreachable(error);
}
/// Decides when to sync, runs one sync at a time, and reports status.
@@ -72,7 +77,14 @@ class SyncCoordinator extends ChangeNotifier {
_subscriptions
..add(
_online.listen((isOnline) {
- if (isOnline) resumed();
+ if (!isOnline) return;
+ // A failed attempt is retried as soon as the network is back, not
+ // when its back-off ends or the usual gap has passed.
+ if (_status.error == null) {
+ resumed();
+ } else {
+ unawaited(syncAll());
+ }
}),
)
..add(_writes.listen((_) => _writeQueued()));
diff --git a/lib/data/sync/api_client.dart b/lib/data/sync/api_client.dart
index c44b380..2b8f71c 100644
--- a/lib/data/sync/api_client.dart
+++ b/lib/data/sync/api_client.dart
@@ -28,7 +28,12 @@ api.OpensplitApi buildApiClient({
/// A request the server refused, or that never got an answer.
class ApiFailure implements Exception {
- const ApiFailure(this.message, {required this.retry, this.code});
+ const ApiFailure(
+ this.message, {
+ required this.retry,
+ this.code,
+ this.unreachable = false,
+ });
/// Reads the `{error: {code, message, retry}}` envelope off a failed request.
factory ApiFailure.from(DioException error) {
@@ -54,6 +59,7 @@ class ApiFailure implements Exception {
retry: status == null || status >= 500
? api.Retry.transient
: api.Retry.permanent,
+ unreachable: _neverAnswered(error),
);
}
@@ -63,6 +69,10 @@ class ApiFailure implements Exception {
/// Null when no server answered in the expected shape.
final api.ErrorCode? code;
+ /// Whether the request never reached a server that answered: the device is
+ /// offline, or the server is out of reach from where it is.
+ final bool unreachable;
+
static api.ErrorError? _decode(Map body) {
try {
return api.Error.fromJson(body).error;
@@ -99,3 +109,17 @@ Future send(Future> request) async {
throw ApiFailure.from(error);
}
}
+
+/// Whether [error] means no server answered at all, as opposed to a server
+/// that answered and refused.
+///
+/// Judged by the request itself rather than by the device's network
+/// interfaces, which report a captive portal or a dead Wi-Fi as connected.
+bool isUnreachable(Object? error) => switch (error) {
+ ApiFailure(:final unreachable) => unreachable,
+ DioException() => _neverAnswered(error),
+ _ => false,
+};
+
+bool _neverAnswered(DioException error) =>
+ error.response == null && error.type != DioExceptionType.cancel;
diff --git a/lib/data/web/boot_hint.dart b/lib/data/web/boot_hint.dart
deleted file mode 100644
index cc616f1..0000000
--- a/lib/data/web/boot_hint.dart
+++ /dev/null
@@ -1,4 +0,0 @@
-/// What the web loader is allowed to assume before any Dart has run.
-library;
-
-export 'boot_hint_stub.dart' if (dart.library.js_interop) 'boot_hint_web.dart';
diff --git a/lib/data/web/boot_hint_stub.dart b/lib/data/web/boot_hint_stub.dart
deleted file mode 100644
index ef42328..0000000
--- a/lib/data/web/boot_hint_stub.dart
+++ /dev/null
@@ -1,4 +0,0 @@
-/// Android has no pre-engine loader to inform, so these do nothing.
-void recordHasGroups(bool hasGroups) {}
-
-void recordSignedIn(bool signedIn) {}
diff --git a/lib/data/web/boot_hint_web.dart b/lib/data/web/boot_hint_web.dart
deleted file mode 100644
index 5deb4ce..0000000
--- a/lib/data/web/boot_hint_web.dart
+++ /dev/null
@@ -1,33 +0,0 @@
-import 'package:web/web.dart' as web;
-
-/// The key `web/index.html` reads to decide whether to draw group cards.
-const _groupsKey = 'opensplit.hasGroups';
-
-/// The key `web/index.html` reads to decide whether to draw the app at all.
-const _sessionKey = 'opensplit.signedIn';
-
-/// Tells the next cold start whether this browser has any groups to show.
-void recordHasGroups(bool hasGroups) {
- _write(_groupsKey, hasGroups);
-}
-
-/// Tells the next cold start whether to draw the app or the welcome screen.
-void recordSignedIn(bool signedIn) {
- _write(_sessionKey, signedIn);
- if (!signedIn) _write(_groupsKey, false);
-}
-
-/// localStorage is the only store readable synchronously on the first frame of
-/// the document. Losing it costs nothing: every key here is absent by default
-/// and the loader's fallback is the quieter drawing, not the louder one.
-void _write(String key, bool value) {
- try {
- if (value) {
- web.window.localStorage.setItem(key, '1');
- } else {
- web.window.localStorage.removeItem(key);
- }
- } catch (_) {
- // Private mode, or site data blocked. The skeleton has a safe default.
- }
-}
diff --git a/lib/data/web/launch_signal.dart b/lib/data/web/launch_signal.dart
new file mode 100644
index 0000000..fc61c58
--- /dev/null
+++ b/lib/data/web/launch_signal.dart
@@ -0,0 +1,5 @@
+/// Telling the page outside Flutter that the app is on screen.
+library;
+
+export 'launch_signal_stub.dart'
+ if (dart.library.js_interop) 'launch_signal_web.dart';
diff --git a/lib/data/web/launch_signal_stub.dart b/lib/data/web/launch_signal_stub.dart
new file mode 100644
index 0000000..bf29146
--- /dev/null
+++ b/lib/data/web/launch_signal_stub.dart
@@ -0,0 +1,2 @@
+/// Android's splash is the platform's, and goes by itself on the first frame.
+void announceLaunchShown() {}
diff --git a/lib/data/web/launch_signal_web.dart b/lib/data/web/launch_signal_web.dart
new file mode 100644
index 0000000..19275fb
--- /dev/null
+++ b/lib/data/web/launch_signal_web.dart
@@ -0,0 +1,13 @@
+import 'package:web/web.dart' as web;
+
+/// The event `web/index.html` waits for before fading out its splash.
+const _launchShown = 'opensplit-launch-shown';
+
+/// Tells `web/index.html` that the first real frame is on screen.
+///
+/// Flutter's own `flutter-first-frame` event fires after the first frame is
+/// built even while that frame is deferred, so it would take the splash down
+/// onto a blank page while the app is still reading saved data.
+void announceLaunchShown() {
+ web.window.dispatchEvent(web.Event(_launchShown));
+}
diff --git a/lib/main.dart b/lib/main.dart
index 67c06ee..50d56cf 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -12,12 +12,18 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'application/backend_providers.dart';
import 'application/preferences_providers.dart';
import 'config.dart';
+import 'data/web/launch_signal.dart';
import 'data/auth/session_store.dart';
import 'presentation/app.dart';
+import 'presentation/launch_hold.dart';
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
+ // The splash stays up while the first screen reads what is saved, so a launch
+ // is one wait that ends on real content.
+ LaunchHold.begin(onShown: announceLaunchShown);
+
// google_fonts falls back to downloading a face it cannot find in the bundle.
GoogleFonts.config.allowRuntimeFetching = false;
diff --git a/lib/presentation/app.dart b/lib/presentation/app.dart
index 3136cd3..d3139e7 100644
--- a/lib/presentation/app.dart
+++ b/lib/presentation/app.dart
@@ -8,11 +8,11 @@ import 'package:in_app_update/in_app_update.dart';
import '../application/local_providers.dart';
import '../application/preferences_providers.dart';
import '../application/push_providers.dart';
+import '../application/ledger_providers.dart';
import '../application/router_provider.dart';
import '../application/session_providers.dart';
import '../application/sync_providers.dart';
import '../data/platform/app_update_service.dart';
-import '../data/web/boot_hint.dart';
import '../data/web/release_updates.dart';
import '../domain/auth_service.dart';
import '../l10n/app_localizations.dart';
@@ -66,11 +66,12 @@ class _OpenSplitAppState extends ConsumerState {
// Built for each account, which starts its automatic sync.
ref.listenManual(syncControllerProvider, (_, _) {}, fireImmediately: true);
- ref.listenManual(signedInProvider, (_, signedIn) {
- // Leaves a note for the next cold start, so the web loader draws the
- // layout this session will actually land on.
- recordSignedIn(signedIn);
- }, fireImmediately: true);
+ // Your own profile is app state, so it is read at launch and kept current:
+ // the Account destination then has it on the frame it is first built,
+ // instead of drawing "no name yet" until it arrives. Building that branch
+ // early would not do it, because Riverpod pauses providers that only
+ // offstage widgets listen to.
+ ref.listenManual(myProfileProvider, (_, _) {});
// A Google flow that left the page finishes here, on the one launch that is
// a return from it.
diff --git a/lib/presentation/launch_hold.dart b/lib/presentation/launch_hold.dart
new file mode 100644
index 0000000..6f2b198
--- /dev/null
+++ b/lib/presentation/launch_hold.dart
@@ -0,0 +1,116 @@
+import 'dart:async';
+
+import 'package:flutter/scheduler.dart';
+import 'package:material_ui/material_ui.dart';
+
+/// Keeps the platform's launch screen up until the first screen can show what
+/// is saved on this device, rather than a placeholder for it.
+///
+/// Flutter takes down the Android splash, and fires the `flutter-first-frame`
+/// event that removes the web splash, when it renders its first frame. [begin]
+/// defers that frame. Widgets still build and lay out underneath, so each
+/// screen's own queries run exactly as they would otherwise; only the pixels
+/// wait. The frame is let through once a built frame contains no
+/// [LaunchPlaceholder], so a launch is one wait however long the disk takes.
+///
+/// Only placeholders for local data take part. Anything waiting on the network
+/// draws as usual, because a launch screen held on a connection is the slow,
+/// online-only start this exists to avoid.
+abstract final class LaunchHold {
+ static int _placeholders = 0;
+ static bool _holding = false;
+ static Timer? _limit;
+ static VoidCallback? _onShown;
+
+ /// Whether the first frame is still being held back.
+ static bool get isHolding => _holding;
+
+ /// Defers the first frame. Call once, before `runApp`.
+ ///
+ /// [onShown] runs once the released frame has been drawn, for a launch
+ /// screen the platform does not take down by itself.
+ ///
+ /// The [limit] is not a tuning knob. It is there so that a local query that
+ /// never answers shows the app, with its navigation and settings, instead of
+ /// a splash that cannot be told apart from a hang. It is longer than SQLite's
+ /// own five-second busy timeout, so a locked database reports its own error
+ /// first.
+ static void begin({
+ Duration limit = const Duration(seconds: 10),
+ VoidCallback? onShown,
+ }) {
+ if (_holding) return;
+ _holding = true;
+ _onShown = onShown;
+ WidgetsBinding.instance.deferFirstFrame();
+ _limit = Timer(limit, _release);
+ _checkAfterNextFrame(settled: false);
+ }
+
+ /// Releases the frame once two frames in a row have had nothing to wait for.
+ ///
+ /// Two, because the router builds its first page a frame after the app
+ /// itself, and that page may be the one with a placeholder in it.
+ static void _checkAfterNextFrame({required bool settled}) {
+ SchedulerBinding.instance.addPostFrameCallback((_) {
+ if (!_holding) return;
+ if (_placeholders > 0) return _checkAfterNextFrame(settled: false);
+ if (settled) return _release();
+ _checkAfterNextFrame(settled: true);
+ SchedulerBinding.instance.scheduleFrame();
+ });
+ }
+
+ static void _release() {
+ if (!_holding) return;
+ _holding = false;
+ _limit?.cancel();
+ _limit = null;
+ WidgetsBinding.instance.allowFirstFrame();
+ final onShown = _onShown;
+ _onShown = null;
+ if (onShown != null) {
+ SchedulerBinding.instance
+ ..addPostFrameCallback((_) => onShown())
+ ..scheduleFrame();
+ }
+ }
+
+ /// Forgets any hold, for tests that begin one.
+ @visibleForTesting
+ static void reset() {
+ _onShown = null;
+ _release();
+ _placeholders = 0;
+ }
+}
+
+/// Marks [child] as standing in for saved data that is still being read.
+///
+/// While one of these is on screen, [LaunchHold] keeps the launch screen up.
+/// After launch it has no effect. Works in both box and sliver positions.
+class LaunchPlaceholder extends StatefulWidget {
+ const LaunchPlaceholder({super.key, required this.child});
+
+ final Widget child;
+
+ @override
+ State createState() => _LaunchPlaceholderState();
+}
+
+class _LaunchPlaceholderState extends State {
+ @override
+ void initState() {
+ super.initState();
+ LaunchHold._placeholders++;
+ }
+
+ @override
+ void dispose() {
+ LaunchHold._placeholders--;
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) => widget.child;
+}
diff --git a/lib/presentation/router.dart b/lib/presentation/router.dart
index b52d9ab..cd78cdb 100644
--- a/lib/presentation/router.dart
+++ b/lib/presentation/router.dart
@@ -1,5 +1,6 @@
import 'package:material_ui/material_ui.dart';
import 'package:go_router/go_router.dart';
+import 'package:animations/animations.dart';
import '../l10n/app_localizations.dart';
import 'navigation.dart';
@@ -78,143 +79,142 @@ GoRouter buildRouter({
/// Nothing above the welcome screen works without a session.
redirect: (context, state) =>
redirectAppRoute(state.uri, signedIn: isSignedIn()),
+ // Every screen that can be pushed is a page on the one root navigator,
+ // which is also where dialogs, date and time pickers and most sheets open.
+ // A second navigator around these, as a ShellRoute would add, makes two
+ // stacks with two tops: the system back gesture then pops the page that is
+ // top of its own stack while a picker sits over it on the other. The
+ // destinations' branches below are the only nested navigators, and each
+ // holds just its destination, so there is never anything in them to pop.
routes: [
- // One shell around everything, for one reason: SelectionArea.
- ShellRoute(
- builder: (context, state, child) => SelectionArea(child: child),
+ // No transition, in both directions.
+ GoRoute(
+ path: '/welcome',
+ pageBuilder: (context, state) =>
+ const NoTransitionPage(child: WelcomeScreen()),
+ ),
+ // The link a friend sends.
+ GoRoute(
+ path: '/join/:token',
+ builder: (context, state) =>
+ JoinScreen(token: state.pathParameters['token']!),
+ ),
+ GoRoute(
+ path: '/archived',
+ builder: (context, state) => const ArchivedGroupsScreen(),
+ ),
+ // Above the shell rather than inside Settings' branch, so it arrives
+ // with a back arrow instead of a menu button -- it is a screen reached
+ // from a destination, not a destination.
+ GoRoute(path: '/about', builder: (context, state) => const AboutScreen()),
+ // Reached from the Account destination, above the shell for the same
+ // reason as About.
+ GoRoute(
+ path: '/account/edit',
+ builder: (context, state) => EditProfileScreen(
+ focus:
+ ProfileField.values
+ .asNameMap()[state.uri.queryParameters['field']] ??
+ ProfileField.name,
+ ),
+ ),
+ GoRoute(
+ path: '/account/save',
+ builder: (context, state) => const SaveAccountScreen(),
+ ),
+ GoRoute(
+ path: '/account/picture',
+ builder: (context, state) => const AvatarPickerScreen.profile(),
+ ),
+ GoRoute(
+ path: '/g/:groupId',
+ builder: (context, state) =>
+ GroupDetailScreen(groupId: state.pathParameters['groupId']!),
routes: [
- // No transition, in both directions.
GoRoute(
- path: '/welcome',
- pageBuilder: (context, state) =>
- const NoTransitionPage(child: WelcomeScreen()),
+ path: 'add',
+ builder: (context, state) =>
+ EntryEditorScreen(groupId: state.pathParameters['groupId']!),
),
- // The link a friend sends.
GoRoute(
- path: '/join/:token',
+ path: 'activity',
builder: (context, state) =>
- JoinScreen(token: state.pathParameters['token']!),
+ ActivityScreen(groupId: state.pathParameters['groupId']!),
),
GoRoute(
- path: '/archived',
- builder: (context, state) => const ArchivedGroupsScreen(),
+ path: 'insights',
+ builder: (context, state) =>
+ InsightsScreen(groupId: state.pathParameters['groupId']!),
),
- // Above the shell rather than inside Settings' branch, so it arrives
- // with a back arrow instead of a menu button -- it is a screen reached
- // from a destination, not a destination.
GoRoute(
- path: '/about',
- builder: (context, state) => const AboutScreen(),
+ path: 'settings',
+ builder: (context, state) =>
+ GroupSettingsScreen(groupId: state.pathParameters['groupId']!),
),
- // Reached from the Account destination, above the shell for the same
- // reason as About.
GoRoute(
- path: '/account/edit',
- builder: (context, state) => EditProfileScreen(
- focus:
- ProfileField.values
- .asNameMap()[state.uri.queryParameters['field']] ??
- ProfileField.name,
- ),
+ path: 'members',
+ builder: (context, state) =>
+ MembersScreen(groupId: state.pathParameters['groupId']!),
),
GoRoute(
- path: '/account/save',
- builder: (context, state) => const SaveAccountScreen(),
+ path: 'picture',
+ builder: (context, state) => AvatarPickerScreen.group(
+ groupId: state.pathParameters['groupId']!,
+ ),
),
GoRoute(
- path: '/account/picture',
- builder: (context, state) => const AvatarPickerScreen.profile(),
+ path: 'settle',
+ builder: (context, state) => SettleUpScreen(
+ groupId: state.pathParameters['groupId']!,
+ fromMemberId: state.uri.queryParameters['from'],
+ toMemberId: state.uri.queryParameters['to'],
+ amountMinor: int.tryParse(
+ state.uri.queryParameters['amount'] ?? '',
+ ),
+ currency: state.uri.queryParameters['currency'],
+ ),
),
GoRoute(
- path: '/g/:groupId',
- builder: (context, state) =>
- GroupDetailScreen(groupId: state.pathParameters['groupId']!),
+ path: 'e/:entryId',
+ builder: (context, state) => EntryEditorScreen(
+ groupId: state.pathParameters['groupId']!,
+ entryId: state.pathParameters['entryId'],
+ ),
+ ),
+ ],
+ ),
+ // The other half of that, and the half that actually does the work: an
+ // outgoing page is only removed once the *incoming* one has finished
+ // arriving, so silencing the welcome screen alone changed nothing while
+ // the destinations still animated in over it.
+ StatefulShellRoute(
+ pageBuilder: (context, state, shell) =>
+ NoTransitionPage(child: AdaptiveNavigation(shell: shell)),
+ navigatorContainerBuilder: (context, shell, children) =>
+ _FadeThroughBranches(index: shell.currentIndex, children: children),
+ branches: [
+ StatefulShellBranch(
routes: [
GoRoute(
- path: 'add',
- builder: (context, state) =>
- EntryEditorScreen(groupId: state.pathParameters['groupId']!),
- ),
- GoRoute(
- path: 'activity',
- builder: (context, state) =>
- ActivityScreen(groupId: state.pathParameters['groupId']!),
- ),
- GoRoute(
- path: 'insights',
- builder: (context, state) =>
- InsightsScreen(groupId: state.pathParameters['groupId']!),
- ),
- GoRoute(
- path: 'settings',
- builder: (context, state) => GroupSettingsScreen(
- groupId: state.pathParameters['groupId']!,
- ),
- ),
- GoRoute(
- path: 'members',
- builder: (context, state) =>
- MembersScreen(groupId: state.pathParameters['groupId']!),
- ),
- GoRoute(
- path: 'picture',
- builder: (context, state) => AvatarPickerScreen.group(
- groupId: state.pathParameters['groupId']!,
- ),
- ),
- GoRoute(
- path: 'settle',
- builder: (context, state) => SettleUpScreen(
- groupId: state.pathParameters['groupId']!,
- fromMemberId: state.uri.queryParameters['from'],
- toMemberId: state.uri.queryParameters['to'],
- amountMinor: int.tryParse(
- state.uri.queryParameters['amount'] ?? '',
- ),
- currency: state.uri.queryParameters['currency'],
- ),
+ path: '/',
+ builder: (context, state) => const GroupListScreen(),
),
+ ],
+ ),
+ // A top-level destination, not a detail reached from Settings.
+ StatefulShellBranch(
+ routes: [
GoRoute(
- path: 'e/:entryId',
- builder: (context, state) => EntryEditorScreen(
- groupId: state.pathParameters['groupId']!,
- entryId: state.pathParameters['entryId'],
- ),
+ path: '/account',
+ builder: (context, state) => const AccountScreen(),
),
],
),
- // The other half of that, and the half that actually does the work: an
- // outgoing page is only removed once the *incoming* one has finished
- // arriving, so silencing the welcome screen alone changed nothing while
- // the destinations still animated in over it.
- StatefulShellRoute.indexedStack(
- pageBuilder: (context, state, shell) =>
- NoTransitionPage(child: AdaptiveNavigation(shell: shell)),
- branches: [
- StatefulShellBranch(
- routes: [
- GoRoute(
- path: '/',
- builder: (context, state) => const GroupListScreen(),
- ),
- ],
- ),
- // A top-level destination, not a detail reached from Settings.
- StatefulShellBranch(
- routes: [
- GoRoute(
- path: '/account',
- builder: (context, state) => const AccountScreen(),
- ),
- ],
- ),
- StatefulShellBranch(
- routes: [
- GoRoute(
- path: '/settings',
- builder: (context, state) => const SettingsScreen(),
- ),
- ],
+ StatefulShellBranch(
+ routes: [
+ GoRoute(
+ path: '/settings',
+ builder: (context, state) => const SettingsScreen(),
),
],
),
@@ -223,6 +223,110 @@ GoRouter buildRouter({
],
);
+/// Shows the selected destination, fading through from the last one.
+///
+/// Material's pattern for moving between top-level destinations, which are not
+/// related to each other in the way a pushed screen is related to the one
+/// below it. Every branch stays built, as in an `IndexedStack`, so each keeps
+/// its scroll position and stack.
+class _FadeThroughBranches extends StatefulWidget {
+ const _FadeThroughBranches({required this.index, required this.children});
+
+ final int index;
+ final List children;
+
+ @override
+ State<_FadeThroughBranches> createState() => _FadeThroughBranchesState();
+}
+
+class _FadeThroughBranchesState extends State<_FadeThroughBranches>
+ with SingleTickerProviderStateMixin {
+ late final _controller =
+ AnimationController(vsync: this, duration: Durations.medium2, value: 1)
+ ..addStatusListener((status) {
+ if (status.isCompleted) setState(() => _leaving = null);
+ });
+
+ /// The destination fading out, while it does.
+ int? _leaving;
+
+ @override
+ void didUpdateWidget(_FadeThroughBranches oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (widget.index == oldWidget.index) return;
+ if (MediaQuery.disableAnimationsOf(context)) return;
+ _leaving = oldWidget.index;
+ _controller.forward(from: 0);
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) => ColoredBox(
+ // Behind both while neither is fully drawn. A phone has nothing else
+ // under the destinations, so without it the midpoint would be black.
+ color: Theme.of(context).scaffoldBackgroundColor,
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ for (final (index, child) in widget.children.indexed)
+ _Branch(
+ selected: index == widget.index,
+ leaving: index == _leaving,
+ fade: _controller,
+ child: child,
+ ),
+ ],
+ ),
+ );
+}
+
+/// One destination: shown, fading out, or kept offstage.
+///
+/// The same widgets in every state, so starting or ending a fade never
+/// rebuilds a branch's navigator from scratch.
+class _Branch extends StatelessWidget {
+ const _Branch({
+ required this.selected,
+ required this.leaving,
+ required this.fade,
+ required this.child,
+ });
+
+ final bool selected;
+ final bool leaving;
+
+ /// Runs forward over a switch: this branch fades in on it when [selected],
+ /// and out on it when [leaving].
+ final Animation fade;
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) => Offstage(
+ offstage: !selected && !leaving,
+ child: TickerMode(
+ enabled: selected,
+ child: IgnorePointer(
+ ignoring: !selected,
+ child: ExcludeSemantics(
+ excluding: !selected,
+ child: FadeThroughTransition(
+ animation: selected ? fade : kAlwaysCompleteAnimation,
+ secondaryAnimation: leaving ? fade : kAlwaysDismissedAnimation,
+ // The one fill is painted behind every branch, by the parent.
+ fillColor: Colors.transparent,
+ child: child,
+ ),
+ ),
+ ),
+ ),
+ );
+}
+
/// The app's only navigation surface, in whichever form the window has room
/// for.
class AdaptiveNavigation extends StatelessWidget {
@@ -234,7 +338,7 @@ class AdaptiveNavigation extends StatelessWidget {
/// Material 3's own default destination width, and the rail's width outright:
/// a destination is padded 8dp either side *within* this, and only pushes
/// past it if a label needs more. None of the three labels is anywhere near,
- /// so the rail is a fixed 80dp and the skeleton can count on it.
+ /// so the rail is a fixed 80dp.
static const double _railWidth = 80;
final StatefulNavigationShell shell;
@@ -262,10 +366,6 @@ class AdaptiveNavigation extends StatelessWidget {
NavigationRail(
selectedIndex: shell.currentIndex,
labelType: NavigationRailLabelType.all,
- // Material's own default, stated rather than inherited because the
- // web loading skeleton draws a rail of exactly this width before
- // Flutter starts — see the 840px block in web/index.html, and the
- // test that holds the two numbers together.
minWidth: _railWidth,
onDestinationSelected: _select,
destinations: [
diff --git a/lib/presentation/screens/account_screen.dart b/lib/presentation/screens/account_screen.dart
index ed330f6..5e6ccb8 100644
--- a/lib/presentation/screens/account_screen.dart
+++ b/lib/presentation/screens/account_screen.dart
@@ -11,6 +11,7 @@ import '../feedback.dart';
import '../widgets/avatar_view.dart';
import '../widgets/page_body.dart';
import '../widgets/segmented_list.dart';
+import '../widgets/sync_status_notice.dart';
import 'edit_profile_screen.dart';
/// Who you are, in one place: the name and payment handle everybody who shares
@@ -169,111 +170,121 @@ class _AccountScreenState extends ConsumerState {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
- final account = ref.watch(accountProvider).value;
- final profile = ref.watch(myProfileProvider).value;
+ final accountState = ref.watch(accountProvider);
+ final profileState = ref.watch(myProfileProvider);
+ final account = accountState.value;
+ final profile = profileState.value;
return DestinationScaffold(
title: 'Account',
slivers: [
- SliverList.list(
- children: [
- _Identity(
- id: account?.id ?? '',
- avatar: profile?.avatar ?? const InitialsAvatar(),
- name: profile?.displayName,
- email: account?.email,
- isGuest: account?.isAnonymous ?? false,
- ),
- if (account != null && account.isAnonymous) ...[
- const SizedBox(height: 16),
- const _SaveAccountCard(),
- ],
-
- const _SectionHeader('Profile'),
- SegmentedList(
- children: [
- _ProfileRow(
- icon: Icons.badge_outlined,
- label: 'Name',
- value: profile?.displayName,
- unset: 'Add the name your groups see',
- field: ProfileField.name,
- ),
- _ProfileRow(
- icon: Icons.account_balance_wallet_outlined,
- label: 'UPI ID',
- value: profile?.upiVpa,
- unset: 'Add one so people can pay you by UPI',
- field: ProfileField.upi,
- ),
+ // Not read yet is not the same as not set: drawn as "Add the name
+ // your groups see" for a frame, a saved name arrives as a correction.
+ if (!accountState.hasValue || !profileState.hasValue)
+ const SliverFillRemaining(
+ hasScrollBody: false,
+ child: SavedDataLoading(label: 'Loading your account'),
+ )
+ else
+ SliverList.list(
+ children: [
+ _Identity(
+ id: account?.id ?? '',
+ avatar: profile?.avatar ?? const InitialsAvatar(),
+ name: profile?.displayName,
+ email: account?.email,
+ isGuest: account?.isAnonymous ?? false,
+ ),
+ if (account != null && account.isAnonymous) ...[
+ const SizedBox(height: 16),
+ const _SaveAccountCard(),
],
- ),
- if (account != null) ...[
- const _SectionHeader('Account'),
+ const _SectionHeader('Profile'),
SegmentedList(
children: [
- // Offered only to an account somebody can get back into.
- if (!account.isAnonymous)
+ _ProfileRow(
+ icon: Icons.badge_outlined,
+ label: 'Name',
+ value: profile?.displayName,
+ unset: 'Add the name your groups see',
+ field: ProfileField.name,
+ ),
+ _ProfileRow(
+ icon: Icons.account_balance_wallet_outlined,
+ label: 'UPI ID',
+ value: profile?.upiVpa,
+ unset: 'Add one so people can pay you by UPI',
+ field: ProfileField.upi,
+ ),
+ ],
+ ),
+
+ if (account != null) ...[
+ const _SectionHeader('Account'),
+ SegmentedList(
+ children: [
+ // Offered only to an account somebody can get back into.
+ if (!account.isAnonymous)
+ ListTile(
+ leading: Icon(
+ Icons.logout,
+ color: theme.colorScheme.error,
+ ),
+ title: Text(
+ 'Sign out',
+ style: TextStyle(color: theme.colorScheme.error),
+ ),
+ subtitle: const Text(
+ 'Removes this device\'s copy. Sign back in to get it again.',
+ ),
+ onTap: _signOut,
+ )
+ // Said out loud rather than left as a gap.
+ else
+ ListTile(
+ leading: Icon(
+ Icons.no_accounts_outlined,
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ title: Text(
+ 'Signing out would end this account',
+ style: TextStyle(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ subtitle: const Text(
+ 'Nothing but this device identifies a guest, so there '
+ 'would be no signing back in. Save your account first.',
+ ),
+ ),
ListTile(
leading: Icon(
- Icons.logout,
+ Icons.delete_forever_outlined,
color: theme.colorScheme.error,
),
title: Text(
- 'Sign out',
+ 'Delete account',
style: TextStyle(color: theme.colorScheme.error),
),
subtitle: const Text(
- 'Removes this device\'s copy. Sign back in to get it again.',
- ),
- onTap: _signOut,
- )
- // Said out loud rather than left as a gap.
- else
- ListTile(
- leading: Icon(
- Icons.no_accounts_outlined,
- color: theme.colorScheme.onSurfaceVariant,
+ 'Removes your account and everything only you can see. '
+ 'Permanent.',
),
- title: Text(
- 'Signing out would end this account',
- style: TextStyle(
- color: theme.colorScheme.onSurfaceVariant,
- ),
- ),
- subtitle: const Text(
- 'Nothing but this device identifies a guest, so there '
- 'would be no signing back in. Save your account first.',
- ),
- ),
- ListTile(
- leading: Icon(
- Icons.delete_forever_outlined,
- color: theme.colorScheme.error,
+ trailing: _deleting
+ ? const SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : null,
+ onTap: _deleting ? null : _deleteAccount,
),
- title: Text(
- 'Delete account',
- style: TextStyle(color: theme.colorScheme.error),
- ),
- subtitle: const Text(
- 'Removes your account and everything only you can see. '
- 'Permanent.',
- ),
- trailing: _deleting
- ? const SizedBox(
- width: 20,
- height: 20,
- child: CircularProgressIndicator(strokeWidth: 2),
- )
- : null,
- onTap: _deleting ? null : _deleteAccount,
- ),
- ],
- ),
+ ],
+ ),
+ ],
],
- ],
- ),
+ ),
],
);
}
diff --git a/lib/presentation/screens/activity_screen.dart b/lib/presentation/screens/activity_screen.dart
index 0e77b37..ee916e5 100644
--- a/lib/presentation/screens/activity_screen.dart
+++ b/lib/presentation/screens/activity_screen.dart
@@ -11,6 +11,7 @@ import '../../domain/models/group_event.dart';
import '../navigation.dart';
import '../widgets/empty_state.dart';
import '../widgets/page_body.dart';
+import '../widgets/sync_status_notice.dart';
/// What has happened to this group's expenses, and who did it.
class ActivityScreen extends ConsumerWidget {
@@ -33,7 +34,7 @@ class ActivityScreen extends ConsumerWidget {
),
body: PageBody(
child: switch (events) {
- null => const Center(child: CircularProgressIndicator()),
+ null => const SavedDataLoading(label: 'Loading activity'),
[] => const _Empty(),
_ => ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
diff --git a/lib/presentation/screens/avatar_picker_screen.dart b/lib/presentation/screens/avatar_picker_screen.dart
index 6ad372f..244fda9 100644
--- a/lib/presentation/screens/avatar_picker_screen.dart
+++ b/lib/presentation/screens/avatar_picker_screen.dart
@@ -10,6 +10,7 @@ import '../navigation.dart';
import '../theme.dart';
import '../widgets/avatar_view.dart';
import '../widgets/page_body.dart';
+import '../widgets/sync_status_notice.dart';
/// Chooses what stands for you, or for a group: initials, an emoji or an
/// icon, on a hue.
@@ -50,7 +51,11 @@ class _AvatarPickerScreenState extends ConsumerState {
}
final id = ref.watch(currentAccountIdProvider);
if (id == null) return null;
- final profile = ref.watch(myProfileProvider).value;
+ // Not until the saved profile has been read: what is shown first becomes
+ // the picture that "unchanged" is measured against.
+ final saved = ref.watch(myProfileProvider);
+ if (!saved.hasValue) return null;
+ final profile = saved.value;
return (
avatar: profile?.avatar ?? const InitialsAvatar(),
name: profile?.displayName ?? '',
@@ -105,7 +110,7 @@ class _AvatarPickerScreenState extends ConsumerState {
),
body: PageBody(
child: subject == null || chosen == null
- ? const Center(child: CircularProgressIndicator())
+ ? const SavedDataLoading(label: 'Loading the current picture')
: _Picker(
subject: subject,
chosen: chosen,
diff --git a/lib/presentation/screens/edit_profile_screen.dart b/lib/presentation/screens/edit_profile_screen.dart
index 9055416..489e605 100644
--- a/lib/presentation/screens/edit_profile_screen.dart
+++ b/lib/presentation/screens/edit_profile_screen.dart
@@ -6,6 +6,7 @@ import '../../data/local/database.dart';
import '../../domain/settle/upi.dart';
import '../navigation.dart';
import '../widgets/page_body.dart';
+import '../widgets/sync_status_notice.dart';
/// Which field the editor opens on.
enum ProfileField { name, upi }
@@ -116,6 +117,9 @@ class _EditProfileScreenState extends ConsumerState {
@override
Widget build(BuildContext context) {
final ready = _original != null;
+ // Still reading the device, as against waiting for a profile this device
+ // has never received: only the second is worth a spinner.
+ final readingSaved = !ref.watch(myProfileProvider).hasValue;
return ListenableBuilder(
listenable: Listenable.merge([_name, _upi]),
@@ -144,7 +148,13 @@ class _EditProfileScreenState extends ConsumerState {
),
body: PageBody(
child: !ready
- ? const Center(child: CircularProgressIndicator())
+ ? readingSaved
+ ? const SavedDataLoading(label: 'Loading your profile')
+ : const Center(
+ child: CircularProgressIndicator(
+ semanticsLabel: 'Waiting for your profile',
+ ),
+ )
: Form(
key: _form,
child: ListView(
diff --git a/lib/presentation/screens/group_detail_screen.dart b/lib/presentation/screens/group_detail_screen.dart
index 1333325..e23d15c 100644
--- a/lib/presentation/screens/group_detail_screen.dart
+++ b/lib/presentation/screens/group_detail_screen.dart
@@ -21,6 +21,7 @@ import '../widgets/empty_state.dart';
import '../widgets/group_cover.dart';
import '../widgets/group_pane.dart';
import '../widgets/group_standing.dart';
+import '../widgets/offline_indicator.dart';
import '../widgets/page_body.dart';
import '../widgets/segmented_list.dart';
import '../widgets/sync_refresh_button.dart';
@@ -145,6 +146,7 @@ class _GroupAppBar extends StatelessWidget {
leading: BackButton(onPressed: () => goBack(context, '/')),
title: Text(ledger.group.name),
actions: [
+ const OfflineIndicator(),
if (kIsWeb) SyncRefreshButton.group(groupId),
IconButton(
tooltip: 'Settle up',
diff --git a/lib/presentation/screens/group_list_screen.dart b/lib/presentation/screens/group_list_screen.dart
index 638801e..2db7d50 100644
--- a/lib/presentation/screens/group_list_screen.dart
+++ b/lib/presentation/screens/group_list_screen.dart
@@ -7,15 +7,15 @@ import '../../application/ledger_providers.dart';
import '../../application/sync_providers.dart';
import '../../data/local/database.dart';
import '../../data/repositories/drift_group_repository.dart';
-import '../../data/web/boot_hint.dart';
+import '../launch_hold.dart';
import '../widgets/avatar_view.dart';
import '../widgets/brand_mark.dart';
import '../widgets/conflicting_edit_banner.dart';
import '../widgets/create_group_sheet.dart';
import '../widgets/empty_state.dart';
-import '../widgets/group_skeleton.dart';
import '../widgets/group_standing.dart';
import '../widgets/link_account_prompt.dart';
+import '../widgets/offline_indicator.dart';
import '../widgets/page_body.dart';
import '../widgets/pull_to_sync.dart';
import '../widgets/segmented_list.dart';
@@ -34,15 +34,6 @@ class GroupListScreen extends ConsumerWidget {
// where.
final source = groupListingsProvider;
- // Leaves a note for the next cold start, so the web loader knows whether to
- // draw group cards or just the chrome.
- ref.listen(source, (_, next) {
- final loaded = next.value;
- if (loaded != null) {
- recordHasGroups(loaded.any((listing) => listing.isCurrent));
- }
- });
-
final groupsAsync = ref.watch(source);
final all = groupsAsync.value ?? const [];
final groups = [
@@ -53,7 +44,15 @@ class GroupListScreen extends ConsumerWidget {
return DestinationScaffold(
titleWidget: const BrandLockup(),
- actions: [if (kIsWeb) const SyncRefreshButton.everything()],
+ actions: [
+ const OfflineIndicator(),
+ if (kIsWeb) const SyncRefreshButton.everything(),
+ ],
+ // The empty state is shown at once rather than held back until the
+ // server confirms it, so an empty list says when it may yet fill.
+ bottom: groups.isEmpty
+ ? const InitialSyncProgress(label: 'Checking for your groups')
+ : null,
// Disabled until the device has learned what a currency is, which is only
// ever true during a brand-new install's first sweep or on a rebuilt
// device with no connection.
@@ -79,7 +78,9 @@ class GroupListScreen extends ConsumerWidget {
groups: groups,
archivedCount: archived,
),
- _ => const [GroupListSkeleton()],
+ // Nothing, rather than placeholder cards: at launch the splash covers
+ // this, and afterwards a local read is too quick to be worth drawing.
+ _ => const [LaunchPlaceholder(child: SliverToBoxAdapter())],
},
);
}
@@ -102,12 +103,12 @@ abstract final class _GroupList {
const UnsyncedChangesBanner(padding: _noticeGap),
const ConflictingEditBanner(padding: _noticeGap),
const LinkAccountPrompt(padding: _noticeGap),
- if (groups.isNotEmpty) const SyncStatusBanner(padding: _noticeGap),
+ const SyncStatusBanner(padding: _noticeGap),
],
),
),
if (groups.isEmpty)
- const SliverToBoxAdapter(child: InitialSyncGate(child: _EmptyState()))
+ const SliverToBoxAdapter(child: _EmptyState())
else
SliverPadding(
padding: const EdgeInsets.only(top: 8),
@@ -175,7 +176,7 @@ class _GroupTile extends ConsumerWidget {
title: Text(group.name, overflow: TextOverflow.ellipsis),
titleTextStyle: Theme.of(context).textTheme.titleMedium,
subtitle: ledger == null
- ? const SizedBox(height: 20)
+ ? const LaunchPlaceholder(child: SizedBox(height: 20))
: GroupStanding(ledger: ledger, currencies: currencies),
);
}
diff --git a/lib/presentation/widgets/group_skeleton.dart b/lib/presentation/widgets/group_skeleton.dart
deleted file mode 100644
index f481f9f..0000000
--- a/lib/presentation/widgets/group_skeleton.dart
+++ /dev/null
@@ -1,191 +0,0 @@
-import 'package:material_ui/material_ui.dart';
-import 'package:flutter/widget_previews.dart';
-
-/// The group list as it looks before the local database has answered.
-class GroupListSkeleton extends StatefulWidget {
- const GroupListSkeleton({super.key, this.cards = 3});
-
- /// Enough to fill a phone without implying a number.
- final int cards;
-
- @override
- State createState() => _GroupListSkeletonState();
-}
-
-class _GroupListSkeletonState extends State
- with SingleTickerProviderStateMixin {
- /// How many times the bars breathe before coming to rest.
- static const _cycles = 5;
-
- /// One controller for every bar on screen rather than one per card. The
- /// per-card offset is applied to the value, not to the clock, which is what
- /// CSS `animation-delay` does and costs two tickers fewer.
- late final AnimationController _breathe = AnimationController(
- vsync: this,
- duration: const Duration(milliseconds: 1800 * _cycles),
- )..forward();
-
- @override
- void dispose() {
- _breathe.dispose();
- super.dispose();
- }
-
- /// The title and detail widths, per card.
- static const _widths = <(double, double)>[
- (0.62, 0.40),
- (0.44, 0.56),
- (0.55, 0.40),
- ];
-
- @override
- Widget build(BuildContext context) {
- // Honoured for the same reason the CSS honours it: a pulse that cannot be
- // turned off is exactly the kind of motion this setting exists to stop.
- final still = MediaQuery.disableAnimationsOf(context);
-
- return SliverPadding(
- padding: const EdgeInsets.fromLTRB(0, 8, 0, 96),
- sliver: SliverList.separated(
- itemCount: widget.cards,
- separatorBuilder: (_, _) => const SizedBox(height: 8),
- itemBuilder: (context, index) {
- final (title, detail) = _widths[index % _widths.length];
- return SkeletonGroupCard(
- breathe: _breathe,
- cycles: _cycles,
- // 0.15s apart at 1.8s a cycle.
- phase: still ? 0 : (index % _widths.length) * (0.15 / 1.8),
- animate: !still,
- titleWidth: title,
- detailWidth: detail,
- );
- },
- ),
- );
- }
-}
-
-/// One card of the skeleton.
-class SkeletonGroupCard extends StatelessWidget {
- const SkeletonGroupCard({
- super.key,
- required this.breathe,
- required this.cycles,
- required this.phase,
- required this.animate,
- required this.titleWidth,
- required this.detailWidth,
- });
-
- final Animation breathe;
- final int cycles;
- final double phase;
- final bool animate;
- final double titleWidth;
- final double detailWidth;
-
- @override
- Widget build(BuildContext context) {
- final theme = Theme.of(context);
- final scheme = theme.colorScheme;
-
- return ExcludeSemantics(
- child: Card.outlined(
- clipBehavior: Clip.antiAlias,
- child: ListTile(
- // Not a placeholder: there is no version of this row without an
- // avatar, so it is drawn in its final colour.
- leading: CircleAvatar(backgroundColor: scheme.secondaryContainer),
- title: _Line(
- style: theme.textTheme.titleMedium,
- barHeight: 16,
- widthFactor: titleWidth,
- breathe: breathe,
- cycles: cycles,
- phase: phase,
- animate: animate,
- ),
- subtitle: _Line(
- style: theme.textTheme.bodyMedium,
- barHeight: 12,
- widthFactor: detailWidth,
- breathe: breathe,
- cycles: cycles,
- phase: phase,
- animate: animate,
- ),
- ),
- ),
- );
- }
-}
-
-/// A bar standing in for a line of text.
-class _Line extends StatelessWidget {
- const _Line({
- required this.style,
- required this.barHeight,
- required this.widthFactor,
- required this.breathe,
- required this.cycles,
- required this.phase,
- required this.animate,
- });
-
- final TextStyle? style;
- final double barHeight;
- final double widthFactor;
- final Animation breathe;
- final int cycles;
- final double phase;
- final bool animate;
-
- @override
- Widget build(BuildContext context) {
- final scheme = Theme.of(context).colorScheme;
-
- return Stack(
- children: [
- // Reserves the line box. Never painted, never read aloud.
- Opacity(opacity: 0, child: Text('', style: style)),
- Positioned.fill(
- child: Align(
- alignment: Alignment.centerLeft,
- child: AnimatedBuilder(
- animation: breathe,
- builder: (context, child) {
- final done = breathe.isCompleted;
- final t = (breathe.value * cycles + phase) % 1;
- // 0%,100% -> 1; 50% -> 0.45, eased both ways.
- final curved = Curves.easeInOut.transform(
- t < 0.5 ? t * 2 : (1 - t) * 2,
- );
- // Rests at full opacity rather than wherever the last frame
- // happened to land, which for the offset cards is not 1.
- final opacity = (animate && !done) ? 1 - (0.55 * curved) : 1.0;
- return Opacity(opacity: opacity, child: child);
- },
- child: FractionallySizedBox(
- widthFactor: widthFactor,
- child: Container(
- height: barHeight,
- decoration: BoxDecoration(
- color: scheme.surfaceContainerHighest,
- borderRadius: BorderRadius.circular(4),
- ),
- ),
- ),
- ),
- ),
- ),
- ],
- );
- }
-}
-
-/// Previews the skeleton beside what it stands in for.
-@Preview(name: 'Group list skeleton', group: 'Empty', size: Size(360, 320))
-Widget groupListSkeletonPreview() => const MaterialApp(
- home: Scaffold(body: CustomScrollView(slivers: [GroupListSkeleton()])),
-);
diff --git a/lib/presentation/widgets/offline_indicator.dart b/lib/presentation/widgets/offline_indicator.dart
new file mode 100644
index 0000000..e45fdf4
--- /dev/null
+++ b/lib/presentation/widgets/offline_indicator.dart
@@ -0,0 +1,56 @@
+import 'package:flutter/widget_previews.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:material_ui/material_ui.dart';
+
+import '../../application/sync_providers.dart';
+
+/// A cloud-off icon for the app bar while no server can be reached.
+///
+/// Tapping or hovering it says what that means. It goes away by itself once a
+/// sync gets through, which the coordinator tries as soon as the network
+/// returns.
+class OfflineIndicator extends ConsumerWidget {
+ const OfflineIndicator({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final status = ref.watch(syncControllerProvider);
+ if (!status.enabled || !status.isOffline) return const SizedBox.shrink();
+ return _OfflineIcon(changesWaiting: status.lastReport?.nextPushAt != null);
+ }
+}
+
+class _OfflineIcon extends StatelessWidget {
+ const _OfflineIcon({required this.changesWaiting});
+
+ final bool changesWaiting;
+
+ @override
+ Widget build(BuildContext context) => Tooltip(
+ message: changesWaiting
+ ? 'Offline. Your changes are saved on this device and will sync when '
+ 'you are back online.'
+ : 'Offline. Showing what is saved on this device.',
+ // A tap, not a long press, because there is nothing else a tap could do.
+ triggerMode: TooltipTriggerMode.tap,
+ child: Padding(
+ // The 48dp an IconButton would occupy, so the actions stay aligned.
+ padding: const EdgeInsets.all(12),
+ child: Icon(
+ Icons.cloud_off_outlined,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ );
+}
+
+/// Previews the indicator without a backend.
+@Preview(name: 'Offline', group: 'Sync', size: Size(360, 80))
+Widget offlineIndicatorPreview() => MaterialApp(
+ home: Scaffold(
+ appBar: AppBar(
+ title: const Text('Groups'),
+ actions: const [_OfflineIcon(changesWaiting: true)],
+ ),
+ ),
+);
diff --git a/lib/presentation/widgets/page_body.dart b/lib/presentation/widgets/page_body.dart
index 438294d..7738b33 100644
--- a/lib/presentation/widgets/page_body.dart
+++ b/lib/presentation/widgets/page_body.dart
@@ -34,6 +34,7 @@ class DestinationScaffold extends StatelessWidget {
this.titleWidget,
required this.slivers,
this.actions,
+ this.bottom,
this.floatingActionButton,
this.wrap,
this.maxWidth = 760,
@@ -49,6 +50,11 @@ class DestinationScaffold extends StatelessWidget {
final Widget? titleWidget;
final List? actions;
+
+ /// A strip along the app bar's lower edge, such as a progress bar, that
+ /// stays put while the page scrolls under it.
+ final PreferredSizeWidget? bottom;
+
final Widget? floatingActionButton;
/// Wraps the scroll view, for the destination that pulls to sync.
@@ -78,6 +84,7 @@ class DestinationScaffold extends StatelessWidget {
SliverAppBar.large(
title: titleWidget ?? Text(title!),
actions: actions,
+ bottom: bottom,
// Collapses into the small bar and stays there, which is what
// Material 3 specifies for a large top app bar: the headline is
// worth the height when you arrive and worth none of it while you
diff --git a/lib/presentation/widgets/sync_status_notice.dart b/lib/presentation/widgets/sync_status_notice.dart
index 6f7449b..6ab0970 100644
--- a/lib/presentation/widgets/sync_status_notice.dart
+++ b/lib/presentation/widgets/sync_status_notice.dart
@@ -3,20 +3,22 @@ import 'package:flutter/widget_previews.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../application/sync_providers.dart';
+import '../launch_hold.dart';
-/// Indicates that the local database has not produced its first result yet.
+/// Stands in, invisibly, for saved content the local database has not
+/// returned yet.
+///
+/// At launch the splash covers it (see [LaunchHold]); after launch a local
+/// read finishes before a spinner would be worth drawing.
class SavedDataLoading extends StatelessWidget {
const SavedDataLoading({super.key, required this.label});
- /// The saved content being opened, distinct from a network refresh.
+ /// The saved content being opened, for screen readers.
final String label;
@override
- Widget build(BuildContext context) => Center(
- child: Padding(
- padding: const EdgeInsets.all(32),
- child: CircularProgressIndicator(semanticsLabel: label),
- ),
+ Widget build(BuildContext context) => LaunchPlaceholder(
+ child: Semantics(label: label, child: const SizedBox.expand()),
);
}
@@ -48,7 +50,39 @@ class InitialSyncGate extends ConsumerWidget {
}
}
+/// A thin bar under the app bar while this session's first full refresh runs.
+///
+/// Saved data is shown as soon as the device has read it; this only says that
+/// more may still arrive from the server. Later refreshes stay quiet, so a
+/// write or a return to the foreground does not flash it. The bar's height is
+/// reserved even while idle, so content does not shift when it disappears.
+class InitialSyncProgress extends ConsumerWidget
+ implements PreferredSizeWidget {
+ const InitialSyncProgress({super.key, required this.label});
+
+ /// What is being checked, for screen readers.
+ final String label;
+
+ @override
+ Size get preferredSize => const Size.fromHeight(4);
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final status = ref.watch(syncControllerProvider);
+ final checking =
+ status.enabled && status.isSyncing && !status.hasCompletedFullSync;
+ return SizedBox.fromSize(
+ size: preferredSize,
+ child: checking ? LinearProgressIndicator(semanticsLabel: label) : null,
+ );
+ }
+}
+
/// Keeps saved data visible while explaining a failed refresh or upload.
+///
+/// Says nothing while offline: for an app that works offline that is a normal
+/// state, not a problem to act on, and [OfflineIndicator] in the app bar
+/// already says it.
class SyncStatusBanner extends ConsumerWidget {
const SyncStatusBanner({super.key, this.padding = EdgeInsets.zero});
@@ -58,10 +92,11 @@ class SyncStatusBanner extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final status = ref.watch(syncControllerProvider);
final refreshFailed = status.error != null;
- if (!status.enabled ||
- (!refreshFailed && status.lastReport?.nextPushAt == null)) {
+ final changesWaiting = status.lastReport?.nextPushAt != null;
+ if (!status.enabled || (!refreshFailed && !changesWaiting)) {
return const SizedBox.shrink();
}
+ if (status.isOffline) return const SizedBox.shrink();
return Padding(
padding: padding,
child: _SyncProblem(
@@ -136,9 +171,3 @@ Widget syncFailurePreview() => MaterialApp(
),
),
);
-
-/// Previews the local database loading state before any saved rows are ready.
-@Preview(name: 'Opening saved groups', group: 'Sync', size: Size(360, 240))
-Widget savedDataLoadingPreview() => const MaterialApp(
- home: Scaffold(body: SavedDataLoading(label: 'Loading saved groups')),
-);
diff --git a/pubspec.lock b/pubspec.lock
index 5dfa23b..5e11c68 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -49,6 +49,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.14.12"
+ animations:
+ dependency: "direct main"
+ description:
+ name: animations
+ sha256: "5ec868ffa78851e6f3f32e9377b780c7ae2087a5523d8b5e6df59bd8287ac5dd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.0"
ansicolor:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 086f092..39d668e 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -36,6 +36,9 @@ dependencies:
flutter_riverpod: ^3.4.3
freezed_annotation: ^3.1.0
go_router: ^18.0.2
+ # Material motion's fade through, for switching destinations: the Flutter
+ # team's implementation of the spec, released on material_ui.
+ animations: ^3.0.0
intl: ^0.20.3
qr_flutter: ^4.1.0
riverpod_annotation: ^4.0.7
diff --git a/test/application/sync_coordinator_test.dart b/test/application/sync_coordinator_test.dart
index 9586ff6..6ecf54a 100644
--- a/test/application/sync_coordinator_test.dart
+++ b/test/application/sync_coordinator_test.dart
@@ -218,6 +218,35 @@ void main() {
expect(coordinator.status.retryAt, isNull);
});
+ testWidgets('a failed attempt is retried as soon as the network returns', (
+ tester,
+ ) async {
+ final online = StreamController();
+ addTearDown(online.close);
+ var fullRuns = 0;
+ final coordinator = SyncCoordinator(
+ syncAll: () async => ++fullRuns == 1 ? _failed : _clean,
+ syncGroups: (_) async => _clean,
+ online: online.stream,
+ );
+ addTearDown(coordinator.dispose);
+
+ coordinator.start();
+ await tester.pump();
+ expect(coordinator.status.error, isNotNull);
+
+ // Well inside both the back-off and the gap between automatic syncs.
+ online.add(true);
+ await tester.pump();
+ expect(fullRuns, 2);
+ expect(coordinator.status.error, isNull);
+
+ // A healthy device coming back online still respects the gap.
+ online.add(true);
+ await tester.pump();
+ expect(fullRuns, 2);
+ });
+
testWidgets('permanent refusals do not schedule automatic retries', (
tester,
) async {
diff --git a/test/data/api_client_test.dart b/test/data/api_client_test.dart
index f57702b..7c6eb5d 100644
--- a/test/data/api_client_test.dart
+++ b/test/data/api_client_test.dart
@@ -31,6 +31,20 @@ void main() {
expect(failure.message, 'Something went wrong.');
});
+ test('a request no server answered reads as unreachable', () {
+ final request = RequestOptions(path: '/api/groups/g');
+ final offline = DioException(
+ requestOptions: request,
+ type: DioExceptionType.connectionError,
+ );
+
+ expect(ApiFailure.from(offline).unreachable, isTrue);
+ expect(isUnreachable(ApiFailure.from(offline)), isTrue);
+ expect(isUnreachable(offline), isTrue);
+ expect(isUnreachable(ApiFailure.from(_refusal(500, 'transient'))), false);
+ expect(isUnreachable(StateError('not a request')), isFalse);
+ });
+
test('a refusal keeps the retry the server gave it', () {
expect(ApiFailure.from(_refusal(409, 'stale')).retry, api.Retry.stale);
expect(
diff --git a/test/presentation/account_profile_test.dart b/test/presentation/account_profile_test.dart
index 45e4ef6..8fd05fb 100644
--- a/test/presentation/account_profile_test.dart
+++ b/test/presentation/account_profile_test.dart
@@ -201,6 +201,37 @@ void main() {
await unmount(tester);
});
+ testWidgets('opened from Settings, it is filled in on its first frame', (
+ tester,
+ ) async {
+ await store(tester, name: 'Ana Lima', upi: 'ana@upi');
+ SharedPreferences.setMockInitialValues({});
+ final prefs = await SharedPreferences.getInstance();
+ await tester.pumpWidget(
+ signedInApp(
+ db: db,
+ prefs: prefs,
+ overrides: [authServiceProvider.overrideWithValue(_Auth(guest: false))],
+ child: const OpenSplitApp(),
+ ),
+ );
+ await settle(tester);
+ // Straight to Settings: Account has never been on screen.
+ ProviderScope.containerOf(
+ tester.element(find.byType(OpenSplitApp)),
+ ).read(routerProvider).go('/settings');
+ await settle(tester);
+
+ await tester.tap(find.text('Account saved'));
+ await tester.pump();
+
+ // Not "Add the name your groups see" corrected a moment later: the
+ // profile was read at launch, before anyone asked for the page.
+ expect(find.text('ana@upi'), findsOneWidget);
+ expect(find.text('Add the name your groups see'), findsNothing);
+ await unmount(tester);
+ });
+
testWidgets('a guest is offered one way to save the account', (tester) async {
await store(tester, name: 'Ana');
await openAccount(tester, guest: true);
diff --git a/test/presentation/launch_hold_test.dart b/test/presentation/launch_hold_test.dart
new file mode 100644
index 0000000..ce60370
--- /dev/null
+++ b/test/presentation/launch_hold_test.dart
@@ -0,0 +1,77 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:material_ui/material_ui.dart';
+import 'package:opensplit/presentation/launch_hold.dart';
+
+void main() {
+ tearDown(LaunchHold.reset);
+
+ testWidgets('holds the first frame until saved data replaces placeholders', (
+ tester,
+ ) async {
+ final loaded = ValueNotifier(false);
+ addTearDown(loaded.dispose);
+
+ LaunchHold.begin(limit: const Duration(seconds: 5));
+ await tester.pumpWidget(
+ ValueListenableBuilder(
+ valueListenable: loaded,
+ builder: (context, isLoaded, _) => isLoaded
+ ? const Text('Saved home group', textDirection: TextDirection.ltr)
+ : const LaunchPlaceholder(child: SizedBox()),
+ ),
+ );
+ await tester.pump();
+ expect(LaunchHold.isHolding, isTrue);
+
+ loaded.value = true;
+ await tester.pump();
+ await tester.pump();
+ expect(LaunchHold.isHolding, isFalse);
+ });
+
+ testWidgets('says when the released frame is drawn, and only then', (
+ tester,
+ ) async {
+ final loaded = ValueNotifier(false);
+ addTearDown(loaded.dispose);
+ var shown = 0;
+
+ LaunchHold.begin(limit: const Duration(seconds: 5), onShown: () => shown++);
+ await tester.pumpWidget(
+ ValueListenableBuilder(
+ valueListenable: loaded,
+ builder: (context, isLoaded, _) => isLoaded
+ ? const Text('Saved home group', textDirection: TextDirection.ltr)
+ : const LaunchPlaceholder(child: SizedBox()),
+ ),
+ );
+ await tester.pump();
+ expect(shown, 0);
+
+ loaded.value = true;
+ await tester.pump();
+ await tester.pump();
+ await tester.pump();
+ expect(shown, 1);
+ });
+
+ testWidgets('releases a screen with nothing to wait for', (tester) async {
+ LaunchHold.begin(limit: const Duration(seconds: 5));
+ await tester.pumpWidget(
+ const Text('Welcome', textDirection: TextDirection.ltr),
+ );
+ await tester.pump();
+ expect(LaunchHold.isHolding, isFalse);
+ });
+
+ testWidgets('gives up at the limit rather than holding the splash', (
+ tester,
+ ) async {
+ LaunchHold.begin(limit: const Duration(milliseconds: 800));
+ await tester.pumpWidget(const LaunchPlaceholder(child: SizedBox()));
+ await tester.pump(const Duration(milliseconds: 799));
+ expect(LaunchHold.isHolding, isTrue);
+ await tester.pump(const Duration(milliseconds: 1));
+ expect(LaunchHold.isHolding, isFalse);
+ });
+}
diff --git a/test/presentation/navigation_test.dart b/test/presentation/navigation_test.dart
index 72c1f66..77e3b04 100644
--- a/test/presentation/navigation_test.dart
+++ b/test/presentation/navigation_test.dart
@@ -1,12 +1,17 @@
import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart';
import 'package:material_ui/material_ui.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
// `group` here is the Riverpod provider function, which collides with the
// test framework's group().
import 'package:opensplit/data/local/database.dart';
import 'package:opensplit/presentation/app.dart';
+import 'package:opensplit/presentation/screens/entry_editor_screen.dart';
+import 'package:opensplit/presentation/screens/group_detail_screen.dart';
+import 'package:opensplit/presentation/screens/group_list_screen.dart';
+import 'package:opensplit/presentation/screens/settings_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../harness.dart';
@@ -39,6 +44,39 @@ Future _chooseDestination(WidgetTester tester, String label) async {
GoRouter _router(WidgetTester tester) =>
GoRouter.of(tester.element(find.byType(Scaffold).first));
+/// Swipes back from the screen's edge, as Android's predictive back does.
+///
+/// Unlike a back button press, a gesture is claimed by the page that is top of
+/// its own navigator, which is what lets a page under a dialog take it.
+Future _swipeBack(WidgetTester tester) async {
+ Future send(String method, [Object? arguments]) =>
+ tester.binding.defaultBinaryMessenger.handlePlatformMessage(
+ 'flutter/backgesture',
+ const StandardMethodCodec().encodeMethodCall(
+ MethodCall(method, arguments),
+ ),
+ (_) {},
+ );
+ await send('startBackGesture', {
+ 'touchOffset': [5.0, 300.0],
+ 'progress': 0.0,
+ 'swipeEdge': 0,
+ });
+ await tester.pump();
+ await send('commitBackGesture');
+ await tester.pumpAndSettle();
+}
+
+/// How visible [screen] is, through every fade above it.
+double _opacityOf(WidgetTester tester, Type screen) => tester
+ .widgetList(
+ find.ancestor(
+ of: find.byType(screen, skipOffstage: false),
+ matching: find.byType(FadeTransition),
+ ),
+ )
+ .fold(1, (opacity, fade) => opacity * fade.opacity.value);
+
/// A group with nothing in it, so there is something to drill into.
Future _seedGroup(AppDatabase db, {DateTime? archivedAt}) async {
// Currencies are reference data the database ships with, so there is
@@ -128,6 +166,47 @@ void main() {
});
});
+ group('switching destination', () {
+ testWidgets('fades through, the way Material moves between them', (
+ tester,
+ ) async {
+ await _pumpApp(tester, db);
+ await tester.tap(find.byTooltip('Open navigation menu'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Settings'));
+ await tester.pump();
+
+ // Partway: the list has faded out, and Settings is still fading in.
+ await tester.pump(const Duration(milliseconds: 150));
+ expect(_opacityOf(tester, GroupListScreen), 0);
+ expect(_opacityOf(tester, SettingsScreen), inExclusiveRange(0, 1));
+
+ await tester.pumpAndSettle();
+ expect(_opacityOf(tester, SettingsScreen), 1);
+ expect(find.byType(GroupListScreen), findsNothing);
+ await _unmount(tester);
+ });
+
+ testWidgets('cuts straight there when animations are turned off', (
+ tester,
+ ) async {
+ tester.platformDispatcher.accessibilityFeaturesTestValue =
+ const FakeAccessibilityFeatures(disableAnimations: true);
+ addTearDown(
+ tester.platformDispatcher.clearAccessibilityFeaturesTestValue,
+ );
+ await _pumpApp(tester, db);
+ await tester.tap(find.byTooltip('Open navigation menu'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Settings'));
+ await tester.pump();
+
+ expect(find.byType(GroupListScreen), findsNothing);
+ expect(_opacityOf(tester, SettingsScreen), 1);
+ await _unmount(tester);
+ });
+ });
+
// The other half of the same rule.
group('drilling down leaves something to come back to', () {
testWidgets('a group covers the navigation menu and pops back', (
@@ -160,6 +239,38 @@ void main() {
});
});
+ group('back closes what is on top', () {
+ testWidgets(
+ 'a swipe back closes the date picker, not the editor under it',
+ (tester) async {
+ await _seedGroup(db);
+ await _pumpApp(tester, db);
+ _router(tester).go('/g/g1/add');
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byTooltip('Change the day'));
+ await tester.pumpAndSettle();
+ expect(find.byType(DatePickerDialog), findsOneWidget);
+
+ // The editor is top of its stack whichever navigator it is on, so the
+ // only thing that keeps it from taking this gesture is the picker
+ // being on the same stack, above it.
+ await _swipeBack(tester);
+ expect(find.byType(DatePickerDialog), findsNothing);
+ expect(find.byType(EntryEditorScreen), findsOneWidget);
+
+ // The group is drawn back in under the gesture, and nothing on the
+ // way trips an assertion.
+ await _swipeBack(tester);
+ expect(find.byType(EntryEditorScreen), findsNothing);
+ expect(find.byType(GroupDetailScreen), findsOneWidget);
+
+ await _unmount(tester);
+ },
+ variant: TargetPlatformVariant.only(TargetPlatform.android),
+ );
+ });
+
group('archived groups', () {
testWidgets('are out of the list but not out of reach', (tester) async {
await _seedGroup(db, archivedAt: DateTime.utc(2026, 3));
diff --git a/test/presentation/profile_loading_test.dart b/test/presentation/profile_loading_test.dart
new file mode 100644
index 0000000..b165e2d
--- /dev/null
+++ b/test/presentation/profile_loading_test.dart
@@ -0,0 +1,98 @@
+import 'dart:async';
+
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:material_ui/material_ui.dart';
+import 'package:opensplit/application/ledger_providers.dart';
+import 'package:opensplit/application/local_providers.dart';
+import 'package:opensplit/data/local/database.dart';
+import 'package:opensplit/application/session_providers.dart';
+import 'package:opensplit/presentation/screens/account_screen.dart';
+import 'package:opensplit/presentation/screens/avatar_picker_screen.dart';
+import 'package:opensplit/presentation/screens/edit_profile_screen.dart';
+import 'package:opensplit/presentation/widgets/sync_status_notice.dart';
+import 'package:opensplit_api/opensplit_api.dart' show AvatarKind;
+
+const _account = 'account-1';
+
+const _saved = Profile(
+ avatarKind: AvatarKind.initials,
+ id: _account,
+ displayName: 'Ana Lima',
+);
+
+/// Reading the saved profile draws nothing; waiting on a server is shown.
+void main() {
+ Future> mount(
+ WidgetTester tester,
+ Widget screen,
+ ) async {
+ final profile = StreamController();
+ addTearDown(profile.close);
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ currentAccountIdProvider.overrideWithValue(_account),
+ myProfileProvider.overrideWith((ref) => profile.stream),
+ accountProvider.overrideWith((ref) => Stream.value(null)),
+ ],
+ child: MaterialApp(home: screen),
+ ),
+ );
+ return profile;
+ }
+
+ testWidgets('the profile editor waits on the device without a spinner', (
+ tester,
+ ) async {
+ final profile = await mount(tester, const EditProfileScreen());
+ expect(find.byType(SavedDataLoading), findsOneWidget);
+ expect(find.byType(CircularProgressIndicator), findsNothing);
+
+ // Read, and not on this device yet: now it is waiting on the server.
+ profile.add(null);
+ await tester.pump();
+ expect(find.byType(SavedDataLoading), findsNothing);
+ expect(find.bySemanticsLabel('Waiting for your profile'), findsOneWidget);
+
+ profile.add(_saved);
+ await tester.pump();
+ expect(find.widgetWithText(TextFormField, 'Ana Lima'), findsOneWidget);
+ });
+
+ testWidgets('the picture picker does not start before the saved picture', (
+ tester,
+ ) async {
+ final profile = await mount(tester, const AvatarPickerScreen.profile());
+ // Before the fix this drew initials at once and kept them as the
+ // "unchanged" picture, whatever the saved one turned out to be.
+ expect(find.byType(SavedDataLoading), findsOneWidget);
+ expect(find.byType(CircularProgressIndicator), findsNothing);
+
+ profile.add(_saved);
+ await tester.pump();
+ expect(find.byType(SavedDataLoading), findsNothing);
+ expect(
+ tester
+ .widget(find.widgetWithText(FilledButton, 'Save'))
+ .onPressed,
+ isNull,
+ reason: 'nothing has been changed yet',
+ );
+ });
+
+ testWidgets('the account page does not show a saved name as unset', (
+ tester,
+ ) async {
+ final profile = await mount(tester, const AccountScreen());
+ await tester.pump();
+ expect(find.byType(SavedDataLoading), findsOneWidget);
+ expect(find.text('Add the name your groups see'), findsNothing);
+
+ profile.add(_saved);
+ await tester.pump();
+ await tester.pump();
+ expect(find.byType(SavedDataLoading), findsNothing);
+ expect(find.text('Ana Lima'), findsWidgets);
+ });
+}
diff --git a/test/presentation/session_sync_test.dart b/test/presentation/session_sync_test.dart
index 54007f2..00aeb06 100644
--- a/test/presentation/session_sync_test.dart
+++ b/test/presentation/session_sync_test.dart
@@ -198,7 +198,7 @@ void main() {
await tester.pump(const Duration(milliseconds: 10));
}
expect(container.read(syncControllerProvider).isSyncing, isTrue);
- expect(find.text('No groups yet'), findsNothing);
+ expect(find.bySemanticsLabel('Checking for your groups'), findsOneWidget);
expect(tester.takeException(), isNull);
auth.currentUser = _otherAccount;
diff --git a/test/presentation/sync_refresh_test.dart b/test/presentation/sync_refresh_test.dart
index a2efb05..9133193 100644
--- a/test/presentation/sync_refresh_test.dart
+++ b/test/presentation/sync_refresh_test.dart
@@ -11,10 +11,13 @@ import 'package:opensplit/application/sync_coordinator.dart';
import 'package:opensplit/application/sync_providers.dart';
import 'package:opensplit/data/local/database.dart';
import 'package:opensplit/data/repositories/drift_group_repository.dart';
+import 'package:dio/dio.dart'
+ show DioException, DioExceptionType, RequestOptions;
import 'package:opensplit/data/sync/sync_engine.dart';
import 'package:opensplit/presentation/screens/group_detail_screen.dart';
import 'package:opensplit/presentation/screens/group_list_screen.dart';
-import 'package:opensplit/presentation/widgets/group_skeleton.dart';
+import 'package:opensplit/presentation/launch_hold.dart';
+import 'package:opensplit/presentation/widgets/offline_indicator.dart';
import 'package:opensplit/presentation/widgets/pull_to_sync.dart';
import 'package:opensplit/presentation/widgets/sync_refresh_button.dart';
import 'package:opensplit/presentation/widgets/sync_status_notice.dart';
@@ -173,7 +176,7 @@ void main() {
await tester.pumpAndSettle();
});
- testWidgets('opening saved groups has a loading state, not a blank body', (
+ testWidgets('opening saved groups holds the launch, not a placeholder', (
tester,
) async {
final groups = StreamController>();
@@ -184,16 +187,101 @@ void main() {
const GroupListScreen(),
groups: groups.stream,
);
- // The skeleton, not a spinner and not a blank body: the point of the
- // assertion is that something group-shaped is on screen before the database
- // has answered, which is what the web loader has already been drawing for
- // the whole of the engine download.
- expect(find.byType(GroupListSkeleton), findsOneWidget);
+ // Nothing group-shaped is drawn while the database has not answered: the
+ // splash covers that wait, and this marker is what keeps it up.
+ expect(find.byType(LaunchPlaceholder, skipOffstage: false), findsOneWidget);
expect(find.text('No groups yet'), findsNothing);
groups.add([_group]);
await tester.pumpAndSettle();
expect(find.text(_group.name), findsOneWidget);
- expect(find.byType(GroupListSkeleton), findsNothing);
+ expect(find.byType(LaunchPlaceholder, skipOffstage: false), findsNothing);
+ });
+
+ testWidgets('an empty list shows at once while the first refresh runs', (
+ tester,
+ ) async {
+ final sync = _TestSync(initial: const SyncStatus(isSyncing: true));
+ await _mount(
+ tester,
+ sync,
+ const GroupListScreen(),
+ groups: Stream.value(const []),
+ );
+ await tester.pump();
+ expect(find.text('No groups yet'), findsOneWidget);
+ expect(find.byType(LinearProgressIndicator), findsOneWidget);
+
+ sync.finish();
+ await tester.pumpAndSettle();
+ expect(find.text('No groups yet'), findsOneWidget);
+ expect(find.byType(LinearProgressIndicator), findsNothing);
+ });
+
+ testWidgets('an empty list keeps its empty state through a failed refresh', (
+ tester,
+ ) async {
+ final sync = _TestSync(initial: const SyncStatus(isSyncing: true));
+ await _mount(
+ tester,
+ sync,
+ const GroupListScreen(),
+ groups: Stream.value(const []),
+ );
+ await tester.pump();
+ sync.show(
+ SyncStatus(
+ lastReport: SyncReport(
+ pushed: 0,
+ pulled: 0,
+ failed: 0,
+ error: StateError('connection unavailable'),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+ expect(find.text('No groups yet'), findsOneWidget);
+ expect(find.text('Could not refresh'), findsOneWidget);
+ expect(find.byType(LinearProgressIndicator), findsNothing);
+ });
+
+ testWidgets('offline is an app bar icon, not a problem card', (tester) async {
+ final sync = _TestSync();
+ await _mount(tester, sync, const GroupListScreen());
+ await tester.pumpAndSettle();
+
+ sync.show(
+ SyncStatus(
+ hasCompletedFullSync: true,
+ lastReport: SyncReport(
+ pushed: 0,
+ pulled: 0,
+ failed: 0,
+ error: DioException(
+ requestOptions: RequestOptions(path: '/api/sync'),
+ type: DioExceptionType.connectionError,
+ ),
+ nextPushAt: DateTime.utc(2026, 10, 10),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+ expect(find.text(_group.name), findsOneWidget);
+ expect(find.byType(OfflineIndicator), findsOneWidget);
+ expect(find.byIcon(Icons.cloud_off_outlined), findsOneWidget);
+ expect(
+ find.byTooltip(
+ 'Offline. Your changes are saved on this device and will sync when '
+ 'you are back online.',
+ ),
+ findsOneWidget,
+ );
+ expect(find.text('Could not refresh'), findsNothing);
+ expect(find.text('Changes waiting to sync'), findsNothing);
+ expect(find.text('Try again'), findsNothing);
+
+ sync.finish();
+ await tester.pumpAndSettle();
+ expect(find.byIcon(Icons.cloud_off_outlined), findsNothing);
});
testWidgets('the group list keeps its saved rows through refresh and error', (
diff --git a/test/presentation/theme_test.dart b/test/presentation/theme_test.dart
index 37a9aa4..57c1851 100644
--- a/test/presentation/theme_test.dart
+++ b/test/presentation/theme_test.dart
@@ -4,8 +4,8 @@ import 'dart:math' as math;
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:image/image.dart' as img;
import 'package:opensplit/presentation/theme.dart';
-import 'package:opensplit/presentation/widgets/group_skeleton.dart';
/// Relative luminance, per WCAG 2.1.
double _luminance(Color c) {
@@ -78,148 +78,36 @@ void main() {
});
});
- group('the web loading skeleton', () {
- test('uses the theme\'s own colours', _skeletonMatchesTheme);
-
- testWidgets('uses the theme\'s own type', (tester) async {
- late TextTheme resolved;
- await tester.pumpWidget(
- MaterialApp(
- theme: buildTheme(Brightness.light),
- // Text geometry is applied by Theme.of, not by the ThemeData
- // constructor, so the real sizes only exist inside a MaterialApp.
- home: Builder(
- builder: (context) {
- resolved = Theme.of(context).textTheme;
- return const SizedBox.shrink();
- },
- ),
- ),
- );
-
- final css = File('web/index.html').readAsStringSync();
-
- // The app bar headline.
- final headline = resolved.headlineMedium!;
- expect(css, contains('font-size: ${headline.fontSize!.round()}px'));
- expect(css, contains('font-weight: ${headline.fontWeight!.value}'));
-
- // The extended FAB's label.
- final label = resolved.labelLarge!;
- expect(css, contains('font-size: ${label.fontSize!.round()}px'));
- expect(css, contains('font-weight: ${label.fontWeight!.value}'));
- });
-
- testWidgets('draws a card the size of the card that replaces it', (
- tester,
- ) async {
- // Measured, not compared.
- await tester.pumpWidget(
- MaterialApp(
- theme: buildTheme(Brightness.light),
- home: const Scaffold(
- body: CustomScrollView(slivers: [GroupListSkeleton()]),
- ),
- ),
- );
- await tester.pump(const Duration(milliseconds: 50));
-
- final card = tester.getSize(find.byType(SkeletonGroupCard).first);
- final css = File('web/index.html').readAsStringSync();
-
- expect(
- css,
- contains('height: ${card.height.round()}px'),
- reason:
- 'web/index.html must draw a ${card.height.round()}dp card, which '
- 'is what a two-line ListTile inside Card.outlined comes to',
- );
- });
-
- testWidgets('gives its app bar the height the app gives one', (
- tester,
- ) async {
- // The other number with no published source.
- await tester.pumpWidget(
- MaterialApp(
- theme: buildTheme(Brightness.light),
- home: const Scaffold(
- body: CustomScrollView(
- slivers: [
- SliverAppBar.large(title: Text('Groups'), pinned: true),
- SliverToBoxAdapter(
- child: SizedBox(key: Key('below'), height: 2000),
- ),
- ],
- ),
- ),
- ),
- );
- await tester.pump();
-
- // Read off where the content starts rather than off the bar's own box:
- // SliverAppBar.large has no single widget whose size is the expanded
- // height, and the number that matters is exactly how far down the page
- // the first card begins.
- final barHeight = tester
- .getTopLeft(find.byKey(const Key('below')))
- .dy
- .round();
- final css = File('web/index.html').readAsStringSync();
-
- expect(
- css,
- contains('height: ${barHeight}px'),
- reason: 'the skeleton bar must be as tall as an expanded large one',
- );
- });
-
- test('agrees with the app on every boot-hint key', () {
- // The loader decides which shape to draw, and whether to fill it, by
- // reading keys the Dart side writes.
- final keys = RegExp(r"""localStorage\.getItem\((['"])(.+?)\1\)""")
- .allMatches(File('web/index.html').readAsStringSync())
- .map((match) => match.group(2)!)
- .toSet();
-
- expect(
- keys,
- hasLength(2),
- reason: 'the loader reads a session hint and a groups hint',
- );
-
- final dart = File('lib/data/web/boot_hint_web.dart').readAsStringSync();
- for (final key in keys) {
- expect(
- dart,
- contains("'$key'"),
- reason: 'boot_hint_web.dart must write $key, which index.html reads',
- );
+ group('the web splash', () {
+ test('uses the theme\'s own colours', _splashMatchesTheme);
+
+ test('draws the mark the size Android 12+ draws it', () {
+ // Android shows assets/splash/splash-icon.png as a 288dp icon; the mark
+ // is whatever share of that image is not transparent.
+ final png = img.decodePng(
+ File('assets/splash/splash-icon.png').readAsBytesSync(),
+ )!;
+ var left = png.width;
+ var right = 0;
+ for (final pixel in png) {
+ if (pixel.a > 0) {
+ left = math.min(left, pixel.x);
+ right = math.max(right, pixel.x);
+ }
}
- });
-
- test('puts its navigation rail where the app puts one', () {
- // Two numbers in two languages describing the same edge.
- final dart = RegExp(
- r'_railBreakpoint\s*=\s*(\d+)',
- ).firstMatch(File('lib/presentation/router.dart').readAsStringSync());
- final width = RegExp(
- r'_railWidth\s*=\s*(\d+)',
- ).firstMatch(File('lib/presentation/router.dart').readAsStringSync());
-
- expect(dart, isNotNull, reason: 'no _railBreakpoint in router.dart');
- expect(width, isNotNull, reason: 'no _railWidth in router.dart');
+ final androidRing = (right - left + 1) / png.width * 288;
+ // The web draws the same mark as SVG: a ring 40.5 units across in a
+ // 48-unit viewBox, in a box the stylesheet sizes.
final css = File('web/index.html').readAsStringSync();
+ final box = RegExp(r'#splash \.mark \{\s*width: (\d+)px').firstMatch(css);
+ expect(box, isNotNull, reason: 'no #splash .mark width in index.html');
+ final webRing = int.parse(box!.group(1)!) * 40.5 / 48;
+
expect(
- css,
- contains('@media (min-width: ${dart!.group(1)}px)'),
- reason: 'web/index.html must show its rail at _railBreakpoint',
- );
- expect(
- css,
- contains('width: ${width!.group(1)}px'),
- reason: 'the skeleton rail must be _railWidth wide',
+ webRing,
+ closeTo(androidRing, 1),
+ reason: 'the web splash mark must match Android\'s (${androidRing}dp)',
);
});
@@ -249,7 +137,7 @@ void main() {
});
});
- // The same problem the web skeleton has, on the other platform: Android
+ // The same problem the web splash has, on the other platform: Android
// paints the window before a line of Dart runs — and on 12 and up paints a
// system splash screen over it — from colours it can only read out of
// resources and generator configs.
@@ -363,7 +251,7 @@ void main() {
});
}
-/// The colours the loading skeleton in `web/index.html` claims to be using.
+/// The colours the splash in `web/index.html` claims to be using.
Map _declaredColors(String css, int from) {
final open = css.indexOf(':root {', from);
expect(open, isNot(-1), reason: 'no :root block after offset $from');
@@ -383,7 +271,7 @@ String _hex(Color c) {
return '#${channel(c.r)}${channel(c.g)}${channel(c.b)}';
}
-void _skeletonMatchesTheme() {
+void _splashMatchesTheme() {
final css = File('web/index.html').readAsStringSync();
final darkBlock = css.indexOf('@media (prefers-color-scheme: dark)');
@@ -400,22 +288,14 @@ void _skeletonMatchesTheme() {
final expected = {
'surface': scheme.surface,
- 'placeholder': scheme.surfaceContainerHighest,
- 'outline-variant': scheme.outlineVariant,
- 'on-surface': scheme.onSurface,
- 'secondary-container': scheme.secondaryContainer,
- 'primary-container': scheme.primaryContainer,
- 'on-primary-container': scheme.onPrimaryContainer,
'primary': scheme.primary,
- 'on-surface-variant': scheme.onSurfaceVariant,
- 'surface-container': scheme.surfaceContainer,
};
expect(
declared.keys.toSet(),
expected.keys.toSet(),
reason:
- 'the ${entry.key} skeleton palette and this test disagree about '
+ 'the ${entry.key} splash palette and this test disagree about '
'which roles it uses',
);
diff --git a/web/index.html b/web/index.html
index 746ee56..300703c 100644
--- a/web/index.html
+++ b/web/index.html
@@ -58,50 +58,22 @@
-