diff --git a/screen_locker/_constants.py b/screen_locker/_constants.py index f77f9b4..36f67fa 100644 --- a/screen_locker/_constants.py +++ b/screen_locker/_constants.py @@ -36,8 +36,8 @@ COMMITMENT_PROMPT_TIMEOUT_SECONDS = 15 # window is exhausted the "Log Manual Workout" option disappears entirely. MANUAL_WORKOUT_BUDGET_PER_7_DAYS = 2 MANUAL_WORKOUT_BUDGET_PER_30_DAYS = 10 -# Minimum start-to-end duration for a manual entry to be accepted. -MANUAL_WORKOUT_MIN_DURATION_MINUTES = 20 +# Manual entries use the shared MIN_WORKOUT_DURATION_MINUTES bar below — +# every workout type answers to the same minimum, whatever the sport. # Minimum chars for the "what was done" activity-details field. MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS = 40 # Minimum chars for each reflection field (what went well / to improve / feeling). @@ -55,19 +55,37 @@ WORKOUT_APP_JSON_REMOTES = ( ) # Port the workout app's HTTP server listens on (no ADB/developer-options needed). WORKOUT_HTTP_PORT = 8765 -MIN_WORKOUT_DURATION_MINUTES = 60 +# The ONE duration bar every workout type answers to — weightlifting, manual +# entries and runs alike. This is the ADVERTISED number: it is the only one +# that may ever appear in a message the user reads. +MIN_WORKOUT_DURATION_MINUTES = 40 +# ...and this is the leeway that is deliberately INVISIBLE. A session is +# actually accepted at MIN_WORKOUT_DURATION_MINUTES - this, so 35 minutes +# passes while every string still says 40. That gap is intentional (the user +# asked for it explicitly): do NOT "fix" the inconsistency by advertising the +# real cutoff, and never interpolate WORKOUT_DURATION_ACCEPT_MINUTES into an +# f-string. +WORKOUT_DURATION_LEEWAY_MINUTES = 5 +# Derived so the advertised bar and the accept bar cannot drift apart. +# Comparisons use this; messages use MIN_WORKOUT_DURATION_MINUTES. Every +# caller compares with `>=` — one operator across all workout types, so a +# 35.0-minute session cannot pass one gate and fail another. +WORKOUT_DURATION_ACCEPT_MINUTES = ( + MIN_WORKOUT_DURATION_MINUTES - WORKOUT_DURATION_LEEWAY_MINUTES +) RUNNERUP_PACKAGES = ("org.runnerup", "org.runnerup.free") RUNNERUP_DB_SDCARD_TMP = "/sdcard/.runnerup_tmp_verification.db" # A run qualifies on EITHER criterion — they are an OR, not an AND. A short -# but long-lasting run counts, and so does a fast 5 km that takes under 40 min. -# Do not reinstate an AND here: it would reject exactly the slow-run case this -# pair exists to allow. -MIN_RUN_DURATION_MINUTES = 40 # strict: "over 40 minutes" +# but long-lasting run counts, and so does a fast 5 km that takes under the +# duration bar. Do not reinstate an AND here: it would reject exactly the +# slow-run case this pair exists to allow. The duration arm is the shared +# MIN_WORKOUT_DURATION_MINUTES above; only the distance arm is run-specific. MIN_RUN_DISTANCE_KM = 3.5 # inclusive: "as low as 3.5 km" # GPS distance tracking undershoots on tight turns/tree cover/urban canyons; # a run within 5% of the minimum is accepted rather than rejected on -# measurement noise. Duration comes from the device clock, not GPS, so it -# gets no such leeway. +# measurement noise. This is SEPARATE from WORKOUT_DURATION_LEEWAY_MINUTES: +# that one is a deliberate concession to the user, this one corrects a +# measurement error. Duration now gets a leeway too, but for the other reason. RUNNERUP_DISTANCE_TOLERANCE = 0.05 RUNNERUP_ACCEPTED_SPORTS: frozenset[int] = frozenset({0, 3, 5}) # 0=RUNNING, 3=ORIENTEERING, 5=TREADMILL diff --git a/screen_locker/_manual_workout.py b/screen_locker/_manual_workout.py index 7ba1ac6..2b89886 100644 --- a/screen_locker/_manual_workout.py +++ b/screen_locker/_manual_workout.py @@ -24,10 +24,11 @@ from screen_locker._constants import ( MANUAL_WORKOUT_BUDGET_PER_7_DAYS, MANUAL_WORKOUT_BUDGET_PER_30_DAYS, MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS, - MANUAL_WORKOUT_MIN_DURATION_MINUTES, MANUAL_WORKOUT_REFLECTION_MIN_CHARS, MANUAL_WORKOUT_RPE_MAX, MANUAL_WORKOUT_RPE_MIN, + MIN_WORKOUT_DURATION_MINUTES, + WORKOUT_DURATION_ACCEPT_MINUTES, ) from screen_locker._log_io import load_workout_log @@ -203,9 +204,11 @@ def validate_manual_workout(draft: ManualWorkoutDraft) -> str | None: duration = _duration_minutes(draft) if duration is None: return "Start/end time must be valid HH:MM, with end after start" - if duration < MANUAL_WORKOUT_MIN_DURATION_MINUTES: + # Accept bar carries the hidden leeway; the message advertises the round + # number only (see _constants.py). + if duration < WORKOUT_DURATION_ACCEPT_MINUTES: return ( - f"Session must be at least {MANUAL_WORKOUT_MIN_DURATION_MINUTES} " + f"Session must be at least {MIN_WORKOUT_DURATION_MINUTES} " f"minutes (currently {duration:.0f})" ) diff --git a/screen_locker/_phone_verification.py b/screen_locker/_phone_verification.py index 0558d8b..a690112 100644 --- a/screen_locker/_phone_verification.py +++ b/screen_locker/_phone_verification.py @@ -22,6 +22,7 @@ from screen_locker._constants import ( ADB_TIMEOUT, MIN_WORKOUT_DURATION_MINUTES, WORKOUT_APP_JSON_REMOTES, + WORKOUT_DURATION_ACCEPT_MINUTES, WORKOUT_HTTP_PORT, ) from screen_locker._log_mixin import write_signed_entry @@ -187,7 +188,9 @@ class PhoneVerificationMixin: if not data.get("exercises"): return "no_exercises", "No exercises found in the workout JSON" duration_min = data.get("duration_seconds", 0) / 60.0 - if duration_min < MIN_WORKOUT_DURATION_MINUTES: + # Accept bar carries the hidden leeway; the message advertises the + # round number only (see _constants.py). + if duration_min < WORKOUT_DURATION_ACCEPT_MINUTES: return ( "too_short", f"Workout too short! {duration_min:.0f} min logged, " diff --git a/screen_locker/_runnerup_verification.py b/screen_locker/_runnerup_verification.py index fb3aa35..d38ba1a 100644 --- a/screen_locker/_runnerup_verification.py +++ b/screen_locker/_runnerup_verification.py @@ -17,10 +17,11 @@ import xml.etree.ElementTree as ET from screen_locker._constants import ( MIN_RUN_DISTANCE_KM, - MIN_RUN_DURATION_MINUTES, + MIN_WORKOUT_DURATION_MINUTES, RUNNERUP_ACCEPTED_SPORTS, RUNNERUP_DISTANCE_TOLERANCE, RUNNERUP_EXPORT_DIRS, + WORKOUT_DURATION_ACCEPT_MINUTES, ) from screen_locker._log_mixin import write_signed_entry from screen_locker._runnerup_db import RunnerUpDbMixin @@ -248,16 +249,17 @@ class RunnerUpVerificationMixin(RunnerUpDbMixin): distance_km = data["distance_m"] / 1000 # Either criterion qualifies the run on its own (see _constants.py). - # Distance gets the GPS tolerance; duration, from the device clock, - # does not. + # Distance gets the GPS tolerance (a measurement correction); duration + # gets the shared hidden leeway (a deliberate concession). Different + # reasons, so they stay separate constants. distance_ok = ( distance_km * (1 + RUNNERUP_DISTANCE_TOLERANCE) >= MIN_RUN_DISTANCE_KM ) - duration_ok = duration_min > MIN_RUN_DURATION_MINUTES + duration_ok = duration_min >= WORKOUT_DURATION_ACCEPT_MINUTES if not distance_ok and not duration_ok: msg = ( f"Run was {distance_km:.1f} km / {duration_min:.0f} min — need " - f"{MIN_RUN_DISTANCE_KM:g}+ km or {MIN_RUN_DURATION_MINUTES}+ min" + f"{MIN_RUN_DISTANCE_KM:g}+ km or {MIN_WORKOUT_DURATION_MINUTES}+ min" ) return "too_short", msg if distance_ok and distance_km < MIN_RUN_DISTANCE_KM: diff --git a/screen_locker/tests/fixtures/duration_threshold.json b/screen_locker/tests/fixtures/duration_threshold.json new file mode 100644 index 0000000..f01f96d --- /dev/null +++ b/screen_locker/tests/fixtures/duration_threshold.json @@ -0,0 +1,23 @@ +{ + "_comment": [ + "Shared literal fixture for the unified workout-duration gate.", + "Read by BOTH the Python locker (test_duration_threshold_fixture.py) and", + "the Flutter app (test/models/duration_threshold_fixture_test.dart).", + "Each language's own round-trip test cannot catch a drift between the two", + "sides -- only a shared literal like this one can. If you change the", + "threshold, change it here and watch both suites fail together.", + "", + "advertised_minutes is the ONLY number that may appear in a user-facing", + "message. accept_minutes is deliberately invisible: a 35-minute session", + "passes while every string still says 40. Do not advertise accept_minutes." + ], + "advertised_minutes": 40, + "leeway_minutes": 5, + "accept_minutes": 35, + "cases": [ + { "duration_minutes": 34, "accepted": false }, + { "duration_minutes": 35, "accepted": true }, + { "duration_minutes": 40, "accepted": true }, + { "duration_minutes": 60, "accepted": true } + ] +} diff --git a/screen_locker/tests/test_duration_threshold_fixture.py b/screen_locker/tests/test_duration_threshold_fixture.py new file mode 100644 index 0000000..295ad66 --- /dev/null +++ b/screen_locker/tests/test_duration_threshold_fixture.py @@ -0,0 +1,87 @@ +"""Cross-language fixture test for the unified workout-duration gate. + +The Dart app mirrors these constants in ``lib/models/manual_workout.dart``. +Each language's own round-trip test passes happily while the two sides +disagree, so the only thing that catches a drift is a shared literal both +read -- ``fixtures/duration_threshold.json``. The Dart twin of this file is +``test/models/duration_threshold_fixture_test.dart``; they must stay in step. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from screen_locker._constants import ( + MIN_WORKOUT_DURATION_MINUTES, + WORKOUT_DURATION_ACCEPT_MINUTES, + WORKOUT_DURATION_LEEWAY_MINUTES, +) +from screen_locker._manual_workout import ManualWorkoutDraft, validate_manual_workout + +FIXTURE = Path(__file__).parent / "fixtures" / "duration_threshold.json" + + +def _fixture() -> dict[str, object]: + """Load the shared cross-language threshold fixture.""" + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + +def _draft(duration_minutes: int) -> ManualWorkoutDraft: + """Build an otherwise-valid draft of exactly ``duration_minutes``.""" + end_h, end_m = divmod(12 * 60 + duration_minutes, 60) + return ManualWorkoutDraft( + sport="other", + start_time="12:00", + end_time=f"{end_h:02d}:{end_m:02d}", + location_name="Home", + transport_method="From bed", + cost="0", + rpe=3, + went_well="Everything went smoothly today, no issues at all", + to_improve="Dumbbell bench press needs assistance exercises", + overall_feeling="Felt strong throughout the whole session today", + reservation_phone="none", + techniques_practiced="FBW", + warm_up_minutes="none", + pain_or_injury="none", + activity_type_other="Weightlifting", + activity_details="Dumbbell Lunge, Press, Row and Dumbbell Curl at home", + equipment="Dumbbells", + ) + + +def test_constants_match_shared_fixture() -> None: + """The Python constants equal the shared literal the Dart side also reads.""" + data = _fixture() + assert data["advertised_minutes"] == MIN_WORKOUT_DURATION_MINUTES + assert data["leeway_minutes"] == WORKOUT_DURATION_LEEWAY_MINUTES + assert data["accept_minutes"] == WORKOUT_DURATION_ACCEPT_MINUTES + + +def test_accept_bar_is_derived_not_hardcoded() -> None: + """The two bars cannot drift: the accept bar is advertised minus leeway.""" + assert ( + WORKOUT_DURATION_ACCEPT_MINUTES + == MIN_WORKOUT_DURATION_MINUTES - WORKOUT_DURATION_LEEWAY_MINUTES + ) + + +@pytest.mark.parametrize("case", _fixture()["cases"]) +def test_boundary_cases_from_fixture(case: dict[str, object]) -> None: + """Every shared boundary case decides the same way on the Python side.""" + error = validate_manual_workout(_draft(int(case["duration_minutes"]))) + assert (error is None) is case["accepted"], ( + f"{case['duration_minutes']} min should be " + f"{'accepted' if case['accepted'] else 'rejected'}, got {error!r}" + ) + + +def test_rejection_message_advertises_40_never_the_real_cutoff() -> None: + """The hidden accept bar must never reach the user's eyes.""" + error = validate_manual_workout(_draft(10)) + assert error is not None + assert str(MIN_WORKOUT_DURATION_MINUTES) in error + assert str(WORKOUT_DURATION_ACCEPT_MINUTES) not in error diff --git a/screen_locker/tests/test_manual_workout.py b/screen_locker/tests/test_manual_workout.py index 95955df..b453f82 100644 --- a/screen_locker/tests/test_manual_workout.py +++ b/screen_locker/tests/test_manual_workout.py @@ -14,8 +14,8 @@ from screen_locker._constants import ( MANUAL_WORKOUT_BUDGET_PER_7_DAYS, MANUAL_WORKOUT_BUDGET_PER_30_DAYS, MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS, - MANUAL_WORKOUT_MIN_DURATION_MINUTES, MANUAL_WORKOUT_REFLECTION_MIN_CHARS, + WORKOUT_DURATION_ACCEPT_MINUTES, ) from screen_locker._manual_workout import ( SPORT_OTHER, @@ -239,7 +239,7 @@ class TestValidateManualWorkout: def test_rejects_too_short_duration(self) -> None: draft = _tt_draft(start_time="12:00", end_time="12:05") - assert MANUAL_WORKOUT_MIN_DURATION_MINUTES > 5 + assert WORKOUT_DURATION_ACCEPT_MINUTES > 5 assert validate_manual_workout(draft) is not None @pytest.mark.parametrize("rpe", [0, 11, -1]) diff --git a/screen_locker/tests/test_phone_verification_part4.py b/screen_locker/tests/test_phone_verification_part4.py index 2d498ec..21a2b02 100644 --- a/screen_locker/tests/test_phone_verification_part4.py +++ b/screen_locker/tests/test_phone_verification_part4.py @@ -12,7 +12,10 @@ import time from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch -from screen_locker._constants import MIN_WORKOUT_DURATION_MINUTES +from screen_locker._constants import ( + MIN_WORKOUT_DURATION_MINUTES, + WORKOUT_DURATION_ACCEPT_MINUTES, +) from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: @@ -59,12 +62,16 @@ class TestValidateJsonData: self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: locker = create_locker(mock_tk, tmp_path) - short_seconds = int((MIN_WORKOUT_DURATION_MINUTES - 10) * 60) + # Derive from the ACCEPT bar, not the advertised one: a duration + # merely under 40 may still clear the hidden leeway and verify. + short_seconds = int((WORKOUT_DURATION_ACCEPT_MINUTES - 5) * 60) status, message = locker._validate_json_data( {"date": _today(), "exercises": ["x"], "duration_seconds": short_seconds} ) assert status == "too_short" + # ...but the message still advertises 40, never the real cutoff. assert f"{MIN_WORKOUT_DURATION_MINUTES}" in message + assert f"{WORKOUT_DURATION_ACCEPT_MINUTES}" not in message def test_verified_all_succeeded( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path diff --git a/screen_locker/tests/test_runnerup_verification_part2.py b/screen_locker/tests/test_runnerup_verification_part2.py index 57e23f3..ab532b0 100644 --- a/screen_locker/tests/test_runnerup_verification_part2.py +++ b/screen_locker/tests/test_runnerup_verification_part2.py @@ -5,6 +5,10 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import MagicMock +from screen_locker._constants import ( + MIN_WORKOUT_DURATION_MINUTES, + WORKOUT_DURATION_ACCEPT_MINUTES, +) from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: @@ -194,22 +198,40 @@ class TestValidateRunnerupDataOrCriteria: ) assert status == "verified" - def test_duration_boundary_is_strict( + def test_duration_boundary_uses_the_hidden_leeway( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: - """Exactly 40 min does not qualify, but 40.1 min does ("over 40").""" + """Runs answer to the shared accept bar (35), not the advertised 40. + + The distance stays at 1 km throughout so the OR distance arm cannot + rescue the run — this pins the duration arm alone. + """ locker = create_locker(mock_tk, tmp_path) - at_boundary, _ = locker._validate_runnerup_data( - {"sport": 0, "duration_seconds": 2400, "distance_m": 1000} + just_under, _ = locker._validate_runnerup_data( + {"sport": 0, "duration_seconds": 2094, "distance_m": 1000} ) - assert at_boundary == "too_short" - just_over, _ = locker._validate_runnerup_data( - {"sport": 0, "duration_seconds": 2406, "distance_m": 1000} + assert just_under == "too_short" + at_accept_bar, _ = locker._validate_runnerup_data( + {"sport": 0, "duration_seconds": 2100, "distance_m": 1000} ) - assert just_over == "verified" + assert at_accept_bar == "verified" + + def test_too_short_message_advertises_40_not_the_real_cutoff( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """The hidden accept bar must never reach the user's eyes.""" + locker = create_locker(mock_tk, tmp_path) + _, message = locker._validate_runnerup_data( + {"sport": 0, "duration_seconds": 600, "distance_m": 1000} + ) + assert str(MIN_WORKOUT_DURATION_MINUTES) in message + assert str(WORKOUT_DURATION_ACCEPT_MINUTES) not in message def test_real_2026_08_14_run_is_verified( self, diff --git a/stronglift_replacement/workout_app/lib/models/manual_workout.dart b/stronglift_replacement/workout_app/lib/models/manual_workout.dart index c845ec4..c632fc9 100644 --- a/stronglift_replacement/workout_app/lib/models/manual_workout.dart +++ b/stronglift_replacement/workout_app/lib/models/manual_workout.dart @@ -27,8 +27,24 @@ const int kManualWorkoutBudgetPer7Days = 2; /// Max manual workouts allowed in any rolling 30-day window. const int kManualWorkoutBudgetPer30Days = 10; -/// Minimum session length, in minutes, for a manual workout to count. -const int kManualWorkoutMinDurationMinutes = 20; +/// The ONE duration bar every workout type answers to, in minutes. This is +/// the ADVERTISED number — the only one that may appear in a message the user +/// reads. Mirrors `MIN_WORKOUT_DURATION_MINUTES` in the locker's +/// `_constants.py`; the two must move together. +const int kMinWorkoutDurationMinutes = 40; + +/// The leeway that is deliberately INVISIBLE. A session is accepted at +/// [kMinWorkoutDurationMinutes] minus this, so 35 minutes passes while every +/// string still says 40. That gap is intentional: do NOT "fix" it by +/// advertising the real cutoff, and never interpolate +/// [kWorkoutDurationAcceptMinutes] into a user-facing string. +const int kWorkoutDurationLeewayMinutes = 5; + +/// Derived so the advertised and accept bars cannot drift apart. Comparisons +/// use this; messages use [kMinWorkoutDurationMinutes]. Compare with `>=`, +/// matching the locker, so 35.0 cannot pass one side and fail the other. +const int kWorkoutDurationAcceptMinutes = + kMinWorkoutDurationMinutes - kWorkoutDurationLeewayMinutes; /// Minimum characters for the "other sport" activity description. const int kManualWorkoutDescriptionMinChars = 40; @@ -204,8 +220,10 @@ String? validateManualWorkout(ManualWorkoutDraft draft) { if (duration == null) { return 'Start/end time must be valid HH:MM, with end after start'; } - if (duration < kManualWorkoutMinDurationMinutes) { - return 'Session must be at least $kManualWorkoutMinDurationMinutes ' + // Accept bar carries the hidden leeway; the message advertises the round + // number only. + if (duration < kWorkoutDurationAcceptMinutes) { + return 'Session must be at least $kMinWorkoutDurationMinutes ' 'minutes (currently ${duration.toStringAsFixed(0)})'; } diff --git a/stronglift_replacement/workout_app/lib/screens/home_screen.dart b/stronglift_replacement/workout_app/lib/screens/home_screen.dart index cbaf511..e1d504b 100644 --- a/stronglift_replacement/workout_app/lib/screens/home_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/home_screen.dart @@ -9,12 +9,34 @@ 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/storage_service.dart'; +import 'package:workout_app/services/sync_status.dart'; +import 'package:workout_app/services/workout_sync_service.dart'; import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/sync_status_card.dart'; /// Home screen: auto-resumes active sessions and shows done-today status. class HomeScreen extends StatefulWidget { /// Creates a [HomeScreen]. - const HomeScreen({super.key}); + /// + /// [syncService], [clock] and [configuredProbe] are injection seams for + /// tests: the real sync service reaches the keystore and the network, and + /// "synced 3h ago" is only assertable against a fixed clock. + const HomeScreen({ + super.key, + this.syncService, + this.clock, + this.configuredProbe, + }); + + /// Sync service to use; defaults to a real [WorkoutSyncService]. + final WorkoutSyncService? syncService; + + /// Source of "now"; defaults to [DateTime.now]. + final DateTime Function()? clock; + + /// Whether this device has sync credentials. Defaults to asking the + /// service itself. + final Future Function()? configuredProbe; @override State createState() => _HomeScreenState(); @@ -27,6 +49,11 @@ class _HomeScreenState extends State { bool _doneToday = false; Map? _savedSession; + /// Null until the first sync tick resolves, which keeps the card off the + /// screen rather than flashing a wrong state on launch. + 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; @@ -59,6 +86,11 @@ class _HomeScreenState extends State { _loading = false; }); + // Sync in the background on every open. Deliberately not awaited: a + // slow or dead network must not hold up the workout screen, it just + // 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; @@ -69,6 +101,62 @@ class _HomeScreenState extends State { } } + /// Opens settings so the user can connect sync, then re-checks on return. + Future _openSyncSettings() async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SettingsScreen()), + ); + unawaited(_load()); + } + + /// 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, + ); + }); + } + + 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; + } + } + Future _openWorkout({bool resume = false}) async { final storage = StorageService.instance; Map? savedState; @@ -141,6 +229,17 @@ class _HomeScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // Above the workout card on purpose: a disconnected phone + // has to say so BEFORE the workout, not after it failed to + // count. + if (_syncStatus case final status?) ...[ + SyncStatusCard( + status: status, + onRetry: () => unawaited(_refreshSyncStatus()), + onSetUp: _openSyncSettings, + ), + const SizedBox(height: 20), + ], _WorkoutCard( type: _nextType, exercises: _exercises, diff --git a/stronglift_replacement/workout_app/lib/services/storage_service.dart b/stronglift_replacement/workout_app/lib/services/storage_service.dart index a182471..b7b1523 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service.dart @@ -704,6 +704,35 @@ class StorageService { static const _progressionSyncedKey = 'progression_synced_at'; + /// When a workout sync last SUCCEEDED, or null if one never has. + /// + /// Drives the home screen's "Out of date" card. Nothing persisted a + /// last-sync time before — which is why the app could sit disconnected for + /// days with nothing on screen saying so. + Future getLastSyncedAt() async { + final raw = await _getSetting(_lastSyncedAtKey); + if (raw == null) return null; + final parsed = DateTime.tryParse(raw); + if (parsed == null) { + // Never silently treat a corrupt timestamp as "never synced": that + // would show the nag card forever with no clue why. + debugPrint( + 'WorkoutApp: last_synced_at is not a valid timestamp ($raw) — ' + 'treating this device as never synced until the next success.', + ); + return null; + } + return parsed; + } + + /// Records that a sync just succeeded. + Future markSyncedNow([DateTime? at]) => _setSetting( + _lastSyncedAtKey, + (at ?? DateTime.now()).toIso8601String(), + ); + + static const _lastSyncedAtKey = 'last_synced_at'; + /// Restores from backup if the local DB is empty (fresh install). /// /// "Empty" means no workout history and no [last_workout_type] setting. diff --git a/stronglift_replacement/workout_app/lib/services/sync_status.dart b/stronglift_replacement/workout_app/lib/services/sync_status.dart new file mode 100644 index 0000000..582d6b2 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/sync_status.dart @@ -0,0 +1,95 @@ +/// The four states the home screen's sync card can show, and the pure +/// function that decides between them. +/// +/// This exists because a workout was once logged into a void: the app was not +/// connected to Firebase, said nothing about it, and the session did not +/// count. Silence was the bug. The card is the fix, and keeping the decision +/// in a pure function means it can be tested without pumping a widget. +library; + +import 'package:flutter/foundation.dart'; +import 'package:workout_app/services/workout_sync_service.dart'; + +/// How long a successful sync stays "fresh" before the card nags. +/// +/// The app is opened before each workout, so anything older than a few hours +/// means the last open did not manage to sync. +const Duration kSyncStaleAfter = Duration(hours: 6); + +/// Which of the four card states applies. Order matters -- see +/// [computeSyncStatus]. +enum SyncState { + /// No credentials at all: this device has never been set up. + notConnected, + + /// Credentials exist but the last attempt errored. + failed, + + /// Last successful sync is older than [kSyncStaleAfter] (or never happened). + outOfDate, + + /// Synced within the freshness window. + synced, +} + +/// A resolved sync state plus the detail the card needs to render. +@immutable +class SyncStatus { + /// Creates a status. + const SyncStatus({required this.state, this.reason, this.age}); + + /// Which card to show. + final SyncState state; + + /// For [SyncState.failed], the human-readable reason from [PushResult]. + final String? reason; + + /// For [SyncState.outOfDate] and [SyncState.synced], how long ago the last + /// successful sync was. Null when there has never been one. + final Duration? age; + + @override + bool operator ==(Object other) => + other is SyncStatus && + other.state == state && + other.reason == reason && + other.age == age; + + @override + int get hashCode => Object.hash(state, reason, age); + + @override + String toString() => 'SyncStatus($state, reason: $reason, age: $age)'; +} + +/// Decides which card to show, first match wins. +/// +/// The ordering is the point. On a fresh install there are no credentials +/// *and* no timestamp, and "Not connected" is the useful thing to say -- +/// "Out of date" would send the user looking for a sync that never existed. +/// +/// - [configured]: does this device have any sync credentials? +/// - [lastResult]: the outcome of the most recent attempt this session. +/// - [lastSyncedAt]: when a sync last succeeded, ever (persisted). +/// - [now]: injected so "3h ago" is testable. +SyncStatus computeSyncStatus({ + required bool configured, + required DateTime now, + PushResult? lastResult, + DateTime? lastSyncedAt, +}) { + if (!configured) { + return const SyncStatus(state: SyncState.notConnected); + } + if (lastResult != null && !lastResult.pushed) { + return SyncStatus(state: SyncState.failed, reason: lastResult.reason); + } + if (lastSyncedAt == null) { + return const SyncStatus(state: SyncState.outOfDate); + } + final age = now.difference(lastSyncedAt); + if (age >= kSyncStaleAfter) { + return SyncStatus(state: SyncState.outOfDate, age: age); + } + return SyncStatus(state: SyncState.synced, age: age); +} diff --git a/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart b/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart index 2984854..3d5ff43 100644 --- a/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart +++ b/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart @@ -174,6 +174,79 @@ class WorkoutSyncService { } } + /// Whether this device has ANY sync credentials -- a GitHub token or a + /// Firebase account. + /// + /// Mirrors the gate every push already applies internally, exposed so the + /// home screen can tell "not set up" (the user must act) apart from "set + /// up but broken" (retrying might help) without duplicating the rule. + Future isConfigured() async { + final settings = await SyncSettings.load(); + return settings.isConfigured || await _hasFirebaseAccount(); + } + + /// Runs a sync tick with no new record, purely to find out whether sync + /// works right now. + /// + /// [push] and [pushManual] both need something to push, and + /// [readMergedWorkoutPayloads] returns `const []` on failure -- it + /// swallows the reason, so neither can answer "is this device actually + /// talking to Firebase?". The home screen's status card needs exactly that + /// answer, with a reason a human can act on, which is why this exists. + /// + /// Same never-throw, always-report contract as [push]. + Future syncNow() async { + final settings = await SyncSettings.load(); + if (!settings.isConfigured && !await _hasFirebaseAccount()) { + const reason = 'sync not configured (no Firebase account, no token)'; + log('WorkoutSyncService.syncNow skipped: $reason', level: 900); + return const PushResult(pushed: false, reason: reason); + } + + final github = GitHubClient( + owner: owner, + repo: repo, + token: settings.token, + httpClient: _httpClient, + ); + final firebase = await _openFirebase(); + final client = firebase == null + ? github + : MirrorStore(primary: firebase, mirror: github); + try { + final path = '$_pathPrefix/$currentSyncDeviceId/$_logFilename'; + final existingText = await client.getFileText(path); + final existingLog = existingText == null + ? {} + : _decode(existingText); + await syncLog( + client: client, + deviceId: currentSyncDeviceId, + legacyDeviceId: legacySyncDeviceId, + pathPrefix: _pathPrefix, + // No new record: whatever this device already has, unchanged. The + // point is the round trip, not the payload. + localLog: existingLog, + encode: _encode, + decode: _decode, + stateStore: await _openStateStore(), + ); + return const PushResult(pushed: true, reason: 'synced'); + } on Object catch (error, stackTrace) { + final reason = 'sync failed: $error'; + log( + 'WorkoutSyncService.syncNow failed', + level: 1000, + error: error, + stackTrace: stackTrace, + ); + return PushResult(pushed: false, reason: reason); + } finally { + github.close(); + firebase?.close(); + } + } + /// Pushes a pre-built manual-workout [record] to this device's log. /// /// Same never-throw, always-report contract as [push]. diff --git a/stronglift_replacement/workout_app/lib/widgets/sync_status_card.dart b/stronglift_replacement/workout_app/lib/widgets/sync_status_card.dart new file mode 100644 index 0000000..ab47144 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/sync_status_card.dart @@ -0,0 +1,135 @@ +/// The home screen's sync-status card. +/// +/// Sits above the workout card so a disconnected phone says so *before* the +/// workout, not after. A workout was once logged into a void because the app +/// stayed silent about being disconnected; the three unhealthy states here +/// all offer the fix rather than just reporting the problem. +library; + +import 'package:flutter/material.dart'; +import 'package:workout_app/services/sync_status.dart'; +import 'package:workout_app/ui/theme.dart'; + +/// Renders a [SyncStatus] as a card, with an action for the unhealthy states. +class SyncStatusCard extends StatelessWidget { + /// Creates the card. + const SyncStatusCard({ + required this.status, + required this.onRetry, + required this.onSetUp, + super.key, + }); + + /// What to show. + final SyncStatus status; + + /// Runs another sync tick ([SyncState.failed] / [SyncState.outOfDate]). + final VoidCallback onRetry; + + /// Opens sync settings ([SyncState.notConnected]). + final VoidCallback onSetUp; + + /// Renders a duration the way a human would say it. + static String describeAge(Duration age) { + if (age.inMinutes < 1) return 'just now'; + if (age.inMinutes < 60) return '${age.inMinutes}m ago'; + if (age.inHours < 24) return '${age.inHours}h ago'; + return '${age.inDays}d ago'; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final statusColors = Theme.of(context).extension()!; + + // The healthy state is a compact one-liner on purpose: a full card that + // is always there becomes wallpaper, and then the unhealthy ones stop + // being noticed too. + if (status.state == SyncState.synced) { + final age = status.age; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.cloud_done, size: 16, color: statusColors.success), + const SizedBox(width: 8), + Text( + age == null ? 'Synced' : 'Synced ${describeAge(age)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: statusColors.success, + ), + ), + ], + ); + } + + // SyncState.synced returned above, so only the three unhealthy states + // reach here. `notConnected` is the default arm rather than a listed one + // so the switch has no unreachable branch to leave uncovered. + final (icon, accent, title, body, actionLabel, action) = switch (status + .state) { + SyncState.failed => ( + Icons.sync_problem, + colorScheme.error, + 'Sync failed', + status.reason ?? 'The last sync attempt did not go through.', + 'Retry', + onRetry, + ), + SyncState.outOfDate => ( + Icons.cloud_queue, + statusColors.warning, + 'Sync out of date', + status.age == null + ? 'This device has never synced successfully.' + : 'Last synced ${describeAge(status.age!)}.', + 'Retry', + onRetry, + ), + // notConnected (and, unreachably, synced) land here. + _ => ( + Icons.cloud_off, + colorScheme.error, + 'Not connected to sync', + 'This workout will NOT count until sync is set up.', + 'Set up sync', + onSetUp, + ), + }; + + return Card( + color: colorScheme.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: accent), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: accent), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: accent, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text(body, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + const SizedBox(width: 8), + TextButton(onPressed: action, child: Text(actionLabel)), + ], + ), + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/test/models/duration_threshold_fixture_test.dart b/stronglift_replacement/workout_app/test/models/duration_threshold_fixture_test.dart new file mode 100644 index 0000000..c96dc6a --- /dev/null +++ b/stronglift_replacement/workout_app/test/models/duration_threshold_fixture_test.dart @@ -0,0 +1,98 @@ +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/manual_workout.dart'; + +/// Cross-language fixture test for the unified workout-duration gate. +/// +/// The locker mirrors these constants in `screen_locker/_constants.py`. Each +/// language's own round-trip test passes happily while the two sides +/// disagree, so the only thing that catches a drift is a shared literal both +/// read. The Python twin of this file is +/// `screen_locker/tests/test_duration_threshold_fixture.py`. +/// +/// The fixture lives in the locker's test tree because that is the side that +/// owns the gate; this app reads it up four directories. +final File _fixtureFile = File( + '../../screen_locker/tests/fixtures/duration_threshold.json', +); + +Map _fixture() => + jsonDecode(_fixtureFile.readAsStringSync()) as Map; + +/// Builds an otherwise-valid "other sport" draft of exactly [durationMinutes]. +ManualWorkoutDraft _draft(int durationMinutes) { + final totalMinutes = 12 * 60 + durationMinutes; + final endH = (totalMinutes ~/ 60).toString().padLeft(2, '0'); + final endM = (totalMinutes % 60).toString().padLeft(2, '0'); + return ManualWorkoutDraft( + sport: kSportOther, + startTime: '12:00', + endTime: '$endH:$endM', + locationName: 'Home', + transportMethod: 'From bed', + cost: '0', + rpe: 3, + wentWell: 'Everything went smoothly today, no issues at all', + toImprove: 'Dumbbell bench press needs assistance exercises', + overallFeeling: 'Felt strong throughout the whole session today', + reservationPhone: 'none', + techniquesPracticed: 'FBW', + warmUpMinutes: 'none', + painOrInjury: 'none', + activityTypeOther: 'Weightlifting', + activityDetails: 'Dumbbell Lunge, Press, Row and Dumbbell Curl at home', + equipment: 'Dumbbells', + ); +} + +void main() { + test('the shared fixture exists where both languages can read it', () { + expect( + _fixtureFile.existsSync(), + isTrue, + reason: 'Shared threshold fixture missing at ${_fixtureFile.path}', + ); + }); + + test('Dart constants match the shared literal the locker also reads', () { + final data = _fixture(); + expect(kMinWorkoutDurationMinutes, data['advertised_minutes']); + expect(kWorkoutDurationLeewayMinutes, data['leeway_minutes']); + expect(kWorkoutDurationAcceptMinutes, data['accept_minutes']); + }); + + test('the accept bar is derived, not hardcoded', () { + expect( + kWorkoutDurationAcceptMinutes, + kMinWorkoutDurationMinutes - kWorkoutDurationLeewayMinutes, + ); + }); + + test('every shared boundary case decides the same way here', () { + for (final dynamic entry in _fixture()['cases'] as List) { + final c = entry as Map; + final minutes = c['duration_minutes'] as int; + final shouldAccept = c['accepted'] as bool; + final error = validateManualWorkout(_draft(minutes)); + expect( + error == null, + shouldAccept, + reason: + '$minutes min should be ${shouldAccept ? "accepted" : "rejected"}, ' + 'got $error', + ); + } + }); + + test('the rejection message advertises 40 and never the real cutoff', () { + final error = validateManualWorkout(_draft(10)); + expect(error, isNotNull); + expect(error, contains('$kMinWorkoutDurationMinutes')); + expect(error, isNot(contains('$kWorkoutDurationAcceptMinutes'))); + }); +} 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 e4d5991..e0fc237 100644 --- a/stronglift_replacement/workout_app/test/screens/home_screen_test.dart +++ b/stronglift_replacement/workout_app/test/screens/home_screen_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; @@ -5,7 +7,9 @@ import 'package:workout_app/models/workout_plan.dart'; import 'package:workout_app/screens/home_screen.dart'; import 'package:workout_app/screens/workout_screen.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_status_card.dart'; import '../fake_secure_storage.dart'; @@ -30,8 +34,18 @@ void main() { await tester.pump(); } - Widget _wrap() => - MaterialApp(theme: buildAppTheme(), home: const HomeScreen()); + // The home screen syncs in the background on every open. Left to itself + // that reaches FlutterSecureStorage, whose platform channel throws under + // `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 _wrap({Future Function()? configuredProbe}) => MaterialApp( + theme: buildAppTheme(), + home: HomeScreen( + configuredProbe: configuredProbe ?? () async => false, + clock: () => DateTime(2026, 8, 15, 18), + ), + ); testWidgets('shows Workout Tracker app bar', (tester) async { await _pump(tester, _wrap()); @@ -190,4 +204,180 @@ void main() { await tester.pump(const Duration(milliseconds: 300)); expect(find.text('Workout Tracker'), findsOneWidget); }); + + group('sync status card', () { + testWidgets('an unconfigured device is warned before it works out', ( + tester, + ) async { + await _pump(tester, _wrap(configuredProbe: () async => false)); + expect(find.byType(SyncStatusCard), findsOneWidget); + expect(find.text('Not connected to sync'), findsOneWidget); + }); + + testWidgets('the card sits ABOVE the workout card', (tester) async { + await _pump(tester, _wrap(configuredProbe: () async => false)); + // The whole point of the placement: the warning has to be read before + // the workout starts, not discovered after it failed to count. + final cardY = tester.getTopLeft(find.byType(SyncStatusCard)).dy; + final workoutY = tester.getTopLeft(find.text('Start Workout A')).dy; + expect(cardY, lessThan(workoutY)); + }); + + testWidgets('a failing sync surfaces the reason on the card', ( + tester, + ) async { + installFakeSecureStorage(); + await _pump( + tester, + MaterialApp( + theme: buildAppTheme(), + home: HomeScreen( + configuredProbe: () async => true, + syncService: _FailingSyncService(), + clock: () => DateTime(2026, 8, 15, 18), + ), + ), + ); + expect(find.text('Sync failed'), findsOneWidget); + expect(find.textContaining('no network'), findsOneWidget); + }); + + testWidgets('a phone that has not synced for days says so, before the tick', + (tester) async { + // Regression: computing the status only AFTER the tick made + // SyncState.outOfDate unreachable in the running app -- a success + // stamps the time (age 0 -> Synced) and a failure shows Sync failed, + // so a stale phone silently looked healthy. + installFakeSecureStorage(); + final now = DateTime(2026, 8, 15, 18); + await tester.runAsync(() async { + await StorageService.instance.markSyncedNow( + now.subtract(const Duration(days: 3)), + ); + }); + await _pump( + tester, + MaterialApp( + theme: buildAppTheme(), + home: HomeScreen( + configuredProbe: () async => true, + syncService: _HangingSyncService(), + clock: () => now, + ), + ), + ); + expect(find.text('Sync out of date'), findsOneWidget); + expect(find.textContaining('3d ago'), findsOneWidget); + }); + + testWidgets('Set up sync opens Settings so the user can fix it', ( + tester, + ) async { + installFakeSecureStorage(); // SettingsScreen reads the token on init + await _pump(tester, _wrap(configuredProbe: () async => false)); + await tester.tap(find.text('Set up sync')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + // The card must lead somewhere: telling the user sync is broken and + // leaving them to find Settings is the silence this whole card fixes. + expect(find.text('Settings'), findsOneWidget); + + // Coming back re-checks sync, so connecting there clears the card + // without needing an app restart. + await tester.runAsync(() async { + tester.state(find.byType(Navigator).last).pop(); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Workout Tracker'), findsOneWidget); + }); + + testWidgets('Retry runs another sync tick', (tester) async { + installFakeSecureStorage(); + var ticks = 0; + await _pump( + tester, + MaterialApp( + theme: buildAppTheme(), + home: HomeScreen( + configuredProbe: () async => true, + syncService: _CountingSyncService(() => ticks++), + clock: () => DateTime(2026, 8, 15, 18), + ), + ), + ); + expect(ticks, 1); // the automatic tick on open + await tester.runAsync(() async { + await tester.tap(find.text('Retry')); + await Future.delayed(const Duration(milliseconds: 200)); + }); + await tester.pump(); + expect(ticks, 2); + }); + + testWidgets('a successful sync stamps the time and shows Synced', ( + tester, + ) async { + installFakeSecureStorage(); + final now = DateTime(2026, 8, 15, 18); + await _pump( + tester, + MaterialApp( + theme: buildAppTheme(), + home: HomeScreen( + configuredProbe: () async => true, + syncService: _OkSyncService(), + clock: () => now, + ), + ), + ); + expect(find.textContaining('Synced'), findsOneWidget); + // The timestamp must actually persist, or the card would go stale + // again on the very next open. Read it inside runAsync: the DB is real + // I/O, and awaiting it under FakeAsync hangs the test. + DateTime? stamped; + await tester.runAsync(() async { + stamped = await StorageService.instance.getLastSyncedAt(); + }); + expect(stamped, now); + }); + }); +} + +/// A sync service whose tick always fails, with a reason worth showing. +class _FailingSyncService extends WorkoutSyncService { + @override + Future syncNow() async => + const PushResult(pushed: false, reason: 'sync failed: no network'); +} + +/// A sync service whose tick always succeeds. +class _OkSyncService extends WorkoutSyncService { + @override + Future syncNow() async => + const PushResult(pushed: true, reason: 'synced'); +} + +/// A sync service whose tick never resolves, holding the card on whatever +/// the persisted state said — which is how a real slow network behaves. +class _HangingSyncService extends WorkoutSyncService { + @override + Future syncNow() => Completer().future; +} + +/// A sync service that counts how many ticks it was asked for. +class _CountingSyncService extends WorkoutSyncService { + _CountingSyncService(this.onTick); + + /// Called once per [syncNow]. + final void Function() onTick; + + @override + Future syncNow() async { + onTick(); + // Fail, so the card keeps offering Retry instead of collapsing to the + // button-less healthy state after the first tick. + return const PushResult(pushed: false, reason: 'sync failed: offline'); + } } diff --git a/stronglift_replacement/workout_app/test/services/storage_service_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_test.dart index 60f498e..c7b21e2 100644 --- a/stronglift_replacement/workout_app/test/services/storage_service_test.dart +++ b/stronglift_replacement/workout_app/test/services/storage_service_test.dart @@ -711,4 +711,49 @@ void main() { expect(await _svc.getWorkoutHistory(), isEmpty); }); }); + + // ── Last-sync timestamp (drives the home screen's status card) ───────────── + + group('last synced at', () { + test('is null before a sync has ever succeeded', () async { + expect(await _svc.getLastSyncedAt(), isNull); + }); + + test('round-trips the time a sync succeeded', () async { + final at = DateTime(2026, 8, 15, 18, 30); + await _svc.markSyncedNow(at); + expect(await _svc.getLastSyncedAt(), at); + }); + + test('defaults to now when no time is given', () async { + final before = DateTime.now().subtract(const Duration(seconds: 1)); + await _svc.markSyncedNow(); + final stored = await _svc.getLastSyncedAt(); + expect(stored, isNotNull); + expect(stored!.isAfter(before), isTrue); + }); + + test('a corrupt stored value reads as never-synced, loudly', () async { + // Seed a garbage timestamp directly: getLastSyncedAt must not throw, + // and must say why rather than silently showing the nag card forever. + final dir = await Directory.systemTemp.createTemp('mw_badstamp'); + final dbFile = p.join(dir.path, 'bad.db'); + StorageService.resetForTesting(dbPath: dbFile); + await StorageService.init(); + await _svc.markSyncedNow(DateTime(2026, 8, 15)); + + final db = await databaseFactory.openDatabase(dbFile); + await db.update( + 'settings', + {'value': 'not-a-timestamp'}, + where: 'key = ?', + whereArgs: ['last_synced_at'], + ); + await db.close(); + + StorageService.resetForTesting(dbPath: dbFile); + await StorageService.init(); + expect(await _svc.getLastSyncedAt(), isNull); + }); + }); } diff --git a/stronglift_replacement/workout_app/test/services/sync_status_test.dart b/stronglift_replacement/workout_app/test/services/sync_status_test.dart new file mode 100644 index 0000000..76cf73f --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/sync_status_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/sync_status.dart'; +import 'package:workout_app/services/workout_sync_service.dart'; + +final _now = DateTime(2026, 8, 15, 18); + +void main() { + group('computeSyncStatus', () { + test('no credentials wins even with a fresh timestamp', () { + // Ordering matters: a device with no credentials must be told to set + // sync up, not that it is merely stale. + final status = computeSyncStatus( + configured: false, + now: _now, + lastSyncedAt: _now.subtract(const Duration(minutes: 1)), + ); + expect(status.state, SyncState.notConnected); + }); + + test('first run — no credentials AND no timestamp — is notConnected', () { + final status = computeSyncStatus(configured: false, now: _now); + expect(status.state, SyncState.notConnected); + }); + + test('a failed attempt beats a fresh timestamp', () { + final status = computeSyncStatus( + configured: true, + now: _now, + lastResult: const PushResult(pushed: false, reason: 'boom'), + lastSyncedAt: _now.subtract(const Duration(minutes: 1)), + ); + expect(status.state, SyncState.failed); + expect(status.reason, 'boom'); + }); + + test('configured but never synced is outOfDate with no age', () { + final status = computeSyncStatus(configured: true, now: _now); + expect(status.state, SyncState.outOfDate); + expect(status.age, isNull); + }); + + test('exactly at the staleness boundary is outOfDate', () { + final status = computeSyncStatus( + configured: true, + now: _now, + lastSyncedAt: _now.subtract(kSyncStaleAfter), + ); + expect(status.state, SyncState.outOfDate); + expect(status.age, kSyncStaleAfter); + }); + + test('just inside the staleness boundary is synced', () { + final status = computeSyncStatus( + configured: true, + now: _now, + lastSyncedAt: _now.subtract( + kSyncStaleAfter - const Duration(minutes: 1), + ), + ); + expect(status.state, SyncState.synced); + }); + + test('a successful result with a fresh timestamp is synced', () { + final status = computeSyncStatus( + configured: true, + now: _now, + lastResult: const PushResult(pushed: true, reason: 'synced'), + lastSyncedAt: _now.subtract(const Duration(minutes: 3)), + ); + expect(status.state, SyncState.synced); + expect(status.age, const Duration(minutes: 3)); + }); + }); + + group('SyncStatus value semantics', () { + test('equal fields compare equal and hash alike', () { + const a = SyncStatus(state: SyncState.failed, reason: 'x'); + const b = SyncStatus(state: SyncState.failed, reason: 'x'); + expect(a, b); + expect(a.hashCode, b.hashCode); + }); + + test('differing fields compare unequal', () { + const a = SyncStatus(state: SyncState.failed, reason: 'x'); + const b = SyncStatus(state: SyncState.failed, reason: 'y'); + const c = SyncStatus(state: SyncState.synced, reason: 'x'); + expect(a, isNot(b)); + expect(a, isNot(c)); + expect(a, isNot(const SyncStatus(state: SyncState.failed, reason: 'x', + age: Duration(seconds: 1)))); + }); + + test('toString names the state and reason', () { + const status = SyncStatus(state: SyncState.failed, reason: 'boom'); + expect(status.toString(), contains('failed')); + expect(status.toString(), contains('boom')); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart b/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart index 69e59e9..ec457ef 100644 --- a/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart +++ b/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart @@ -96,6 +96,7 @@ WorkoutSyncService _service({ void main() { _cutoverTests(); + _manualWorkoutSurvivalTests(); // installFakeSecureStorage touches the test binary messenger, which needs // the binding up first (widget tests get this for free via testWidgets). TestWidgetsFlutterBinding.ensureInitialized(); @@ -518,6 +519,155 @@ void main() { return (client: client, puts: puts); } +/// Regression tests for the one thing that went RIGHT on 2026-08-15. +/// +/// A manual workout was logged while the phone was disconnected. When +/// Firebase later connected, the entry was NOT erased -- the user called this +/// out as good behaviour worth keeping. It is currently correct by +/// construction (`MirrorStore.getFileText` falls through to the GitHub mirror +/// when the Firebase primary returns null, so a first connect reads the prior +/// log rather than an empty one), which is exactly why it needs pinning: +/// nothing else would fail if that fall-through were removed. +void _manualWorkoutSurvivalTests() { + group('connecting to Firebase does not erase local manual workouts', () { + /// A manual record already in this device's log, logged while offline. + Record priorManual() => Record( + id: 'manual:2026-08-01T10:00', + fields: { + 'payload': ( + {'kind': 'manual_workout', 'date': '2026-08-01'}, + Hlc.newTick('phone'), + ), + }, + ); + + Map decodeLogPut(List<_PutCall> calls) => + jsonDecode( + utf8.decode( + base64.decode(_logPut(calls).body['content'] as String), + ), + ) + as Map; + + test('a first Firebase connect keeps a manual record only GitHub has', + () async { + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final existingLog = jsonEncode({ + priorManual().id: priorManual().toJson(), + }); + final (:httpClient, :putCalls) = _mockGitHub( + contentResponses: { + 'screen-locker-sync/devices': _response(200, []), + 'screen-locker-sync/devices/phone/log.json': _fileContaining( + existingLog, + ), + }, + ); + // Firebase answers 'null' to every GET -- the fresh-connect state, and + // the exact condition under which a naive implementation would treat + // the remote as empty and overwrite the log with just the new record. + final firebase = _fakeFirebase(); + + final newManual = Record( + id: 'manual:2026-08-15T12:18', + fields: { + 'payload': ( + {'kind': 'manual_workout', 'date': '2026-08-15'}, + Hlc.newTick('phone'), + ), + }, + ); + final result = await _service( + httpClient: httpClient, + firebaseFactory: () async => firebase.client, + ).pushManual(newManual); + + expect(result.pushed, isTrue); + final pushedLog = decodeLogPut(putCalls); + expect( + pushedLog.keys, + containsAll([priorManual().id, newManual.id]), + reason: 'the offline-logged manual workout must survive the connect', + ); + }); + + test('syncNow reports "not configured" rather than pretending to work', + () async { + installFakeSecureStorage(); + final (:httpClient, :putCalls) = _mockGitHub(); + + final result = await _service(httpClient: httpClient).syncNow(); + + expect(putCalls, isEmpty); + expect(result.pushed, isFalse); + // The card renders this reason, so it has to name the actual problem. + expect(result.reason, contains('not configured')); + }); + + test('syncNow works on a device with no log yet', () async { + // Nothing to preserve on a brand-new device: the tick must still + // succeed rather than failing on the absent log. + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final (:httpClient, :putCalls) = _mockGitHub( + contentResponses: {'screen-locker-sync/devices': _response(200, [])}, + ); + + final result = await _service(httpClient: httpClient).syncNow(); + + expect(result.pushed, isTrue); + expect(result.reason, 'synced'); + }); + + test('syncNow reports the reason when the round trip fails', () async { + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final httpClient = http_testing.MockClient( + (request) async => http.Response('', 500), + ); + + final result = await _service(httpClient: httpClient).syncNow(); + + // Never throws into the UI, but never silent either: this string is + // exactly what the "Sync failed" card shows the user. + expect(result.pushed, isFalse); + expect(result.reason, contains('sync failed')); + }); + + test('a plain sync tick on first connect does not drop the log', () async { + // syncNow() pushes no new record, so if the fresh-connect read came + // back empty this would write an EMPTY log over a good one -- the + // worst version of the bug, triggered just by opening the app. + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final existingLog = jsonEncode({ + priorManual().id: priorManual().toJson(), + }); + final (:httpClient, :putCalls) = _mockGitHub( + contentResponses: { + 'screen-locker-sync/devices': _response(200, []), + 'screen-locker-sync/devices/phone/log.json': _fileContaining( + existingLog, + ), + }, + ); + final firebase = _fakeFirebase(); + + final result = await _service( + httpClient: httpClient, + firebaseFactory: () async => firebase.client, + ).syncNow(); + + expect(result.pushed, isTrue); + final logPuts = putCalls.where((c) => c.path.endsWith('log.json')); + if (logPuts.isNotEmpty) { + expect( + decodeLogPut(putCalls).keys, + contains(priorManual().id), + reason: 'a no-op tick must never blank the log', + ); + } + }); + }); +} + void _cutoverTests() { group('Firebase cutover', () { test('pushes to Firebase and still mirrors to GitHub', () async { diff --git a/stronglift_replacement/workout_app/test/widget_test.dart b/stronglift_replacement/workout_app/test/widget_test.dart index 24638e1..30b951d 100644 --- a/stronglift_replacement/workout_app/test/widget_test.dart +++ b/stronglift_replacement/workout_app/test/widget_test.dart @@ -5,6 +5,8 @@ import 'package:workout_app/main.dart'; import 'package:workout_app/services/http_server_service.dart'; import 'package:workout_app/services/storage_service.dart'; +import 'fake_secure_storage.dart'; + void main() { setUpAll(() { sqfliteFfiInit(); @@ -12,6 +14,12 @@ void main() { }); setUp(() async { + // These tests build the whole WorkoutApp, so they cannot inject a sync + // probe the way home_screen_test does. The home screen now syncs in the + // background on open, which reaches FlutterSecureStorage -- whose real + // platform channel throws under `flutter test`. Install the fake instead + // of letting an unrelated MissingPluginException fail these. + installFakeSecureStorage(); StorageService.resetForTesting(); await StorageService.init(); }); diff --git a/stronglift_replacement/workout_app/test/widgets/sync_status_card_test.dart b/stronglift_replacement/workout_app/test/widgets/sync_status_card_test.dart new file mode 100644 index 0000000..f9d83e6 --- /dev/null +++ b/stronglift_replacement/workout_app/test/widgets/sync_status_card_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/sync_status.dart'; +import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/sync_status_card.dart'; + +void main() { + var retried = 0; + var setUpTapped = 0; + + setUp(() { + retried = 0; + setUpTapped = 0; + }); + + Widget wrap(SyncStatus status) => MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: SyncStatusCard( + status: status, + onRetry: () => retried++, + onSetUp: () => setUpTapped++, + ), + ), + ); + + testWidgets('notConnected says the workout will not count and offers setup', ( + tester, + ) async { + await tester.pumpWidget( + wrap(const SyncStatus(state: SyncState.notConnected)), + ); + expect(find.text('Not connected to sync'), findsOneWidget); + // The consequence is the whole point of the card -- a workout logged + // while disconnected does not count, which is what cost a real session. + expect(find.textContaining('NOT count'), findsOneWidget); + await tester.tap(find.text('Set up sync')); + expect(setUpTapped, 1); + expect(retried, 0); + }); + + testWidgets('failed shows the concrete reason and retries', (tester) async { + await tester.pumpWidget( + wrap( + const SyncStatus( + state: SyncState.failed, + reason: 'sync failed: connection refused', + ), + ), + ); + expect(find.text('Sync failed'), findsOneWidget); + expect(find.textContaining('connection refused'), findsOneWidget); + await tester.tap(find.text('Retry')); + expect(retried, 1); + }); + + testWidgets('failed falls back to generic text when reason is null', ( + tester, + ) async { + await tester.pumpWidget(wrap(const SyncStatus(state: SyncState.failed))); + expect(find.textContaining('did not go through'), findsOneWidget); + }); + + testWidgets('outOfDate reports how long ago and retries', (tester) async { + await tester.pumpWidget( + wrap( + const SyncStatus( + state: SyncState.outOfDate, + age: Duration(hours: 9), + ), + ), + ); + expect(find.text('Sync out of date'), findsOneWidget); + expect(find.textContaining('9h ago'), findsOneWidget); + await tester.tap(find.text('Retry')); + expect(retried, 1); + }); + + testWidgets('outOfDate with no age says it has never synced', (tester) async { + await tester.pumpWidget( + wrap(const SyncStatus(state: SyncState.outOfDate)), + ); + expect(find.textContaining('never synced'), findsOneWidget); + }); + + testWidgets('synced is a compact line with no button', (tester) async { + await tester.pumpWidget( + wrap( + const SyncStatus(state: SyncState.synced, age: Duration(minutes: 3)), + ), + ); + expect(find.textContaining('Synced 3m ago'), findsOneWidget); + // Healthy state must not carry an action: a card that is always there + // with a button becomes wallpaper and stops being read. + expect(find.byType(TextButton), findsNothing); + expect(find.byType(Card), findsNothing); + }); + + testWidgets('synced with no age still renders', (tester) async { + await tester.pumpWidget(wrap(const SyncStatus(state: SyncState.synced))); + expect(find.text('Synced'), findsOneWidget); + }); + + group('describeAge', () { + test('renders each magnitude the way a human would say it', () { + expect(SyncStatusCard.describeAge(const Duration(seconds: 5)), 'just now'); + expect(SyncStatusCard.describeAge(const Duration(minutes: 5)), '5m ago'); + expect(SyncStatusCard.describeAge(const Duration(hours: 5)), '5h ago'); + expect(SyncStatusCard.describeAge(const Duration(days: 5)), '5d ago'); + }); + }); +}