mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 12:07:44 +01:00
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
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:
parent
6abb553156
commit
c63a02f9eb
@ -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
|
||||
|
||||
@ -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})"
|
||||
)
|
||||
|
||||
|
||||
@ -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, "
|
||||
|
||||
@ -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:
|
||||
|
||||
23
screen_locker/tests/fixtures/duration_threshold.json
vendored
Normal file
23
screen_locker/tests/fixtures/duration_threshold.json
vendored
Normal 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 }
|
||||
]
|
||||
}
|
||||
87
screen_locker/tests/test_duration_threshold_fixture.py
Normal file
87
screen_locker/tests/test_duration_threshold_fixture.py
Normal 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
|
||||
@ -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])
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)})';
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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].
|
||||
|
||||
@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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')));
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user