Add phone-workout GitHub sync via shared crdt_sync (Workstream C)
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

Sync the workout log across devices using the shared crdt-sync /
crdt_sync_dart CRDT transport (same scheme as diet_guard).

- Python: new _workout_sync.py + _temperature_status_mixin.py; wire sync
  into screen_lock.py / status_view.py; add the crdt-sync git dependency
  and test_sync_fixtures / test_workout_sync suites.
- Dart (workout_app): new workout_sync_service.dart, github_device_auth.dart,
  sync_settings.dart with tests + fakes; settings/workout screens updated.
- docs: workstream-d-shared-crdt-transport.md.

Verification: pytest 747 passed at 100% coverage; flutter analyze clean;
flutter test passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
This commit is contained in:
Krzysztof Rudnicki 2026-07-11 00:06:59 +02:00
parent 494cc74bb5
commit 399f9815c4
44 changed files with 2880 additions and 125 deletions

View File

@ -3,14 +3,36 @@
## Status
Scoping pass complete — see `workstream-d-scoping-findings.md` (2026-07-04)
for the answers to the four questions below and two sub-decisions that fell
out of it. Still not implemented; those sub-decisions need a user call
before any code is written. Split out from the original status-view plan
for the answers to the four questions below and the two sub-decisions that
fell out of it (both resolved by explicit user call, 2026-07-04).
**The two shared libraries are now implemented and locally verified
(2026-07-05):**
- `~/crdt-sync` (Python) — 60 tests, 100% branch coverage, clean
ruff/mypy/pylint/bandit/pre-commit. Local git repo, one commit.
- `~/crdt_sync_dart` (pure Dart, no Flutter dep) — 59 tests, clean
`dart analyze`. Local git repo, one commit.
Both implement the unified LWW-map-with-sticky-remove scheme (`Hlc`,
`Record`/`merge_record`, `Log`/`merge_logs`) plus a GitHub Contents-API
transport client and a domain-agnostic `sync_log`/`syncLog` orchestrator.
Both were originally pushed as standalone public repos (2026-07-05, matching
`gatelock`'s convention), then consolidated (2026-07-10) into the shared
`github.com/kuhyx/utils` monorepo alongside `gatelock` and `guard-lib`, each
as its own subdirectory. Consume via
`crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync`
(Python) or a pubspec `git:`/`path:` dependency pointing at
`crdt_sync_dart-v0.1.0`/`crdt_sync_dart` (Dart).
**Not yet done — none of the four apps have been migrated onto it yet**:
todo (off `sqlite_crdt`), diet-guard (Python + Dart merge code), wake-alarm
(`shutdown-wrapper.sh` rewrite), and screen-locker's own Workstream C
adoption. Each is its own separate, production-code-touching task per the
scoping doc's per-app cost estimates.
Split out from the original status-view plan
(`~/.claude/plans/screen-locker-status-streamed-feigenbaum.md`, Workstream A).
The plan is explicit: **"Scope and spec this as its own dedicated task
before writing any code — do not start it opportunistically inside
Workstream A/B/C work."** This document is orientation for that future
scoping pass, not a spec to start coding from.
## The decision this workstream implements

View File

@ -4,8 +4,11 @@ version = "1.0.0"
description = "Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration"
requires-python = ">=3.10"
# gatelock: shared lock-window/HMAC backend, also used by diet_guard and wake_alarm
# crdt-sync: shared CRDT merge scheme + GitHub-Contents-API sync transport,
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
dependencies = [
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.1.0#subdirectory=gatelock",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync",
]
[project.scripts]
@ -53,9 +56,19 @@ unfixable = []
"PLC0415", "PLR0913", "PLR2004", "PTH", "RUF003", "S101", "SLF001",
]
"screen_locker/_runnerup_verification.py" = ["S314"]
# URL is always built from the hardcoded https:// _WTTR_URL constant with
# only the (quoted) city interpolated — never user/attacker controlled.
# ruff's S310 can't verify that via static analysis of a variable URL, so
# it always flags urlopen() here regardless; a runtime scheme check was
# considered and rejected (it can't ever trigger, so it's just an untested
# branch and a crash risk in the lock's startup path for zero real benefit).
"screen_locker/_temperature.py" = ["S310"]
"screen_locker/_shutdown_base.py" = ["SLF001"]
"screen_locker/_status.py" = ["PLR0915", "SLF001", "T201"]
"screen_locker/status_view.py" = ["PLC0415", "SLF001", "T201"]
# StatusWindow.__init__ takes one arg per independently-mocked dependency
# (verifier_factory, temperature_fetcher, log_file, ...) — same
# dependency-injection rationale as _status_data.py's gather_status below.
"screen_locker/status_view.py" = ["PLC0415", "PLR0913", "SLF001", "T201"]
# explain_lock_decision takes one arg per real file path + sick history, and
# returns early per lock-decision-chain stage (see module docstring) — both
# are the deliberate design, not something to collapse into a config object.
@ -67,6 +80,10 @@ unfixable = []
# validation-chain shape as _compliance_state.py's explain_lock_decision) —
# deliberate design, not something to collapse into a config object.
"screen_locker/_manual_workout.py" = ["C901", "PLR0911"]
# _verify_phone_workout returns early per verification-chain stage (clock
# check, sync, ADB, HTTP, then terminal statuses) — same shape as the two
# ignores above, not something to collapse into a config object.
"screen_locker/_phone_verification.py" = ["C901", "PLR0911"]
"scripts/check_file_length.py" = ["PTH123"]
[tool.ruff.lint.pydocstyle]
@ -162,6 +179,10 @@ addopts = [
"--cov-branch",
"--cov-report=term-missing",
"--cov-report=lcov",
# A fixture-only plugin module, not conftest.py (already at the
# repo's 400-line-per-file cap) -- see its own docstring.
"-p",
"screen_locker.tests.test_sync_fixtures",
]
filterwarnings = [
"error",

View File

@ -243,6 +243,7 @@ def explain_lock_decision(
)
expired = pending and not window_open
early_bird_open_and_pending = pending and window_open
window_end_label = "09:00" if extended_early_bird else "08:30"
sick_today = is_sick_day_today(sick_history, today=today_str)
logged = has_logged_today(log_file, today=today_str)
@ -342,10 +343,12 @@ def explain_lock_decision(
result = _check(
"early_bird_time_fresh",
fired=window_open,
reason_true="Currently inside the early-bird window (05:00-08:30/09:00).",
reason_true=(
f"Currently inside the early-bird window (05:00-{window_end_label})."
),
reason_false="Not currently inside the early-bird window.",
terminal_reason="Inside the early-bird window — lock skipped, pending marker "
"would be saved for the 08:30/09:00 re-check.",
f"would be saved for the {window_end_label} re-check.",
)
if result is not None:
return result

View File

@ -108,3 +108,20 @@ RUNNERUP_EXPORT_DIRS: tuple[str, ...] = (
"/sdcard/Documents/RunnerUp",
"/storage/emulated/0/Documents/RunnerUp",
)
# ---------------------------------------------------------------------------
# Sync (phone workout data) — Workstream C, built on the shared crdt-sync
# library. GitHub is used purely as dumb file storage via the REST Contents
# API (not a git clone), same pattern as diet_guard's cross-device sync. The
# phone app pushes to devices/<SYNC_PHONE_DEVICE_ID>/log.json; the PC only
# ever reads that one path, it never pushes here itself.
# ---------------------------------------------------------------------------
SYNC_REPO_OWNER: str = "kuhyx"
SYNC_REPO_NAME: str = "syncs"
SYNC_PHONE_DEVICE_ID: str = "phone"
# A fine-grained GitHub PAT, scoped to just SYNC_REPO_NAME's contents. The
# user creates this once via github.com and saves it here, mode 600. Never
# committed — this path is outside the repo entirely. Unlike diet_guard,
# this file being absent is a normal, expected state (sync is optional here).
SYNC_TOKEN_FILE: Path = Path.home() / ".config" / "screen_locker" / "sync_token"
SYNC_TIMEOUT_SECONDS: float = 10.0

View File

@ -1,4 +1,4 @@
"""Phone workout verification: ADB pull first, HTTP scan as fallback."""
"""Phone workout verification: GitHub sync first, ADB/HTTP as fallback."""
from __future__ import annotations
@ -24,6 +24,7 @@ from screen_locker._constants import (
WORKOUT_HTTP_PORT,
)
from screen_locker._time_check import check_clock_skew
from screen_locker._workout_sync import pull_synced_workout
_HTTPConnection = _http_client.HTTPConnection
_HTTPException = _http_client.HTTPException
@ -243,16 +244,39 @@ class PhoneVerificationMixin:
# ── Main verification entry point ─────────────────────────────────────────
def _verify_phone_workout(self) -> tuple[str, str]:
"""Verify today's workout: ADB pull if available, HTTP scan as fallback.
"""Verify today's workout: GitHub sync first, ADB/HTTP as fallback.
Returns (status, message). Status values:
verified / too_short / not_verified / no_phone /
verified / too_short / not_verified / no_phone / sync_failed /
stale / no_exercises / clock_tampered.
"""
clock_ok, clock_msg = check_clock_skew()
if not clock_ok:
return "clock_tampered", clock_msg
# GitHub sync is the primary channel — it works without the phone
# being on the same network as the PC. Only a *verified* sync result
# short-circuits ADB/HTTP: a stale/incomplete cloud entry (e.g. the
# phone's last push predates today, or a later push failed offline)
# must not preempt a fresher ADB pull, so anything less than
# "verified" is kept as a fallback-of-last-resort candidate instead
# of being returned immediately. A sync error never blocks unlocking
# either; `sync_failed` only surfaces if ADB/HTTP also come up empty.
synced_data, sync_error = pull_synced_workout()
sync_result: tuple[str, str] | None = None
if synced_data is not None:
sync_result = self._validate_json_data(synced_data)
if sync_result[0] == "verified":
return sync_result
_logger.info(
"Synced data not fully verified (%s) — trying ADB/HTTP...",
sync_result[0],
)
elif sync_error is not None:
_logger.info(
"GitHub sync unavailable (%s) — trying ADB/HTTP...", sync_error
)
# Prefer ADB when a device is visible, but if the pull yields no usable
# JSON, fall through to the HTTP/WiFi scan — the app's in-memory HTTP
# server may still hold today's workout even when the file pull fails.
@ -268,6 +292,10 @@ class PhoneVerificationMixin:
data = self._fetch_http_workout()
if data is not None:
return self._validate_json_data(data)
if sync_result is not None:
return sync_result
if sync_error is not None:
return "sync_failed", sync_error
if adb_connected:
return (
"not_verified",

View File

@ -6,6 +6,9 @@ user claims about the temperature.
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as _FutureTimeoutError
from dataclasses import dataclass
import json
import logging
import urllib.error
@ -15,13 +18,29 @@ _logger = logging.getLogger(__name__)
_WTTR_URL = "https://wttr.in/{city}?format=j1"
_TIMEOUT_SECONDS = 5
# urlopen's `timeout` bounds the socket connect/read, but not necessarily DNS
# resolution (`getaddrinfo`) on every platform/resolver — a stalled resolver
# can block far past `_TIMEOUT_SECONDS` with no visible failure. This is a
# second, hard wall-clock ceiling enforced independently in a worker thread.
HARD_TIMEOUT_SECONDS = 6.0
def fetch_current_temp_celsius(city: str) -> float | None:
@dataclass(frozen=True)
class TemperatureCheck:
"""Result of a bounded live temperature fetch."""
temp_celsius: float | None
timed_out: bool
def _fetch_current_temp_celsius_unbounded(city: str) -> float | None:
"""Return the current temperature in °C for *city*, or None on failure.
Uses wttr.in's JSON API (no API key required). Returns None on network
errors, timeouts, or unexpected response shapes so callers can fail-closed.
May block past `_TIMEOUT_SECONDS` if DNS resolution stalls — callers
should go through `fetch_current_temp_with_status` for a hard bound
instead of calling this directly.
"""
url = _WTTR_URL.format(city=urllib.request.quote(city, safe=""))
try:
@ -37,12 +56,26 @@ def fetch_current_temp_celsius(city: str) -> float | None:
return None
def is_too_hot(city: str, threshold: float) -> float | None:
"""Return the current temperature if it exceeds *threshold*, else None.
def fetch_current_temp_with_status(
city: str, *, hard_timeout: float = HARD_TIMEOUT_SECONDS
) -> TemperatureCheck:
"""Fetch °C for *city*, bounded to *hard_timeout* seconds wall-clock.
Fail-closed: API unavailable → returns None → no heat skip offered.
Runs the fetch in a worker thread and stops waiting on it after
*hard_timeout* regardless of what the network call is doing internally
(DNS, connect, read). The worker thread itself can't be killed and may
keep running in the background after we give up on it — acceptable for
a fire-and-forget diagnostic read. Distinguishes "timed out" from other
failures (network/parse errors) so callers can report which happened.
"""
temp = fetch_current_temp_celsius(city)
if temp is None:
return None
return temp if temp >= threshold else None
executor = ThreadPoolExecutor(max_workers=1)
future = executor.submit(_fetch_current_temp_celsius_unbounded, city)
executor.shutdown(wait=False)
try:
temp = future.result(timeout=hard_timeout)
except _FutureTimeoutError:
_logger.warning(
"Temperature fetch exceeded hard timeout of %.1fs", hard_timeout
)
return TemperatureCheck(temp_celsius=None, timed_out=True)
return TemperatureCheck(temp_celsius=temp, timed_out=False)

View File

@ -0,0 +1,100 @@
"""Live Warsaw-temperature background check mixin for ``StatusWindow``.
Fetches the same reading the real locker's heat-skip check would see
(``_temperature.fetch_current_temp_with_status``), in a background thread,
bounded to ``_temperature.HARD_TIMEOUT_SECONDS`` — display-only, never
writes to ``workout_log.json``. Host class must provide:
- ``self.root`` — the Tk root, for ``root.after`` polling.
- ``self.temperature_fetcher`` — injected fetch callable (tests override it).
- ``self._temp_future`` / ``self._temp_result`` — state slots, initialized
by the host's ``__init__``.
- ``self._last_snapshot`` — the most recently rendered ``StatusSnapshot``.
- ``self.render(snapshot)`` — full redraw, called after a result resolves.
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
from typing import TYPE_CHECKING
from screen_locker._constants import HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD
from screen_locker._temperature import HARD_TIMEOUT_SECONDS
if TYPE_CHECKING:
import tkinter as tk
from screen_locker._temperature import TemperatureCheck
class TemperatureStatusMixin:
"""Provides the background Warsaw-temperature fetch/poll/render."""
def _section_temperature(self, parent: tk.Widget) -> None:
"""Render the live Warsaw temperature.
The same reading the real locker's heat-skip check would see,
fetched in the background.
"""
del parent
self._label("Warsaw Temperature", font_size=16, pady=8)
result = self._temp_result
if result is None:
self._text("Checking Warsaw temperature…", font_size=12, color="#888888")
return
if result.timed_out:
self._text(
f"Warsaw temperature check timed out after ~"
f"{int(HARD_TIMEOUT_SECONDS)}s — network may be down.",
font_size=12,
color="#ff8844",
)
return
if result.temp_celsius is None:
self._text(
"Warsaw temperature check failed (network/API error).",
font_size=12,
color="#ff8844",
)
return
hot = result.temp_celsius >= HEAT_SKIP_TEMP_THRESHOLD
self._text(
f"Warsaw: {result.temp_celsius:.0f}°C (heat-skip threshold "
f"{HEAT_SKIP_TEMP_THRESHOLD}°C)",
font_size=12,
color="#ff8844" if hot else "#cccccc",
)
if hot:
self._text("Would trigger heat-skip today.", font_size=11, color="#ff4444")
def _start_temperature_check(self) -> None:
"""Submit a background Warsaw-temperature fetch and start polling it.
Unlike ``_on_check_phone_clicked`` (an explicit, opt-in user action),
this runs on every window open and refresh, so the first poll is
always deferred through ``root.after`` rather than checked
synchronously — otherwise a worker thread that happens to finish
before this call returns would trigger a second, redundant render
from within the same call stack that hasn't finished the first one.
"""
executor = ThreadPoolExecutor(max_workers=1)
self._temp_future = executor.submit(self.temperature_fetcher, HEAT_SKIP_CITY)
executor.shutdown(wait=False)
self.root.after(500, self._poll_temperature_check)
def _poll_temperature_check(self) -> None:
"""Poll the background temperature-check future until it resolves."""
if self._temp_future is not None and self._temp_future.done():
self._on_temperature_check_result(self._temp_future.result())
else:
self.root.after(500, self._poll_temperature_check)
def _on_temperature_check_result(self, result: TemperatureCheck) -> None:
"""Store the resolved temperature check and redraw to show it.
Re-renders the last snapshot already on screen rather than
re-gathering from disk — the temperature reading is independent of
on-disk workout state, so there's nothing new to read.
"""
self._temp_result = result
self.render(self._last_snapshot)

View File

@ -0,0 +1,108 @@
"""Pulls the phone's last-synced workout via crdt-sync's GitHub transport.
GitHub is used purely as dumb file storage (see ``crdt_sync``'s own docs) --
the phone app pushes its completed-workout log to a private repo; this
module only ever reads it, it never pushes here itself (the PC has no
workout data of its own to contribute). Sync is optional: an unconfigured
token is a normal, expected state, not an error -- unlike diet_guard, where
sync is core to the app.
"""
from __future__ import annotations
import json
import logging
from crdt_sync import GitHubSyncClient, GitHubSyncError, Record
from screen_locker._constants import (
SYNC_PHONE_DEVICE_ID,
SYNC_REPO_NAME,
SYNC_REPO_OWNER,
SYNC_TIMEOUT_SECONDS,
SYNC_TOKEN_FILE,
)
_logger = logging.getLogger(__name__)
_LOG_PATH = f"screen-locker-sync/devices/{SYNC_PHONE_DEVICE_ID}/log.json"
_PAYLOAD_FIELD = "payload"
def read_sync_token() -> str | None:
"""Return the saved sync PAT, or None if sync isn't configured.
Unlike diet_guard's equivalent, an absent or empty token file is a
normal state here -- sync is an optional primary channel, not something
the app requires to function.
"""
if not SYNC_TOKEN_FILE.exists():
return None
token = SYNC_TOKEN_FILE.read_text().strip()
return token or None
def _latest_payload(log_json: str) -> dict | None:
"""Return the most recently pushed workout payload from a log.json blob.
Raises:
TypeError: If the top-level JSON isn't an object, a record's shape
doesn't match what :meth:`Record.from_dict` expects, or the
payload field itself isn't a JSON object.
KeyError: If a record is missing an expected key.
ValueError: Via ``json.loads``, if the text isn't valid JSON, or via
:meth:`crdt_sync.Hlc.from_str` on a malformed clock string.
"""
raw = json.loads(log_json)
if not isinstance(raw, dict):
msg = f"top-level sync payload is not a JSON object: {raw!r}"
raise TypeError(msg)
records = [Record.from_dict(data) for data in raw.values()]
candidates = [record for record in records if _PAYLOAD_FIELD in record.fields]
if not candidates:
return None
latest = max(candidates, key=lambda record: record.fields[_PAYLOAD_FIELD][1])
payload = latest.fields[_PAYLOAD_FIELD][0]
if not isinstance(payload, dict):
msg = f"payload field is not a JSON object: {payload!r}"
raise TypeError(msg)
return payload
def pull_synced_workout() -> tuple[dict | None, str | None]:
"""Return ``(data, error)``: the phone's last-synced workout, if any.
- No token configured: ``(None, None)`` -- benign, sync is optional.
- Nothing pushed yet (repo reachable, path unused): ``(None, None)``.
- A real sync error (network, bad token, repo unreachable):
``(None, <message>)``.
- Corrupt/malformed sync data: ``(None, "corrupt sync data: ...")``.
- Success: ``(payload_dict, None)``.
"""
token = read_sync_token()
if token is None:
return None, None
client = GitHubSyncClient(
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
token,
timeout_seconds=SYNC_TIMEOUT_SECONDS,
)
try:
text = client.get_file_text(_LOG_PATH)
except GitHubSyncError as exc:
return None, str(exc)
if text is None:
return None, None
try:
payload = _latest_payload(text)
except (ValueError, KeyError, TypeError) as exc:
_logger.warning("Corrupt sync data at %s: %s", _LOG_PATH, exc)
return None, f"corrupt sync data: {exc}"
return payload, None

View File

@ -46,7 +46,7 @@ from screen_locker._runnerup_verification import RunnerUpVerificationMixin
from screen_locker._shutdown import ShutdownMixin
from screen_locker._shutdown_base import reset_to_base_if_new_day
from screen_locker._sick_dialog import SickDialogMixin
from screen_locker._temperature import is_too_hot
from screen_locker._temperature import fetch_current_temp_with_status
from screen_locker._ui_flows import UIFlowsMixin
from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin
from screen_locker._ui_widgets import UIWidgetsMixin
@ -224,17 +224,36 @@ class ScreenLocker(
_logger.info("Auto-fill extra bonus: +%dh shutdown time.", bonus)
def _check_heat_skip_exit(self) -> None:
"""Exit early if today qualifies for the extreme-heat skip dialog."""
hot_temp = is_too_hot(HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD)
if hot_temp is None:
"""Exit early if today qualifies for the extreme-heat skip dialog.
Fail-closed by construction: a failed or timed-out temperature fetch
falls straight through to the lock, same as "not hot enough" — the
only difference is this logs *why* explicitly, so a fetch failure
is never silently indistinguishable from a normal day in the logs.
"""
check = fetch_current_temp_with_status(HEAT_SKIP_CITY)
if check.timed_out:
_logger.warning(
"Heat-skip temperature check timed out — defaulting to lock."
)
return
if check.temp_celsius is None:
_logger.warning(
"Heat-skip temperature check failed (network/API error) — "
"defaulting to lock."
)
return
if check.temp_celsius < HEAT_SKIP_TEMP_THRESHOLD:
return
_logger.info(
"Temperature %.0f°C exceeds threshold — showing heat-skip dialog.",
hot_temp,
check.temp_celsius,
)
if self._show_heat_skip_dialog(hot_temp):
self._save_heat_skip_log(hot_temp)
_logger.info("User skipped workout due to heat (%.0f°C).", hot_temp)
if self._show_heat_skip_dialog(check.temp_celsius):
self._save_heat_skip_log(check.temp_celsius)
_logger.info(
"User skipped workout due to heat (%.0f°C).", check.temp_celsius
)
sys.exit(0)
def _apply_weekly_shutdown_bonus(self) -> None:

View File

@ -1,12 +1,20 @@
"""Read-only Tkinter status window plus a lightweight i3blocks summary CLI.
Opening or refreshing this window only ever reads files already on disk
(via ``_status_data.gather_status``). The "Check Phone" button submits
``PhoneVerificationMixin._verify_phone_workout`` to a background thread —
mirroring the existing submit/poll/``Future`` idiom used by
``_ui_flows_relaxed.py`` — and only ever *displays* the result; it never
calls ``save_workout_log()``. The one deliberate exception is "Log Manual
Workout": a user-initiated, explicit evidence-form submission (see
(via ``_status_data.gather_status``), with two deliberate exceptions that
only ever *display* a result and never write to ``workout_log.json``:
* "Check Phone" submits ``PhoneVerificationMixin._verify_phone_workout`` to a
background thread — mirroring the existing submit/poll/``Future`` idiom
used by ``_ui_flows_relaxed.py``.
* The live Warsaw temperature (see ``_section_temperature``) is fetched via
``_temperature.fetch_current_temp_with_status`` on open and on every
refresh, in its own background thread, bounded to
``_temperature.HARD_TIMEOUT_SECONDS`` — the same call the real locker's
heat-skip check makes, so the window shows what the lock actually sees.
The one exception that *does* write is "Log Manual Workout": a
user-initiated, explicit evidence-form submission (see
``_manual_workout_dialog.ManualWorkoutDialogMixin``) — never a silent log.
"""
@ -30,6 +38,10 @@ from screen_locker._status_data import (
format_summary_line,
gather_status,
)
from screen_locker._temperature import (
fetch_current_temp_with_status,
)
from screen_locker._temperature_status_mixin import TemperatureStatusMixin
from screen_locker._ui_widgets import UIWidgetsMixin
from screen_locker._weekly_check import (
WEEKLY_WORKOUT_MINIMUM as _WEEKLY_WORKOUT_MINIMUM,
@ -40,6 +52,7 @@ if TYPE_CHECKING:
from concurrent.futures import Future
from screen_locker._compliance_state import LockExplanation
from screen_locker._temperature import TemperatureCheck
from screen_locker.screen_lock import ScreenLocker
_DEFAULT_LOG_FILE = Path(__file__).resolve().parent / "workout_log.json"
@ -60,7 +73,7 @@ def _make_bare_verifier(log_file: Path) -> ScreenLocker:
return verifier
class StatusWindow(UIWidgetsMixin, ManualWorkoutDialogMixin):
class StatusWindow(UIWidgetsMixin, ManualWorkoutDialogMixin, TemperatureStatusMixin):
"""Thin Tk view over a :class:`StatusSnapshot`.
Formatting logic lives in ``_status_data``, not here.
@ -74,27 +87,37 @@ class StatusWindow(UIWidgetsMixin, ManualWorkoutDialogMixin):
on_refresh: Callable[[], None],
log_file: Path = _DEFAULT_LOG_FILE,
verifier_factory: Callable[[Path], ScreenLocker] = _make_bare_verifier,
temperature_fetcher: Callable[
[str], TemperatureCheck
] = fetch_current_temp_with_status,
) -> None:
"""Build the window's container and render *snapshot* immediately."""
self.root = root
self.on_refresh = on_refresh
self.log_file = log_file
self.verifier_factory = verifier_factory
self.temperature_fetcher = temperature_fetcher
self.demo_mode = True # only affects UIWidgetsMixin._button's cursor
self._phone_future: Future[tuple[str, str]] | None = None
self._phone_check_result: tuple[str, str] | None = None
self._manual_workout_saved_message: str | None = None
self._temp_future: Future[TemperatureCheck] | None = None
self._temp_result: TemperatureCheck | None = None
self._last_snapshot = snapshot
self.container = tk.Frame(root, bg="#1a1a1a")
self.container.pack(fill="both", expand=True)
self._start_temperature_check()
self.render(snapshot)
def render(self, snapshot: StatusSnapshot) -> None:
"""Redraw the whole window from *snapshot*."""
self._last_snapshot = snapshot
self.clear_container()
self._label("Workout Status", font_size=28, pady=15)
self._section_today(self.container, snapshot.today)
self._section_week(self.container, snapshot.week)
self._section_lock_explanation(self.container, snapshot.lock_explanation)
self._section_temperature(self.container)
self._section_sick_budget(self.container, snapshot.sick_budget)
self._section_manual_workout_budget(
self.container, snapshot.manual_workout_budget
@ -187,13 +210,6 @@ class StatusWindow(UIWidgetsMixin, ManualWorkoutDialogMixin):
font_size=11,
color="#ffaa00",
)
if expl.fired and not expl.heat_skip_evaluated:
self._text(
"Live Warsaw temperature is not checked here — the real "
"locker may still offer a heat-skip.",
font_size=11,
color="#888888",
)
def _section_sick_budget(self, parent: tk.Widget, sick: SickBudgetStatus) -> None:
"""Render rolling sick-day budget usage."""
@ -249,8 +265,10 @@ class StatusWindow(UIWidgetsMixin, ManualWorkoutDialogMixin):
self._text(shutdown.explanation, font_size=10, color="#666666")
def _on_refresh_clicked(self) -> None:
"""Clear any stale phone-check result and re-gather the snapshot."""
"""Clear any stale phone-check/temperature results, re-check both."""
self._phone_check_result = None
self._temp_result = None
self._start_temperature_check()
self.on_refresh()
def _on_check_phone_clicked(self) -> None:

View File

@ -23,6 +23,7 @@ from screen_locker._status_data import (
StatusSnapshot,
WeeklySummary,
)
from screen_locker._temperature import TemperatureCheck
from screen_locker.status_view import StatusWindow
_WEEKDAY_LABELS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
@ -152,10 +153,21 @@ def _button_texts(mock_tk: MagicMock) -> set[str]:
return {c.kwargs.get("text") for c in mock_tk.Button.call_args_list}
def _temp_check(
*, temp_celsius: float | None = 20.0, timed_out: bool = False
) -> TemperatureCheck:
return TemperatureCheck(temp_celsius=temp_celsius, timed_out=timed_out)
def _make_window(
mock_tk: MagicMock, snapshot: StatusSnapshot, **kwargs: object
) -> StatusWindow:
"""Build a StatusWindow with an instant, non-hot, non-network temperature
fetcher by default, so unrelated tests never trigger real network I/O —
tests exercising temperature behavior pass their own ``temperature_fetcher``.
"""
root = MagicMock()
kwargs.setdefault("temperature_fetcher", lambda _city: _temp_check())
return StatusWindow(
root, snapshot, on_refresh=kwargs.pop("on_refresh", MagicMock()), **kwargs
)

View File

@ -103,18 +103,18 @@ def mock_subprocess_run() -> Generator[MagicMock]:
@pytest.fixture(autouse=True)
def _block_real_network() -> Iterator[None]:
"""Block real subnet probes for every test.
"""Block real subnet probes and wttr.in calls for every test.
``_scan_for_http_server`` / ``_try_wireless_reconnect`` open real TCP
sockets to scan the LAN; without this an unmocked ``_verify_phone_workout``
would actually reach the phone over the network (flaky, environment-coupled).
Defaults ``create_connection`` to refuse — tests needing a successful probe
patch it locally, which takes precedence inside the test body.
Otherwise phone verification / StatusWindow's temperature fetch would
reach the real network. Tests needing a real probe patch it locally.
"""
with patch(
targets = (
"screen_locker._phone_verification.socket.create_connection",
side_effect=OSError("network blocked in tests"),
):
"screen_locker._temperature.urllib.request.urlopen",
)
with ExitStack() as stack:
for target in targets:
stack.enter_context(patch(target, side_effect=OSError("blocked")))
yield

View File

@ -148,6 +148,27 @@ class TestExplainLockDecision:
)
assert result.fired is False
assert result.stage == "early_bird_time_fresh"
assert "08:30" in result.reason
assert "09:00" not in result.reason
def test_fresh_early_bird_time_names_09_00_when_extended(
self, tmp_path: Path
) -> None:
"""The re-check time isn't ambiguous — it's whichever one actually applies."""
files = self._files(tmp_path)
now = datetime.now(tz=timezone.utc).astimezone().replace(hour=6, minute=0)
result = explain_lock_decision(
**files,
sick_history=SickHistory(),
extended_early_bird=True,
weekly_minimum_met=False,
relaxed_day=False,
now=now,
)
assert result.fired is False
assert result.stage == "early_bird_time_fresh"
assert "09:00" in result.reason
assert "08:30" not in result.reason
def test_relaxed_day_skips(self, tmp_path: Path) -> None:
files = self._files(tmp_path)

View File

@ -252,3 +252,89 @@ class TestVerifyPhoneWorkoutFallthrough:
):
status, _ = locker._verify_phone_workout()
assert status == "no_phone"
def test_synced_data_is_validated_directly_without_adb_or_http(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""A successful sync pull short-circuits ADB/HTTP entirely."""
locker = create_locker(mock_tk, tmp_path)
synced_data = {
"date": _today(),
"exercises": ["a"],
"duration_seconds": 4000,
"succeeded": True,
}
adb = MagicMock()
http = MagicMock()
with (
patch(
"screen_locker._phone_verification.check_clock_skew",
return_value=(True, ""),
),
patch(
"screen_locker._phone_verification.pull_synced_workout",
return_value=(synced_data, None),
),
patch.object(locker, "_is_phone_connected", adb),
patch.object(locker, "_fetch_http_workout", http),
):
status, _ = locker._verify_phone_workout()
assert status == "verified"
adb.assert_not_called()
http.assert_not_called()
def test_sync_error_falls_through_to_a_successful_fallback_silently(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""A sync error must never block a result the fallback can still find."""
locker = create_locker(mock_tk, tmp_path)
http_data = {
"date": _today(),
"exercises": ["a"],
"duration_seconds": 4000,
"succeeded": True,
}
with (
patch(
"screen_locker._phone_verification.check_clock_skew",
return_value=(True, ""),
),
patch(
"screen_locker._phone_verification.pull_synced_workout",
return_value=(None, "network unreachable"),
),
patch.object(locker, "_is_phone_connected", return_value=False),
patch.object(locker, "_fetch_http_workout", return_value=http_data),
):
status, _ = locker._verify_phone_workout()
assert status == "verified"
def test_sync_error_and_empty_fallback_returns_sync_failed(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Nothing found anywhere AND sync errored → sync_failed, not no_phone."""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker._phone_verification.check_clock_skew",
return_value=(True, ""),
),
patch(
"screen_locker._phone_verification.pull_synced_workout",
return_value=(None, "bad token"),
),
patch.object(locker, "_is_phone_connected", return_value=False),
patch.object(locker, "_fetch_http_workout", return_value=None),
):
status, message = locker._verify_phone_workout()
assert status == "sync_failed"
assert message == "bad token"

View File

@ -137,6 +137,69 @@ class TestScanForHttpServer:
assert locker._scan_for_http_server() is None
class TestVerifyPhoneWorkoutSyncStaleness:
"""A stale/non-verified sync result must not preempt a fresher fallback."""
def test_stale_synced_data_falls_through_to_a_fresh_adb_pull(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A stale cloud entry must not preempt a fresher ADB result."""
locker = create_locker(mock_tk, tmp_path)
stale_synced = {
"date": "2000-01-01",
"exercises": ["old"],
"duration_seconds": 4000,
"succeeded": True,
}
fresh_adb = {
"date": _today(),
"exercises": ["new"],
"duration_seconds": 4000,
"succeeded": True,
}
with (
patch(
"screen_locker._phone_verification.check_clock_skew",
return_value=(True, ""),
),
patch(
"screen_locker._phone_verification.pull_synced_workout",
return_value=(stale_synced, None),
),
patch.object(locker, "_is_phone_connected", return_value=True),
patch.object(locker, "_pull_workout_app_json", return_value=fresh_adb),
):
status, _ = locker._verify_phone_workout()
assert status == "verified"
def test_stale_synced_data_used_as_last_resort_when_fallback_is_empty(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A non-verified sync result still beats no_phone/not_verified."""
locker = create_locker(mock_tk, tmp_path)
stale_synced = {
"date": "2000-01-01",
"exercises": ["old"],
"duration_seconds": 4000,
"succeeded": True,
}
with (
patch(
"screen_locker._phone_verification.check_clock_skew",
return_value=(True, ""),
),
patch(
"screen_locker._phone_verification.pull_synced_workout",
return_value=(stale_synced, None),
),
patch.object(locker, "_is_phone_connected", return_value=False),
patch.object(locker, "_fetch_http_workout", return_value=None),
):
status, message = locker._verify_phone_workout()
assert status == "stale"
assert "2000-01-01" in message
class TestFetchHttpWorkout:
"""Tests for _fetch_http_workout over the local HTTP server."""

View File

@ -376,5 +376,3 @@ class TestPullAndParseTcx:
# ---------------------------------------------------------------------------
# _verify_runnerup_via_files
# ---------------------------------------------------------------------------

View File

@ -80,7 +80,6 @@ def _write_tcx(tmp_path: Path, content: str, name: str = "activity.tcx") -> str:
# ---------------------------------------------------------------------------
class TestVerifyRunnerupViaFiles:
"""Tests for _verify_runnerup_via_files (lines 147-165)."""
@ -168,5 +167,3 @@ class TestVerifyRunnerupViaFiles:
# ---------------------------------------------------------------------------
# _scan_and_fill_week_runnerup
# ---------------------------------------------------------------------------

View File

@ -81,7 +81,6 @@ def _write_tcx(tmp_path: Path, content: str, name: str = "activity.tcx") -> str:
# ---------------------------------------------------------------------------
class TestScanAndFillWeekRunnerup:
"""Tests for _scan_and_fill_week_runnerup (lines 186-248)."""
@ -350,5 +349,3 @@ class TestScanAndFillWeekRunnerup:
# ---------------------------------------------------------------------------
# _find_runnerup_package
# ---------------------------------------------------------------------------

View File

@ -83,7 +83,6 @@ def _write_tcx(tmp_path: Path, content: str, name: str = "activity.tcx") -> str:
# ---------------------------------------------------------------------------
class TestFindRunnerupPackage:
"""Tests for _find_runnerup_package (lines 256-260)."""
@ -319,5 +318,3 @@ class TestQueryTodaysRun:
# ---------------------------------------------------------------------------
# _verify_runnerup_via_db
# ---------------------------------------------------------------------------

View File

@ -84,7 +84,6 @@ def _write_tcx(tmp_path: Path, content: str, name: str = "activity.tcx") -> str:
# ---------------------------------------------------------------------------
class TestVerifyRunnerupViaDb:
"""Tests for _verify_runnerup_via_db (lines 364-376)."""

View File

@ -107,9 +107,8 @@ class TestSectionLockExplanation:
texts = _texts(mock_tk)
assert any(t == "Full lock." for t in texts)
assert any("Pending auto-upgrade: will try phone" in t for t in texts)
assert any("Live Warsaw temperature" in t for t in texts)
def test_not_fired_no_auto_upgrade_no_heat_line(self, mock_tk: MagicMock) -> None:
def test_not_fired_no_auto_upgrade(self, mock_tk: MagicMock) -> None:
snap = _snapshot(
lock_explanation=_lock_explanation(fired=False, reason="Skipped.")
)
@ -117,16 +116,17 @@ class TestSectionLockExplanation:
texts = _texts(mock_tk)
assert any(t == "Skipped." for t in texts)
assert not any("Pending auto-upgrade" in t for t in texts)
assert not any("Live Warsaw temperature" in t for t in texts)
def test_fired_but_heat_skip_already_evaluated_no_heat_line(
def test_temperature_section_renders_regardless_of_heat_skip_evaluated(
self, mock_tk: MagicMock
) -> None:
"""The live temperature section is unconditional — see
``TestTemperatureSectionRendering`` for its actual content states."""
snap = _snapshot(
lock_explanation=_lock_explanation(fired=True, heat_skip_evaluated=True)
)
_make_window(mock_tk, snap)
assert not any("Live Warsaw temperature" in t for t in _texts(mock_tk))
assert any("Warsaw Temperature" in t for t in _texts(mock_tk))
class TestSectionSickBudget:

View File

@ -16,6 +16,7 @@ from screen_locker.tests._status_view_helpers import (
_lock_explanation,
_make_window,
_snapshot,
_temp_check,
_texts,
_week,
)
@ -220,3 +221,96 @@ class TestMain:
captured["on_refresh"]()
assert mock_gather.call_count == calls_before + 1
class TestTemperatureSectionRendering:
def test_shows_checking_message_before_result(self, mock_tk: MagicMock) -> None:
window = _make_window(mock_tk, _snapshot())
window._temp_result = None
window.render(_snapshot())
assert any("Checking Warsaw temperature" in t for t in _texts(mock_tk))
def test_shows_resolved_temperature(self, mock_tk: MagicMock) -> None:
window = _make_window(mock_tk, _snapshot())
window._temp_result = _temp_check(temp_celsius=22.0)
window.render(_snapshot())
assert any("Warsaw: 22" in t for t in _texts(mock_tk))
def test_flags_would_trigger_heat_skip_when_hot(self, mock_tk: MagicMock) -> None:
window = _make_window(mock_tk, _snapshot())
window._temp_result = _temp_check(temp_celsius=35.0)
window.render(_snapshot())
assert any("Would trigger heat-skip today." in t for t in _texts(mock_tk))
def test_does_not_flag_heat_skip_when_below_threshold(
self, mock_tk: MagicMock
) -> None:
window = _make_window(mock_tk, _snapshot())
window._temp_result = _temp_check(temp_celsius=22.0)
window.render(_snapshot())
assert not any("Would trigger heat-skip today." in t for t in _texts(mock_tk))
def test_shows_timeout_message(self, mock_tk: MagicMock) -> None:
window = _make_window(mock_tk, _snapshot())
window._temp_result = _temp_check(temp_celsius=None, timed_out=True)
window.render(_snapshot())
assert any("timed out" in t for t in _texts(mock_tk))
def test_shows_failure_message_when_not_timed_out(self, mock_tk: MagicMock) -> None:
window = _make_window(mock_tk, _snapshot())
window._temp_result = _temp_check(temp_celsius=None, timed_out=False)
window.render(_snapshot())
assert any("failed (network/API error)" in t for t in _texts(mock_tk))
class TestTemperatureCheckFlow:
def test_init_starts_temperature_future(self, mock_tk: MagicMock) -> None:
window = _make_window(mock_tk, _snapshot())
assert window._temp_future is not None
def test_poll_routes_to_result_when_future_done(self, mock_tk: MagicMock) -> None:
window = _make_window(mock_tk, _snapshot())
mock_future = MagicMock()
mock_future.done.return_value = True
mock_future.result.return_value = _temp_check(temp_celsius=25.0)
window._temp_future = mock_future
with patch.object(window, "_on_temperature_check_result") as mock_handle:
window._poll_temperature_check()
mock_handle.assert_called_once_with(mock_future.result.return_value)
def test_poll_waits_when_future_not_done(self, mock_tk: MagicMock) -> None:
window = _make_window(mock_tk, _snapshot())
mock_future = MagicMock()
mock_future.done.return_value = False
window._temp_future = mock_future
with patch.object(window, "_on_temperature_check_result") as mock_handle:
window._poll_temperature_check()
mock_handle.assert_not_called()
window.root.after.assert_called_with(500, window._poll_temperature_check)
def test_poll_waits_when_future_is_none(self, mock_tk: MagicMock) -> None:
window = _make_window(mock_tk, _snapshot())
window._temp_future = None
with patch.object(window, "_on_temperature_check_result") as mock_handle:
window._poll_temperature_check()
mock_handle.assert_not_called()
def test_result_rerenders_last_snapshot_without_gathering_from_disk(
self, mock_tk: MagicMock
) -> None:
"""The temperature reading is independent of on-disk workout state —
must not trigger a real ``gather_status()`` disk read."""
window = _make_window(mock_tk, _snapshot())
result = _temp_check(temp_celsius=19.0)
with patch("screen_locker.status_view.gather_status") as mock_gather:
window._on_temperature_check_result(result)
assert window._temp_result == result
mock_gather.assert_not_called()
def test_refresh_clears_result_and_restarts_check(self, mock_tk: MagicMock) -> None:
window = _make_window(mock_tk, _snapshot(), on_refresh=MagicMock())
window._temp_result = _temp_check(temp_celsius=30.0)
with patch.object(window, "_start_temperature_check") as mock_start:
window._on_refresh_clicked()
assert window._temp_result is None
mock_start.assert_called_once()

View File

@ -0,0 +1,35 @@
"""Isolation fixture for the phone-workout GitHub sync token.
Split out of ``conftest.py`` (already at the repo's 400-line-per-file cap)
and re-exported there so pytest still picks this up as an autouse fixture
for every test in this directory.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
@pytest.fixture(autouse=True)
def isolate_sync_token(tmp_path: Path) -> Iterator[None]:
"""Redirect SYNC_TOKEN_FILE to tmp_path so tests never see a real token.
Without this, any test calling ``_verify_phone_workout`` would fall
through to ``pull_synced_workout()``, which -- if a real
``~/.config/screen_locker/sync_token`` happens to exist on the host --
would make a real GitHub API call. Defaulting to a nonexistent tmp_path
file makes ``read_sync_token()`` return None, the same benign "sync not
configured" state every existing test already assumes.
"""
with patch(
"screen_locker._workout_sync.SYNC_TOKEN_FILE",
tmp_path / "sync_token",
):
yield

View File

@ -2,10 +2,11 @@
from __future__ import annotations
import time
from unittest.mock import MagicMock, patch
import urllib.error
from screen_locker._temperature import fetch_current_temp_celsius, is_too_hot
from screen_locker._temperature import TemperatureCheck, fetch_current_temp_with_status
def _mock_urlopen_returning(body: bytes) -> MagicMock:
@ -16,35 +17,39 @@ def _mock_urlopen_returning(body: bytes) -> MagicMock:
return mock_urlopen
class TestFetchCurrentTempCelsius:
class TestFetchCurrentTempWithStatus:
def test_returns_parsed_temperature_on_success(self) -> None:
body = b'{"current_condition": [{"temp_C": "27"}]}'
with patch(
"screen_locker._temperature.urllib.request.urlopen",
_mock_urlopen_returning(body),
):
assert fetch_current_temp_celsius("Warsaw") == 27.0
result = fetch_current_temp_with_status("Warsaw")
assert result == TemperatureCheck(temp_celsius=27.0, timed_out=False)
def test_returns_none_on_url_error(self) -> None:
with patch(
"screen_locker._temperature.urllib.request.urlopen",
side_effect=urllib.error.URLError("unreachable"),
):
assert fetch_current_temp_celsius("Warsaw") is None
result = fetch_current_temp_with_status("Warsaw")
assert result == TemperatureCheck(temp_celsius=None, timed_out=False)
def test_returns_none_on_timeout(self) -> None:
def test_returns_none_on_timeout_error(self) -> None:
with patch(
"screen_locker._temperature.urllib.request.urlopen",
side_effect=TimeoutError("timed out"),
):
assert fetch_current_temp_celsius("Warsaw") is None
result = fetch_current_temp_with_status("Warsaw")
assert result == TemperatureCheck(temp_celsius=None, timed_out=False)
def test_returns_none_on_os_error(self) -> None:
with patch(
"screen_locker._temperature.urllib.request.urlopen",
side_effect=OSError("network unreachable"),
):
assert fetch_current_temp_celsius("Warsaw") is None
result = fetch_current_temp_with_status("Warsaw")
assert result == TemperatureCheck(temp_celsius=None, timed_out=False)
def test_returns_none_on_missing_key(self) -> None:
body = b'{"unexpected": "shape"}'
@ -52,7 +57,8 @@ class TestFetchCurrentTempCelsius:
"screen_locker._temperature.urllib.request.urlopen",
_mock_urlopen_returning(body),
):
assert fetch_current_temp_celsius("Warsaw") is None
result = fetch_current_temp_with_status("Warsaw")
assert result == TemperatureCheck(temp_celsius=None, timed_out=False)
def test_returns_none_on_empty_current_condition(self) -> None:
body = b'{"current_condition": []}'
@ -60,7 +66,8 @@ class TestFetchCurrentTempCelsius:
"screen_locker._temperature.urllib.request.urlopen",
_mock_urlopen_returning(body),
):
assert fetch_current_temp_celsius("Warsaw") is None
result = fetch_current_temp_with_status("Warsaw")
assert result == TemperatureCheck(temp_celsius=None, timed_out=False)
def test_returns_none_on_non_numeric_temp(self) -> None:
body = b'{"current_condition": [{"temp_C": "not-a-number"}]}'
@ -68,31 +75,25 @@ class TestFetchCurrentTempCelsius:
"screen_locker._temperature.urllib.request.urlopen",
_mock_urlopen_returning(body),
):
assert fetch_current_temp_celsius("Warsaw") is None
result = fetch_current_temp_with_status("Warsaw")
assert result == TemperatureCheck(temp_celsius=None, timed_out=False)
def test_returns_none_on_invalid_json(self) -> None:
with patch(
"screen_locker._temperature.urllib.request.urlopen",
_mock_urlopen_returning(b"not json{{{"),
):
assert fetch_current_temp_celsius("Warsaw") is None
result = fetch_current_temp_with_status("Warsaw")
assert result == TemperatureCheck(temp_celsius=None, timed_out=False)
def test_reports_timed_out_when_fetch_exceeds_hard_timeout(self) -> None:
def _slow_fetch(_city: str) -> float | None:
time.sleep(0.3)
return 27.0
class TestIsTooHot:
def test_returns_none_when_fetch_fails(self) -> None:
with patch(
"screen_locker._temperature.fetch_current_temp_celsius", return_value=None
"screen_locker._temperature._fetch_current_temp_celsius_unbounded",
side_effect=_slow_fetch,
):
assert is_too_hot("Warsaw", 33) is None
def test_returns_none_when_below_threshold(self) -> None:
with patch(
"screen_locker._temperature.fetch_current_temp_celsius", return_value=20.0
):
assert is_too_hot("Warsaw", 33) is None
def test_returns_temp_when_at_or_above_threshold(self) -> None:
with patch(
"screen_locker._temperature.fetch_current_temp_celsius", return_value=33.0
):
assert is_too_hot("Warsaw", 33) == 33.0
result = fetch_current_temp_with_status("Warsaw", hard_timeout=0.05)
assert result == TemperatureCheck(temp_celsius=None, timed_out=True)

View File

@ -5,6 +5,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker._temperature import TemperatureCheck
from screen_locker.screen_lock import ScreenLocker
from screen_locker.tests.conftest import create_locker, create_locker_relaxed_day
@ -216,7 +217,8 @@ class TestHeatSkipBranch:
with (
patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False),
patch(
"screen_locker.screen_lock.is_too_hot", return_value=None
"screen_locker.screen_lock.fetch_current_temp_with_status",
return_value=TemperatureCheck(temp_celsius=20.0, timed_out=False),
) as mock_hot,
patch.object(ScreenLocker, "_show_heat_skip_dialog") as mock_dialog,
):
@ -234,7 +236,10 @@ class TestHeatSkipBranch:
) -> None:
with (
patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False),
patch("screen_locker.screen_lock.is_too_hot", return_value=35.0),
patch(
"screen_locker.screen_lock.fetch_current_temp_with_status",
return_value=TemperatureCheck(temp_celsius=35.0, timed_out=False),
),
patch.object(ScreenLocker, "_show_heat_skip_dialog", return_value=True),
patch.object(ScreenLocker, "_save_heat_skip_log") as mock_save,
):
@ -251,7 +256,10 @@ class TestHeatSkipBranch:
) -> None:
with (
patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False),
patch("screen_locker.screen_lock.is_too_hot", return_value=35.0),
patch(
"screen_locker.screen_lock.fetch_current_temp_with_status",
return_value=TemperatureCheck(temp_celsius=35.0, timed_out=False),
),
patch.object(ScreenLocker, "_show_heat_skip_dialog", return_value=False),
patch.object(ScreenLocker, "_save_heat_skip_log") as mock_save,
):
@ -259,3 +267,43 @@ class TestHeatSkipBranch:
mock_save.assert_not_called()
mock_sys_exit.assert_not_called()
def test_fetch_timed_out_defaults_to_lock_no_dialog(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""A timed-out temperature check must fail closed, not skip the lock."""
with (
patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False),
patch(
"screen_locker.screen_lock.fetch_current_temp_with_status",
return_value=TemperatureCheck(temp_celsius=None, timed_out=True),
),
patch.object(ScreenLocker, "_show_heat_skip_dialog") as mock_dialog,
):
create_locker(mock_tk, tmp_path, has_logged=False)
mock_dialog.assert_not_called()
mock_sys_exit.assert_not_called()
def test_fetch_failed_defaults_to_lock_no_dialog(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""A failed (non-timeout) temperature check must also fail closed."""
with (
patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False),
patch(
"screen_locker.screen_lock.fetch_current_temp_with_status",
return_value=TemperatureCheck(temp_celsius=None, timed_out=False),
),
patch.object(ScreenLocker, "_show_heat_skip_dialog") as mock_dialog,
):
create_locker(mock_tk, tmp_path, has_logged=False)
mock_dialog.assert_not_called()
mock_sys_exit.assert_not_called()

View File

@ -0,0 +1,120 @@
"""Tests for the phone-workout GitHub sync pull (crdt-sync transport)."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
from crdt_sync import GitHubSyncError, Hlc, Record
import pytest
from screen_locker import _workout_sync
# The autouse `_isolate_sync_token` fixture in conftest.py already redirects
# `_workout_sync.SYNC_TOKEN_FILE` into a per-test tmp_path, so tests here can
# write to it directly without needing their own isolation fixture.
def _record_json(payload: dict, *, wall_time_ms: int = 1000) -> dict:
hlc = Hlc(wall_time_ms=wall_time_ms, counter=0, node_id="phone")
record = Record(id="x", fields={"payload": (payload, hlc)})
return record.to_dict()
class TestReadSyncToken:
def test_returns_none_when_file_is_missing(self) -> None:
assert _workout_sync.read_sync_token() is None
def test_returns_none_when_file_is_empty(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text(" \n")
assert _workout_sync.read_sync_token() is None
def test_returns_the_stripped_token(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text(" abc123 \n")
assert _workout_sync.read_sync_token() == "abc123"
class TestLatestPayload:
def test_returns_none_for_an_empty_log(self) -> None:
assert _workout_sync._latest_payload("{}") is None
def test_returns_the_only_records_payload(self) -> None:
log = {"a": _record_json({"succeeded": True})}
assert _workout_sync._latest_payload(json.dumps(log)) == {"succeeded": True}
def test_returns_the_payload_with_the_greatest_clock(self) -> None:
log = {
"old": _record_json({"succeeded": False}, wall_time_ms=100),
"new": _record_json({"succeeded": True}, wall_time_ms=200),
}
assert _workout_sync._latest_payload(json.dumps(log)) == {"succeeded": True}
def test_skips_records_with_no_payload_field(self) -> None:
no_payload = Record(
id="a",
fields={"other": (1, Hlc(wall_time_ms=1, counter=0, node_id="phone"))},
)
log = {"a": no_payload.to_dict()}
assert _workout_sync._latest_payload(json.dumps(log)) is None
def test_raises_type_error_when_top_level_is_not_an_object(self) -> None:
with pytest.raises(TypeError, match="not a JSON object"):
_workout_sync._latest_payload(json.dumps([1, 2, 3]))
def test_raises_type_error_when_payload_field_is_not_an_object(self) -> None:
log = {"a": _record_json("not-a-dict")}
with pytest.raises(TypeError, match="payload field is not a JSON object"):
_workout_sync._latest_payload(json.dumps(log))
def test_raises_key_error_for_a_malformed_record(self) -> None:
log = {"a": {"id": "a"}} # missing "fields"
with pytest.raises(KeyError):
_workout_sync._latest_payload(json.dumps(log))
def test_raises_value_error_for_invalid_json(self) -> None:
with pytest.raises(ValueError, match="Expecting property name"):
_workout_sync._latest_payload("{not valid json")
def _mock_client(*, get_file_text: object) -> MagicMock:
client = MagicMock()
if isinstance(get_file_text, Exception):
client.get_file_text.side_effect = get_file_text
else:
client.get_file_text.return_value = get_file_text
return client
class TestPullSyncedWorkout:
def test_returns_none_none_when_no_token_is_configured(self) -> None:
with patch.object(_workout_sync, "GitHubSyncClient") as client_cls:
assert _workout_sync.pull_synced_workout() == (None, None)
client_cls.assert_not_called()
def test_returns_the_error_message_on_a_sync_error(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _mock_client(get_file_text=GitHubSyncError("offline"))
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
assert _workout_sync.pull_synced_workout() == (None, "offline")
def test_returns_none_none_when_nothing_has_been_pushed_yet(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _mock_client(get_file_text=None)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
assert _workout_sync.pull_synced_workout() == (None, None)
def test_returns_the_payload_on_success(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
log = json.dumps({"a": _record_json({"succeeded": True})})
client = _mock_client(get_file_text=log)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
assert _workout_sync.pull_synced_workout() == ({"succeeded": True}, None)
def test_returns_a_corrupt_data_message_on_malformed_sync_data(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _mock_client(get_file_text="{not valid json")
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
data, error = _workout_sync.pull_synced_workout()
assert data is None
assert error is not None
assert "corrupt sync data" in error

View File

@ -3,15 +3,28 @@
library;
import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/models/workout_plan.dart';
import 'package:workout_app/services/github_device_auth.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/sync_settings.dart';
/// How to style a [_SyncStatusBadge].
enum _SyncStatusKind { success, pending, error }
/// Screen for editing per-exercise thresholds and manual weight overrides.
class SettingsScreen extends StatefulWidget {
/// Creates a [SettingsScreen].
const SettingsScreen({super.key});
const SettingsScreen({super.key, this.httpClient});
/// Injectable HTTP client; tests pass a `MockClient` so the device-flow
/// requests never hit the real network.
final http.Client? httpClient;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
@ -27,6 +40,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Debounce weight saves to avoid resetting streaks on every tap.
final Map<String, Timer> _weightTimers = {};
final _tokenController = TextEditingController();
// Persistent (not a transient SnackBar) so the result of Connect GitHub /
// Save is still visible if the user looks back at the screen later --
// mirrors diet-guard/todo's `_status` field in their settings screens.
String? _syncStatus;
_SyncStatusKind _syncStatusKind = _SyncStatusKind.pending;
void _setSyncStatus(String message, _SyncStatusKind kind) {
setState(() {
_syncStatus = message;
_syncStatusKind = kind;
});
}
@override
void initState() {
super.initState();
@ -38,11 +66,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
for (final t in _weightTimers.values) {
t.cancel();
}
_tokenController.dispose();
super.dispose();
}
Future<void> _load() async {
final states = await StorageService.instance.getAllExerciseStates();
final syncSettings = await SyncSettings.load();
if (mounted) {
setState(() {
for (final s in states) {
@ -50,8 +80,90 @@ class _SettingsScreenState extends State<SettingsScreen> {
_failThresholds[s.name] = s.failThreshold;
_weights[s.name] = s.weight;
}
_tokenController.text = syncSettings.token;
_loading = false;
});
// Reflect an already-configured token immediately on open, not only
// after Connect GitHub / Save is tapped this session.
if (syncSettings.isConfigured) {
_setSyncStatus('Connected.', _SyncStatusKind.success);
}
}
}
Future<void> _saveToken() async {
final saved = await SyncSettings(
token: _tokenController.text.trim(),
).save();
if (!mounted) return;
_setSyncStatus(
saved ? 'Sync token saved.' : 'Could not save token on this device.',
saved ? _SyncStatusKind.success : _SyncStatusKind.error,
);
}
/// Runs the OAuth device flow and, on success, saves the resulting token
/// and verifies it actually works against the sync repo -- a saved token
/// that can't reach `$syncRepoOwner/$syncRepoName` (wrong scope, revoked,
/// etc.) must be surfaced immediately, not discovered on the next workout.
Future<void> _connectGitHub() async {
final auth = GitHubDeviceAuth(
clientId: SyncSettings.defaultClientId,
httpClient: widget.httpClient,
);
try {
final device = await auth.requestDeviceCode();
if (!mounted) return;
final token = await showDialog<String>(
context: context,
barrierDismissible: false,
builder: (_) => _DeviceCodeDialog(device: device, auth: auth),
);
if (token != null && token.isNotEmpty) {
setState(() => _tokenController.text = token);
_setSyncStatus('Connected — verifying…', _SyncStatusKind.pending);
final saved = await SyncSettings(token: token).save();
if (!saved) {
if (!mounted) return;
_setSyncStatus(
'Connected, but could not save the token on this device.',
_SyncStatusKind.error,
);
return;
}
await _verifyConnection(token);
}
} on Exception catch (e) {
if (!mounted) return;
_setSyncStatus('Could not start device flow: $e', _SyncStatusKind.error);
} finally {
auth.close();
}
}
/// Confirms [token] can actually read `$syncRepoOwner/$syncRepoName`.
Future<void> _verifyConnection(String token) async {
final client = GitHubClient(
owner: syncRepoOwner,
repo: syncRepoName,
token: token,
httpClient: widget.httpClient,
);
try {
await client.getFileText('devices/phone/log.json');
if (!mounted) return;
_setSyncStatus(
'Connected and verified via GitHub.',
_SyncStatusKind.success,
);
} on GitHubSyncError catch (e) {
if (!mounted) return;
_setSyncStatus(
'Connected, but could not verify: $e',
_SyncStatusKind.error,
);
} finally {
client.close();
}
}
@ -185,6 +297,42 @@ class _SettingsScreenState extends State<SettingsScreen> {
_onThresholdChanged(name, _successThresholds[name]!, v),
);
}),
const SizedBox(height: 20),
const _SectionHeader('GITHUB SYNC'),
const SizedBox(height: 4),
const Text(
'Authorize in your browser -- no token to paste. Syncs to '
'$syncRepoOwner/$syncRepoName. Workouts push automatically '
'on completion.',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
const SizedBox(height: 12),
if (_syncStatus != null) ...[
_SyncStatusBadge(text: _syncStatus!, kind: _syncStatusKind),
const SizedBox(height: 12),
],
ElevatedButton.icon(
onPressed: _connectGitHub,
icon: const Icon(Icons.login),
label: const Text('Connect GitHub'),
),
const SizedBox(height: 8),
ExpansionTile(
title: const Text(
'Advanced: paste a token instead',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
collapsedIconColor: Colors.white54,
iconColor: Colors.white54,
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(top: 8, bottom: 8),
children: [
_SyncTokenField(
controller: _tokenController,
onSave: _saveToken,
),
],
),
],
),
);
@ -285,6 +433,101 @@ class _StepperButton extends StatelessWidget {
}
}
/// A visible, colored status pill for the GitHub sync connection state --
/// placed directly under the section description (not buried below the
/// collapsed Advanced field) so "am I connected?" has an immediate answer.
class _SyncStatusBadge extends StatelessWidget {
const _SyncStatusBadge({required this.text, required this.kind});
final String text;
final _SyncStatusKind kind;
@override
Widget build(BuildContext context) {
final color = switch (kind) {
_SyncStatusKind.success => Colors.greenAccent,
_SyncStatusKind.error => Colors.redAccent,
_SyncStatusKind.pending => Colors.white70,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: Row(
children: [
if (kind == _SyncStatusKind.pending)
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Icon(
kind == _SyncStatusKind.success
? Icons.check_circle
: Icons.error,
color: color,
size: 16,
),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: TextStyle(
color: color,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
class _SyncTokenField extends StatelessWidget {
const _SyncTokenField({required this.controller, required this.onSave});
final TextEditingController controller;
final VoidCallback onSave;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: controller,
obscureText: true,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'GitHub PAT',
hintStyle: TextStyle(color: Colors.grey.shade600),
filled: true,
fillColor: Colors.grey.shade800,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
),
),
const SizedBox(width: 8),
ElevatedButton(onPressed: onSave, child: const Text('Save')),
],
);
}
}
class _ExerciseThresholdCard extends StatelessWidget {
const _ExerciseThresholdCard({
required this.name,
@ -392,3 +635,107 @@ class _ThresholdRow extends StatelessWidget {
);
}
}
/// Dialog shown during the device flow: displays the user code, opens the
/// verification page, and polls until authorized -- popping the token (or
/// null if cancelled / failed).
class _DeviceCodeDialog extends StatefulWidget {
const _DeviceCodeDialog({required this.device, required this.auth});
final DeviceCodeResponse device;
final GitHubDeviceAuth auth;
@override
State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState();
}
class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
String? _error;
@override
void initState() {
super.initState();
unawaited(_poll());
}
Future<void> _poll() async {
try {
final token = await widget.auth.pollForToken(widget.device);
if (mounted) Navigator.of(context).pop(token);
} on Exception catch (e) {
if (mounted) setState(() => _error = '$e');
}
}
Future<void> _openPage() async {
await Clipboard.setData(ClipboardData(text: widget.device.userCode));
await launchUrl(
Uri.parse(widget.device.verificationUri),
mode: LaunchMode.externalApplication,
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: Colors.grey.shade900,
title: const Text(
'Authorize on GitHub',
style: TextStyle(color: Colors.white),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Enter this code on GitHub:',
style: TextStyle(color: Colors.white70),
),
const SizedBox(height: 8),
SelectableText(
widget.device.userCode,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
if (_error == null)
const Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Expanded(
child: Text(
'Waiting for authorization…',
style: TextStyle(color: Colors.white70),
),
),
],
)
else
Text(_error!, style: const TextStyle(color: Colors.redAccent)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text(
'Cancel',
style: TextStyle(color: Colors.white70),
),
),
FilledButton.icon(
onPressed: _openPage,
icon: const Icon(Icons.open_in_new),
label: const Text('Open GitHub & copy code'),
),
],
);
}
}

View File

@ -12,6 +12,7 @@ import 'package:workout_app/models/set_result.dart';
import 'package:workout_app/models/workout_session.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/sync_service.dart';
import 'package:workout_app/services/workout_sync_service.dart';
import 'package:workout_app/widgets/break_banner.dart';
import 'package:workout_app/widgets/exercise_tile.dart';
import 'package:workout_app/widgets/workout_summary_dialog.dart';
@ -462,6 +463,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
await storage.clearActiveSession();
final syncResult = await _sync.writeWorkoutResult(session);
unawaited(WorkoutSyncService().push(session));
if (!mounted) return;
unawaited(

View File

@ -1,22 +1,58 @@
/// Backup service: mirrors the DB as JSON to external storage so data survives
/// uninstall/reinstall on Android 11+ where internal storage is wiped.
/// Backup service: mirrors the DB (and, deliberately, the GitHub sync token)
/// as files on external storage so they survive uninstall/reinstall on
/// Android 11+, where internal storage -- including the Keystore-backed
/// secure storage the token normally lives in -- is wiped.
///
/// Mirroring the token here is a deliberate, explicit trade-off: it moves
/// the token from Keystore-only secrecy to plaintext-on-external-storage,
/// which is readable by any app holding storage permission or by plugging
/// the phone into a PC. That's a real credential-exposure increase over
/// diet-guard/todo (which keep their tokens Keystore-only and do not survive
/// reinstall) -- accepted here because losing sync on every reinstall during
/// active development was worse in practice than the exposure risk.
library;
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:permission_handler/permission_handler.dart';
/// Path where the backup JSON lives on external storage.
const String kBackupPath = '/sdcard/WorkoutTracker/backup.json';
/// Default directory backups live in on external storage.
const String kBackupDir = '/sdcard/WorkoutTracker';
/// Handles exporting and importing the workout database as a JSON file on
/// external storage, which persists across app uninstalls.
/// Path where the backup JSON lives on external storage.
const String kBackupPath = '$kBackupDir/backup.json';
/// Path where the GitHub sync token is mirrored on external storage.
const String kSyncTokenBackupPath = '$kBackupDir/sync_token';
/// Handles exporting and importing the workout database (and the GitHub
/// sync token) as files on external storage, which persist across app
/// uninstalls.
class BackupService {
BackupService._();
/// The singleton instance.
static final BackupService instance = BackupService._();
// Overridable for unit tests (set by resetForTesting) so round-trips can
// be verified against a real temp directory instead of `/sdcard`, which
// doesn't exist on the test host.
static String _baseDir = kBackupDir;
/// The directory backup files are currently written to/read from.
/// Test-only.
@visibleForTesting
static String get baseDirForTesting => _baseDir;
/// Points the backup files at this directory instead of [kBackupDir].
/// Test-only.
@visibleForTesting
static set baseDirForTesting(String dir) => _baseDir = dir;
String get _backupPath => '$_baseDir/backup.json';
String get _syncTokenPath => '$_baseDir/sync_token';
// ── Permission ─────────────────────────────────────────────────────────────
/// Returns true if the app has MANAGE_EXTERNAL_STORAGE.
@ -35,19 +71,19 @@ class BackupService {
// ── Export ─────────────────────────────────────────────────────────────────
/// Writes [data] to [kBackupPath] as pretty-printed JSON.
/// Writes [data] to the backup path as pretty-printed JSON.
///
/// Silently swallows errors (e.g. permission not granted, no external
/// storage) so the caller never crashes.
Future<void> export(Map<String, dynamic> data) async {
try {
final dir = Directory('/sdcard/WorkoutTracker');
final dir = Directory(_baseDir);
if (!dir.existsSync()) {
dir.createSync(recursive: true);
}
await File(kBackupPath).writeAsString(
const JsonEncoder.withIndent(' ').convert(data),
);
await File(
_backupPath,
).writeAsString(const JsonEncoder.withIndent(' ').convert(data));
} on Exception {
// Backup is best-effort; never throw.
}
@ -59,7 +95,7 @@ class BackupService {
/// is unreadable.
Future<Map<String, dynamic>?> readBackup() async {
try {
final f = File(kBackupPath);
final f = File(_backupPath);
if (!f.existsSync()) return null;
final raw = await f.readAsString();
return jsonDecode(raw) as Map<String, dynamic>;
@ -67,4 +103,38 @@ class BackupService {
return null;
}
}
// ── Sync token backup ────────────────────────────────────────────────────
/// Mirrors [token] to external storage (deleting the file when empty).
/// Best-effort like [export]; see the library doc for the security
/// trade-off this represents.
Future<void> exportSyncToken(String token) async {
try {
final f = File(_syncTokenPath);
if (token.isEmpty) {
if (f.existsSync()) await f.delete();
return;
}
final dir = Directory(_baseDir);
if (!dir.existsSync()) {
dir.createSync(recursive: true);
}
await f.writeAsString(token);
} on Exception {
// Backup is best-effort; never throw.
}
}
/// Returns the mirrored sync token, or null if none exists / unreadable.
Future<String?> readSyncToken() async {
try {
final f = File(_syncTokenPath);
if (!f.existsSync()) return null;
final token = await f.readAsString();
return token.isEmpty ? null : token;
} on Exception {
return null;
}
}
}

View File

@ -0,0 +1,164 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
/// First-stage response of the GitHub OAuth Device Flow: the code the user
/// types on github.com and the URL to type it into.
class DeviceCodeResponse {
/// Creates a [DeviceCodeResponse].
const DeviceCodeResponse({
required this.deviceCode,
required this.userCode,
required this.verificationUri,
required this.interval,
required this.expiresIn,
});
/// Parses GitHub's `POST /login/device/code` response body.
factory DeviceCodeResponse.fromJson(Map<String, dynamic> json) {
return DeviceCodeResponse(
deviceCode: json['device_code'] as String,
userCode: json['user_code'] as String,
verificationUri: json['verification_uri'] as String,
interval: (json['interval'] as int?) ?? 5,
expiresIn: (json['expires_in'] as int?) ?? 900,
);
}
/// Opaque code the client polls with (not shown to the user).
final String deviceCode;
/// Short code the user enters on the verification page.
final String userCode;
/// Page the user opens to enter [userCode] (github.com/login/device).
final String verificationUri;
/// Minimum seconds to wait between polls.
final int interval;
/// Seconds until [deviceCode] expires.
final int expiresIn;
}
/// Raised when the device-flow authorization fails or is declined.
class DeviceAuthException implements Exception {
/// Creates a [DeviceAuthException] for the given GitHub error [code].
DeviceAuthException(this.code, this.message);
/// GitHub error code, e.g. `access_denied`, `expired_token`.
final String code;
/// Human-readable description of [code].
final String message;
@override
String toString() => 'DeviceAuthException($code): $message';
}
/// Implements the GitHub OAuth **Device Flow** so the user can authorize the
/// app by visiting a URL and entering a short code — no token pasting.
///
/// Device flow needs only a public `client_id` (no client secret), which
/// makes it safe for a distributed app. The resulting access token is then
/// used exactly like a PAT by [WorkoutSyncService].
///
/// References:
/// - POST https://github.com/login/device/code
/// - POST https://github.com/login/oauth/access_token
class GitHubDeviceAuth {
/// Creates a [GitHubDeviceAuth] for the given OAuth App [clientId].
GitHubDeviceAuth({
required this.clientId,
this.scope = 'repo',
http.Client? httpClient,
Future<void> Function(Duration)? delay,
}) : _http = httpClient ?? http.Client(),
// Indirection so tests can skip real waiting between polls.
_delay = delay ?? Future<void>.delayed;
/// The GitHub OAuth App's public client id.
final String clientId;
/// OAuth scope requested. `repo` is required for private-repo contents.
final String scope;
final http.Client _http;
final Future<void> Function(Duration) _delay;
static const _deviceCodeUrl = 'https://github.com/login/device/code';
static const _tokenUrl = 'https://github.com/login/oauth/access_token';
static const _grantType = 'urn:ietf:params:oauth:grant-type:device_code';
/// Step 1: ask GitHub for a device + user code.
Future<DeviceCodeResponse> requestDeviceCode() async {
final res = await _http.post(
Uri.parse(_deviceCodeUrl),
headers: const {'Accept': 'application/json'},
body: {'client_id': clientId, 'scope': scope},
);
if (res.statusCode != 200) {
throw DeviceAuthException('http_${res.statusCode}', res.body);
}
return DeviceCodeResponse.fromJson(
jsonDecode(res.body) as Map<String, dynamic>,
);
}
/// Step 2: poll until the user authorizes, returning the access token.
///
/// Honors GitHub's pacing protocol: `authorization_pending` keeps polling,
/// `slow_down` increases the interval, and terminal errors throw a
/// [DeviceAuthException].
Future<String> pollForToken(DeviceCodeResponse device) async {
var intervalSeconds = device.interval;
final deadline = DateTime.now().add(Duration(seconds: device.expiresIn));
while (DateTime.now().isBefore(deadline)) {
await _delay(Duration(seconds: intervalSeconds));
final Map<String, dynamic> json;
try {
final res = await _http.post(
Uri.parse(_tokenUrl),
headers: const {'Accept': 'application/json'},
body: {
'client_id': clientId,
'device_code': device.deviceCode,
'grant_type': _grantType,
},
);
json = jsonDecode(res.body) as Map<String, dynamic>;
} on Exception {
// A dropped connection or malformed response is transient noise on a
// long-lived poll loop (e.g. a flaky network) -- retry on the next
// interval rather than aborting the whole ~15-minute authorization
// window over one blip. The deadline above still bounds this.
continue;
}
final token = json['access_token'] as String?;
if (token != null) return token;
switch (json['error'] as String?) {
case 'authorization_pending':
continue; // User has not finished authorizing yet.
case 'slow_down':
// GitHub asks us to back off; obey its new interval.
intervalSeconds = (json['interval'] as int?) ?? intervalSeconds + 5;
case final String error:
throw DeviceAuthException(
error,
(json['error_description'] as String?) ?? error,
);
case null:
throw DeviceAuthException('unknown', 'Unexpected response: $json');
}
}
throw DeviceAuthException('expired_token', 'Device code expired.');
}
/// Closes the underlying HTTP client.
void close() => _http.close();
}

View File

@ -0,0 +1,89 @@
/// Locally-stored GitHub sync token, trimmed from diet-guard's
/// `sync_settings.dart`: this app only ever talks to one hardcoded repo, so
/// there's no owner/repo to persist -- just the pasted PAT (kept as a
/// fallback; see [defaultClientId] for the primary "Connect GitHub" path).
///
/// Unlike diet-guard/todo, the token is also mirrored to external storage
/// via [BackupService] so it survives an app uninstall -- see that class's
/// library doc for the security trade-off this represents.
library;
import 'package:flutter/services.dart' show PlatformException;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:workout_app/services/backup_service.dart';
/// The repo owner/org, matching the PC's `SYNC_REPO_OWNER` constant.
const syncRepoOwner = 'kuhyx';
/// The repo name, matching the PC's `SYNC_REPO_NAME` constant.
const syncRepoName = 'syncs';
/// The GitHub token is kept in the OS keystore (Android Keystore) via
/// [FlutterSecureStorage] -- it never touches `SharedPreferences`, so there's
/// no plaintext-migration path to carry (unlike diet-guard/todo, which
/// predate this app and had an existing plaintext copy to migrate away from).
class SyncSettings {
/// Creates a [SyncSettings] from its token.
const SyncSettings({required this.token});
/// A GitHub PAT with contents read/write on `$syncRepoOwner/$syncRepoName`.
final String token;
/// True when a token has been pasted and sync can be attempted.
bool get isConfigured => token.isNotEmpty;
/// This app's own GitHub OAuth App (device-flow enabled) client id, baked
/// in so "Connect GitHub" works with zero setup. Registered 2026-07-06 at
/// github.com/settings/developers, device flow enabled -- distinct from
/// the sibling diet-guard/todo apps' OAuth Apps, which belong to different
/// products and can't be shared across them.
static const defaultClientId = 'Ov23liJNyu82dkIUiXSZ';
static const _secureToken = 'sync.token';
/// Default options keep us off the deprecated `encryptedSharedPreferences`
/// path on Android.
static const _secure = FlutterSecureStorage();
/// Loads the token from the keystore, falling back to the external-storage
/// backup (and re-seeding the keystore from it) if the keystore is empty --
/// which is exactly what happens right after a reinstall, since the
/// keystore entry does not survive uninstall but the backup file does.
static Future<SyncSettings> load() async {
String? token;
try {
token = await _secure.read(key: _secureToken);
} on PlatformException {
token = null;
}
if (token == null || token.isEmpty) {
final backedUp = await BackupService.instance.readSyncToken();
if (backedUp != null && backedUp.isNotEmpty) {
token = backedUp;
try {
await _secure.write(key: _secureToken, value: backedUp);
} on PlatformException {
// Keystore unavailable; still return the recovered token below.
}
}
}
return SyncSettings(token: token ?? '');
}
/// Persists [token] to the keystore (deleting the entry when empty) and
/// mirrors it to external storage so it survives an app uninstall.
/// Returns false if the platform secret service is unavailable.
Future<bool> save() async {
await BackupService.instance.exportSyncToken(token);
try {
if (token.isEmpty) {
await _secure.delete(key: _secureToken);
} else {
await _secure.write(key: _secureToken, value: token);
}
return true;
} on PlatformException {
return false;
}
}
}

View File

@ -0,0 +1,88 @@
/// Pushes completed workout sessions to the shared GitHub sync repo.
library;
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:workout_app/models/workout_session.dart';
import 'package:workout_app/services/sync_settings.dart';
const _deviceId = 'phone';
const _pathPrefix = 'screen-locker-sync/devices';
const _logFilename = 'log.json';
String _encode(Log log) =>
jsonEncode(log.map((id, record) => MapEntry(id, record.toJson())));
Log _decode(String text) => (jsonDecode(text) as Map<String, dynamic>).map(
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
);
/// Mirrors the PC-side `_workout_sync.py`: GitHub is used purely as dumb
/// file storage via the Contents API, one [Record] per completed session,
/// merged onto whatever this phone has already pushed. A missing token or a
/// failed push is swallowed here -- sync being unconfigured or unreachable
/// must never crash or delay the workout-completion flow that calls [push].
class WorkoutSyncService {
/// Creates a [WorkoutSyncService]. [owner]/[repo]/[httpClient] default to
/// the real `syncs` repo (`screen-locker-sync/` subdirectory) and a fresh
/// [http.Client]; tests
/// override them to point at an in-memory [http.testing.MockClient]
/// instead of the real network.
WorkoutSyncService({
this.owner = syncRepoOwner,
this.repo = syncRepoName,
http.Client? httpClient,
// Dart forbids private named params, so this can't be an initializing
// formal; assign it explicitly (mirrors crdt_sync_dart's GitHubClient).
// ignore: prefer_initializing_formals
}) : _httpClient = httpClient;
/// The repo owner/org to push to.
final String owner;
/// The repo name to push to.
final String repo;
final http.Client? _httpClient;
/// Pushes [session] to `devices/phone/log.json`, merging with whatever
/// this device has already pushed. No-ops silently if sync isn't
/// configured; logs (but does not rethrow) any [GitHubSyncError].
Future<void> push(WorkoutSession session) async {
final settings = await SyncSettings.load();
if (!settings.isConfigured) return;
final client = GitHubClient(
owner: owner,
repo: repo,
token: settings.token,
httpClient: _httpClient,
);
try {
const path = '$_pathPrefix/$_deviceId/$_logFilename';
final existingText = await client.getFileText(path);
final existingLog = existingText == null
? <String, Record>{}
: _decode(existingText);
final record = Record(
id: session.startTime.toIso8601String(),
fields: {'payload': (session.toJson(), Hlc.newTick(_deviceId))},
);
final localLog = mergeLogs(existingLog, {record.id: record});
await syncLog(
client: client,
deviceId: _deviceId,
pathPrefix: _pathPrefix,
localLog: localLog,
encode: _encode,
decode: _decode,
);
} on GitHubSyncError catch (error) {
debugPrint('WorkoutSyncService.push failed: $error');
} finally {
client.close();
}
}
}

View File

@ -50,7 +50,7 @@ packages:
source: hosted
version: "4.2.1"
audioplayers_platform_interface:
dependency: transitive
dependency: "direct dev"
description:
name: audioplayers_platform_interface
sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
@ -113,6 +113,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crdt_sync:
dependency: "direct main"
description:
path: crdt_sync_dart
ref: "crdt_sync_dart-v0.1.0"
resolved-ref: "6b14e6d36d137d90851f00d1e282a2069c13d3a6"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.1.0"
crypto:
dependency: transitive
description:
@ -190,6 +199,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev"
source: hosted
version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
url: "https://pub.dev"
source: hosted
version: "3.0.1"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
url: "https://pub.dev"
source: hosted
version: "4.2.2"
flutter_test:
dependency: "direct dev"
description: flutter
@ -217,7 +274,7 @@ packages:
source: hosted
version: "2.0.0"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@ -653,6 +710,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
url: "https://pub.dev"
source: hosted
version: "6.3.32"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description:
@ -742,5 +863,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.38.4"
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.44.0"

View File

@ -5,20 +5,29 @@ publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.12.0
sdk: ^3.12.2
dependencies:
audioplayers: ^6.4.0
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.1.0
path: crdt_sync_dart
flutter:
sdk: flutter
flutter_secure_storage: ^10.3.1
http: ^1.6.0
path: ^1.9.1
path_provider: ^2.1.5
permission_handler: ^12.0.0
shared_preferences: ^2.5.3
sqflite: ^2.4.2
url_launcher: ^6.3.2
vibration: ^3.1.0
dev_dependencies:
audioplayers_platform_interface: ^7.1.1
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter

View File

@ -0,0 +1,133 @@
/// Fakes the `audioplayers` platform channels so widget tests exercising
/// [AudioPlayer] (e.g. WorkoutScreen's break-end sound) don't hit a real
/// platform and throw `MissingPluginException`.
library;
import 'dart:async';
import 'dart:typed_data';
import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart';
class _FakeAudioplayersPlatform extends AudioplayersPlatformInterface {
final _controllers = <String, StreamController<AudioEvent>>{};
StreamController<AudioEvent> _controllerFor(String playerId) =>
_controllers.putIfAbsent(
playerId,
() => StreamController<AudioEvent>.broadcast(),
);
@override
Future<void> create(String playerId) async {}
@override
Future<void> dispose(String playerId) async {
await _controllers.remove(playerId)?.close();
}
@override
Future<void> pause(String playerId) async {}
@override
Future<void> stop(String playerId) async {}
@override
Future<void> resume(String playerId) async {}
@override
Future<void> release(String playerId) async {}
@override
Future<void> seek(String playerId, Duration position) async {}
@override
Future<void> setBalance(String playerId, double balance) async {}
@override
Future<void> setVolume(String playerId, double volume) async {}
@override
Future<void> setReleaseMode(
String playerId,
ReleaseMode releaseMode,
) async {}
@override
Future<void> setPlaybackRate(String playerId, double playbackRate) async {}
@override
Future<void> setSourceUrl(
String playerId,
String url, {
bool? isLocal,
String? mimeType,
}) async {
_controllerFor(
playerId,
).add(const AudioEvent(eventType: AudioEventType.prepared, isPrepared: true));
}
@override
Future<void> setSourceBytes(
String playerId,
Uint8List bytes, {
String? mimeType,
}) async {
_controllerFor(
playerId,
).add(const AudioEvent(eventType: AudioEventType.prepared, isPrepared: true));
}
@override
Future<void> setAudioContext(
String playerId,
AudioContext audioContext,
) async {}
@override
Future<void> setPlayerMode(String playerId, PlayerMode playerMode) async {}
@override
Future<int?> getDuration(String playerId) async => 0;
@override
Future<int?> getCurrentPosition(String playerId) async => 0;
@override
Future<void> emitLog(String playerId, String message) async {}
@override
Future<void> emitError(String playerId, String code, String message) async {}
@override
Stream<AudioEvent> getEventStream(String playerId) =>
_controllerFor(playerId).stream;
}
class _FakeGlobalAudioplayersPlatform
implements GlobalAudioplayersPlatformInterface {
final _controller = StreamController<GlobalAudioEvent>.broadcast();
@override
Future<void> init() async {}
@override
Future<void> setGlobalAudioContext(AudioContext ctx) async {}
@override
Future<void> emitGlobalLog(String message) async {}
@override
Future<void> emitGlobalError(String code, String message) async {}
@override
Stream<GlobalAudioEvent> getGlobalEventStream() => _controller.stream;
}
/// Installs fake `audioplayers` platform implementations. Call once from a
/// test's `setUp` before pumping any widget that creates an [AudioPlayer].
void installFakeAudioPlatform() {
AudioplayersPlatformInterface.instance = _FakeAudioplayersPlatform();
GlobalAudioplayersPlatformInterface.instance =
_FakeGlobalAudioplayersPlatform();
}

View File

@ -0,0 +1,52 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// In-memory fake for the `flutter_secure_storage` platform channel so tests
/// never touch the real OS keystore. Install it from a test (or `setUp`) and
/// it auto-removes on tear down.
///
/// Pass [throwing] to simulate a host with no secret service: every call
/// raises a [PlatformException], which exercises the "save failed" path in
/// [SyncSettings].
void installFakeSecureStorage({
Map<String, String>? initial,
bool throwing = false,
}) {
const channel = MethodChannel(
'plugins.it_nomads.com/flutter_secure_storage',
);
final store = <String, String>{...?initial};
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (call) async {
if (throwing) {
throw PlatformException(code: 'unavailable');
}
final args = (call.arguments as Map?) ?? const <Object?, Object?>{};
final key = args['key'] as String?;
switch (call.method) {
case 'read':
return store[key];
case 'write':
store[key!] = args['value'] as String;
return null;
case 'delete':
store.remove(key);
return null;
case 'containsKey':
return store.containsKey(key);
case 'readAll':
return Map<String, String>.from(store);
case 'deleteAll':
store.clear();
return null;
default:
return null;
}
});
addTearDown(() {
messenger.setMockMethodCallHandler(channel, null);
});
}

View File

@ -1,10 +1,41 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:url_launcher_platform_interface/link.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/models/workout_plan.dart';
import 'package:workout_app/screens/settings_screen.dart';
import 'package:workout_app/services/storage_service.dart';
import '../fake_secure_storage.dart';
/// Stub launcher that records the URL instead of opening it, so the device
/// dialog's "Open GitHub & copy code" can be exercised without a real
/// platform channel.
class _FakeUrlLauncher extends UrlLauncherPlatform
with MockPlatformInterfaceMixin {
String? launched;
@override
final LinkDelegate? linkDelegate = null;
@override
Future<bool> supportsMode(PreferredLaunchMode mode) async => true;
@override
Future<bool> launchUrl(String url, LaunchOptions options) async {
launched = url;
return true;
}
}
void main() {
setUpAll(() {
sqfliteFfiInit();
@ -14,6 +45,7 @@ void main() {
setUp(() async {
StorageService.resetForTesting();
await StorageService.init();
installFakeSecureStorage();
});
Future<void> _pump(WidgetTester tester, Widget w) async {
@ -24,7 +56,35 @@ void main() {
await tester.pump();
}
Widget _wrap() => const MaterialApp(home: SettingsScreen());
Widget _wrap({http.Client? httpClient}) =>
MaterialApp(home: SettingsScreen(httpClient: httpClient));
/// Drains the device flow's real `Future.delayed` poll (GitHubDeviceAuth
/// injects no test delay, so under `runAsync` it is a genuine Timer, not
/// the fake-clock one `tester.pump(duration)` advances) by interleaving
/// real waits with frame pumps until [done] is true or [maxTries] is hit.
Future<void> _pumpUntil(
WidgetTester tester,
bool Function() done, {
int maxTries = 200,
}) async {
for (var i = 0; i < maxTries && !done(); i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
await tester.pump();
}
}
/// The GITHUB SYNC section sits below several exercises' worth of weight
/// rows and threshold cards; `ListView` only builds elements within the
/// viewport (+ cache extent) even for a plain `children:` list, so it must
/// be scrolled into view before `find` can see it.
Future<void> _scrollToGitHubSync(WidgetTester tester) async {
await tester.scrollUntilVisible(
find.text('Connect GitHub'),
500,
scrollable: find.byType(Scrollable),
);
}
testWidgets('shows Settings app bar', (tester) async {
await _pump(tester, _wrap());
@ -94,6 +154,7 @@ void main() {
expect(find.text('Reset to defaults?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('Reset to defaults?'), findsNothing);
});
@ -115,4 +176,196 @@ void main() {
await StorageService.instance.getExerciseState(workoutA.first.name);
expect(state!.weight, workoutA.first.weight);
});
testWidgets('shows GITHUB SYNC section with a Connect GitHub button', (
tester,
) async {
await _pump(tester, _wrap());
await _scrollToGitHubSync(tester);
expect(find.text('GITHUB SYNC'), findsOneWidget);
expect(find.text('Connect GitHub'), findsOneWidget);
});
testWidgets(
'shows Connected immediately when a token is already saved',
(tester) async {
installFakeSecureStorage(initial: {'sync.token': 'already-saved'});
await _pump(tester, _wrap());
await _scrollToGitHubSync(tester);
expect(find.text('Connected.'), findsOneWidget);
},
);
testWidgets('Advanced section reveals the PAT fallback field', (
tester,
) async {
await _pump(tester, _wrap());
await _scrollToGitHubSync(tester);
expect(find.text('Save'), findsNothing);
await tester.tap(find.text('Advanced: paste a token instead'));
await tester.pumpAndSettle();
expect(find.widgetWithText(TextField, ''), findsOneWidget);
expect(find.text('Save'), findsOneWidget);
});
testWidgets('saving a pasted token shows a confirmation', (tester) async {
await _pump(tester, _wrap());
await _scrollToGitHubSync(tester);
await tester.tap(find.text('Advanced: paste a token instead'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.widgetWithText(ElevatedButton, 'Save'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'a-pasted-token');
await tester.tap(find.text('Save'));
await tester.pump();
expect(find.text('Sync token saved.'), findsOneWidget);
});
testWidgets('device flow failure to start shows a message', (tester) async {
final mock = MockClient((_) async => http.Response('nope', 422));
await tester.runAsync(() async {
await tester.pumpWidget(_wrap(httpClient: mock));
await Future<void>.delayed(const Duration(milliseconds: 300));
await tester.pump();
await _scrollToGitHubSync(tester);
await tester.tap(find.text('Connect GitHub'));
await _pumpUntil(
tester,
() =>
find
.textContaining('Could not start device flow')
.evaluate()
.isNotEmpty,
);
expect(
find.textContaining('Could not start device flow'),
findsOneWidget,
);
});
});
testWidgets('device flow happy path saves and verifies the token', (
tester,
) async {
final mock = MockClient((req) async {
if (req.url.path.contains('device/code')) {
return http.Response(
jsonEncode({
'device_code': 'dev123',
'user_code': 'WXYZ-1234',
'verification_uri': 'https://github.com/login/device',
'interval': 0,
'expires_in': 900,
}),
200,
);
}
if (req.url.path.contains('oauth/access_token')) {
return http.Response(jsonEncode({'access_token': 'gho_test'}), 200);
}
// The post-connect verification GET on devices/phone/log.json.
return http.Response(
jsonEncode({'content': base64Encode(utf8.encode('{}'))}),
200,
);
});
await tester.runAsync(() async {
await tester.pumpWidget(_wrap(httpClient: mock));
await Future<void>.delayed(const Duration(milliseconds: 300));
await tester.pump();
await _scrollToGitHubSync(tester);
await tester.tap(find.text('Connect GitHub'));
await _pumpUntil(
tester,
() => find.text('WXYZ-1234').evaluate().isNotEmpty,
);
expect(find.text('WXYZ-1234'), findsOneWidget);
await _pumpUntil(
tester,
() =>
find
.textContaining('Connected and verified via GitHub')
.evaluate()
.isNotEmpty,
);
expect(
find.textContaining('Connected and verified via GitHub'),
findsOneWidget,
);
});
});
testWidgets('device dialog: failed poll shows the error and Open launches', (
tester,
) async {
final launcher = _FakeUrlLauncher();
UrlLauncherPlatform.instance = launcher;
// The dialog's Open button copies the code to the clipboard first;
// there's no clipboard plugin in the test host, so stub the channel.
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async => null,
);
addTearDown(
() => messenger.setMockMethodCallHandler(SystemChannels.platform, null),
);
final mock = MockClient((req) async {
if (req.url.path.contains('device/code')) {
return http.Response(
jsonEncode({
'device_code': 'dev123',
'user_code': 'WXYZ-1234',
'verification_uri': 'https://github.com/login/device',
'interval': 0,
'expires_in': 900,
}),
200,
);
}
return http.Response(
jsonEncode({'error': 'access_denied', 'error_description': 'no'}),
200,
);
});
await tester.runAsync(() async {
await tester.pumpWidget(_wrap(httpClient: mock));
await Future<void>.delayed(const Duration(milliseconds: 300));
await tester.pump();
await _scrollToGitHubSync(tester);
await tester.tap(find.text('Connect GitHub'));
await _pumpUntil(
tester,
() => find.text('WXYZ-1234').evaluate().isNotEmpty,
);
expect(find.text('WXYZ-1234'), findsOneWidget);
await _pumpUntil(
tester,
() => find.textContaining('access_denied').evaluate().isNotEmpty,
);
expect(find.textContaining('access_denied'), findsOneWidget);
await tester.tap(find.text('Open GitHub & copy code'));
await tester.pump();
expect(launcher.launched, 'https://github.com/login/device');
await tester.tap(find.text('Cancel'));
await tester.pump();
});
});
}

View File

@ -5,6 +5,10 @@ import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/screens/workout_screen.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/widgets/exercise_tile.dart';
import 'package:workout_app/widgets/rep_circle.dart';
import '../fake_audio_platform.dart';
import '../fake_secure_storage.dart';
const _exercises = [
Exercise(name: 'Squat', sets: 3, reps: 5, weight: 20.0),
@ -33,6 +37,14 @@ void main() {
setUp(() async {
StorageService.resetForTesting();
await StorageService.init();
// WorkoutScreen fires an unawaited WorkoutSyncService().push() on
// completion, which reads the sync token via FlutterSecureStorage --
// without this, the unmocked platform channel throws
// MissingPluginException as an unhandled Future error.
installFakeSecureStorage();
// The break-end sound creates a real AudioPlayer; fake its platform
// channels too, for the same reason as above.
installFakeAudioPlatform();
});
Future<void> _pump(WidgetTester tester, Widget w) async {
@ -75,6 +87,7 @@ void main() {
expect(find.text('Reset workout?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('Reset workout?'), findsNothing);
});

View File

@ -0,0 +1,104 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/backup_service.dart';
void main() {
late Directory tempDir;
setUp(() {
tempDir = Directory.systemTemp.createTempSync('backup_service_test_');
BackupService.baseDirForTesting = tempDir.path;
});
tearDown(() {
BackupService.baseDirForTesting = kBackupDir;
tempDir.deleteSync(recursive: true);
});
test('baseDirForTesting getter reflects the last setter value', () {
expect(BackupService.baseDirForTesting, tempDir.path);
});
test('export then readBackup round-trips the data', () async {
await BackupService.instance.export({'a': 1, 'b': 'two'});
final result = await BackupService.instance.readBackup();
expect(result, {'a': 1, 'b': 'two'});
});
test('readBackup returns null when nothing was exported', () async {
expect(await BackupService.instance.readBackup(), isNull);
});
test('readBackup returns null for unreadable JSON', () async {
File('${tempDir.path}/backup.json').writeAsStringSync('not json');
expect(await BackupService.instance.readBackup(), isNull);
});
test('exportSyncToken then readSyncToken round-trips the token', () async {
await BackupService.instance.exportSyncToken('gho_abc123');
expect(await BackupService.instance.readSyncToken(), 'gho_abc123');
});
test('readSyncToken returns null when nothing was exported', () async {
expect(await BackupService.instance.readSyncToken(), isNull);
});
test('exportSyncToken with an empty token deletes an existing file', () async {
await BackupService.instance.exportSyncToken('gho_abc123');
await BackupService.instance.exportSyncToken('');
expect(await BackupService.instance.readSyncToken(), isNull);
});
test('exportSyncToken with an empty token is a no-op when no file exists', () async {
await BackupService.instance.exportSyncToken('');
expect(await BackupService.instance.readSyncToken(), isNull);
});
test('export creates the target directory if missing', () async {
final nested = Directory('${tempDir.path}/nested');
BackupService.baseDirForTesting = nested.path;
await BackupService.instance.export({'x': 1});
expect(await BackupService.instance.readBackup(), {'x': 1});
});
test('exportSyncToken creates the target directory if missing', () async {
final nested = Directory('${tempDir.path}/nested2');
BackupService.baseDirForTesting = nested.path;
await BackupService.instance.exportSyncToken('tok');
expect(await BackupService.instance.readSyncToken(), 'tok');
});
test('export swallows errors when the path cannot be created', () async {
// A regular file used as the "directory" makes Directory.createSync
// throw -- export must swallow it, not crash the caller.
final blocker = File('${tempDir.path}/blocker');
blocker.writeAsStringSync('x');
BackupService.baseDirForTesting = blocker.path;
await BackupService.instance.export({'x': 1});
// No exception reaching here is the assertion.
});
test(
'exportSyncToken swallows errors when the path cannot be created',
() async {
final blocker = File('${tempDir.path}/blocker2');
blocker.writeAsStringSync('x');
BackupService.baseDirForTesting = blocker.path;
await BackupService.instance.exportSyncToken('tok');
},
);
test('readBackup swallows filesystem errors', () async {
// Points at a directory instead of a file so File.readAsString throws.
final asDir = Directory('${tempDir.path}/backup.json');
asDir.createSync();
expect(await BackupService.instance.readBackup(), isNull);
});
test('readSyncToken swallows filesystem errors', () async {
final asDir = Directory('${tempDir.path}/sync_token');
asDir.createSync();
expect(await BackupService.instance.readSyncToken(), isNull);
});
}

View File

@ -0,0 +1,205 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:workout_app/services/github_device_auth.dart';
/// Builds an auth instance whose polls resolve instantly (no real waiting).
GitHubDeviceAuth authWith(http.Client client) => GitHubDeviceAuth(
clientId: 'test-client-id',
httpClient: client,
delay: (_) => Future<void>.value(),
);
const _device = DeviceCodeResponse(
deviceCode: 'dev-123',
userCode: 'WXYZ-1234',
verificationUri: 'https://github.com/login/device',
interval: 1,
expiresIn: 900,
);
void main() {
test('requestDeviceCode parses the device + user code', () async {
final client = MockClient((req) async {
expect(req.url.toString(), contains('login/device/code'));
expect(req.bodyFields['client_id'], 'test-client-id');
expect(req.bodyFields['scope'], 'repo');
return http.Response(
jsonEncode({
'device_code': 'dev-123',
'user_code': 'WXYZ-1234',
'verification_uri': 'https://github.com/login/device',
'interval': 5,
'expires_in': 900,
}),
200,
);
});
final res = await authWith(client).requestDeviceCode();
expect(res.deviceCode, 'dev-123');
expect(res.userCode, 'WXYZ-1234');
expect(res.verificationUri, 'https://github.com/login/device');
});
test('requestDeviceCode throws on a non-200 response', () async {
final client = MockClient((_) async => http.Response('nope', 422));
expect(
() => authWith(client).requestDeviceCode(),
throwsA(isA<DeviceAuthException>()),
);
});
test('pollForToken returns the token after authorization_pending', () async {
var calls = 0;
final client = MockClient((req) async {
calls++;
// Pending on the first two polls, then success.
if (calls < 3) {
return http.Response(
jsonEncode({'error': 'authorization_pending'}),
200,
);
}
return http.Response(
jsonEncode({'access_token': 'gho_abc', 'token_type': 'bearer'}),
200,
);
});
final token = await authWith(client).pollForToken(_device);
expect(token, 'gho_abc');
expect(calls, 3);
});
test('pollForToken retries past a dropped connection', () async {
var calls = 0;
final client = MockClient((req) async {
calls++;
if (calls == 1) {
throw http.ClientException(
'Connection closed before full header was received',
);
}
return http.Response(jsonEncode({'access_token': 'gho_retry'}), 200);
});
final token = await authWith(client).pollForToken(_device);
expect(token, 'gho_retry');
expect(calls, 2);
});
test('pollForToken retries past a malformed response body', () async {
var calls = 0;
final client = MockClient((req) async {
calls++;
if (calls == 1) return http.Response('not json', 200);
return http.Response(jsonEncode({'access_token': 'gho_ok2'}), 200);
});
final token = await authWith(client).pollForToken(_device);
expect(token, 'gho_ok2');
expect(calls, 2);
});
test('pollForToken obeys slow_down and still succeeds', () async {
var calls = 0;
final client = MockClient((req) async {
calls++;
if (calls == 1) {
return http.Response(
jsonEncode({'error': 'slow_down', 'interval': 1}),
200,
);
}
return http.Response(jsonEncode({'access_token': 'gho_xyz'}), 200);
});
final token = await authWith(client).pollForToken(_device);
expect(token, 'gho_xyz');
});
test('pollForToken throws on access_denied', () async {
final client = MockClient(
(req) async => http.Response(
jsonEncode({'error': 'access_denied', 'error_description': 'no'}),
200,
),
);
expect(
() => authWith(client).pollForToken(_device),
throwsA(
isA<DeviceAuthException>().having(
(e) => e.code,
'code',
'access_denied',
),
),
);
});
test('pollForToken honors slow_down then succeeds', () async {
var calls = 0;
final client = MockClient((req) async {
calls++;
if (calls == 1) {
return http.Response(
jsonEncode({'error': 'slow_down', 'interval': 0}),
200,
);
}
return http.Response(jsonEncode({'access_token': 'gho_ok'}), 200);
});
expect(await authWith(client).pollForToken(_device), 'gho_ok');
expect(calls, 2);
});
test('pollForToken throws on an unexpected response shape', () async {
final client = MockClient(
(_) async => http.Response(jsonEncode({'foo': 'bar'}), 200),
);
expect(
() => authWith(client).pollForToken(_device),
throwsA(isA<DeviceAuthException>()),
);
});
test('pollForToken throws when the device code has expired', () async {
final client = MockClient(
(_) async => http.Response(jsonEncode({'access_token': 'x'}), 200),
);
const expired = DeviceCodeResponse(
deviceCode: 'd',
userCode: 'u',
verificationUri: 'v',
interval: 1,
expiresIn: 0, // deadline is now → loop body never runs
);
expect(
() => authWith(client).pollForToken(expired),
throwsA(
isA<DeviceAuthException>().having(
(e) => e.code,
'code',
'expired_token',
),
),
);
});
test('defaults to a real http client and delay when none are injected', () {
// Omitting httpClient/delay exercises the `?? http.Client()` and
// `?? Future.delayed` constructor fallbacks; no request is made.
final auth = GitHubDeviceAuth(clientId: 'c');
addTearDown(auth.close);
});
test('DeviceAuthException.toString includes code and message', () {
final e = DeviceAuthException('access_denied', 'no');
expect(e.toString(), 'DeviceAuthException(access_denied): no');
});
}

View File

@ -0,0 +1,119 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/backup_service.dart';
import 'package:workout_app/services/sync_settings.dart';
import '../fake_secure_storage.dart';
void main() {
// installFakeSecureStorage touches the test binary messenger, which needs
// the binding up first (widget tests get this for free via testWidgets).
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
setUp(() {
tempDir = Directory.systemTemp.createTempSync('sync_settings_test_');
BackupService.baseDirForTesting = tempDir.path;
});
tearDown(() {
BackupService.baseDirForTesting = kBackupDir;
tempDir.deleteSync(recursive: true);
});
test('load returns an empty token on a fresh install', () async {
installFakeSecureStorage();
final s = await SyncSettings.load();
expect(s.token, '');
expect(s.isConfigured, isFalse);
});
test('load reads a previously saved token from the keystore', () async {
installFakeSecureStorage(initial: {'sync.token': 'fromKeystore'});
final s = await SyncSettings.load();
expect(s.token, 'fromKeystore');
expect(s.isConfigured, isTrue);
});
test('save persists the token to the keystore', () async {
installFakeSecureStorage();
final ok = await const SyncSettings(token: 'tok').save();
expect(ok, isTrue);
final s = await SyncSettings.load();
expect(s.token, 'tok');
});
test('save with an empty token deletes any existing entry', () async {
installFakeSecureStorage(initial: {'sync.token': 'stale'});
final ok = await const SyncSettings(token: '').save();
expect(ok, isTrue);
final s = await SyncSettings.load();
expect(s.token, '');
});
test('load returns an empty token when the keystore is unavailable', () async {
installFakeSecureStorage(throwing: true);
final s = await SyncSettings.load();
expect(s.token, '');
});
test('save returns false when the keystore is unavailable', () async {
installFakeSecureStorage(throwing: true);
final ok = await const SyncSettings(token: 'tok').save();
expect(ok, isFalse);
});
test('defaultClientId is baked in for the Connect GitHub button', () {
expect(SyncSettings.defaultClientId, isNotEmpty);
});
test('save mirrors the token to the external-storage backup', () async {
installFakeSecureStorage();
await const SyncSettings(token: 'gho_backed_up').save();
expect(
await BackupService.instance.readSyncToken(),
'gho_backed_up',
);
});
test(
'save with an empty token clears the external-storage backup too',
() async {
installFakeSecureStorage();
await const SyncSettings(token: 'gho_x').save();
await const SyncSettings(token: '').save();
expect(await BackupService.instance.readSyncToken(), isNull);
},
);
test(
'load recovers the token from the backup when the keystore is empty '
'(e.g. right after a reinstall) and re-seeds the keystore',
() async {
installFakeSecureStorage();
await BackupService.instance.exportSyncToken('gho_recovered');
final s = await SyncSettings.load();
expect(s.token, 'gho_recovered');
// Re-seeded so the next load doesn't need the backup fallback.
final again = await SyncSettings.load();
expect(again.token, 'gho_recovered');
},
);
test(
'load recovers from the backup even when the keystore write fails',
() async {
installFakeSecureStorage(throwing: true);
await BackupService.instance.exportSyncToken('gho_recovered2');
final s = await SyncSettings.load();
expect(s.token, 'gho_recovered2');
},
);
}

View File

@ -0,0 +1,148 @@
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/models/exercise_result.dart';
import 'package:workout_app/models/set_result.dart';
import 'package:workout_app/models/workout_session.dart';
import 'package:workout_app/services/sync_settings.dart';
import 'package:workout_app/services/workout_sync_service.dart';
import '../fake_secure_storage.dart';
http.Response _response(int statusCode, [Object? jsonBody]) =>
http.Response(jsonEncode(jsonBody ?? {}), statusCode);
http.Response _fileContaining(String text) =>
_response(200, {'content': base64.encode(utf8.encode(text))});
class _PutCall {
_PutCall(this.path, this.body);
final String path;
final Map<String, dynamic> body;
}
/// Builds a mock HTTP client backed by an in-memory router, matching
/// `crdt_sync_dart`'s own `sync_test.dart` helper: GET
/// `.../contents/<key>` returns [contentResponses]`[key]` (404 if absent),
/// the bare repo-existence GET always succeeds, and every PUT is recorded.
({http.Client httpClient, List<_PutCall> putCalls}) _mockGitHub({
Map<String, http.Response> contentResponses = const {},
}) {
final putCalls = <_PutCall>[];
final client = http_testing.MockClient((request) async {
final path = request.url.path;
if (!path.contains('/contents/')) {
return _response(200);
}
final key = path.split('/contents/').last;
if (request.method == 'PUT') {
putCalls.add(
_PutCall(key, jsonDecode(request.body) as Map<String, dynamic>),
);
return _response(200);
}
return contentResponses[key] ?? _response(404);
});
return (httpClient: client, putCalls: putCalls);
}
WorkoutSession _session({DateTime? startTime}) => WorkoutSession(
workoutType: 'A',
startTime: startTime ?? DateTime(2026, 7, 5, 9),
endTime: DateTime(2026, 7, 5, 10),
exercises: [
ExerciseResult(
exercise: const Exercise(name: 'Squat', sets: 3, reps: 5, weight: 20),
sets: List.generate(
3,
(_) => const SetResult(targetReps: 5, doneReps: 5, weight: 20),
),
),
],
);
void main() {
// installFakeSecureStorage touches the test binary messenger, which needs
// the binding up first (widget tests get this for free via testWidgets).
TestWidgetsFlutterBinding.ensureInitialized();
test('does nothing when no token is configured', () async {
installFakeSecureStorage();
final (:httpClient, :putCalls) = _mockGitHub();
await WorkoutSyncService(httpClient: httpClient).push(_session());
expect(putCalls, isEmpty);
});
test('pushes a new record when nothing has been synced yet', () async {
installFakeSecureStorage(initial: {'sync.token': 'tok'});
final (:httpClient, :putCalls) = _mockGitHub(
contentResponses: {'screen-locker-sync/devices': _response(200, [])},
);
final session = _session();
await WorkoutSyncService(httpClient: httpClient).push(session);
expect(putCalls, hasLength(1));
expect(putCalls.single.path, 'screen-locker-sync/devices/phone/log.json');
final pushedLog =
jsonDecode(
utf8.decode(
base64.decode(putCalls.single.body['content'] as String),
),
)
as Map<String, dynamic>;
expect(pushedLog.keys, [session.startTime.toIso8601String()]);
});
test('merges the new session onto an already-pushed log', () async {
installFakeSecureStorage(initial: {'sync.token': 'tok'});
final earlier = Record(
id: 'earlier',
fields: {
'payload': ({'workout_type': 'B'}, Hlc.newTick('phone')),
},
);
final existingLog = jsonEncode({'earlier': earlier.toJson()});
final (:httpClient, :putCalls) = _mockGitHub(
contentResponses: {
'screen-locker-sync/devices': _response(200, []),
'screen-locker-sync/devices/phone/log.json': _fileContaining(
existingLog,
),
},
);
final session = _session();
await WorkoutSyncService(httpClient: httpClient).push(session);
final pushedLog =
jsonDecode(
utf8.decode(
base64.decode(putCalls.single.body['content'] as String),
),
)
as Map<String, dynamic>;
expect(
pushedLog.keys,
containsAll(['earlier', session.startTime.toIso8601String()]),
);
});
test('a GitHubSyncError is swallowed, not rethrown', () async {
installFakeSecureStorage(initial: {'sync.token': 'tok'});
final httpClient = http_testing.MockClient(
(request) async => http.Response('', 500),
);
await expectLater(
WorkoutSyncService(httpClient: httpClient).push(_session()),
completes,
);
});
}

View File

@ -9,10 +9,10 @@ void main() {
testWidgets('displays label and formatted time', (tester) async {
await tester.pumpWidget(
_wrap(
const BreakBanner(
BreakBanner(
breakRemaining: 90,
breakLabel: 'Rest',
onSkip: null,
onSkip: () {},
),
),
);
@ -23,10 +23,10 @@ void main() {
testWidgets('formats time below one minute correctly', (tester) async {
await tester.pumpWidget(
_wrap(
const BreakBanner(
BreakBanner(
breakRemaining: 5,
breakLabel: 'Warmup rest',
onSkip: null,
onSkip: () {},
),
),
);
@ -51,10 +51,10 @@ void main() {
testWidgets('zero seconds formats as 00:00', (tester) async {
await tester.pumpWidget(
_wrap(
const BreakBanner(
BreakBanner(
breakRemaining: 0,
breakLabel: 'Rest',
onSkip: null,
onSkip: () {},
),
),
);

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/widgets/exercise_tile.dart';
import 'package:workout_app/widgets/rep_circle.dart';
Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child));