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 @@ -