workout_app: open straight into today's workout on launch
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-09-28 19:01:55 +02:00
parent deb89b3e03
commit 98bfe7d2c3
20 changed files with 713 additions and 89 deletions

View File

@ -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<bool> 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<HomeScreen> createState() => _HomeScreenState();
}
@ -58,9 +68,13 @@ class _HomeScreenState extends State<HomeScreen> {
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<HomeScreen> {
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<HomeScreen> {
// 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<void> _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;

View File

@ -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<bool> _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;
}
}
}

View File

@ -14,7 +14,16 @@ extension _HomeScreenNavigation on _HomeScreenState {
unawaited(_load());
}
Future<void> _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<void> _openWorkout({
bool resume = false,
bool auto = false,
bool syncNotSetUp = false,
}) async {
final storage = StorageService.instance;
Map<String, dynamic>? 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<void>(
builder: (_) => WorkoutScreen(
workoutType: type,
exercises: exercises,
savedState: savedState,
),
),
auto
? PageRouteBuilder<void>(
pageBuilder: (_, _, _) => screen,
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
)
: MaterialPageRoute<void>(builder: (_) => screen),
);
unawaited(_load());
}

View File

@ -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<void> _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;
}
}
}

View File

@ -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<WorkoutScreen> createState() => _WorkoutScreenState();
}
@ -96,6 +101,9 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
late final AppLifecycleListener _lifecycle;
late final BreakServiceController _breaks;
/// The one in-flight start of [_breaks]; see `_ensureBreakService`.
Future<void>? _breakServiceStart;
final _audio = AudioPlayer();
final _sync = SyncService();
bool _finished = false;
@ -120,7 +128,8 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
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<WorkoutScreen> {
inBreak: _inBreak,
breakRemaining: _breakRemaining,
finished: _finished,
syncNotSetUp: widget.syncNotSetUp,
allSetsCompleted: _allSetsCompleted,
onSkipBreak: _skipBreak,
onReset: () => unawaited(_confirmReset()),

View File

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

View File

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

View File

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

View File

@ -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<Map<String, dynamic>> payloads, DateTime day) {
final key = dayKey(day);
return payloads.any((p) => '${p['date']}'.startsWith(key));
}

View File

@ -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),
),
),
],
),
);
}
}

View File

@ -26,11 +26,19 @@ Future<void> 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<bool> 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<bool> Function()? configuredProbe,
bool openWorkoutOnLaunch = false,
}) => MaterialApp(
theme: buildAppTheme(),
home: HomeScreen(
configuredProbe: configuredProbe ?? () async => false,
clock: () => DateTime(2026, 8, 15, 18),
openWorkoutOnLaunch: openWorkoutOnLaunch,
),
);

View File

@ -56,6 +56,20 @@ Map<String, dynamic> 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<String, dynamic> blankSaved([List<Exercise> exercises = testExercises]) => {
'workoutType': 'A',
'startTimeMs': DateTime.now().millisecondsSinceEpoch,
'tapped': [for (final e in exercises) List<bool>.filled(e.sets, false)],
'doneReps': [for (final e in exercises) List<int>.filled(e.sets, e.reps)],
'warmupTapped': List<bool>.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),

View File

@ -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<Map<String, dynamic>> payloads;
final Object? error;
@override
Future<PushResult> syncNow() async =>
const PushResult(pushed: false, reason: 'sync failed: offline');
@override
Future<List<Map<String, dynamic>>> 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<void> _launch(WidgetTester tester, Widget app) async {
await tester.runAsync(() async {
await tester.pumpWidget(app);
await Future<void>.delayed(const Duration(milliseconds: 300));
await tester.pump(); // fire the post-frame auto-open
await Future<void>.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<NavigatorState>(find.byType(Navigator).last).pop();
await Future<void>.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);
});
}

View File

@ -105,14 +105,27 @@ void main() {
await tester.runAsync(
() => StorageService.instance.saveActiveSession({
'workoutType': 'A',
'startTime': DateTime.now().toIso8601String(),
'exercises': <dynamic>[],
'startTimeMs': DateTime.now().millisecondsSinceEpoch,
'tapped': [for (final e in workoutA) List<bool>.filled(e.sets, false)],
'doneReps': [
for (final e in workoutA) List<int>.filled(e.sets, e.reps),
],
'warmupTapped': List<bool>.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<void>.delayed(const Duration(milliseconds: 300));
await tester.pump(); // fire the post-frame auto-resume callback
await Future<void>.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<void>.delayed(const Duration(milliseconds: 300));
await tester.pump(); // fire the post-frame auto-resume callback
await Future<void>.delayed(const Duration(milliseconds: 300));

View File

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

View File

@ -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<void>.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<void>.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<void>.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);
});
}

View File

@ -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,
),
);
}

View File

@ -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<void> pump(WidgetTester tester, {Map<String, dynamic>? 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 {

View File

@ -53,7 +53,10 @@ void main() {
}
Future<void> pump(WidgetTester tester, {Map<String, dynamic>? saved}) =>
pumpWorkout(tester, wrapWorkout(savedState: saved, breakClient: client));
pumpWorkout(
tester,
wrapWorkout(savedState: saved ?? blankSaved(), breakClient: client),
);
BreakSnapshot lastPushed() => client.pushed.last;

View File

@ -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);
});
});
}