Add sync-status card and duration-threshold fixture
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 18:41:31 +02:00
parent 6abb553156
commit c63a02f9eb
22 changed files with 1353 additions and 37 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<bool> Function()? configuredProbe;
@override
State<HomeScreen> createState() => _HomeScreenState();
@ -27,6 +49,11 @@ class _HomeScreenState extends State<HomeScreen> {
bool _doneToday = false;
Map<String, dynamic>? _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<HomeScreen> {
_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<HomeScreen> {
}
}
/// Opens settings so the user can connect sync, then re-checks on return.
Future<void> _openSyncSettings() async {
await Navigator.of(context).push(
MaterialPageRoute<void>(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<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,
);
});
}
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<void> _openWorkout({bool resume = false}) async {
final storage = StorageService.instance;
Map<String, dynamic>? savedState;
@ -141,6 +229,17 @@ class _HomeScreenState extends State<HomeScreen> {
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,

View File

@ -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<DateTime?> 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<void> 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.

View File

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

View File

@ -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<bool> 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<PushResult> 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
? <String, Record>{}
: _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].

View File

@ -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<AppStatusColors>()!;
// 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)),
],
),
),
);
}
}

View File

@ -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<String, dynamic> _fixture() =>
jsonDecode(_fixtureFile.readAsStringSync()) as Map<String, dynamic>;
/// 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<dynamic>) {
final c = entry as Map<String, dynamic>;
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')));
});
}

View File

@ -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<bool> 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<NavigatorState>(find.byType(Navigator).last).pop();
await Future<void>.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<void>.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<PushResult> syncNow() async =>
const PushResult(pushed: false, reason: 'sync failed: no network');
}
/// A sync service whose tick always succeeds.
class _OkSyncService extends WorkoutSyncService {
@override
Future<PushResult> 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<PushResult> syncNow() => Completer<PushResult>().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<PushResult> 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');
}
}

View File

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

View File

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

View File

@ -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<String, dynamic> decodeLogPut(List<_PutCall> calls) =>
jsonDecode(
utf8.decode(
base64.decode(_logPut(calls).body['content'] as String),
),
)
as Map<String, dynamic>;
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 {

View File

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

View File

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