diff --git a/stronglift_replacement/workout_app/lib/screens/home_screen.dart b/stronglift_replacement/workout_app/lib/screens/home_screen.dart index 2099050..07ea1f3 100644 --- a/stronglift_replacement/workout_app/lib/screens/home_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/home_screen.dart @@ -2,6 +2,7 @@ library; import 'dart:async'; +import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/sandbox/sandbox_log.dart'; @@ -9,6 +10,8 @@ import 'package:workout_app/screens/history_screen.dart'; import 'package:workout_app/screens/manual_workout_screen.dart'; import 'package:workout_app/screens/settings_screen.dart'; import 'package:workout_app/screens/workout_screen.dart'; +import 'package:workout_app/services/done_today.dart'; +import 'package:workout_app/services/lock_mode.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/sync_status.dart'; import 'package:workout_app/services/workout_sync_service.dart'; @@ -16,7 +19,9 @@ import 'package:workout_app/ui/theme.dart'; import 'package:workout_app/widgets/sync_status_card.dart'; part 'home_screen_cards.dart'; +part 'home_screen_launch.dart'; part 'home_screen_navigation.dart'; +part 'home_screen_sync.dart'; /// Home screen: auto-resumes active sessions and shows done-today status. class HomeScreen extends StatefulWidget { @@ -30,6 +35,7 @@ class HomeScreen extends StatefulWidget { this.syncService, this.clock, this.configuredProbe, + this.openWorkoutOnLaunch = true, }); /// Sync service to use; defaults to a real [WorkoutSyncService]. @@ -42,6 +48,10 @@ class HomeScreen extends StatefulWidget { /// service itself. final Future Function()? configuredProbe; + /// Whether the first load may skip home and open the workout. The app + /// always does; tests of the home screen itself switch it off. + final bool openWorkoutOnLaunch; + @override State createState() => _HomeScreenState(); } @@ -58,9 +68,13 @@ class _HomeScreenState extends State { SyncStatus? _syncStatus; bool _syncing = false; - /// True after the first load auto-navigated to an in-progress workout, - /// so returning from workout does not auto-navigate again. - bool _hasAutoResumed = false; + /// True once the first load has decided whether to open the workout, so + /// returning from it (or from anywhere) lands on home. + bool _hasAutoOpened = false; + + /// A synced record (manual workout, PC run) covers today. Sticky for the + /// process: only the launch reads the backends for it. + bool _syncedToday = false; @override void initState() { @@ -75,19 +89,31 @@ class _HomeScreenState extends State { final saved = await storage.loadActiveSession(); final lastDate = await storage.getLastWorkoutDate(); final today = DateTime.now(); - final doneToday = + final doneLocally = lastDate != null && lastDate.year == today.year && lastDate.month == today.month && lastDate.day == today.day; + final firstLoad = !_hasAutoOpened; + _hasAutoOpened = true; + final launch = await _decideLaunch( + firstLoad: firstLoad, + hasSaved: saved != null, + doneLocally: doneLocally, + today: today, + ); + _syncedToday = _syncedToday || launch.syncedToday; if (mounted) { setState(() { _nextType = nextType; _exercises = exercises; _savedSession = saved; - _doneToday = doneToday; - _loading = false; + _doneToday = doneLocally || _syncedToday; + // An auto-open keeps the spinner up and pushes with no transition, + // so the home card is never drawn under the workout first. The + // `_load` that runs when the workout pops clears it. + _loading = launch.open; }); // Sync in the background on every open. Deliberately not awaited: a @@ -95,64 +121,27 @@ class _HomeScreenState extends State { // changes what the card says when it lands. unawaited(_refreshSyncStatus()); - // Auto-resume active session on first load (app launch). - if (saved != null && !_hasAutoResumed) { - _hasAutoResumed = true; + if (launch.open) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) unawaited(_openWorkout(resume: true)); - }); - } - } - } - - /// Opens settings so the user can connect sync, then re-checks on return. - /// Runs a sync tick and folds the outcome into the status card. - /// - /// Never throws: [WorkoutSyncService.syncNow] reports failures as a - /// [PushResult] rather than an exception, and the card is where that - /// reason finally becomes visible to the user. - Future _refreshSyncStatus() async { - if (_syncing) return; // a tick is already in flight - _syncing = true; - final storage = StorageService.instance; - final sync = widget.syncService ?? WorkoutSyncService(); - final now = (widget.clock ?? DateTime.now)(); - try { - final configured = await (widget.configuredProbe ?? sync.isConfigured)(); - final storedAt = await storage.getLastSyncedAt(); - - // Show what the PERSISTED state says before the tick resolves. Without - // this pass the card can never say "out of date": by the time a tick - // has finished it has either stamped the time (so the age is zero) or - // failed (so the card is "Sync failed"), and a phone that has not - // synced for days would look healthy for the whole tick. This is also - // the honest reading while the network is still being waited on. - if (mounted) { - setState(() { - _syncStatus = computeSyncStatus( - configured: configured, - now: now, - lastSyncedAt: storedAt, + if (!mounted) return; + unawaited( + _openWorkout( + resume: saved != null, + auto: true, + syncNotSetUp: launch.syncNotSetUp, + ), ); }); + } else { + SandboxLog.event('home shown', {'doneToday': _doneToday}); } - - final result = configured ? await sync.syncNow() : null; - if (result != null && result.pushed) { - await storage.markSyncedNow(now); - } - final status = computeSyncStatus( - configured: configured, - now: now, - lastResult: result, - lastSyncedAt: await storage.getLastSyncedAt(), - ); - if (mounted) setState(() => _syncStatus = status); - } finally { - _syncing = false; } } + /// Runs [fn] inside `setState` on behalf of this library's extensions, + /// which cannot call the `@protected` original. + void _applyState(VoidCallback fn) => setState(fn); + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; diff --git a/stronglift_replacement/workout_app/lib/screens/home_screen_launch.dart b/stronglift_replacement/workout_app/lib/screens/home_screen_launch.dart new file mode 100644 index 0000000..636f1fa --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/home_screen_launch.dart @@ -0,0 +1,82 @@ +// The launch-time decision: open the workout straight away, or show home. +// +// A `part` for the same reason as home_screen_navigation.dart: it reaches the +// private `_HomeScreenState` members, and stays setState-free so `_load` keeps +// the one `setState` that applies the answer. +part of 'home_screen.dart'; + +/// How long a cold launch waits for the synced records before opening the +/// workout anyway. Fail-open on purpose: a wrong auto-open costs one back +/// press (the break service only starts on the first tap), while a dead +/// network must never hold the workout hostage. +const Duration _kSyncedTodayTimeout = Duration(seconds: 4); + +/// What the first load of this process decided. +typedef _LaunchDecision = ({ + /// Push the workout (resuming if a session is saved) without showing home. + bool open, + + /// A synced record already covers today. + bool syncedToday, + + /// This device has no sync credentials; the workout screen must say so. + bool syncNotSetUp, +}); + +/// Opening the workout on launch, the way todo opens the editor. +extension _HomeScreenLaunch on _HomeScreenState { + /// Decides whether this load skips the home screen. + /// + /// Only the first load of the process decides anything: coming back from a + /// workout, settings or the background must land on home. A saved session + /// always resumes (as it always has). A fresh workout opens only when + /// nothing counts for today yet -- and never in PC lock mode, where the + /// workout route cannot be popped and the manual-log button on home would + /// become unreachable. + Future<_LaunchDecision> _decideLaunch({ + required bool firstLoad, + required bool hasSaved, + required bool doneLocally, + required DateTime today, + }) async { + const stay = (open: false, syncedToday: false, syncNotSetUp: false); + if (!firstLoad || !widget.openWorkoutOnLaunch) return stay; + final wantsFresh = !hasSaved && !doneLocally && !lockModeEnabled; + if (!hasSaved && !wantsFresh) return stay; + + final sync = widget.syncService ?? WorkoutSyncService(); + final configured = await (widget.configuredProbe ?? sync.isConfigured)(); + final syncedToday = + wantsFresh && configured && await _syncedWorkoutToday(sync, today); + return ( + open: hasSaved || !syncedToday, + syncedToday: syncedToday, + syncNotSetUp: !configured, + ); + } + + /// Whether a synced record -- a manual workout, the PC's verified run, a + /// session from another device -- is dated [today]. + /// + /// Never throws: an unreadable backend answers "no", loudly, so the + /// workout still opens. + Future _syncedWorkoutToday( + WorkoutSyncService sync, + DateTime today, + ) async { + try { + final payloads = await sync.readMergedWorkoutPayloads().timeout( + _kSyncedTodayTimeout, + ); + return anyWorkoutOn(payloads, today); + } on Object catch (error) { + log( + 'HomeScreen: could not read synced workouts to check for one logged ' + 'today ($error) — opening the workout anyway; back out if you ' + 'already trained.', + level: 900, + ); + return false; + } + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/home_screen_navigation.dart b/stronglift_replacement/workout_app/lib/screens/home_screen_navigation.dart index c0b9161..75eb694 100644 --- a/stronglift_replacement/workout_app/lib/screens/home_screen_navigation.dart +++ b/stronglift_replacement/workout_app/lib/screens/home_screen_navigation.dart @@ -14,7 +14,16 @@ extension _HomeScreenNavigation on _HomeScreenState { unawaited(_load()); } - Future _openWorkout({bool resume = false}) async { + /// Pushes the workout, resuming the saved session when [resume]. + /// + /// [auto] is the launch-time open: it replaces home without a transition, + /// so the first screen after loading is the workout itself. [syncNotSetUp] + /// carries the sync warning the skipped home card would have shown. + Future _openWorkout({ + bool resume = false, + bool auto = false, + bool syncNotSetUp = false, + }) async { final storage = StorageService.instance; Map? savedState; var type = _nextType; @@ -28,15 +37,25 @@ extension _HomeScreenNavigation on _HomeScreenState { } if (!mounted) return; - SandboxLog.event('open workout', {'type': type, 'resume': resume}); + SandboxLog.event('open workout', { + 'type': type, + 'resume': resume, + 'auto': auto, + }); + final screen = WorkoutScreen( + workoutType: type, + exercises: exercises, + savedState: savedState, + syncNotSetUp: syncNotSetUp, + ); await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => WorkoutScreen( - workoutType: type, - exercises: exercises, - savedState: savedState, - ), - ), + auto + ? PageRouteBuilder( + pageBuilder: (_, _, _) => screen, + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + ) + : MaterialPageRoute(builder: (_) => screen), ); unawaited(_load()); } diff --git a/stronglift_replacement/workout_app/lib/screens/home_screen_sync.dart b/stronglift_replacement/workout_app/lib/screens/home_screen_sync.dart new file mode 100644 index 0000000..8db84fb --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/home_screen_sync.dart @@ -0,0 +1,56 @@ +// The home screen's sync tick: what the status card says, and when. +// +// A `part` so the extension keeps reaching the private `_HomeScreenState` +// fields; it mutates them through the `_applyState` shim because `setState` +// is `@protected` and cannot be called from an extension. +part of 'home_screen.dart'; + +/// Keeps the sync status card honest. +extension _HomeScreenSync on _HomeScreenState { + /// Runs a sync tick and folds the outcome into the status card. + /// + /// Never throws: [WorkoutSyncService.syncNow] reports failures as a + /// [PushResult] rather than an exception, and the card is where that + /// reason finally becomes visible to the user. + Future _refreshSyncStatus() async { + if (_syncing) return; // a tick is already in flight + _syncing = true; + final storage = StorageService.instance; + final sync = widget.syncService ?? WorkoutSyncService(); + final now = (widget.clock ?? DateTime.now)(); + try { + final configured = await (widget.configuredProbe ?? sync.isConfigured)(); + final storedAt = await storage.getLastSyncedAt(); + + // Show what the PERSISTED state says before the tick resolves. Without + // this pass the card can never say "out of date": by the time a tick + // has finished it has either stamped the time (so the age is zero) or + // failed (so the card is "Sync failed"), and a phone that has not + // synced for days would look healthy for the whole tick. This is also + // the honest reading while the network is still being waited on. + if (mounted) { + _applyState(() { + _syncStatus = computeSyncStatus( + configured: configured, + now: now, + lastSyncedAt: storedAt, + ); + }); + } + + final result = configured ? await sync.syncNow() : null; + if (result != null && result.pushed) { + await storage.markSyncedNow(now); + } + final status = computeSyncStatus( + configured: configured, + now: now, + lastResult: result, + lastSyncedAt: await storage.getLastSyncedAt(), + ); + if (mounted) _applyState(() => _syncStatus = status); + } finally { + _syncing = false; + } + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart index c54ed55..4efac65 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart @@ -29,6 +29,7 @@ import 'package:workout_app/services/workout_sync_service.dart'; import 'package:workout_app/ui/theme.dart'; import 'package:workout_app/widgets/break_banner.dart'; import 'package:workout_app/widgets/exercise_tile.dart'; +import 'package:workout_app/widgets/sync_setup_banner.dart'; import 'package:workout_app/widgets/workout_summary_dialog.dart'; part 'workout_screen_body.dart'; @@ -48,6 +49,7 @@ class WorkoutScreen extends StatefulWidget { super.key, this.savedState, this.breakClient, + this.syncNotSetUp = false, }); /// 'A' or 'B' — used for history and progression. @@ -64,6 +66,9 @@ class WorkoutScreen extends StatefulWidget { @visibleForTesting final ForegroundBreakClient? breakClient; + /// Shows the "sync not set up" strip; the home card that says it is skipped. + final bool syncNotSetUp; + @override State createState() => _WorkoutScreenState(); } @@ -96,6 +101,9 @@ class _WorkoutScreenState extends State { late final AppLifecycleListener _lifecycle; late final BreakServiceController _breaks; + + /// The one in-flight start of [_breaks]; see `_ensureBreakService`. + Future? _breakServiceStart; final _audio = AudioPlayer(); final _sync = SyncService(); bool _finished = false; @@ -120,7 +128,8 @@ class _WorkoutScreenState extends State { unawaited(_loadExerciseStates()); WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_settleExpiredBreak()); - unawaited(_startBreakService()); + // A fresh workout waits for its first tap; see `_ensureBreakService`. + if (saved != null) _ensureBreakService(); }); } @@ -221,6 +230,7 @@ class _WorkoutScreenState extends State { inBreak: _inBreak, breakRemaining: _breakRemaining, finished: _finished, + syncNotSetUp: widget.syncNotSetUp, allSetsCompleted: _allSetsCompleted, onSkipBreak: _skipBreak, onReset: () => unawaited(_confirmReset()), diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_body.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_body.dart index b7acaf9..7064798 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_body.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_body.dart @@ -23,6 +23,7 @@ class _WorkoutBody extends StatelessWidget { required this.inBreak, required this.breakRemaining, required this.finished, + required this.syncNotSetUp, required this.allSetsCompleted, required this.onSkipBreak, required this.onReset, @@ -58,6 +59,9 @@ class _WorkoutBody extends StatelessWidget { /// Whether the workout is over; hides Reset and Finish. final bool finished; + /// Whether to show the "sync not set up" strip above the rest strip. + final bool syncNotSetUp; + /// Whether every set is recorded; enables Finish. final bool allSetsCompleted; @@ -86,6 +90,7 @@ class _WorkoutBody extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ + if (syncNotSetUp) const SyncSetupBanner(), BreakBanner( active: inBreak, breakRemaining: breakRemaining, diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_intents.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_intents.dart index 681335d..f2686c8 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_intents.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_intents.dart @@ -91,6 +91,19 @@ extension _WorkoutScreenIntents on _WorkoutScreenState { return count; } + /// Starts the foreground service once per screen, on the first event that + /// makes the workout real. + /// + /// A resumed session starts it on open. A fresh one waits for its first + /// set or warmup: launch now opens the workout by itself, and merely + /// opening the app must not leave a notification (or a session) behind. + /// + /// No `_finished` check: every save path already returns once the workout + /// is finished, and Finish stops the service itself. + void _ensureBreakService() { + _breakServiceStart ??= _startBreakService(); + } + /// Hands the workout to the foreground service, then applies anything the /// user already pressed on a notification from a previous run of the app. /// @@ -102,6 +115,10 @@ extension _WorkoutScreenIntents on _WorkoutScreenState { _buildSnapshot(), onDrainNudge: () => unawaited(_drainIntents()), ); + // `push` is a no-op until `start` returns, and `start` can sit on the + // notification-permission dialog while the user keeps tapping sets. + // Re-send the current state so none of those taps is lost. + await _breaks.push(_buildSnapshot()); if (result.needsPermissionWarning && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart index be0b36e..f1ab5b8 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart @@ -88,6 +88,7 @@ extension _WorkoutScreenSession on _WorkoutScreenState { SandboxLog.event('session save', {'toFirebase': toFirebase}); final data = _activeSessionData(); await StorageService.instance.saveActiveSession(data); + _ensureBreakService(); // The one seam the notification is fed from. Every event that moves the // workout on -- a set, a warmup, a rep decrement, a skip, a reset, a // drained press -- already comes through here, so hanging the push on it diff --git a/stronglift_replacement/workout_app/lib/services/done_today.dart b/stronglift_replacement/workout_app/lib/services/done_today.dart new file mode 100644 index 0000000..0e2845d --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/done_today.dart @@ -0,0 +1,26 @@ +/// Whether a synced workout already covers a given day. +/// +/// The home screen opens a fresh workout by itself on launch unless one +/// already counts for today -- and "counts" is wider than this phone's own +/// history: a manual workout, the PC's verified RunnerUp run, or a session +/// logged on another device all mean there is nothing left to push for. +library; + +/// The `YYYY-MM-DD` key a workout record is dated with. +/// +/// The PC dates every synced record by its `log.json` day key, which is a +/// local calendar day -- the same calendar [DateTime.now] reads on the phone. +String dayKey(DateTime day) => + '${day.year.toString().padLeft(4, '0')}-' + '${day.month.toString().padLeft(2, '0')}-' + '${day.day.toString().padLeft(2, '0')}'; + +/// Whether any of [payloads] (merged cross-device workout records) is dated +/// [day]. +/// +/// A prefix match, so both a bare `2026-09-28` and a full ISO timestamp from +/// a StrongLifts session read as the same day. +bool anyWorkoutOn(Iterable> payloads, DateTime day) { + final key = dayKey(day); + return payloads.any((p) => '${p['date']}'.startsWith(key)); +} diff --git a/stronglift_replacement/workout_app/lib/widgets/sync_setup_banner.dart b/stronglift_replacement/workout_app/lib/widgets/sync_setup_banner.dart new file mode 100644 index 0000000..b4e75a7 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/sync_setup_banner.dart @@ -0,0 +1,42 @@ +/// A one-line "sync is not set up" strip for the top of the workout screen. +/// +/// The home screen's `SyncStatusCard` used to be the only place this was +/// said, and it deliberately sat *before* the workout. Now that a launch +/// can jump straight into the workout, the warning has to travel with it or +/// a workout gets logged into a void again. +library; + +import 'package:flutter/material.dart'; +import 'package:workout_app/ui/theme.dart'; + +/// Tells the user this workout will not reach the PC until sync is set up. +class SyncSetupBanner extends StatelessWidget { + /// Creates the banner. + const SyncSetupBanner({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.xs, + ), + child: Row( + children: [ + Icon(Icons.cloud_off, size: 16, color: colorScheme.error), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + 'Sync not set up — this workout will NOT count. ' + 'Set it up in Settings.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.error), + ), + ), + ], + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/test/screens/_home_test_fixtures.dart b/stronglift_replacement/workout_app/test/screens/_home_test_fixtures.dart index 4372060..20431d7 100644 --- a/stronglift_replacement/workout_app/test/screens/_home_test_fixtures.dart +++ b/stronglift_replacement/workout_app/test/screens/_home_test_fixtures.dart @@ -26,11 +26,19 @@ Future pumpHome(WidgetTester tester, Widget w) async { // `flutter test`, so every test here would fail on a MissingPluginException // that has nothing to do with what it is asserting. Report "not // configured" instead: no credentials means no sync tick is attempted. -Widget wrapHome({Future Function()? configuredProbe}) => MaterialApp( +// +// The launch-time auto-open is off by default here: these files test the +// home screen itself, and a fresh database would otherwise skip straight +// past it into the workout. home_launch_test.dart covers the auto-open. +Widget wrapHome({ + Future Function()? configuredProbe, + bool openWorkoutOnLaunch = false, +}) => MaterialApp( theme: buildAppTheme(), home: HomeScreen( configuredProbe: configuredProbe ?? () async => false, clock: () => DateTime(2026, 8, 15, 18), + openWorkoutOnLaunch: openWorkoutOnLaunch, ), ); diff --git a/stronglift_replacement/workout_app/test/screens/_workout_screen_test_fixtures.dart b/stronglift_replacement/workout_app/test/screens/_workout_screen_test_fixtures.dart index dfe7411..76f80ea 100644 --- a/stronglift_replacement/workout_app/test/screens/_workout_screen_test_fixtures.dart +++ b/stronglift_replacement/workout_app/test/screens/_workout_screen_test_fixtures.dart @@ -56,6 +56,20 @@ Map savedWithBreak( .millisecondsSinceEpoch, }; +/// A saved session with nothing recorded yet, for [exercises]. +/// +/// The notification tests start from this rather than a fresh screen: a +/// fresh workout no longer starts the break service until its first set, so +/// only a restored session has the service (and its queued presses) live +/// from the moment the screen opens. +Map blankSaved([List exercises = testExercises]) => { + 'workoutType': 'A', + 'startTimeMs': DateTime.now().millisecondsSinceEpoch, + 'tapped': [for (final e in exercises) List.filled(e.sets, false)], + 'doneReps': [for (final e in exercises) List.filled(e.sets, e.reps)], + 'warmupTapped': List.filled(exercises.length, false), +}; + const testExercises = [ Exercise(name: 'Squat', sets: 3, reps: 5, weight: 20.0), Exercise(name: 'Press', sets: 3, reps: 5, weight: 15.0), diff --git a/stronglift_replacement/workout_app/test/screens/home_launch_test.dart b/stronglift_replacement/workout_app/test/screens/home_launch_test.dart new file mode 100644 index 0000000..2ed2188 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/home_launch_test.dart @@ -0,0 +1,160 @@ +// The launch-time auto-open: a cold start with nothing done today skips the +// home screen and lands on the workout, the way todo lands on the editor. +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/screens/home_screen.dart'; +import 'package:workout_app/screens/workout_screen.dart'; +import 'package:workout_app/services/done_today.dart'; +import 'package:workout_app/services/lock_mode.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/services/workout_sync_service.dart'; +import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/sync_setup_banner.dart'; + +import '_home_test_fixtures.dart'; + +/// A configured backend whose merged records are [payloads] -- or which +/// throws [error] instead, as an unreachable repo would. +class _RecordsSyncService extends WorkoutSyncService { + _RecordsSyncService({this.payloads = const [], this.error}); + + final List> payloads; + final Object? error; + + @override + Future syncNow() async => + const PushResult(pushed: false, reason: 'sync failed: offline'); + + @override + Future>> readMergedWorkoutPayloads() async { + if (error case final e?) throw e; + return payloads; + } +} + +Widget _app({WorkoutSyncService? sync, bool configured = false}) => MaterialApp( + theme: buildAppTheme(), + home: HomeScreen(syncService: sync, configuredProbe: () async => configured), +); + +/// Drives the first load and the post-frame push on the real loop: the load +/// awaits sqflite, which hangs under the widget test's fake async. +Future _launch(WidgetTester tester, Widget app) async { + await tester.runAsync(() async { + await tester.pumpWidget(app); + await Future.delayed(const Duration(milliseconds: 300)); + await tester.pump(); // fire the post-frame auto-open + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); +} + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + }); + + testWidgets('nothing done today: the workout opens without showing home', ( + tester, + ) async { + await _launch(tester, _app()); + expect(find.byType(WorkoutScreen), findsOneWidget); + // No transition: the home card was never built underneath. + expect(find.text('Start Workout A'), findsNothing); + // Sync is not set up, and the card that says so was skipped. + expect(find.byType(SyncSetupBanner), findsOneWidget); + }); + + testWidgets('a workout already in local history keeps home', (tester) async { + await tester.runAsync( + () => StorageService.instance.saveSession( + date: dayKey(DateTime.now()), + workoutType: 'A', + durationSeconds: 1800, + succeeded: true, + json: '{"exercises":[]}', + ), + ); + await _launch(tester, _app()); + expect(find.byType(WorkoutScreen), findsNothing); + expect(find.text('Done for today!'), findsOneWidget); + }); + + testWidgets('a synced record dated today (a run, a manual log) keeps home', ( + tester, + ) async { + final sync = _RecordsSyncService( + payloads: [ + {'kind': 'runnerup_verified', 'date': dayKey(DateTime.now())}, + ], + ); + await _launch(tester, _app(sync: sync, configured: true)); + expect(find.byType(WorkoutScreen), findsNothing); + expect(find.text('Done for today!'), findsOneWidget); + }); + + testWidgets('synced records from other days still open, without banner', ( + tester, + ) async { + final sync = _RecordsSyncService( + payloads: [ + {'kind': 'runnerup_verified', 'date': '2020-01-01'}, + ], + ); + await _launch(tester, _app(sync: sync, configured: true)); + expect(find.byType(WorkoutScreen), findsOneWidget); + expect(find.byType(SyncSetupBanner), findsNothing); + }); + + testWidgets('an unreadable backend fails open into the workout', ( + tester, + ) async { + final sync = _RecordsSyncService(error: TimeoutException('slow repo')); + await _launch(tester, _app(sync: sync, configured: true)); + expect(find.byType(WorkoutScreen), findsOneWidget); + }); + + testWidgets('PC lock mode keeps home, where manual logging lives', ( + tester, + ) async { + lockModeEnabled = true; + addTearDown(() => lockModeEnabled = false); + await _launch(tester, _app()); + expect(find.byType(WorkoutScreen), findsNothing); + expect(find.text('Start Workout A'), findsOneWidget); + }); + + testWidgets('backing out lands on home, with no session left behind', ( + tester, + ) async { + await _launch(tester, _app()); + expect(find.byType(WorkoutScreen), findsOneWidget); + + await tester.runAsync(() async { + tester.state(find.byType(Navigator).last).pop(); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + expect(find.byType(WorkoutScreen), findsNothing); + // Only the first load decides: the reload after the pop stays on home. + expect(find.text('Start Workout A'), findsOneWidget); + final saved = await tester.runAsync( + () => StorageService.instance.loadActiveSession(), + ); + expect(saved, isNull); + }); + + testWidgets('the fixtures switch the auto-open off', (tester) async { + await pumpHome(tester, wrapHome()); + expect(find.byType(WorkoutScreen), findsNothing); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/home_screen_test.dart b/stronglift_replacement/workout_app/test/screens/home_screen_test.dart index 45926ed..b8c9319 100644 --- a/stronglift_replacement/workout_app/test/screens/home_screen_test.dart +++ b/stronglift_replacement/workout_app/test/screens/home_screen_test.dart @@ -105,14 +105,27 @@ void main() { await tester.runAsync( () => StorageService.instance.saveActiveSession({ 'workoutType': 'A', - 'startTime': DateTime.now().toIso8601String(), - 'exercises': [], + 'startTimeMs': DateTime.now().millisecondsSinceEpoch, + 'tapped': [for (final e in workoutA) List.filled(e.sets, false)], + 'doneReps': [ + for (final e in workoutA) List.filled(e.sets, e.reps), + ], + 'warmupTapped': List.filled(workoutA.length, false), }), ); - await pumpHome(tester, wrapHome()); + // The launch decision awaits the sync probe before the post-frame push, + // so both run on the real loop (sqflite hangs under fake async). + await tester.runAsync(() async { + await tester.pumpWidget(wrapHome(openWorkoutOnLaunch: true)); + await Future.delayed(const Duration(milliseconds: 300)); + await tester.pump(); // fire the post-frame auto-resume callback + await Future.delayed(const Duration(milliseconds: 300)); + }); await tester.pump(const Duration(milliseconds: 300)); - // The post-frame auto-resume pushed the workout screen. - expect(find.textContaining('Workout A'), findsWidgets); + // The post-frame auto-resume pushed the workout screen. Asserted by + // type: the screen has no app bar, and the home card that used to supply + // a matching "Workout A" underneath is no longer drawn. + expect(find.byType(WorkoutScreen), findsOneWidget); }); testWidgets('active session shows Resume, returns (98) and re-enters (153)', ( @@ -134,7 +147,7 @@ void main() { // Drive the whole first-load + post-frame auto-resume push on the real loop // (auto-resume awaits getCurrentExercises, which hangs under FakeAsync). await tester.runAsync(() async { - await tester.pumpWidget(wrapHome()); + await tester.pumpWidget(wrapHome(openWorkoutOnLaunch: true)); await Future.delayed(const Duration(milliseconds: 300)); await tester.pump(); // fire the post-frame auto-resume callback await Future.delayed(const Duration(milliseconds: 300)); diff --git a/stronglift_replacement/workout_app/test/screens/home_sync_card_test.dart b/stronglift_replacement/workout_app/test/screens/home_sync_card_test.dart index 67bfa8b..45ad2f8 100644 --- a/stronglift_replacement/workout_app/test/screens/home_sync_card_test.dart +++ b/stronglift_replacement/workout_app/test/screens/home_sync_card_test.dart @@ -56,6 +56,7 @@ void main() { MaterialApp( theme: buildAppTheme(), home: HomeScreen( + openWorkoutOnLaunch: false, configuredProbe: () async => true, syncService: FailingSyncService(), clock: () => DateTime(2026, 8, 15, 18), @@ -85,6 +86,7 @@ void main() { MaterialApp( theme: buildAppTheme(), home: HomeScreen( + openWorkoutOnLaunch: false, configuredProbe: () async => true, syncService: HangingSyncService(), clock: () => now, @@ -127,6 +129,7 @@ void main() { MaterialApp( theme: buildAppTheme(), home: HomeScreen( + openWorkoutOnLaunch: false, configuredProbe: () async => true, syncService: CountingSyncService(() => ticks++), clock: () => DateTime(2026, 8, 15, 18), @@ -152,6 +155,7 @@ void main() { MaterialApp( theme: buildAppTheme(), home: HomeScreen( + openWorkoutOnLaunch: false, configuredProbe: () async => true, syncService: OkSyncService(), clock: () => now, diff --git a/stronglift_replacement/workout_app/test/screens/workout_lazy_service_test.dart b/stronglift_replacement/workout_app/test/screens/workout_lazy_service_test.dart new file mode 100644 index 0000000..1a4b17d --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/workout_lazy_service_test.dart @@ -0,0 +1,112 @@ +// When the break service starts: on open for a restored session, on the +// first tap for a fresh one -- the launch opens the workout by itself, and +// just opening the app must not leave a notification behind. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/screens/workout_screen.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/rep_circle.dart'; +import 'package:workout_app/widgets/sync_setup_banner.dart'; + +import '../fake_audio_platform.dart'; +import '../fake_break_service.dart'; +import '../fake_secure_storage.dart'; +import '_workout_screen_test_fixtures.dart'; + +void main() { + late FakeForegroundBreakClient client; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakeSecureStorage(); + installFakeAudioPlatform(); + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + client = FakeForegroundBreakClient(); + }); + + int starts() => client.calls.where((c) => c == 'start').length; + + testWidgets('a fresh workout does not start the service on open', ( + tester, + ) async { + await pumpWorkout(tester, wrapWorkout(breakClient: client)); + expect(starts(), 0); + expect(client.pushed, isEmpty); + // Nothing recorded, so nothing saved: backing out leaves no session. + final saved = await tester.runAsync( + () => StorageService.instance.loadActiveSession(), + ); + expect(saved, isNull); + }); + + testWidgets('the first set starts it, once, and re-sends the state', ( + tester, + ) async { + await pumpWorkout(tester, wrapWorkout(breakClient: client)); + await tapReal(tester, find.byType(RepCircle).first); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + + expect(starts(), 1); + // The push after start carries whatever was tapped while the + // permission prompt could have been up. + final afterStart = client.calls.sublist(client.calls.indexOf('start')); + expect(afterStart, contains('push')); + expect(client.pushed.last.setsRemaining, 5); + + await tapReal(tester, find.byType(RepCircle).first); // a rep decrement + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + expect(starts(), 1, reason: 'one service per workout screen'); + }); + + testWidgets('a warmup is enough to start it', (tester) async { + await pumpWorkout(tester, wrapWorkout(breakClient: client)); + await tapReal(tester, find.bySemanticsLabel('Squat warmup')); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + expect(starts(), 1); + }); + + testWidgets('a restored session starts it on open', (tester) async { + await pumpWorkout( + tester, + wrapWorkout(savedState: blankSaved(), breakClient: client), + ); + expect(starts(), 1); + }); + + testWidgets('the sync strip shows only when asked for', (tester) async { + await pumpWorkout( + tester, + MaterialApp( + theme: buildAppTheme(), + home: WorkoutScreen( + workoutType: 'A', + exercises: testExercises, + breakClient: client, + syncNotSetUp: true, + ), + ), + ); + expect(find.byType(SyncSetupBanner), findsOneWidget); + expect(find.textContaining('will NOT count'), findsOneWidget); + + await pumpWorkout(tester, wrapWorkout(breakClient: client)); + expect(find.byType(SyncSetupBanner), findsNothing); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/workout_pause_test.dart b/stronglift_replacement/workout_app/test/screens/workout_pause_test.dart index 8e4e4fe..4f3df3b 100644 --- a/stronglift_replacement/workout_app/test/screens/workout_pause_test.dart +++ b/stronglift_replacement/workout_app/test/screens/workout_pause_test.dart @@ -52,7 +52,11 @@ void main() { await tester.runAsync(() => _pause('Situp')); await pumpWorkout( tester, - wrapWorkout(exercises: const [_lunge, _situp], breakClient: client), + wrapWorkout( + exercises: const [_lunge, _situp], + savedState: blankSaved(const [_lunge, _situp]), + breakClient: client, + ), ); } diff --git a/stronglift_replacement/workout_app/test/screens/workout_screen_intents_stale_test.dart b/stronglift_replacement/workout_app/test/screens/workout_screen_intents_stale_test.dart index 90bcedd..910c2ad 100644 --- a/stronglift_replacement/workout_app/test/screens/workout_screen_intents_stale_test.dart +++ b/stronglift_replacement/workout_app/test/screens/workout_screen_intents_stale_test.dart @@ -33,8 +33,8 @@ void main() { await StorageService.init(); installFakeSecureStorage(); installFakeAudioPlatform(); - SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync - .empty(); + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); client = FakeForegroundBreakClient(); }); @@ -51,7 +51,10 @@ void main() { } Future pump(WidgetTester tester, {Map? saved}) => - pumpWorkout(tester, wrapWorkout(savedState: saved, breakClient: client)); + pumpWorkout( + tester, + wrapWorkout(savedState: saved ?? blankSaved(), breakClient: client), + ); BreakSnapshot lastPushed() => client.pushed.last; @@ -75,32 +78,32 @@ void main() { } testWidgets('an exercise index this workout does not have', (tester) async { - await pump(tester); + await pump(tester); await expectNoChange(tester, [(BreakIntentKind.done, 99, 0)]); }); testWidgets('a set index this exercise does not have', (tester) async { - await pump(tester); + await pump(tester); await expectNoChange(tester, [(BreakIntentKind.done, 0, 99)]); }); testWidgets('a negative index', (tester) async { - await pump(tester); + await pump(tester); await expectNoChange(tester, [(BreakIntentKind.minusRep, -1, 0)]); }); testWidgets('Done on a set that is already recorded', (tester) async { - await pump(tester, saved: savedWithBreak(90)); + await pump(tester, saved: savedWithBreak(90)); await expectNoChange(tester, [(BreakIntentKind.done, 0, 0)]); }); testWidgets('− 1 rep on a set that was never recorded', (tester) async { - await pump(tester); + await pump(tester); await expectNoChange(tester, [(BreakIntentKind.minusRep, 1, 2)]); }); testWidgets('Skip break with no rest running', (tester) async { - await pump(tester); + await pump(tester); await expectNoChange(tester, [(BreakIntentKind.skipBreak, 0, 0)]); }); }); @@ -116,10 +119,7 @@ void main() { ..grantOnRequest = false; await pump(tester); - expect( - find.textContaining('Notifications are off'), - findsOneWidget, - ); + expect(find.textContaining('Notifications are off'), findsOneWidget); }); testWidgets('no warning when notifications were allowed', (tester) async { diff --git a/stronglift_replacement/workout_app/test/screens/workout_screen_intents_test.dart b/stronglift_replacement/workout_app/test/screens/workout_screen_intents_test.dart index 6a96e72..4c7666f 100644 --- a/stronglift_replacement/workout_app/test/screens/workout_screen_intents_test.dart +++ b/stronglift_replacement/workout_app/test/screens/workout_screen_intents_test.dart @@ -53,7 +53,10 @@ void main() { } Future pump(WidgetTester tester, {Map? saved}) => - pumpWorkout(tester, wrapWorkout(savedState: saved, breakClient: client)); + pumpWorkout( + tester, + wrapWorkout(savedState: saved ?? blankSaved(), breakClient: client), + ); BreakSnapshot lastPushed() => client.pushed.last; diff --git a/stronglift_replacement/workout_app/test/services/done_today_test.dart b/stronglift_replacement/workout_app/test/services/done_today_test.dart new file mode 100644 index 0000000..4dfeb66 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/done_today_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/done_today.dart'; + +final _day = DateTime(2026, 9, 28, 7, 30); + +void main() { + group('dayKey', () { + test('zero-pads month and day', () { + expect(dayKey(DateTime(2026, 3, 4)), '2026-03-04'); + }); + + test('ignores the time of day', () { + expect(dayKey(_day), '2026-09-28'); + }); + }); + + group('anyWorkoutOn', () { + test('a bare day key from the PC log matches', () { + expect( + anyWorkoutOn([ + {'kind': 'runnerup_verified', 'date': '2026-09-28'}, + ], _day), + isTrue, + ); + }); + + test('a full ISO timestamp from a session matches its day', () { + expect( + anyWorkoutOn([ + {'date': '2026-09-28T06:05:44.066'}, + ], _day), + isTrue, + ); + }); + + test('another day does not count', () { + expect( + anyWorkoutOn([ + {'date': '2026-09-27'}, + {'date': '2026-09-29T00:01:00'}, + ], _day), + isFalse, + ); + }); + + test('a record with no date does not count', () { + expect( + anyWorkoutOn([ + {'kind': 'manual_workout'}, + ], _day), + isFalse, + ); + }); + + test('no records at all is not done', () { + expect(anyWorkoutOn(const [], _day), isFalse); + }); + }); +}