mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
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
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:
parent
deb89b3e03
commit
98bfe7d2c3
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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));
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user