mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 16:14:01 +01:00
Make workout app and locker text-free
Painted chrome becomes icons or symbols; old words stay as spoken labels or code comments. OS window titles kept. Tests build today from the local day. Todo: ed07cff3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e08a520019
commit
b6db88f9b9
@ -42,10 +42,14 @@ def build_heat_skip_content(
|
||||
outer = tk.Frame(parent, bg=_COLORS.palette.bg)
|
||||
outer.place(relx=0.5, rely=0.5, anchor="center")
|
||||
|
||||
# textgate: glyphs + numbers only, warning colour as the second channel.
|
||||
# Was "\u2600 Too hot to workout?" / "<city>: 31\u00b0C (threshold: 30\u00b0C)" /
|
||||
# "Skip today's workout due to extreme heat?". Plain geometric glyphs,
|
||||
# not the sun (U+2600 can fall back to a colour-emoji font and crash Tk).
|
||||
tk.Label(
|
||||
outer,
|
||||
text="\u2600 Too hot to workout?",
|
||||
font=_COLORS.font("body", bold=True, family=_FONT),
|
||||
text="\u25b2",
|
||||
font=_COLORS.font("display", bold=True, family=_FONT),
|
||||
bg=_COLORS.palette.bg,
|
||||
fg=_COLORS.palette.warning,
|
||||
).pack(pady=(0, _COLORS.space("sm")))
|
||||
@ -53,28 +57,22 @@ def build_heat_skip_content(
|
||||
tk.Label(
|
||||
outer,
|
||||
text=(
|
||||
f"{HEAT_SKIP_CITY}: {temp:.0f}\u00b0C"
|
||||
f" (threshold: {HEAT_SKIP_TEMP_THRESHOLD}\u00b0C)"
|
||||
f"{HEAT_SKIP_CITY} {temp:.0f}\u00b0C"
|
||||
f" \u2265 {HEAT_SKIP_TEMP_THRESHOLD}\u00b0C"
|
||||
),
|
||||
font=_COLORS.font("body", family=_FONT),
|
||||
bg=_COLORS.palette.bg,
|
||||
fg=_COLORS.palette.muted,
|
||||
fg=_COLORS.palette.warning,
|
||||
).pack(pady=_COLORS.space("xs"))
|
||||
|
||||
tk.Label(
|
||||
outer,
|
||||
text="Skip today's workout due to extreme heat?",
|
||||
font=_COLORS.font("body", family=_FONT),
|
||||
bg=_COLORS.palette.bg,
|
||||
fg=_COLORS.palette.muted,
|
||||
).pack(pady=(_COLORS.space("xs"), 0))
|
||||
|
||||
btn_frame = tk.Frame(outer, bg=_COLORS.palette.bg)
|
||||
btn_frame.pack(pady=_COLORS.space("lg"))
|
||||
|
||||
# Was "Skip workout" (warning fill) / "No, I'll workout" (neutral fill).
|
||||
skip_button = tk.Button(
|
||||
btn_frame,
|
||||
text="Skip workout",
|
||||
name="skip",
|
||||
text="\u21b7",
|
||||
command=on_skip,
|
||||
bg=_COLORS.palette.warning,
|
||||
fg=_COLORS.palette.on_fill,
|
||||
@ -89,7 +87,8 @@ def build_heat_skip_content(
|
||||
|
||||
decline_button = tk.Button(
|
||||
btn_frame,
|
||||
text="No, I'll workout",
|
||||
name="decline",
|
||||
text="▶",
|
||||
command=on_decline,
|
||||
bg=_COLORS.palette.field_bg,
|
||||
fg=_COLORS.palette.fg,
|
||||
|
||||
@ -29,6 +29,12 @@ from screen_locker._ui_widgets import disable_paste
|
||||
if TYPE_CHECKING:
|
||||
from screen_locker._manual_workout import ManualWorkoutDraft
|
||||
|
||||
# textgate: plain glyphs (no colour emoji: Tk 8.6 can crash on them).
|
||||
SCHEDULED_GLYPH = "✓"
|
||||
"""Heading of the scheduled screen (success colour)."""
|
||||
BACK_GLYPH = "↩"
|
||||
"""The scheduled screen's only button: back to the form's host."""
|
||||
|
||||
|
||||
class ManualWorkoutDateMixin:
|
||||
"""Date row, budget gate and future-dated submission of the manual form."""
|
||||
@ -59,9 +65,10 @@ class ManualWorkoutDateMixin:
|
||||
)
|
||||
entry.pack(side="right", padx=self._colors.space("xs"))
|
||||
disable_paste(entry)
|
||||
# Was "Workout date (today to +7 days):" -- a day square + the range.
|
||||
tk.Label(
|
||||
row,
|
||||
text="Workout date (today to +7 days):",
|
||||
text="□ +0…+7",
|
||||
font=self._colors.font("label"),
|
||||
fg=self._colors.palette.fg,
|
||||
bg=self._colors.palette.bg,
|
||||
@ -94,23 +101,21 @@ class ManualWorkoutDateMixin:
|
||||
def _show_manual_workout_scheduled(self, reason: str) -> None:
|
||||
"""Confirm a future entry and go back; it does NOT unlock anything."""
|
||||
self.clear_container()
|
||||
# Was "✓ Workout Scheduled" + "Nothing is credited today. It counts by
|
||||
# itself on that date, and can be deleted in the phone app until
|
||||
# then." + an "OK" button: a green tick, the reason, a back arrow.
|
||||
self._label(
|
||||
"✓ Workout Scheduled",
|
||||
SCHEDULED_GLYPH,
|
||||
role="display",
|
||||
scale=1.3,
|
||||
color=self._colors.palette.success,
|
||||
pad="lg",
|
||||
)
|
||||
self._text(reason, role="subtitle", color=self._colors.palette.success)
|
||||
self._text(
|
||||
"Nothing is credited today. It counts by itself on that date, and "
|
||||
"can be deleted in the phone app until then.",
|
||||
color=self._colors.palette.muted,
|
||||
)
|
||||
row = self._button_row()
|
||||
self._button(
|
||||
row,
|
||||
"OK",
|
||||
BACK_GLYPH,
|
||||
bg=self._colors.palette.accent,
|
||||
command=self._on_manual_workout_cancelled,
|
||||
width=12,
|
||||
|
||||
@ -23,12 +23,20 @@ from screen_locker import _manual_workout
|
||||
from screen_locker._constants import (
|
||||
MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
||||
)
|
||||
from screen_locker._manual_workout_date import ManualWorkoutDateMixin
|
||||
from screen_locker._manual_workout_date import BACK_GLYPH, ManualWorkoutDateMixin
|
||||
from screen_locker._manual_workout_sport_fields import ManualWorkoutSportFieldsMixin
|
||||
from screen_locker._manual_workout_widgets import ManualWorkoutFormWidgetsMixin
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# textgate glyphs (plain Unicode, no colour emoji: Tk 8.6 can crash on them).
|
||||
FORM_GLYPH = "≡"
|
||||
"""Form heading (accent); was "Log Manual Workout"."""
|
||||
EXHAUSTED_GLYPH = "✕"
|
||||
"""Budget used up (danger); was the "budget exhausted" sentence."""
|
||||
SUBMIT_GLYPH = "✓"
|
||||
"""Submit button (accent fill); was "SUBMIT"."""
|
||||
|
||||
_SPORT_LABEL_TO_CODE = {
|
||||
label: code for code, label in _manual_workout.SPORT_LABELS.items()
|
||||
}
|
||||
@ -44,12 +52,11 @@ class ManualWorkoutDialogMixin(
|
||||
def _show_manual_workout_form(self) -> None:
|
||||
"""Render the manual-workout evidence form, or a budget-exhausted note."""
|
||||
self.clear_container()
|
||||
self._label("Log Manual Workout", color=self._colors.palette.accent, pad="xs")
|
||||
# textgate: glyphs, colours second. Was "Log Manual Workout" heading /
|
||||
# "Manual-workout budget exhausted for this window." / "BACK".
|
||||
self._label(FORM_GLYPH, color=self._colors.palette.accent, pad="xs")
|
||||
if self._mw_budget_closed():
|
||||
self._text(
|
||||
"Manual-workout budget exhausted for this window.",
|
||||
color=self._colors.palette.danger,
|
||||
)
|
||||
self._label(EXHAUSTED_GLYPH, color=self._colors.palette.danger, pad="xs")
|
||||
self._text(
|
||||
_manual_workout.budget_summary(self.log_file),
|
||||
color=self._colors.palette.muted,
|
||||
@ -57,7 +64,7 @@ class ManualWorkoutDialogMixin(
|
||||
row = self._button_row()
|
||||
self._button(
|
||||
row,
|
||||
"BACK",
|
||||
BACK_GLYPH,
|
||||
bg=self._colors.palette.field_bg,
|
||||
command=self._on_manual_workout_cancelled,
|
||||
width=12,
|
||||
@ -158,14 +165,14 @@ class ManualWorkoutDialogMixin(
|
||||
button_row = self._button_row()
|
||||
self._button(
|
||||
button_row,
|
||||
"SUBMIT",
|
||||
SUBMIT_GLYPH,
|
||||
bg=self._colors.palette.accent,
|
||||
command=self._submit_manual_workout_form,
|
||||
width=12,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
self._button(
|
||||
button_row,
|
||||
"BACK",
|
||||
BACK_GLYPH,
|
||||
bg=self._colors.palette.field_bg,
|
||||
command=self._on_manual_workout_cancelled,
|
||||
width=12,
|
||||
|
||||
@ -49,13 +49,7 @@ class ManualWorkoutSportFieldsMixin:
|
||||
value=_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS]
|
||||
)
|
||||
row = tk.Frame(parent, bg=self._colors.palette.bg)
|
||||
tk.Label(
|
||||
row,
|
||||
text="Sport:",
|
||||
font=self._colors.font("body"),
|
||||
fg=self._colors.palette.fg,
|
||||
bg=self._colors.palette.bg,
|
||||
).pack(side="left", padx=self._colors.space("xs"))
|
||||
# textgate: the "Sport:" caption is gone; the radio row is the cue.
|
||||
for label in _manual_workout.SPORT_LABELS.values():
|
||||
tk.Radiobutton(
|
||||
row,
|
||||
|
||||
@ -201,9 +201,10 @@ class WindowSetupMixin:
|
||||
|
||||
def _setup_demo_close_button(self) -> None:
|
||||
"""Add close button for demo mode."""
|
||||
# Was "✕ Close Demo": the cross on the danger fill says it alone.
|
||||
close_btn = tk.Button(
|
||||
self.root,
|
||||
text="✕ Close Demo",
|
||||
text="✕",
|
||||
font=self._colors.font("caption"),
|
||||
bg=self._colors.palette.danger,
|
||||
fg=self._colors.palette.on_fill,
|
||||
|
||||
@ -37,7 +37,7 @@ def setup_unlock(
|
||||
if seed_today_type is None:
|
||||
log_file.write_text("{}")
|
||||
else:
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
log_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
|
||||
@ -199,7 +199,8 @@ def _isolate_shutdown_base(tmp_path: Path) -> Iterator[None]:
|
||||
refresh sees an empty unit dir, never the real one.
|
||||
"""
|
||||
target = tmp_path / "shutdown_base.json"
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
# Local day, as the code compares it (_day.today_str), not the UTC one.
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
target.write_text(json.dumps(dict.fromkeys(["last_reset_date", *_BONUS], today)))
|
||||
with (
|
||||
patch("screen_locker._constants.SHUTDOWN_BASE_FILE", target),
|
||||
|
||||
@ -22,7 +22,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
return datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
return datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
class TestIsScheduledSkipToday:
|
||||
|
||||
@ -19,7 +19,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
return datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
return datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
class TestExplainLockDecision:
|
||||
|
||||
@ -49,7 +49,7 @@ class TestTryAutoUpgradeEarlyBird:
|
||||
assert result is True
|
||||
with log_file.open() as f:
|
||||
data: dict[str, Any] = json.load(f)
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
assert data[today][0]["workout_data"]["type"] == "phone_verified"
|
||||
assert data[today][0]["workout_data"]["after_early_bird"] == "true"
|
||||
|
||||
|
||||
@ -81,7 +81,7 @@ class TestIsEarlyBirdPending:
|
||||
) -> None:
|
||||
"""Return False when HMAC verification fails."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
pending_file = tmp_path / "early_bird_pending.json"
|
||||
pending_file.write_text(json.dumps({"date": today, "hmac": "bad"}))
|
||||
with (
|
||||
@ -105,7 +105,7 @@ class TestIsEarlyBirdPending:
|
||||
) -> None:
|
||||
"""Return True when today's marker is present and HMAC-valid."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
pending_file = tmp_path / "early_bird_pending.json"
|
||||
pending_file.write_text(json.dumps({"date": today, "hmac": "sig"}))
|
||||
with (
|
||||
@ -125,7 +125,7 @@ class TestIsEarlyBirdPending:
|
||||
) -> None:
|
||||
"""Unsigned marker is accepted when no HMAC key is configured."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
pending_file = tmp_path / "early_bird_pending.json"
|
||||
pending_file.write_text(json.dumps({"date": today}))
|
||||
with (
|
||||
@ -163,7 +163,7 @@ class TestSaveEarlyBirdPending:
|
||||
assert pending_file.exists()
|
||||
with pending_file.open() as f:
|
||||
data: dict[str, Any] = json.load(f)
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
assert data["date"] == today
|
||||
assert not locker.log_file.exists()
|
||||
|
||||
|
||||
@ -2,8 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from screen_locker._constants import HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD
|
||||
from screen_locker._heat_skip import HeatSkipMixin
|
||||
|
||||
|
||||
@ -15,11 +17,15 @@ class _HeatSkipHost(HeatSkipMixin):
|
||||
self.save_workout_log = MagicMock()
|
||||
|
||||
|
||||
def _button_command(mock_tk: MagicMock, text: str):
|
||||
def _button_command(mock_tk: MagicMock, name: str):
|
||||
"""The command of the button created with Tk widget ``name``.
|
||||
|
||||
By name, not by glyph: the buttons carry symbols only (textgate).
|
||||
"""
|
||||
for call in mock_tk.Button.call_args_list:
|
||||
if call.kwargs.get("text") == text:
|
||||
if call.kwargs.get("name") == name:
|
||||
return call.kwargs["command"]
|
||||
msg = f"No button with text {text!r} was created"
|
||||
msg = f"No button named {name!r} was created"
|
||||
raise AssertionError(msg)
|
||||
|
||||
|
||||
@ -31,7 +37,7 @@ class TestShowHeatSkipDialog:
|
||||
host = _HeatSkipHost()
|
||||
|
||||
def _simulate_click() -> None:
|
||||
_button_command(mock_tk, "Skip workout")()
|
||||
_button_command(mock_tk, "skip")()
|
||||
|
||||
mock_tk.Tk.return_value.mainloop.side_effect = _simulate_click
|
||||
assert host._show_heat_skip_dialog(35.0) is True
|
||||
@ -42,19 +48,21 @@ class TestShowHeatSkipDialog:
|
||||
host = _HeatSkipHost()
|
||||
|
||||
def _simulate_click() -> None:
|
||||
_button_command(mock_tk, "No, I'll workout")()
|
||||
_button_command(mock_tk, "decline")()
|
||||
|
||||
mock_tk.Tk.return_value.mainloop.side_effect = _simulate_click
|
||||
assert host._show_heat_skip_dialog(35.0) is False
|
||||
|
||||
def test_dialog_shows_temperature_and_threshold(self, mock_tk: MagicMock) -> None:
|
||||
"""The dialog text shows the rounded temperature and mentions the threshold."""
|
||||
"""The dialog shows the rounded temperature against the threshold, no words."""
|
||||
host = _HeatSkipHost()
|
||||
mock_tk.Tk.return_value.mainloop.side_effect = None
|
||||
host._show_heat_skip_dialog(36.4)
|
||||
texts = [c.kwargs.get("text", "") for c in mock_tk.Label.call_args_list]
|
||||
assert any("36°C" in t for t in texts)
|
||||
assert any("threshold" in t for t in texts)
|
||||
assert any(f"36°C ≥ {HEAT_SKIP_TEMP_THRESHOLD}°C" in t for t in texts)
|
||||
# Only the city (config data) may carry letters: no chrome sentence.
|
||||
chrome = [t.replace(HEAT_SKIP_CITY, "") for t in texts]
|
||||
assert not any(re.search(r"[A-Za-z]{2,}", t) for t in chrome)
|
||||
|
||||
|
||||
class TestSaveHeatSkipLog:
|
||||
|
||||
@ -140,7 +140,7 @@ class TestHasLoggedToday:
|
||||
) -> None:
|
||||
"""Test when today's workout is logged with valid HMAC."""
|
||||
log_file = tmp_path / "log.json"
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
log_file.write_text(
|
||||
json.dumps({today: {"workout": "data", "hmac": "valid"}}),
|
||||
)
|
||||
@ -161,7 +161,7 @@ class TestHasLoggedToday:
|
||||
) -> None:
|
||||
"""Test rejects entry when HMAC verification fails."""
|
||||
log_file = tmp_path / "log.json"
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
log_file.write_text(
|
||||
json.dumps({today: {"workout": "data", "hmac": "tampered"}}),
|
||||
)
|
||||
@ -182,7 +182,7 @@ class TestHasLoggedToday:
|
||||
) -> None:
|
||||
"""Accept unsigned entry when HMAC key is unavailable."""
|
||||
log_file = tmp_path / "log.json"
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
log_file.write_text(
|
||||
json.dumps({today: {"workout": "data"}}),
|
||||
)
|
||||
@ -209,7 +209,7 @@ class TestHasLoggedToday:
|
||||
) -> None:
|
||||
"""Reject unsigned entry when HMAC key IS available."""
|
||||
log_file = tmp_path / "log.json"
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
log_file.write_text(
|
||||
json.dumps({today: {"workout": "data"}}),
|
||||
)
|
||||
|
||||
@ -36,7 +36,7 @@ class TestSaveWorkoutLog:
|
||||
assert log_file.exists()
|
||||
with log_file.open() as f:
|
||||
data: dict[str, Any] = json.load(f)
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
assert today in data
|
||||
# The log now holds a list of entries per day (multiple workouts/day).
|
||||
assert data[today][0]["workout_data"]["type"] == "running"
|
||||
@ -61,7 +61,7 @@ class TestSaveWorkoutLog:
|
||||
|
||||
with log_file.open() as f:
|
||||
data: dict[str, Any] = json.load(f)
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
assert "hmac" not in data[today][0]
|
||||
|
||||
def test_save_to_existing_file(
|
||||
@ -86,7 +86,7 @@ class TestSaveWorkoutLog:
|
||||
with log_file.open() as f:
|
||||
data: dict[str, Any] = json.load(f)
|
||||
assert "2020-01-01" in data
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
assert today in data
|
||||
|
||||
def test_save_with_corrupted_existing_file(
|
||||
@ -110,7 +110,7 @@ class TestSaveWorkoutLog:
|
||||
|
||||
with log_file.open() as f:
|
||||
data: dict[str, Any] = json.load(f)
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
assert today in data
|
||||
|
||||
def test_save_with_write_error(
|
||||
|
||||
@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._manual_schedule import ScheduleResult
|
||||
from screen_locker._manual_workout_date import BACK_GLYPH, SCHEDULED_GLYPH
|
||||
from screen_locker.tests import test_manual_workout_dialog_part2 as dialog_tests
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
@ -139,12 +140,12 @@ class TestScheduledConfirmation:
|
||||
buttons = {
|
||||
c.kwargs.get("text"): c.kwargs for c in mock_tk.Button.call_args_list
|
||||
}
|
||||
assert set(buttons) == {"OK"}
|
||||
buttons["OK"]["command"]()
|
||||
assert set(buttons) == {BACK_GLYPH}
|
||||
buttons[BACK_GLYPH]["command"]()
|
||||
cancelled.assert_called_once_with()
|
||||
saved.assert_not_called()
|
||||
texts = [c.kwargs.get("text", "") for c in mock_tk.Label.call_args_list]
|
||||
assert any("Workout Scheduled" in t for t in texts)
|
||||
assert SCHEDULED_GLYPH in texts
|
||||
assert any("scheduled for tomorrow" in t for t in texts)
|
||||
|
||||
|
||||
|
||||
@ -6,6 +6,8 @@ from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker import _manual_workout
|
||||
from screen_locker._manual_workout_date import BACK_GLYPH
|
||||
from screen_locker._manual_workout_dialog import EXHAUSTED_GLYPH, SUBMIT_GLYPH
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -25,8 +27,8 @@ class TestShowManualWorkoutForm:
|
||||
with patch(_CLOSED, return_value=False):
|
||||
locker._show_manual_workout_form()
|
||||
button_texts = {c.kwargs.get("text") for c in mock_tk.Button.call_args_list}
|
||||
assert "SUBMIT" in button_texts
|
||||
assert "BACK" in button_texts
|
||||
assert SUBMIT_GLYPH in button_texts
|
||||
assert BACK_GLYPH in button_texts
|
||||
|
||||
def test_shows_exhausted_message_and_hides_submit(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
@ -36,10 +38,10 @@ class TestShowManualWorkoutForm:
|
||||
with patch(_CLOSED, return_value=True):
|
||||
locker._show_manual_workout_form()
|
||||
button_texts = {c.kwargs.get("text") for c in mock_tk.Button.call_args_list}
|
||||
assert "BACK" in button_texts
|
||||
assert "SUBMIT" not in button_texts
|
||||
assert BACK_GLYPH in button_texts
|
||||
assert SUBMIT_GLYPH not in button_texts
|
||||
text_calls = [c.kwargs.get("text", "") for c in mock_tk.Label.call_args_list]
|
||||
assert any("exhausted" in t for t in text_calls)
|
||||
assert EXHAUSTED_GLYPH in text_calls
|
||||
|
||||
|
||||
class TestSportToggle:
|
||||
|
||||
@ -64,7 +64,7 @@ class TestTryAutoUpgradeEarlyBirdRunnerUp:
|
||||
result = locker._try_auto_upgrade_early_bird()
|
||||
|
||||
assert result is True
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
data = json.loads(log_file.read_text())
|
||||
assert data[today][0]["workout_data"]["type"] == "runnerup_verified"
|
||||
assert data[today][0]["workout_data"]["after_early_bird"] == "true"
|
||||
|
||||
@ -78,7 +78,7 @@ class TestResetToBaseIfNewDay:
|
||||
|
||||
def test_returns_false_when_already_reset_today(self, tmp_path: Path) -> None:
|
||||
"""Same-day last_reset_date → early False return (line 63)."""
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
f = tmp_path / "state.json"
|
||||
f.write_text(json.dumps({"last_reset_date": today}))
|
||||
assert reset_to_base_if_new_day(f, self._make_mixin()) is False
|
||||
|
||||
@ -34,7 +34,7 @@ class TestRelaxedDayAlreadySkippedBranch:
|
||||
# script_dir.parent/"log.json" resolves to tmp_path/"log.json" -- NOT
|
||||
# tmp_path/"screen_locker"/"log.json".
|
||||
log_file = tmp_path / "log.json"
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
write_signed_entry(log_file, today, {"type": "relaxed_day_skip"})
|
||||
|
||||
with (
|
||||
|
||||
@ -56,19 +56,21 @@ class _DemoHooks:
|
||||
|
||||
def build_surface(self, parent: tk.Misc, _surface: object) -> None:
|
||||
"""Draw the status line and the release/reacquire buttons."""
|
||||
# textgate: glyphs only, colours as the second channel. Was "DEMO
|
||||
# LOCK — local grab, VT switching still enabled" / "Press “Start
|
||||
# workout”. While the app holds the screen, press Ctrl+Shift+Q to
|
||||
# escape." / "Start workout" / "✕ Close demo (or press Escape)".
|
||||
# The escape chord stays visible: it is what the user must press.
|
||||
tk.Label(
|
||||
parent,
|
||||
text="DEMO LOCK — local grab, VT switching still enabled",
|
||||
text="▣",
|
||||
font=("TkDefaultFont", 18, "bold"),
|
||||
bg="#1B1D21",
|
||||
fg="#E6E6E6",
|
||||
).pack(pady=(120, 8))
|
||||
self.status = tk.Label(
|
||||
parent,
|
||||
text=(
|
||||
"Press “Start workout”. While the app holds the screen, "
|
||||
"press Ctrl+Shift+Q to escape."
|
||||
),
|
||||
text="Ctrl+Shift+Q",
|
||||
font=("TkDefaultFont", 12),
|
||||
bg="#1B1D21",
|
||||
fg="#9AA0A6",
|
||||
@ -76,7 +78,7 @@ class _DemoHooks:
|
||||
self.status.pack(pady=8)
|
||||
tk.Button(
|
||||
parent,
|
||||
text="Start workout",
|
||||
text="▶",
|
||||
font=("TkDefaultFont", 14, "bold"),
|
||||
bg="#3B82F6",
|
||||
fg="#FFFFFF",
|
||||
@ -86,7 +88,7 @@ class _DemoHooks:
|
||||
).pack(pady=16)
|
||||
tk.Button(
|
||||
parent,
|
||||
text="✕ Close demo (or press Escape)",
|
||||
text="✕",
|
||||
bg="#EF4444",
|
||||
fg="#FFFFFF",
|
||||
command=self._close,
|
||||
|
||||
@ -192,6 +192,7 @@ class _WorkoutAppState extends State<WorkoutApp> with WidgetsBindingObserver {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
// Same proper noun as BreakNotificationText.channelName.
|
||||
title: 'Workout Tracker',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildAppTheme(),
|
||||
@ -200,7 +201,8 @@ class _WorkoutAppState extends State<WorkoutApp> with WidgetsBindingObserver {
|
||||
// ribbon on every screen, in the error colour, on the sandbox only.
|
||||
builder: (context, child) => Sandbox.enabled
|
||||
? Banner(
|
||||
message: 'SANDBOX',
|
||||
// Was 'SANDBOX': the red corner ribbon alone marks it.
|
||||
message: '',
|
||||
location: BannerLocation.topEnd,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
child: child,
|
||||
|
||||
@ -224,10 +224,13 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
],
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ElevatedButton.icon(
|
||||
child: ElevatedButton(
|
||||
onPressed: _connectGitHub,
|
||||
icon: const Icon(Icons.login),
|
||||
label: const Text('Connect GitHub'),
|
||||
child: const Icon(
|
||||
Icons.login,
|
||||
size: 20,
|
||||
semanticLabel: 'Connect GitHub',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@ -80,17 +80,23 @@ class _SyncTokenField extends StatelessWidget {
|
||||
// inputDecorationTheme (theme.dart) — only the field-specific
|
||||
// hint/padding need setting here.
|
||||
decoration: InputDecoration(
|
||||
hintText: 'GitHub PAT',
|
||||
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.vpn_key,
|
||||
semanticLabel: 'GitHub PAT',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(onPressed: onSave, child: const Text('Save')),
|
||||
ElevatedButton(
|
||||
onPressed: onSave,
|
||||
child: const Icon(Icons.save, semanticLabel: 'Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -140,19 +146,17 @@ class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return AlertDialog(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
title: Text(
|
||||
'Authorize on GitHub',
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
// Was "Authorize on GitHub" / "Enter this code on GitHub:" /
|
||||
// "Waiting for authorization…": key glyph, the code, a spinner.
|
||||
title: Icon(
|
||||
Icons.vpn_key,
|
||||
color: colorScheme.onSurface,
|
||||
semanticLabel: 'Authorize on GitHub',
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Enter this code on GitHub:',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
widget.device.userCode,
|
||||
style: TextStyle(
|
||||
@ -163,21 +167,13 @@ class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_error == null)
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Waiting for authorization…',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
semanticsLabel: 'Waiting for authorization…',
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(_error!, style: TextStyle(color: colorScheme.error)),
|
||||
@ -186,15 +182,19 @@ class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
semanticLabel: 'Cancel',
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
// Was "Open GitHub & copy code".
|
||||
FilledButton(
|
||||
onPressed: _openPage,
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: const Text('Open GitHub & copy code'),
|
||||
child: const Icon(
|
||||
Icons.open_in_new,
|
||||
semanticLabel: 'Open GitHub & copy code',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -6,6 +6,7 @@ library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:math';
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -161,7 +162,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
title: Text('Progress', style: TextStyle(color: colorScheme.onSurface)),
|
||||
title: const Icon(Icons.timeline, size: 20, semanticLabel: 'Progress'),
|
||||
iconTheme: IconThemeData(color: colorScheme.onSurface),
|
||||
),
|
||||
body: _loading
|
||||
|
||||
@ -40,19 +40,33 @@ extension _HistoryPlanned on _HistoryScreenState {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete planned workout?'),
|
||||
// Was "Delete planned workout?" / "<source> on <date> at <time>.
|
||||
// It will not be credited." -- now the bin glyph plus the entry's
|
||||
// own data (source, date, time).
|
||||
title: Icon(
|
||||
Icons.delete_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
semanticLabel: 'Delete planned workout?',
|
||||
),
|
||||
content: Text(
|
||||
'${payload['source'] ?? 'Manual workout'} on $date at '
|
||||
'${payload['start_time']}. It will not be credited.',
|
||||
[
|
||||
'${payload['source'] ?? ''}',
|
||||
date,
|
||||
'${payload['start_time']}',
|
||||
].where((part) => part.isNotEmpty).join(' · '),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('KEEP'),
|
||||
child: const Icon(Icons.close, semanticLabel: 'KEEP'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('DELETE'),
|
||||
child: Icon(
|
||||
Icons.delete_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
semanticLabel: 'DELETE',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -62,11 +76,16 @@ extension _HistoryPlanned on _HistoryScreenState {
|
||||
// loaded, and a delete confirmed after midnight would cancel a workout
|
||||
// that is now today's -- one the PC may already have credited.
|
||||
if (!_isPlanned(payload, dayKey((widget.clock ?? DateTime.now)()))) {
|
||||
// Was "Not deleted — <date> has started, so it is no longer a
|
||||
// planned workout.": a blocked glyph next to the date.
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Not deleted — $date has started, so it is no longer '
|
||||
'a planned workout.',
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.block, semanticLabel: 'Not deleted'),
|
||||
const SizedBox(width: 8),
|
||||
Text(date),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -81,9 +100,20 @@ extension _HistoryPlanned on _HistoryScreenState {
|
||||
});
|
||||
if (!mounted) return;
|
||||
if (!result.pushed) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Not deleted — ${result.reason}')));
|
||||
// Was "Not deleted — <reason>"; the reason goes to the log instead.
|
||||
developer.log(
|
||||
'History: planned $id not deleted: ${result.reason}',
|
||||
level: 900,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Icon(
|
||||
Icons.cloud_off,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
semanticLabel: 'Not deleted',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _load();
|
||||
@ -141,8 +171,11 @@ class _PlannedWorkoutTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete planned workout',
|
||||
icon: Icon(Icons.delete_outline, color: colorScheme.error),
|
||||
icon: Icon(
|
||||
Icons.delete_outline,
|
||||
color: colorScheme.error,
|
||||
semanticLabel: 'Delete planned workout',
|
||||
),
|
||||
onPressed: onDelete,
|
||||
),
|
||||
],
|
||||
|
||||
@ -164,8 +164,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
onPressed: _openRun,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.edit_note, color: colorScheme.onSurface),
|
||||
tooltip: 'Log manual workout',
|
||||
icon: Icon(
|
||||
Icons.edit_note,
|
||||
color: colorScheme.onSurface,
|
||||
semanticLabel: 'Log manual workout',
|
||||
),
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const ManualWorkoutScreen(),
|
||||
@ -173,15 +176,21 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'History',
|
||||
icon: Icon(Icons.history, color: colorScheme.onSurface),
|
||||
icon: Icon(
|
||||
Icons.history,
|
||||
color: colorScheme.onSurface,
|
||||
semanticLabel: 'History',
|
||||
),
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const HistoryScreen()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Settings',
|
||||
icon: Icon(Icons.settings, color: colorScheme.onSurface),
|
||||
icon: Icon(
|
||||
Icons.settings,
|
||||
color: colorScheme.onSurface,
|
||||
semanticLabel: 'Settings',
|
||||
),
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
|
||||
|
||||
@ -50,7 +50,11 @@ extension _ManualWorkoutFormParts on _ManualWorkoutScreenState {
|
||||
TextButton(
|
||||
key: const Key('mw_pick_date'),
|
||||
onPressed: _submitting ? null : _pickDate,
|
||||
child: const Text('CHANGE DATE'),
|
||||
child: const Icon(
|
||||
Icons.calendar_today,
|
||||
size: 20,
|
||||
semanticLabel: 'CHANGE DATE',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -179,7 +179,7 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: ElevatedButton(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
child: const Text('SUBMIT'),
|
||||
child: const Icon(Icons.save, semanticLabel: 'SUBMIT'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@ -178,11 +179,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
await (widget.progressionPusher ??
|
||||
ProgressionSyncService().pushProgression)();
|
||||
if (!mounted || pushed.changed) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Saved on this phone, not synced: ${pushed.reason}'),
|
||||
),
|
||||
);
|
||||
_flagUnsynced(pushed.reason);
|
||||
}
|
||||
|
||||
/// Shows a deloaded target everywhere this screen holds a copy of it.
|
||||
|
||||
@ -23,26 +23,29 @@ extension _SettingsScreenActions on _SettingsScreenState {
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
title: Text(
|
||||
'Reset to defaults?',
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
content: Text(
|
||||
'All weights and thresholds will be reset. '
|
||||
'Streak counters will be cleared.',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
// Was "Reset to defaults?" / "All weights and thresholds will be
|
||||
// reset. Streak counters will be cleared.".
|
||||
title: Icon(
|
||||
Icons.restart_alt,
|
||||
color: colorScheme.error,
|
||||
semanticLabel: 'Reset to defaults?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
semanticLabel: 'Cancel',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text('Reset', style: TextStyle(color: colorScheme.error)),
|
||||
child: Icon(
|
||||
Icons.restart_alt,
|
||||
color: colorScheme.error,
|
||||
semanticLabel: 'Reset',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -83,4 +86,20 @@ extension _SettingsScreenActions on _SettingsScreenState {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Flags an exercise edit that stayed on this phone (the push did not
|
||||
/// happen). Was "Saved on this phone, not synced: `reason`"; the reason
|
||||
/// now goes to the log, the screen shows the glyph.
|
||||
void _flagUnsynced(String reason) {
|
||||
log('Settings: saved on this phone, not synced: $reason', level: 900);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Icon(
|
||||
Icons.cloud_off,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
semanticLabel: 'Saved on this phone, not synced',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,9 +21,17 @@ extension _SettingsScreenDeload on _SettingsScreenState {
|
||||
if (!mounted) return null;
|
||||
final next = result.state;
|
||||
if (next == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(result.reason)));
|
||||
// Was the refusal reason as a sentence; it goes to the log instead.
|
||||
log('Settings: deload of $name refused: ${result.reason}', level: 900);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Icon(
|
||||
Icons.block,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
semanticLabel: 'Deload failed',
|
||||
),
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
SandboxLog.event('manual deload', {
|
||||
|
||||
@ -78,9 +78,10 @@ class _PcLinkSectionState extends State<_PcLinkSection> {
|
||||
key: const Key('pc-host'),
|
||||
controller: _host,
|
||||
keyboardType: TextInputType.url,
|
||||
// Was labelText 'PC address'.
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
labelText: 'PC address',
|
||||
prefixIcon: Icon(Icons.computer, semanticLabel: 'PC address'),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -90,7 +91,7 @@ class _PcLinkSectionState extends State<_PcLinkSection> {
|
||||
onPressed: () => unawaited(_save()),
|
||||
// Distinct label: the sandbox section has its own "Save", and
|
||||
// two identical labels cannot be driven by element.
|
||||
child: const Text('Save', semanticsLabel: 'Save PC address'),
|
||||
child: const Icon(Icons.save, semanticLabel: 'Save PC address'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -89,22 +89,29 @@ class _SandboxSectionState extends State<_SandboxSection> {
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
ElevatedButton(
|
||||
onPressed: () => unawaited(_wipe()),
|
||||
icon: const Icon(Icons.delete_sweep),
|
||||
label: const Text('Wipe sandbox data'),
|
||||
child: Icon(
|
||||
Icons.delete_sweep,
|
||||
color: colorScheme.error,
|
||||
semanticLabel: 'Wipe sandbox data',
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
ElevatedButton(
|
||||
onPressed: () => unawaited(_markTodayNotDone()),
|
||||
icon: const Icon(Icons.undo),
|
||||
label: const Text('Mark today not done'),
|
||||
child: const Icon(
|
||||
Icons.undo,
|
||||
semanticLabel: 'Mark today not done',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Text('Rest length (seconds)')),
|
||||
// Was "Rest length (seconds)"; the field shows the number.
|
||||
const Icon(Icons.timer, semanticLabel: 'Rest length (seconds)'),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: TextField(
|
||||
@ -112,16 +119,14 @@ class _SandboxSectionState extends State<_SandboxSection> {
|
||||
controller: _rest,
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
hintText: 'seconds',
|
||||
),
|
||||
decoration: const InputDecoration(isDense: true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () => unawaited(_saveRest()),
|
||||
child: const Text('Save'),
|
||||
// Spoken label distinct from the PC LINK "Save PC address".
|
||||
child: const Icon(Icons.save, semanticLabel: 'Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -129,20 +129,28 @@ class _OfflineBackupSection extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (storageGranted)
|
||||
const Row(
|
||||
// Was "Storage permission granted": storage glyph + green tick.
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Expanded(child: Text('Storage permission granted')),
|
||||
const Icon(Icons.sd_storage, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
size: 20,
|
||||
color: Theme.of(context).extension<AppStatusColors>()!.success,
|
||||
semanticLabel: 'Storage permission granted',
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ElevatedButton.icon(
|
||||
child: ElevatedButton(
|
||||
onPressed: onGrantStorage,
|
||||
icon: const Icon(Icons.sd_storage),
|
||||
label: const Text('Grant storage permission'),
|
||||
child: const Icon(
|
||||
Icons.sd_storage,
|
||||
semanticLabel: 'Grant storage permission',
|
||||
),
|
||||
),
|
||||
),
|
||||
DataTransferButtons(
|
||||
@ -176,7 +184,7 @@ class _SettingsAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return AppBar(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
title: Text('Settings', style: TextStyle(color: colorScheme.onSurface)),
|
||||
title: const Icon(Icons.settings, size: 20, semanticLabel: 'Settings'),
|
||||
iconTheme: IconThemeData(color: colorScheme.onSurface),
|
||||
actions: [
|
||||
const RunnerUpImportBadge(),
|
||||
|
||||
@ -25,7 +25,7 @@ class _SoundSection extends StatelessWidget {
|
||||
child: SwitchListTile(
|
||||
value: enabled,
|
||||
onChanged: onChanged,
|
||||
title: Text('Sound', style: TextStyle(color: colorScheme.onSurface)),
|
||||
title: const Icon(Icons.volume_up, size: 20, semanticLabel: 'Sound'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -200,7 +200,16 @@ extension _WorkoutScreenBreaks on _WorkoutScreenState {
|
||||
ExerciseState? _deloadRefused(String why) {
|
||||
log('WorkoutScreen: deload not applied — $why', level: 900);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(why)));
|
||||
// Was the refusal reason as a sentence; it stays in the log above.
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Icon(
|
||||
Icons.block,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
semanticLabel: 'Deload refused',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -13,21 +13,28 @@ extension _WorkoutScreenDialogs on _WorkoutScreenState {
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
title: Text(
|
||||
'Finish workout?',
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
// Was "Finish workout?" with Cancel / Finish buttons.
|
||||
title: Icon(
|
||||
Icons.done_all,
|
||||
color: status.success,
|
||||
semanticLabel: 'Finish workout?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
semanticLabel: 'Cancel',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text('Finish', style: TextStyle(color: status.success)),
|
||||
child: Icon(
|
||||
Icons.done_all,
|
||||
color: status.success,
|
||||
semanticLabel: 'Finish',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -42,25 +49,29 @@ extension _WorkoutScreenDialogs on _WorkoutScreenState {
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
title: Text(
|
||||
'Reset workout?',
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
content: Text(
|
||||
'All progress will be lost.',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
// Was "Reset workout?" / "All progress will be lost." with
|
||||
// Cancel / Reset buttons: the reset glyph in the error colour.
|
||||
title: Icon(
|
||||
Icons.restart_alt,
|
||||
color: colorScheme.error,
|
||||
semanticLabel: 'Reset workout? All progress will be lost.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
semanticLabel: 'Cancel',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text('Reset', style: TextStyle(color: colorScheme.error)),
|
||||
child: Icon(
|
||||
Icons.restart_alt,
|
||||
color: colorScheme.error,
|
||||
semanticLabel: 'Reset',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -123,9 +123,10 @@ extension _WorkoutScreenIntents on _WorkoutScreenState {
|
||||
if (result.needsPermissionWarning && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Notifications are off — the rest timer still runs and still '
|
||||
'sounds, but there will be no status-bar countdown.',
|
||||
content: Icon(
|
||||
Icons.notifications_off,
|
||||
size: 20,
|
||||
semanticLabel: 'Notifications are off',
|
||||
),
|
||||
duration: Duration(seconds: 6),
|
||||
),
|
||||
|
||||
@ -39,37 +39,56 @@ class BreakNotificationCopy {
|
||||
/// and testable without a device.
|
||||
abstract final class BreakNotificationText {
|
||||
/// Renders the notification for [snapshot] as of [now].
|
||||
///
|
||||
/// Symbols and numbers only (textgate). The same glyphs as the alert
|
||||
/// notifications in break_service_port_flutter.dart: [doneGlyph] for a
|
||||
/// finished workout, [breakOverGlyph] when the rest is up.
|
||||
static BreakNotificationCopy render(BreakSnapshot snapshot, DateTime now) {
|
||||
if (snapshot.finished) {
|
||||
return const BreakNotificationCopy(
|
||||
'Workout complete',
|
||||
'Open the app to save it.',
|
||||
);
|
||||
// Was "Workout complete" / "Open the app to save it."
|
||||
return const BreakNotificationCopy(doneGlyph, '');
|
||||
}
|
||||
return BreakNotificationCopy(
|
||||
_title(snapshot, now),
|
||||
_body(snapshot),
|
||||
);
|
||||
return BreakNotificationCopy(_title(snapshot, now), _body(snapshot));
|
||||
}
|
||||
|
||||
/// Finished workout (all sets, or the workout itself).
|
||||
static const doneGlyph = '✅';
|
||||
|
||||
/// The rest period is over.
|
||||
static const breakOverGlyph = '⏰';
|
||||
|
||||
/// A rest period is counting down.
|
||||
static const breakGlyph = '⏱';
|
||||
|
||||
/// Lifting: no rest running.
|
||||
static const liftGlyph = '🏋';
|
||||
|
||||
/// Name of every notification channel: the app's proper noun only
|
||||
/// (textgate settled decision 4), the same as `MaterialApp.title`.
|
||||
static const channelName = 'Workout Tracker';
|
||||
|
||||
// Was "Workout A · lift" / "Workout A · break over" / "Workout A · break
|
||||
// 1:47".
|
||||
static String _title(BreakSnapshot s, DateTime now) {
|
||||
final prefix = 'Workout ${s.workoutType}';
|
||||
final prefix = '$liftGlyph ${s.workoutType}';
|
||||
if (!s.hasBreak) {
|
||||
return '$prefix · lift';
|
||||
return prefix;
|
||||
}
|
||||
final clock = BreakClock(
|
||||
endTime: DateTime.fromMillisecondsSinceEpoch(s.breakEndMs),
|
||||
durationSecs: s.breakDurationSecs,
|
||||
);
|
||||
final remaining = clock.remainingSecsAt(now);
|
||||
if (remaining <= 0) return '$prefix · break over';
|
||||
return '$prefix · break ${formatMmSs(remaining)}';
|
||||
if (remaining <= 0) return '$prefix · $breakOverGlyph';
|
||||
return '$prefix · $breakGlyph ${formatMmSs(remaining)}';
|
||||
}
|
||||
|
||||
// Was "Every set is done — finish the workout." / "Next: Squat — set 3/5
|
||||
// \n5 reps @ 40 kg". The exercise name is user content.
|
||||
static String _body(BreakSnapshot s) {
|
||||
if (!s.hasNextSet) return 'Every set is done — finish the workout.';
|
||||
return 'Next: ${s.nextExName} — set ${s.nextSetNumber}/${s.nextTotalSets}\n'
|
||||
'${s.nextReps} reps @ ${formatWeight(s.nextWeight)} kg';
|
||||
if (!s.hasNextSet) return doneGlyph;
|
||||
return '→ ${s.nextExName} ${s.nextSetNumber}/${s.nextTotalSets}\n'
|
||||
'${s.nextReps} × ${formatWeight(s.nextWeight)} kg';
|
||||
}
|
||||
|
||||
/// Formats [totalSecs] as `m:ss`, e.g. 107 -> `1:47`.
|
||||
|
||||
@ -30,6 +30,9 @@ const kBreakEndSilentChannelId = 'break_end_silent_v1';
|
||||
/// Notification id of the terminal "workout complete" message.
|
||||
const kWorkoutDoneNotificationId = 8802;
|
||||
|
||||
/// Name of every notification channel here: the app's proper noun.
|
||||
const String _kChannelName = BreakNotificationText.channelName;
|
||||
|
||||
/// The real [BreakServicePort], running inside the foreground-service isolate.
|
||||
class FlutterBreakServicePort implements BreakServicePort {
|
||||
/// Creates a port. Cheap: nothing happens until [ensureIsolateReady].
|
||||
@ -64,7 +67,9 @@ class FlutterBreakServicePort implements BreakServicePort {
|
||||
?.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
kBreakEndChannelId,
|
||||
'Break finished',
|
||||
// Channel names are the app name only (textgate settled
|
||||
// decision 4). Was 'Break finished' / 'Break finished, no tone'.
|
||||
_kChannelName,
|
||||
description: 'Sounds when a rest period between sets is over.',
|
||||
importance: Importance.max,
|
||||
sound: RawResourceAndroidNotificationSound('break_end'),
|
||||
@ -77,7 +82,7 @@ class FlutterBreakServicePort implements BreakServicePort {
|
||||
?.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
kBreakEndSilentChannelId,
|
||||
'Break finished, no tone',
|
||||
_kChannelName,
|
||||
importance: Importance.max,
|
||||
playSound: false,
|
||||
),
|
||||
@ -110,12 +115,13 @@ class FlutterBreakServicePort implements BreakServicePort {
|
||||
}
|
||||
await _notifications.show(
|
||||
id: kBreakEndNotificationId,
|
||||
title: 'Break over',
|
||||
body: 'Back to the bar.',
|
||||
// Was "Break over" / "Back to the bar.".
|
||||
title: BreakNotificationText.breakOverGlyph,
|
||||
body: '',
|
||||
notificationDetails: NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
sound ? kBreakEndChannelId : kBreakEndSilentChannelId,
|
||||
sound ? 'Break finished' : 'Break finished, no tone',
|
||||
_kChannelName,
|
||||
playSound: sound,
|
||||
importance: Importance.max,
|
||||
priority: Priority.max,
|
||||
@ -134,12 +140,13 @@ class FlutterBreakServicePort implements BreakServicePort {
|
||||
Future<void> finishAndStop() async {
|
||||
await _notifications.show(
|
||||
id: kWorkoutDoneNotificationId,
|
||||
title: 'Workout complete',
|
||||
body: 'Open the app to save it.',
|
||||
// Was "Workout complete" / "Open the app to save it.".
|
||||
title: BreakNotificationText.doneGlyph,
|
||||
body: '',
|
||||
notificationDetails: const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
kBreakEndChannelId,
|
||||
'Break finished',
|
||||
_kChannelName,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
|
||||
@ -7,6 +7,7 @@ import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
import 'package:workout_app/models/break_snapshot.dart';
|
||||
import 'package:workout_app/services/break_intent_queue.dart';
|
||||
import 'package:workout_app/services/break_intent_store.dart';
|
||||
import 'package:workout_app/services/break_notification_text.dart';
|
||||
import 'package:workout_app/services/break_service_port.dart';
|
||||
import 'package:workout_app/services/break_service_port_flutter.dart';
|
||||
import 'package:workout_app/services/break_task_handler.dart';
|
||||
@ -21,7 +22,8 @@ import 'package:workout_app/services/foreground_break_client.dart';
|
||||
const kWorkoutOngoingChannelId = 'workout_ongoing_v1';
|
||||
|
||||
/// Button labels, parallel to [BreakNotificationAction.inButtonOrder].
|
||||
const _buttonLabels = ['✓ Done', '− 1 rep', 'Skip break'];
|
||||
/// Symbols only; was ['✓ Done', '− 1 rep', 'Skip break'].
|
||||
const _buttonLabels = ['✓', '−1', '⏭'];
|
||||
|
||||
/// Entry point the plugin calls in the background isolate.
|
||||
// coverage:ignore-start
|
||||
@ -57,8 +59,7 @@ class _BreakServiceTaskHandler extends TaskHandler {
|
||||
void onRepeatEvent(DateTime timestamp) => _handler.tick(timestamp);
|
||||
|
||||
@override
|
||||
void onReceiveData(Object data) =>
|
||||
_handler.receiveData(data, DateTime.now());
|
||||
void onReceiveData(Object data) => _handler.receiveData(data, DateTime.now());
|
||||
|
||||
@override
|
||||
void onNotificationButtonPressed(String id) =>
|
||||
@ -92,7 +93,9 @@ class FlutterForegroundBreakClient implements ForegroundBreakClient {
|
||||
FlutterForegroundTask.init(
|
||||
androidNotificationOptions: AndroidNotificationOptions(
|
||||
channelId: kWorkoutOngoingChannelId,
|
||||
channelName: 'Workout in progress',
|
||||
// App name only (textgate settled decision 4); was 'Workout in
|
||||
// progress'.
|
||||
channelName: BreakNotificationText.channelName,
|
||||
channelDescription:
|
||||
'Shows the rest countdown and the next set while a workout '
|
||||
'runs. Kept silent on purpose: the break-end alert has its own '
|
||||
@ -130,8 +133,10 @@ class FlutterForegroundBreakClient implements ForegroundBreakClient {
|
||||
}
|
||||
final result = await FlutterForegroundTask.startService(
|
||||
serviceTypes: [ForegroundServiceTypes.dataSync],
|
||||
notificationTitle: 'Workout ${snapshot.workoutType}',
|
||||
notificationText: 'Starting…',
|
||||
// Same prefix as BreakNotificationText; was 'Workout A' / 'Starting…'.
|
||||
notificationTitle:
|
||||
'${BreakNotificationText.liftGlyph} ${snapshot.workoutType}',
|
||||
notificationText: '…',
|
||||
notificationButtons: [
|
||||
for (final (i, id) in BreakNotificationAction.inButtonOrder.indexed)
|
||||
NotificationButton(id: id, text: _buttonLabels[i]),
|
||||
|
||||
@ -4,6 +4,7 @@ library;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
import 'package:workout_app/services/break_notification_text.dart';
|
||||
import 'package:workout_app/services/foreground_run_client.dart';
|
||||
|
||||
/// Channel the ongoing run notification lives on.
|
||||
@ -72,7 +73,8 @@ class FlutterForegroundRunClient implements ForegroundRunClient {
|
||||
FlutterForegroundTask.init(
|
||||
androidNotificationOptions: AndroidNotificationOptions(
|
||||
channelId: kRunOngoingChannelId,
|
||||
channelName: '\u{1F3C3}',
|
||||
// App name only (textgate settled decision 4); was the runner emoji.
|
||||
channelName: BreakNotificationText.channelName,
|
||||
onlyAlertOnce: true,
|
||||
),
|
||||
iosNotificationOptions: const IOSNotificationOptions(),
|
||||
|
||||
@ -98,28 +98,35 @@ class BreakBanner extends StatelessWidget {
|
||||
visible: active,
|
||||
child: TextButton(
|
||||
onPressed: onSkip,
|
||||
child: Text('Skip', style: TextStyle(color: colorScheme.primary)),
|
||||
child: Icon(
|
||||
Icons.skip_next,
|
||||
color: colorScheme.primary,
|
||||
semanticLabel: 'Skip',
|
||||
),
|
||||
),
|
||||
),
|
||||
_slot(
|
||||
visible: !finished,
|
||||
child: TextButton(
|
||||
onPressed: onReset,
|
||||
child: Text('Reset', style: TextStyle(color: colorScheme.error)),
|
||||
child: Icon(
|
||||
Icons.restart_alt,
|
||||
color: colorScheme.error,
|
||||
semanticLabel: 'Reset',
|
||||
),
|
||||
),
|
||||
),
|
||||
_slot(
|
||||
visible: !finished,
|
||||
child: TextButton(
|
||||
onPressed: canFinish ? onFinish : null,
|
||||
child: Text(
|
||||
'Finish',
|
||||
style: TextStyle(
|
||||
color: canFinish
|
||||
? status.success
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
// Muted while finishing is not allowed yet, as the old label.
|
||||
child: Icon(
|
||||
Icons.done_all,
|
||||
color: canFinish
|
||||
? status.success
|
||||
: colorScheme.onSurfaceVariant,
|
||||
semanticLabel: 'Finish',
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -89,14 +89,15 @@ class _ExerciseSettingsSheetState extends State<ExerciseSettingsSheet> {
|
||||
setState(() => _s = next);
|
||||
}
|
||||
|
||||
// Numbers and symbols only. Was "+X kg per step; +1 rep once at the Y kg
|
||||
// cap" / "+1 rep per step; the weight never changes" / "+1 rep per step
|
||||
// up to N, then +X kg and back to M".
|
||||
String get _modeHint => switch (_s.mode) {
|
||||
ProgressionMode.weight =>
|
||||
'+$kWeightIncrement kg per step; +1 rep once at the '
|
||||
'${_s.maxWeight} kg cap',
|
||||
ProgressionMode.reps => '+1 rep per step; the weight never changes',
|
||||
'+$kWeightIncrement kg · ${_s.maxWeight} kg ⇒ +1',
|
||||
ProgressionMode.reps => '+1',
|
||||
ProgressionMode.doubleProgression =>
|
||||
'+1 rep per step up to ${_s.repsHigh}, then +$kWeightIncrement kg '
|
||||
'and back to ${_s.repsLow}',
|
||||
'+1 … ${_s.repsHigh} ⇒ +$kWeightIncrement kg, ${_s.repsLow}',
|
||||
};
|
||||
|
||||
@override
|
||||
@ -130,15 +131,16 @@ class _ExerciseSettingsSheetState extends State<ExerciseSettingsSheet> {
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: ProgressionMode.weight,
|
||||
label: Text('Weight'),
|
||||
// Was 'Weight' / 'Reps' / 'Reps→kg'.
|
||||
label: Icon(Icons.fitness_center, semanticLabel: 'Weight'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ProgressionMode.reps,
|
||||
label: Text('Reps'),
|
||||
label: Icon(Icons.repeat, semanticLabel: 'Reps'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ProgressionMode.doubleProgression,
|
||||
label: Text('Reps→kg'),
|
||||
label: Icon(Icons.trending_up, semanticLabel: 'Reps→kg'),
|
||||
),
|
||||
],
|
||||
selected: {_s.mode},
|
||||
@ -160,12 +162,13 @@ class _ExerciseSettingsSheetState extends State<ExerciseSettingsSheet> {
|
||||
maintainAnimation: true,
|
||||
maintainState: true,
|
||||
child: _SettingRow(
|
||||
label: '+kg at',
|
||||
// Was "+kg at [N] reps, restart at [M]".
|
||||
label: '+kg',
|
||||
name: 'Top reps',
|
||||
value: _s.repsHigh,
|
||||
min: _s.repsLow + 1,
|
||||
max: _maxRepsHigh,
|
||||
suffix: 'reps, restart at',
|
||||
suffix: '↺',
|
||||
trailing: _Stepper(
|
||||
name: 'Restart reps',
|
||||
value: _s.repsLow,
|
||||
@ -179,17 +182,22 @@ class _ExerciseSettingsSheetState extends State<ExerciseSettingsSheet> {
|
||||
const SizedBox(height: 4),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Warmup set'),
|
||||
// Was 'Warmup set'; the sun matches the warmup rest row.
|
||||
title: const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Icon(Icons.wb_sunny, semanticLabel: 'Warmup set'),
|
||||
),
|
||||
value: _s.hasWarmup,
|
||||
onChanged: (v) => _update(_s.copyWith(hasWarmup: v)),
|
||||
),
|
||||
_SettingRow(
|
||||
label: '↑ after',
|
||||
// Was "↑ after [N] wins, ↓ after [M]".
|
||||
label: '↑',
|
||||
name: 'Wins needed',
|
||||
value: _s.successThreshold,
|
||||
min: 1,
|
||||
max: 5,
|
||||
suffix: 'wins, ↓ after',
|
||||
suffix: '↓',
|
||||
trailing: _Stepper(
|
||||
name: 'Fails allowed',
|
||||
value: _s.failThreshold,
|
||||
|
||||
@ -23,11 +23,11 @@ class _DeloadRow extends StatelessWidget {
|
||||
height: 40,
|
||||
child: Row(
|
||||
children: [
|
||||
// Was "Deload <target> → <change>" / "Deload nothing lower to
|
||||
// go to": the down-trend glyph is the button, the numbers stay.
|
||||
Expanded(
|
||||
child: Text(
|
||||
canDeload
|
||||
? 'Deload ${_target(state)} → $change'
|
||||
: 'Deload nothing lower to go to',
|
||||
canDeload ? '${_target(state)} → $change' : '—',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: AppTextSize.label,
|
||||
@ -37,7 +37,11 @@ class _DeloadRow extends StatelessWidget {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: canDeload ? onDeload : null,
|
||||
child: const Text('Deload now'),
|
||||
child: Icon(
|
||||
Icons.trending_down,
|
||||
color: canDeload ? colorScheme.error : null,
|
||||
semanticLabel: 'Deload now',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -62,26 +66,33 @@ Future<bool> _confirmDeload(BuildContext context, ExerciseState s) async {
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
title: Text(
|
||||
'Deload ${s.name}?',
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
// Was "Deload <name>?" / "<target> → <change>\n\nStreaks reset. Only
|
||||
// finished workouts raise it again.": glyph + the numbers.
|
||||
title: Icon(
|
||||
Icons.trending_down,
|
||||
color: colorScheme.error,
|
||||
semanticLabel: 'Deload ${s.name}?',
|
||||
),
|
||||
content: Text(
|
||||
'${_target(s)} → $change\n\n'
|
||||
'Streaks reset. Only finished workouts raise it again.',
|
||||
'${_target(s)} → $change',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
semanticLabel: 'Cancel',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: Text('Deload', style: TextStyle(color: colorScheme.error)),
|
||||
child: Icon(
|
||||
Icons.trending_down,
|
||||
color: colorScheme.error,
|
||||
semanticLabel: 'Deload',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -35,7 +35,7 @@ class _RestRows extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('Rest ✓', style: style),
|
||||
Text('✓', style: style),
|
||||
const SizedBox(width: 8),
|
||||
_stepper(
|
||||
'Success rest',
|
||||
@ -60,7 +60,7 @@ class _RestRows extends StatelessWidget {
|
||||
maintainState: true,
|
||||
child: Row(
|
||||
children: [
|
||||
Text('Warmup rest', style: style),
|
||||
Text('☀', style: style),
|
||||
const SizedBox(width: 8),
|
||||
_stepper(
|
||||
'Warmup rest',
|
||||
|
||||
@ -174,7 +174,13 @@ class _PauseRow extends StatelessWidget {
|
||||
child: Row(
|
||||
children: until == null
|
||||
? [
|
||||
Text('Pause for', style: style),
|
||||
// Was "Pause for [N] days": a calendar for the day count,
|
||||
// the pause glyph only on the button.
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_Stepper(
|
||||
name: 'Pause days',
|
||||
@ -184,9 +190,15 @@ class _PauseRow extends StatelessWidget {
|
||||
onChanged: onDaysChanged,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('days', style: style),
|
||||
const Spacer(),
|
||||
TextButton(onPressed: onPause, child: const Text('Pause')),
|
||||
TextButton(
|
||||
onPressed: onPause,
|
||||
child: const Icon(
|
||||
Icons.pause,
|
||||
size: 20,
|
||||
semanticLabel: 'Pause',
|
||||
),
|
||||
),
|
||||
]
|
||||
: [
|
||||
Icon(
|
||||
@ -200,7 +212,14 @@ class _PauseRow extends StatelessWidget {
|
||||
style: style,
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(onPressed: onResume, child: const Text('Resume')),
|
||||
TextButton(
|
||||
onPressed: onResume,
|
||||
child: const Icon(
|
||||
Icons.play_arrow,
|
||||
size: 20,
|
||||
semanticLabel: 'Resume',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
16
stronglift_replacement/workout_app/test/icon_finders.dart
Normal file
16
stronglift_replacement/workout_app/test/icon_finders.dart
Normal file
@ -0,0 +1,16 @@
|
||||
/// Finders for icon-only controls (textgate: the old words survive only as
|
||||
/// the icon's spoken `semanticLabel`).
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// The [Icon] whose spoken label is exactly [label].
|
||||
Finder iconLabeled(String label) => find.byWidgetPredicate(
|
||||
(w) => w is Icon && w.semanticLabel == label,
|
||||
description: 'Icon labelled "$label"',
|
||||
);
|
||||
|
||||
/// The nearest [T] (a button type) around the icon labelled [label].
|
||||
Finder buttonLabeled<T extends Widget>(String label) =>
|
||||
find.ancestor(of: iconLabeled(label), matching: find.byType(T));
|
||||
@ -16,6 +16,8 @@ import 'package:workout_app/services/map_tile_cache.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
|
||||
export '../icon_finders.dart';
|
||||
|
||||
Future<void> pumpHistory(WidgetTester tester, Widget w) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(w);
|
||||
|
||||
@ -11,6 +11,8 @@ import 'package:workout_app/screens/home_screen.dart';
|
||||
import 'package:workout_app/services/workout_sync_service.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
|
||||
export '../icon_finders.dart';
|
||||
|
||||
// runAsync steps outside FakeAsync so real I/O (NetworkInterface.list) completes.
|
||||
Future<void> pumpHome(WidgetTester tester, Widget w) async {
|
||||
await tester.runAsync(() async {
|
||||
|
||||
@ -7,6 +7,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/services/workout_sync_service.dart';
|
||||
|
||||
import '../icon_finders.dart';
|
||||
|
||||
export '../icon_finders.dart';
|
||||
|
||||
class FakeManualSync extends WorkoutSyncService {
|
||||
FakeManualSync({
|
||||
this.payloads = const [],
|
||||
@ -39,7 +43,7 @@ Map<String, dynamic> manualEntry(String date) => {
|
||||
|
||||
/// Opens the date picker, taps [day] of the shown month, confirms.
|
||||
Future<void> pickDay(WidgetTester tester, String day) async {
|
||||
await tester.tap(find.text('CHANGE DATE'));
|
||||
await tester.tap(iconLabeled('CHANGE DATE'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(day));
|
||||
await tester.tap(find.text('OK'));
|
||||
|
||||
@ -12,6 +12,8 @@ import 'package:workout_app/services/foreground_break_client.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
import 'package:workout_app/widgets/break_banner.dart';
|
||||
|
||||
export '../icon_finders.dart';
|
||||
|
||||
Map<String, dynamic> completeSaved() => {
|
||||
'workoutType': 'A',
|
||||
'startTimeMs': DateTime.now().millisecondsSinceEpoch,
|
||||
|
||||
@ -17,6 +17,7 @@ import 'package:workout_app/services/sync_settings.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
|
||||
import '../fake_secure_storage.dart';
|
||||
import '../icon_finders.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
|
||||
@ -77,7 +78,7 @@ void main() {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Connect GitHub'));
|
||||
await tester.tap(iconLabeled('Connect GitHub'));
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
() => find
|
||||
@ -124,7 +125,7 @@ void main() {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Connect GitHub'));
|
||||
await tester.tap(iconLabeled('Connect GitHub'));
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
() => find.text('WXYZ-1234').evaluate().isNotEmpty,
|
||||
|
||||
@ -17,6 +17,7 @@ import 'package:workout_app/services/sync_settings.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
|
||||
import '../fake_secure_storage.dart';
|
||||
import '../icon_finders.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
|
||||
@ -98,7 +99,7 @@ void main() {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Connect GitHub'));
|
||||
await tester.tap(iconLabeled('Connect GitHub'));
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
() => find.textContaining('NOT syncing').evaluate().isNotEmpty,
|
||||
@ -140,7 +141,7 @@ void main() {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Connect GitHub'));
|
||||
await tester.tap(iconLabeled('Connect GitHub'));
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
() => find.textContaining('verifying').evaluate().isNotEmpty,
|
||||
|
||||
@ -17,6 +17,7 @@ import 'package:workout_app/services/sync_settings.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
|
||||
import '../fake_secure_storage.dart';
|
||||
import '../icon_finders.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
|
||||
@ -112,7 +113,7 @@ void main() {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Connect GitHub'));
|
||||
await tester.tap(iconLabeled('Connect GitHub'));
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
() => find.text('WXYZ-1234').evaluate().isNotEmpty,
|
||||
@ -125,11 +126,11 @@ void main() {
|
||||
);
|
||||
expect(find.textContaining('access_denied'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Open GitHub & copy code'));
|
||||
await tester.tap(iconLabeled('Open GitHub & copy code'));
|
||||
await tester.pump();
|
||||
expect(launcher.launched, 'https://github.com/login/device');
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.tap(iconLabeled('Cancel'));
|
||||
await tester.pump();
|
||||
});
|
||||
});
|
||||
@ -166,7 +167,7 @@ void main() {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Connect GitHub'));
|
||||
await tester.tap(iconLabeled('Connect GitHub'));
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
() => find
|
||||
@ -174,10 +175,7 @@ void main() {
|
||||
.evaluate()
|
||||
.isNotEmpty,
|
||||
);
|
||||
expect(
|
||||
find.textContaining('could not save the token'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('could not save the token'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -17,6 +17,13 @@ import 'package:workout_app/services/sync_settings.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
|
||||
import '../fake_secure_storage.dart';
|
||||
import '../icon_finders.dart';
|
||||
|
||||
/// The token field: the one whose prefix icon is labelled 'GitHub PAT'.
|
||||
final _patField = find.ancestor(
|
||||
of: iconLabeled('GitHub PAT'),
|
||||
matching: find.byType(TextField),
|
||||
);
|
||||
|
||||
/// 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
|
||||
@ -78,52 +85,50 @@ void main() {
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Advanced sync (GitHub)'), findsOneWidget);
|
||||
expect(find.text('Connect GitHub'), findsOneWidget);
|
||||
expect(iconLabeled('Connect GitHub'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'a saved token is VERIFIED on open, not blindly trusted',
|
||||
(tester) async {
|
||||
// Regression: the badge used to read "Connected." whenever a token
|
||||
// string existed, so a revoked token showed green while every sync
|
||||
// 401'd and history silently stayed empty.
|
||||
installFakeSecureStorage(initial: {'sync.token': 'revoked-token'});
|
||||
final mock = MockClient(
|
||||
(req) async => http.Response('Bad credentials', 401),
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(_wrap(httpClient: mock));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pump();
|
||||
testWidgets('a saved token is VERIFIED on open, not blindly trusted', (
|
||||
tester,
|
||||
) async {
|
||||
// Regression: the badge used to read "Connected." whenever a token
|
||||
// string existed, so a revoked token showed green while every sync
|
||||
// 401'd and history silently stayed empty.
|
||||
installFakeSecureStorage(initial: {'sync.token': 'revoked-token'});
|
||||
final mock = MockClient(
|
||||
(req) async => http.Response('Bad credentials', 401),
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(_wrap(httpClient: mock));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Connected.'), findsNothing);
|
||||
expect(find.textContaining('NOT connected'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
expect(find.text('Connected.'), findsNothing);
|
||||
expect(find.textContaining('NOT connected'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'a saved token that GitHub accepts reports verified',
|
||||
(tester) async {
|
||||
installFakeSecureStorage(initial: {'sync.token': 'good-token'});
|
||||
final mock = MockClient(
|
||||
(req) async => http.Response(
|
||||
jsonEncode({'content': base64Encode(utf8.encode('{}')), 'sha': 's'}),
|
||||
200,
|
||||
),
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(_wrap(httpClient: mock));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pump();
|
||||
testWidgets('a saved token that GitHub accepts reports verified', (
|
||||
tester,
|
||||
) async {
|
||||
installFakeSecureStorage(initial: {'sync.token': 'good-token'});
|
||||
final mock = MockClient(
|
||||
(req) async => http.Response(
|
||||
jsonEncode({'content': base64Encode(utf8.encode('{}')), 'sha': 's'}),
|
||||
200,
|
||||
),
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(_wrap(httpClient: mock));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.textContaining('Connected and verified via GitHub'),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(
|
||||
find.textContaining('Connected and verified via GitHub'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the PAT fallback field is present', (tester) async {
|
||||
await tester.runAsync(() async {
|
||||
@ -132,8 +137,8 @@ void main() {
|
||||
});
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Save'), findsOneWidget);
|
||||
expect(find.widgetWithText(TextField, 'GitHub PAT'), findsOneWidget);
|
||||
expect(iconLabeled('Save'), findsOneWidget);
|
||||
expect(_patField, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('saving a pasted token shows a confirmation', (tester) async {
|
||||
@ -143,11 +148,8 @@ void main() {
|
||||
});
|
||||
await tester.pump();
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'GitHub PAT'),
|
||||
'a-pasted-token',
|
||||
);
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.enterText(_patField, 'a-pasted-token');
|
||||
await tester.tap(iconLabeled('Save'));
|
||||
await tester.pump();
|
||||
expect(find.text('Sync token saved.'), findsOneWidget);
|
||||
});
|
||||
|
||||
@ -107,9 +107,9 @@ Future<void> _open(
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(WidgetTester tester, String button) async {
|
||||
await tester.tap(find.byTooltip('Delete planned workout'));
|
||||
await tester.tap(iconLabeled('Delete planned workout'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(button));
|
||||
await tester.tap(iconLabeled(button));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
@ -142,7 +142,7 @@ void main() {
|
||||
expect(find.text('No workouts yet.'), findsNothing);
|
||||
expect(find.text('PLANNED (not done yet)'), findsOneWidget);
|
||||
expect(find.text('2026-10-17 10:00 · Planned'), findsOneWidget);
|
||||
expect(find.byTooltip('Delete planned workout'), findsOneWidget);
|
||||
expect(iconLabeled('Delete planned workout'), findsOneWidget);
|
||||
// Today's entry already happened: done, and not deletable.
|
||||
expect(find.text('2026-10-16 · Manual'), findsOneWidget);
|
||||
expect(find.text('2026-10-14 · Manual'), findsOneWidget);
|
||||
@ -193,7 +193,8 @@ void main() {
|
||||
await _confirmDelete(tester, 'DELETE');
|
||||
await _settle(tester, 2);
|
||||
|
||||
expect(find.textContaining('Not deleted — push failed'), findsOneWidget);
|
||||
expect(iconLabeled('Not deleted'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.cloud_off), findsOneWidget);
|
||||
expect(find.text('2026-10-17 10:00 · Planned'), findsOneWidget);
|
||||
});
|
||||
|
||||
@ -204,14 +205,15 @@ void main() {
|
||||
final github = _github(_seed());
|
||||
await _open(tester, github.client, clock: () => now);
|
||||
|
||||
await tester.tap(find.byTooltip('Delete planned workout'));
|
||||
await tester.tap(iconLabeled('Delete planned workout'));
|
||||
await tester.pumpAndSettle();
|
||||
now = DateTime(2026, 10, 17, 0, 1);
|
||||
await tester.tap(find.text('DELETE'));
|
||||
await tester.tap(iconLabeled('DELETE'));
|
||||
await tester.pump();
|
||||
await _settle(tester, 2);
|
||||
|
||||
expect(find.textContaining('no longer a planned workout'), findsOneWidget);
|
||||
expect(iconLabeled('Not deleted'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.block), findsOneWidget);
|
||||
expect(
|
||||
github.files.containsKey('screen-locker-sync/devices/phone/log.json'),
|
||||
isFalse,
|
||||
|
||||
@ -12,6 +12,7 @@ import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
|
||||
import '../fake_secure_storage.dart';
|
||||
import '../icon_finders.dart';
|
||||
import '_history_test_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
@ -31,7 +32,7 @@ void main() {
|
||||
|
||||
testWidgets('shows Progress app bar', (tester) async {
|
||||
await pumpHistory(tester, wrapHistory());
|
||||
expect(find.text('Progress'), findsOneWidget);
|
||||
expect(iconLabeled('Progress'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows "No workouts yet." when history is empty', (tester) async {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:workout_app/models/workout_plan.dart';
|
||||
import 'package:workout_app/screens/settings_screen.dart';
|
||||
import 'package:workout_app/screens/workout_screen.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
|
||||
@ -40,7 +40,7 @@ void main() {
|
||||
await tester.tap(find.byIcon(Icons.history));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.text('Progress'), findsOneWidget);
|
||||
expect(iconLabeled('Progress'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('settings icon navigates to settings screen', (tester) async {
|
||||
@ -48,7 +48,7 @@ void main() {
|
||||
await tester.tap(find.byIcon(Icons.settings));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.text('Settings'), findsOneWidget);
|
||||
expect(find.byType(SettingsScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('"Done for today" message shows after saving a session today', (
|
||||
@ -188,7 +188,7 @@ void main() {
|
||||
await tester.tap(find.byIcon(Icons.settings));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.text('Settings'), findsOneWidget);
|
||||
expect(find.byType(SettingsScreen), findsOneWidget);
|
||||
|
||||
// Pop back; the trailing _load() (line 135) needs the real loop for DB I/O.
|
||||
await tester.runAsync(() async {
|
||||
|
||||
@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:workout_app/models/workout_plan.dart';
|
||||
import 'package:workout_app/screens/home_screen.dart';
|
||||
import 'package:workout_app/screens/settings_screen.dart';
|
||||
import 'package:workout_app/screens/workout_screen.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/services/workout_sync_service.dart';
|
||||
@ -108,7 +109,7 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
// The card must lead somewhere: telling the user sync is broken and
|
||||
// leaving them to find Settings is the silence this whole card fixes.
|
||||
expect(find.text('Settings'), findsOneWidget);
|
||||
expect(find.byType(SettingsScreen), findsOneWidget);
|
||||
|
||||
// Coming back re-checks sync, so connecting there clears the card
|
||||
// without needing an app restart.
|
||||
|
||||
@ -18,7 +18,7 @@ void main() {
|
||||
await pickDay(tester, '14');
|
||||
expect(find.text('Workout date: 2026-07-14 (tomorrow)'), findsOneWidget);
|
||||
await fillValidTt(tester);
|
||||
await tester.tap(find.text('SUBMIT'));
|
||||
await tester.tap(iconLabeled('SUBMIT'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sync.pushed!.id, 'manual:2026-07-14T18:00');
|
||||
final payload = sync.pushed!.fields['payload']!.$1! as Map;
|
||||
@ -33,7 +33,7 @@ void main() {
|
||||
clock: fixedManualNow,
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('CHANGE DATE'));
|
||||
await tester.tap(iconLabeled('CHANGE DATE'));
|
||||
await tester.pumpAndSettle();
|
||||
final picker = tester.widget<DatePickerDialog>(
|
||||
find.byType(DatePickerDialog),
|
||||
@ -55,7 +55,7 @@ void main() {
|
||||
clock: fixedManualNow,
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('CHANGE DATE'));
|
||||
await tester.tap(iconLabeled('CHANGE DATE'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
@ -76,7 +76,7 @@ void main() {
|
||||
);
|
||||
expect(find.textContaining('2/2 per 7 days'), findsOneWidget);
|
||||
expect(find.textContaining('budget exhausted'), findsOneWidget);
|
||||
expect(find.text('SUBMIT'), findsNothing);
|
||||
expect(iconLabeled('SUBMIT'), findsNothing);
|
||||
// The date row stays reachable while the window is full.
|
||||
await pickDay(tester, '20');
|
||||
expect(
|
||||
@ -86,7 +86,7 @@ void main() {
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('budget exhausted'), findsNothing);
|
||||
expect(find.text('SUBMIT'), findsOneWidget);
|
||||
expect(iconLabeled('SUBMIT'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('re-logging onto a deleted record is refused', (tester) async {
|
||||
@ -96,13 +96,13 @@ void main() {
|
||||
ManualWorkoutScreen(syncService: sync, clock: fixedManualNow),
|
||||
);
|
||||
await fillValidTt(tester);
|
||||
await tester.tap(find.text('SUBMIT'));
|
||||
await tester.tap(iconLabeled('SUBMIT'));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('was deleted earlier'), findsOneWidget);
|
||||
expect(sync.pushed, isNull);
|
||||
// A different start time is a different record id.
|
||||
await tester.enterText(find.byKey(const Key('mw_start_time')), '17:30');
|
||||
await tester.tap(find.text('SUBMIT'));
|
||||
await tester.tap(iconLabeled('SUBMIT'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sync.pushed!.id, 'manual:2026-07-13T17:30');
|
||||
});
|
||||
@ -118,13 +118,13 @@ void main() {
|
||||
ManualWorkoutScreen(syncService: sync, clock: fixedManualNow),
|
||||
);
|
||||
await fillValidTt(tester);
|
||||
await tester.tap(find.text('SUBMIT'));
|
||||
await tester.tap(iconLabeled('SUBMIT'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sync.pushed, isNotNull);
|
||||
expect(find.text('Not saved — push failed: 500'), findsOneWidget);
|
||||
expect(find.text('Log Manual Workout'), findsOneWidget);
|
||||
final submit = tester.widget<ElevatedButton>(
|
||||
find.widgetWithText(ElevatedButton, 'SUBMIT'),
|
||||
buttonLabeled<ElevatedButton>('SUBMIT'),
|
||||
);
|
||||
expect(submit.onPressed, isNotNull);
|
||||
});
|
||||
@ -140,12 +140,12 @@ void main() {
|
||||
);
|
||||
await fillValidTt(tester);
|
||||
now = DateTime(2026, 7, 14, 0, 1);
|
||||
await tester.tap(find.text('SUBMIT'));
|
||||
await tester.tap(iconLabeled('SUBMIT'));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('is in the past'), findsOneWidget);
|
||||
expect(sync.pushed, isNull);
|
||||
// The picker would assert on an initial date before its range.
|
||||
await tester.tap(find.text('CHANGE DATE'));
|
||||
await tester.tap(iconLabeled('CHANGE DATE'));
|
||||
await tester.pumpAndSettle();
|
||||
final picker = tester.widget<DatePickerDialog>(
|
||||
find.byType(DatePickerDialog),
|
||||
@ -169,7 +169,7 @@ void main() {
|
||||
await pickDay(tester, '20');
|
||||
await fillValidTt(tester);
|
||||
now = DateTime(2026, 7, 12, 12);
|
||||
await tester.tap(find.text('SUBMIT'));
|
||||
await tester.tap(iconLabeled('SUBMIT'));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('at most 7 days ahead'), findsOneWidget);
|
||||
expect(sync.pushed, isNull);
|
||||
|
||||
@ -14,7 +14,7 @@ void main() {
|
||||
ManualWorkoutScreen(syncService: FakeManualSync(), clock: fixedManualNow),
|
||||
);
|
||||
expect(find.text('Log Manual Workout'), findsOneWidget);
|
||||
expect(find.text('SUBMIT'), findsOneWidget);
|
||||
expect(iconLabeled('SUBMIT'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Manual around 2026-07-13: 0/2 per 7 days · 0/10 per 30 days'),
|
||||
findsOneWidget,
|
||||
@ -48,7 +48,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
expect(find.textContaining('budget exhausted'), findsOneWidget);
|
||||
expect(find.text('SUBMIT'), findsNothing);
|
||||
expect(iconLabeled('SUBMIT'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows a validation error and does not push', (tester) async {
|
||||
@ -57,7 +57,7 @@ void main() {
|
||||
tester,
|
||||
ManualWorkoutScreen(syncService: sync, clock: fixedManualNow),
|
||||
);
|
||||
await tester.tap(find.text('SUBMIT'));
|
||||
await tester.tap(iconLabeled('SUBMIT'));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('is required'), findsOneWidget);
|
||||
expect(sync.pushed, isNull);
|
||||
@ -70,7 +70,7 @@ void main() {
|
||||
ManualWorkoutScreen(syncService: sync, clock: fixedManualNow),
|
||||
);
|
||||
await fillValidTt(tester);
|
||||
await tester.tap(find.text('SUBMIT'));
|
||||
await tester.tap(iconLabeled('SUBMIT'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sync.pushed, isNotNull);
|
||||
expect(sync.pushed!.id, 'manual:2026-07-13T18:00');
|
||||
@ -119,7 +119,7 @@ void main() {
|
||||
await tester.enterText(find.byKey(const Key('mw_location_name')), 'Club');
|
||||
await tester.enterText(find.byKey(const Key('mw_transport_method')), 'car');
|
||||
await tester.enterText(find.byKey(const Key('mw_cost')), '30');
|
||||
await tester.tap(find.text('SUBMIT'));
|
||||
await tester.tap(iconLabeled('SUBMIT'));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('Activity type is required'), findsOneWidget);
|
||||
expect(sync.pushed, isNull);
|
||||
|
||||
@ -12,6 +12,7 @@ import 'package:workout_app/widgets/exercise_settings_sheet.dart';
|
||||
|
||||
import '../fake_pc_pairing.dart';
|
||||
import '../fake_secure_storage.dart';
|
||||
import '../icon_finders.dart';
|
||||
|
||||
const _situp = 'Situp exercise settings';
|
||||
const _lunge = 'Dumbbell Lunge exercise settings';
|
||||
@ -74,10 +75,10 @@ void main() {
|
||||
|
||||
/// Taps "Deload now" and confirms; the storage write is a real future.
|
||||
Future<void> deloadNow(WidgetTester tester) async {
|
||||
await tester.tap(find.text('Deload now'));
|
||||
await tester.tap(iconLabeled('Deload now'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Deload'));
|
||||
await tester.tap(buttonLabeled<TextButton>('Deload'));
|
||||
await tester.pump();
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 300)),
|
||||
@ -121,7 +122,7 @@ void main() {
|
||||
await openExercise(tester, _lunge);
|
||||
await deloadNow(tester);
|
||||
|
||||
expect(find.text('Deload 5 kg × 12 → 2.5 kg'), findsOneWidget);
|
||||
expect(find.text('5 kg × 12 → 2.5 kg'), findsOneWidget);
|
||||
expect((await stored(tester, 'Dumbbell Lunge')).weight, 5);
|
||||
expect(pushes, 0);
|
||||
|
||||
@ -130,7 +131,7 @@ void main() {
|
||||
expect(await lungeWeight(tester), '5.0kg');
|
||||
});
|
||||
|
||||
testWidgets('a refused deload shows why and pushes nothing', (tester) async {
|
||||
testWidgets('a refused deload is flagged and pushes nothing', (tester) async {
|
||||
await pumpSettings(tester);
|
||||
await openExercise(tester, _situp);
|
||||
// The sheet still offers a step down, but the store is already at 0 kg.
|
||||
@ -139,8 +140,8 @@ void main() {
|
||||
);
|
||||
await deloadNow(tester);
|
||||
|
||||
expect(find.textContaining('Situp is already at 0 kg × 30'), findsOne);
|
||||
expect(find.text('Deload 10 kg × 30 → 7.5 kg'), findsOneWidget);
|
||||
expect(iconLabeled('Deload failed'), findsOne);
|
||||
expect(find.text('10 kg × 30 → 7.5 kg'), findsOneWidget);
|
||||
await closeSheet(tester);
|
||||
expect(pushes, 0);
|
||||
});
|
||||
|
||||
@ -11,6 +11,7 @@ import 'package:workout_app/widgets/exercise_settings_sheet.dart';
|
||||
|
||||
import '../fake_pc_pairing.dart';
|
||||
import '../fake_secure_storage.dart';
|
||||
import '../icon_finders.dart';
|
||||
|
||||
const _situp = 'Situp exercise settings';
|
||||
|
||||
@ -110,9 +111,9 @@ void main() {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
});
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Reps'));
|
||||
await tester.tap(iconLabeled('Reps'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Pause'));
|
||||
await tester.tap(iconLabeled('Pause'));
|
||||
await tester.pump();
|
||||
await closeSheet(tester);
|
||||
|
||||
@ -133,7 +134,7 @@ void main() {
|
||||
expect(pushes, 0);
|
||||
});
|
||||
|
||||
testWidgets('a push that did not happen says so', (tester) async {
|
||||
testWidgets('a push that did not happen is flagged', (tester) async {
|
||||
pushResult = const ProgressionSyncResult(
|
||||
changed: false,
|
||||
reason: 'no Firebase account on this device',
|
||||
@ -145,11 +146,6 @@ void main() {
|
||||
await closeSheet(tester);
|
||||
|
||||
expect(pushes, 1);
|
||||
expect(
|
||||
find.text(
|
||||
'Saved on this phone, not synced: no Firebase account on this device',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(iconLabeled('Saved on this phone, not synced'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import 'package:workout_app/ui/theme.dart';
|
||||
|
||||
import '../fake_pc_pairing.dart';
|
||||
import '../fake_secure_storage.dart';
|
||||
import '../icon_finders.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
@ -90,7 +91,7 @@ void main() {
|
||||
});
|
||||
Sandbox.restSecs = 30;
|
||||
await pump(tester);
|
||||
await tapReal(tester, find.text('Wipe sandbox data'));
|
||||
await tapReal(tester, iconLabeled('Wipe sandbox data'));
|
||||
expect(find.textContaining('Wiped —'), findsOneWidget);
|
||||
expect(Sandbox.restSecs, Sandbox.defaultRestSecs);
|
||||
final active = await tester.runAsync(
|
||||
@ -101,7 +102,7 @@ void main() {
|
||||
|
||||
testWidgets('mark today not done reports what it removed', (tester) async {
|
||||
await pump(tester);
|
||||
await tapReal(tester, find.text('Mark today not done'));
|
||||
await tapReal(tester, iconLabeled('Mark today not done'));
|
||||
expect(find.textContaining('Nothing logged today'), findsOneWidget);
|
||||
|
||||
final today = DateTime.now().toIso8601String().substring(0, 10);
|
||||
@ -114,7 +115,7 @@ void main() {
|
||||
json: '{}',
|
||||
),
|
||||
);
|
||||
await tapReal(tester, find.text('Mark today not done'));
|
||||
await tapReal(tester, iconLabeled('Mark today not done'));
|
||||
expect(find.textContaining('Removed 1 workout'), findsOneWidget);
|
||||
});
|
||||
|
||||
@ -126,7 +127,7 @@ void main() {
|
||||
// The row's own button: the PC LINK section has a "Save" too.
|
||||
final save = find.descendant(
|
||||
of: find.ancestor(of: field, matching: find.byType(Row)).first,
|
||||
matching: find.text('Save'),
|
||||
matching: iconLabeled('Save'),
|
||||
);
|
||||
await tester.enterText(field, 'abc');
|
||||
await tapReal(tester, save);
|
||||
@ -163,7 +164,7 @@ void main() {
|
||||
);
|
||||
expect(saved, 10);
|
||||
|
||||
await tapReal(tester, find.text('Wipe sandbox data'));
|
||||
await tapReal(tester, iconLabeled('Wipe sandbox data'));
|
||||
expect(Sandbox.runReplayFactor, Sandbox.defaultReplayFactor);
|
||||
});
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import 'package:workout_app/ui/theme.dart';
|
||||
|
||||
import '../fake_pc_pairing.dart';
|
||||
import '../fake_secure_storage.dart';
|
||||
import '../icon_finders.dart';
|
||||
|
||||
Widget _settings() => MaterialApp(
|
||||
theme: buildAppTheme(),
|
||||
@ -41,7 +42,7 @@ Future<void> _reveal(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Finder get _switch => find.widgetWithText(SwitchListTile, 'Sound');
|
||||
Finder get _switch => buttonLabeled<SwitchListTile>('Sound');
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
|
||||
@ -12,6 +12,7 @@ import 'package:workout_app/ui/theme.dart';
|
||||
|
||||
import '../fake_pc_pairing.dart';
|
||||
import '../fake_secure_storage.dart';
|
||||
import '../icon_finders.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
@ -86,11 +87,11 @@ void main() {
|
||||
await tester.tap(find.text('Reset defaults'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(find.text('Reset to defaults?'), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
expect(iconLabeled('Reset to defaults?'), findsOneWidget);
|
||||
await tester.tap(iconLabeled('Cancel'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(find.text('Reset to defaults?'), findsNothing);
|
||||
expect(iconLabeled('Reset to defaults?'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Reset dialog confirms and resets data', (tester) async {
|
||||
@ -103,7 +104,7 @@ void main() {
|
||||
await tester.tap(find.text('Reset defaults'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.tap(find.text('Reset'));
|
||||
await tester.tap(iconLabeled('Reset'));
|
||||
await tester.runAsync(() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
@ -130,7 +131,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Grant storage permission'),
|
||||
iconLabeled('Grant storage permission'),
|
||||
500,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
@ -141,14 +142,14 @@ void main() {
|
||||
);
|
||||
|
||||
// The scroll step can leave the row just below the fold.
|
||||
await tester.ensureVisible(find.text('Grant storage permission'));
|
||||
await tester.ensureVisible(iconLabeled('Grant storage permission'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Grant storage permission'));
|
||||
await tester.tap(iconLabeled('Grant storage permission'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(asked, isTrue, reason: 'the grant page is opened on demand only');
|
||||
expect(find.text('Storage permission granted'), findsOneWidget);
|
||||
expect(find.text('Grant storage permission'), findsNothing);
|
||||
expect(iconLabeled('Storage permission granted'), findsOneWidget);
|
||||
expect(iconLabeled('Grant storage permission'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('offline backup reports an already-granted permission', (
|
||||
@ -156,13 +157,13 @@ void main() {
|
||||
) async {
|
||||
await _pump(tester, _wrap(storageChecker: () async => true));
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Storage permission granted'),
|
||||
iconLabeled('Storage permission granted'),
|
||||
500,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
|
||||
expect(find.text('Storage permission granted'), findsOneWidget);
|
||||
expect(find.text('Grant storage permission'), findsNothing);
|
||||
expect(iconLabeled('Storage permission granted'), findsOneWidget);
|
||||
expect(iconLabeled('Grant storage permission'), findsNothing);
|
||||
expect(
|
||||
find.textContaining('Granted.', skipOffstage: false),
|
||||
findsOneWidget,
|
||||
|
||||
@ -12,6 +12,7 @@ import 'package:workout_app/ui/theme.dart';
|
||||
|
||||
import '../fake_pc_pairing.dart';
|
||||
import '../fake_secure_storage.dart';
|
||||
import '../icon_finders.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
@ -83,7 +84,7 @@ void main() {
|
||||
|
||||
testWidgets('shows Settings app bar', (tester) async {
|
||||
await _pump(tester, _wrap());
|
||||
expect(find.text('Settings'), findsOneWidget);
|
||||
expect(iconLabeled('Settings'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows WEIGHTS, TARGET REPS and PER EXERCISE', (tester) async {
|
||||
|
||||
@ -12,6 +12,7 @@ import 'package:workout_app/ui/theme.dart';
|
||||
|
||||
import '../fake_pc_pairing.dart';
|
||||
import '../fake_secure_storage.dart';
|
||||
import '../icon_finders.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
@ -94,9 +95,7 @@ void main() {
|
||||
expect(find.text('Advanced sync (GitHub)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('opening Sync settings pushes the shared screen', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('opening Sync settings pushes the shared screen', (tester) async {
|
||||
await _pump(tester, _wrap());
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Sync settings'),
|
||||
@ -125,7 +124,7 @@ void main() {
|
||||
await tester.tap(find.text('Advanced sync (GitHub)'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Connect GitHub'), findsOneWidget);
|
||||
expect(iconLabeled('Connect GitHub'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
|
||||
@ -54,7 +54,7 @@ void main() {
|
||||
// Success break (5 done >= 5 target): the 3-minute rest.
|
||||
expect(restSecs(tester), inInclusiveRange(175, 180));
|
||||
|
||||
await tapReal(tester, find.text('Skip'));
|
||||
await tapReal(tester, iconLabeled('Skip'));
|
||||
expect(restRunning(tester), isFalse);
|
||||
});
|
||||
|
||||
@ -98,12 +98,12 @@ void main() {
|
||||
// resumes into _finishWorkout in the same zone, and _finishWorkout does
|
||||
// several sqflite writes that would hang under FakeAsync.
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish'));
|
||||
await tester.tap(buttonLabeled<TextButton>('Finish'));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
await tester.pump();
|
||||
expect(find.text('Finish workout?'), findsOneWidget);
|
||||
expect(iconLabeled('Finish workout?'), findsOneWidget);
|
||||
// The dialog's Finish button is the last 'Finish' in the tree.
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish').last);
|
||||
await tester.tap(buttonLabeled<TextButton>('Finish').last);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
await tester.pump();
|
||||
});
|
||||
@ -131,15 +131,18 @@ void main() {
|
||||
await pumpWorkout(tester, wrapWorkout());
|
||||
|
||||
// Opening the confirm dialog does no DB work.
|
||||
await tester.tap(find.text('Reset'));
|
||||
await tester.tap(iconLabeled('Reset'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(find.text('Reset workout?'), findsOneWidget);
|
||||
expect(
|
||||
iconLabeled('Reset workout? All progress will be lost.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// Confirm: clearActiveSession is a DB write, so run on the real loop.
|
||||
// (The dialog's Reset button is the last 'Reset' in the tree.)
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Reset').last);
|
||||
await tester.tap(buttonLabeled<TextButton>('Reset').last);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pump();
|
||||
|
||||
@ -54,10 +54,10 @@ void main() {
|
||||
|
||||
Future<void> finish(WidgetTester tester) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish'));
|
||||
await tester.tap(buttonLabeled<TextButton>('Finish'));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish').last);
|
||||
await tester.tap(buttonLabeled<TextButton>('Finish').last);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
@ -51,10 +51,10 @@ void main() {
|
||||
);
|
||||
client.failStop = true;
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish'));
|
||||
await tester.tap(buttonLabeled<TextButton>('Finish'));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish').last);
|
||||
await tester.tap(buttonLabeled<TextButton>('Finish').last);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
await tester.pump();
|
||||
});
|
||||
@ -63,9 +63,6 @@ void main() {
|
||||
expect(client.failStop, isFalse, reason: 'the failing stop really ran');
|
||||
expect(tester.takeException(), isNull);
|
||||
// The summary's PC line: this test host is never paired.
|
||||
expect(
|
||||
find.text('PC: will credit within 60 s (via sync)'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('PC: will credit within 60 s (via sync)'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@ -127,7 +127,7 @@ void main() {
|
||||
expect(tileRects(), idle);
|
||||
await _shot(tester, 'break');
|
||||
|
||||
await tapReal(tester, find.text('Skip'));
|
||||
await tapReal(tester, iconLabeled('Skip'));
|
||||
expect(restRunning(tester), isFalse);
|
||||
expect(tileRects(), idle);
|
||||
|
||||
@ -140,9 +140,9 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(ExerciseSettingsSheet), findsOneWidget);
|
||||
final sheetIdle = tester.getRect(find.byType(ExerciseSettingsSheet));
|
||||
await tapReal(tester, find.text('Reps→kg'));
|
||||
await tapReal(tester, iconLabeled('Reps→kg'));
|
||||
expect(tester.getRect(find.byType(ExerciseSettingsSheet)), sheetIdle);
|
||||
await tapReal(tester, find.text('Warmup set'));
|
||||
await tapReal(tester, iconLabeled('Warmup set'));
|
||||
expect(tester.getRect(find.byType(ExerciseSettingsSheet)), sheetIdle);
|
||||
// The sheet scrolls only as a fallback for a short screen; on the phone
|
||||
// everything, rest rows included, fits with nothing to scroll.
|
||||
@ -167,7 +167,7 @@ void main() {
|
||||
|
||||
// Every set recorded: filled cards, Finish enabled, same geometry.
|
||||
for (var i = 0; i < find.byType(RepCircle).evaluate().length; i++) {
|
||||
if (restRunning(tester)) await tapReal(tester, find.text('Skip'));
|
||||
if (restRunning(tester)) await tapReal(tester, iconLabeled('Skip'));
|
||||
final circle = tester.widget<RepCircle>(find.byType(RepCircle).at(i));
|
||||
if (!circle.tapped) await tapReal(tester, find.byType(RepCircle).at(i));
|
||||
}
|
||||
|
||||
@ -71,7 +71,7 @@ void main() {
|
||||
) async {
|
||||
await pumpPausedSitup(tester);
|
||||
for (var s = 0; s < _lunge.sets; s++) {
|
||||
if (restRunning(tester)) await tapReal(tester, find.text('Skip'));
|
||||
if (restRunning(tester)) await tapReal(tester, iconLabeled('Skip'));
|
||||
await tapReal(tester, find.byType(RepCircle).at(s));
|
||||
}
|
||||
expect(
|
||||
@ -82,10 +82,10 @@ void main() {
|
||||
expect(client.pushed.last.setsRemaining, 0);
|
||||
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish'));
|
||||
await tester.tap(buttonLabeled<TextButton>('Finish'));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish').last);
|
||||
await tester.tap(buttonLabeled<TextButton>('Finish').last);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
await tester.pump();
|
||||
});
|
||||
@ -114,13 +114,13 @@ void main() {
|
||||
find.bySemanticsLabel('Dumbbell Lunge progression settings'),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tapReal(tester, find.text('Pause'));
|
||||
await tapReal(tester, iconLabeled('Pause'));
|
||||
expect(restRunning(tester), isFalse);
|
||||
expect(find.text('Resume'), findsOneWidget);
|
||||
expect(iconLabeled('Resume'), findsOneWidget);
|
||||
|
||||
// Resuming brings the sets back; the tap made before the pause stays.
|
||||
await tapReal(tester, find.text('Resume'));
|
||||
Navigator.of(tester.element(find.text('Pause'))).pop();
|
||||
await tapReal(tester, iconLabeled('Resume'));
|
||||
Navigator.of(tester.element(iconLabeled('Pause'))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(RepCircle), findsNWidgets(4));
|
||||
expect(tester.widget<RepCircle>(find.byType(RepCircle).first).tapped, true);
|
||||
|
||||
@ -68,24 +68,24 @@ void main() {
|
||||
find.bySemanticsLabel('Dumbbell Lunge progression settings'),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1)); // sheet slide-in
|
||||
await tapReal(tester, find.text('Deload now'));
|
||||
await tapReal(tester, iconLabeled('Deload now'));
|
||||
}
|
||||
|
||||
/// Deloads Lunge through the sheet, confirming or cancelling the dialog.
|
||||
Future<void> deloadLunge(WidgetTester tester, {bool confirm = true}) async {
|
||||
await openDeload(tester);
|
||||
expect(find.text('Deload Dumbbell Lunge?'), findsOneWidget);
|
||||
await tapReal(tester, find.text(confirm ? 'Deload' : 'Cancel'));
|
||||
expect(iconLabeled('Deload Dumbbell Lunge?'), findsOneWidget);
|
||||
await tapReal(tester, iconLabeled(confirm ? 'Deload' : 'Cancel'));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
/// Finish -> confirm -> summary dialog up, with the writes done.
|
||||
Future<void> finish(WidgetTester tester) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish'));
|
||||
await tester.tap(buttonLabeled<TextButton>('Finish'));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish').last);
|
||||
await tester.tap(buttonLabeled<TextButton>('Finish').last);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
await tester.pump();
|
||||
});
|
||||
@ -150,7 +150,7 @@ void main() {
|
||||
await tester.tapAt(const Offset(5, 5)); // scrim: close the sheet
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
for (var s = 0; s < _both.length * 3; s++) {
|
||||
if (restRunning(tester)) await tapReal(tester, find.text('Skip'));
|
||||
if (restRunning(tester)) await tapReal(tester, iconLabeled('Skip'));
|
||||
await tapReal(tester, find.byType(RepCircle).at(s));
|
||||
}
|
||||
|
||||
@ -179,7 +179,7 @@ void main() {
|
||||
|
||||
await deloadLunge(tester);
|
||||
|
||||
expect(find.textContaining('already at 0 kg'), findsOneWidget);
|
||||
expect(iconLabeled('Deload refused'), findsOneWidget);
|
||||
expect(find.text('3×12×7.5kg'), findsOneWidget);
|
||||
});
|
||||
|
||||
@ -200,10 +200,7 @@ void main() {
|
||||
|
||||
await deloadLunge(tester);
|
||||
|
||||
expect(
|
||||
find.textContaining('The deload could not be saved'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(iconLabeled('Deload refused'), findsOneWidget);
|
||||
expect(find.text('3×12×7.5kg'), findsOneWidget);
|
||||
});
|
||||
|
||||
@ -223,7 +220,7 @@ void main() {
|
||||
|
||||
await deloadLunge(tester);
|
||||
|
||||
expect(find.textContaining('already finished'), findsOneWidget);
|
||||
expect(iconLabeled('Deload refused'), findsOneWidget);
|
||||
final stored = await tester.runAsync(
|
||||
() => StorageService.instance.getExerciseState(_lunge.name),
|
||||
);
|
||||
@ -236,7 +233,7 @@ void main() {
|
||||
await pumpBoth(tester);
|
||||
await openDeload(tester);
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.text('Deload'));
|
||||
await tester.tap(iconLabeled('Deload'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
@ -119,12 +119,12 @@ void main() {
|
||||
..grantOnRequest = false;
|
||||
await pump(tester);
|
||||
|
||||
expect(find.textContaining('Notifications are off'), findsOneWidget);
|
||||
expect(iconLabeled('Notifications are off'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('no warning when notifications were allowed', (tester) async {
|
||||
await pump(tester);
|
||||
expect(find.textContaining('Notifications are off'), findsNothing);
|
||||
expect(iconLabeled('Notifications are off'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a press that arrives after the workout is finished is dropped', (
|
||||
@ -134,10 +134,10 @@ void main() {
|
||||
// Whole flow on the real loop: _confirmFinish's showDialog resumes into
|
||||
// _finishWorkout, which does several sqflite writes.
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish'));
|
||||
await tester.tap(buttonLabeled<TextButton>('Finish'));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish').last);
|
||||
await tester.tap(buttonLabeled<TextButton>('Finish').last);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
await tester.pump();
|
||||
});
|
||||
@ -180,10 +180,10 @@ void main() {
|
||||
testWidgets('finishing the workout DOES stop the service', (tester) async {
|
||||
await pump(tester, saved: completeSaved());
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish'));
|
||||
await tester.tap(buttonLabeled<TextButton>('Finish'));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish').last);
|
||||
await tester.tap(buttonLabeled<TextButton>('Finish').last);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
@ -54,8 +54,8 @@ void main() {
|
||||
|
||||
testWidgets('Reset and Finish buttons are present', (tester) async {
|
||||
await pumpWorkout(tester, wrapWorkout());
|
||||
expect(find.text('Reset'), findsOneWidget);
|
||||
expect(find.text('Finish'), findsOneWidget);
|
||||
expect(iconLabeled('Reset'), findsOneWidget);
|
||||
expect(iconLabeled('Finish'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Finish button is disabled when not all sets done', (
|
||||
@ -63,21 +63,22 @@ void main() {
|
||||
) async {
|
||||
await pumpWorkout(tester, wrapWorkout());
|
||||
final finishButton = tester.widget<TextButton>(
|
||||
find.widgetWithText(TextButton, 'Finish'),
|
||||
buttonLabeled<TextButton>('Finish'),
|
||||
);
|
||||
expect(finishButton.onPressed, isNull);
|
||||
});
|
||||
|
||||
testWidgets('Reset dialog shows and cancels', (tester) async {
|
||||
await pumpWorkout(tester, wrapWorkout());
|
||||
await tester.tap(find.text('Reset'));
|
||||
await tester.tap(iconLabeled('Reset'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(find.text('Reset workout?'), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
const title = 'Reset workout? All progress will be lost.';
|
||||
expect(iconLabeled(title), findsOneWidget);
|
||||
await tester.tap(iconLabeled('Cancel'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(find.text('Reset workout?'), findsNothing);
|
||||
expect(iconLabeled(title), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tapping a set circle marks it as tapped', (tester) async {
|
||||
@ -107,7 +108,7 @@ void main() {
|
||||
};
|
||||
await pumpWorkout(tester, wrapWorkout(savedState: saved));
|
||||
final finishButton = tester.widget<TextButton>(
|
||||
find.widgetWithText(TextButton, 'Finish'),
|
||||
buttonLabeled<TextButton>('Finish'),
|
||||
);
|
||||
expect(finishButton.onPressed, isNotNull);
|
||||
});
|
||||
@ -128,11 +129,11 @@ void main() {
|
||||
'warmupTapped': [false, false],
|
||||
};
|
||||
await pumpWorkout(tester, wrapWorkout(savedState: saved));
|
||||
await tester.tap(find.text('Finish'));
|
||||
await tester.tap(iconLabeled('Finish'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(find.text('Finish workout?'), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
expect(iconLabeled('Finish workout?'), findsOneWidget);
|
||||
await tester.tap(iconLabeled('Cancel'));
|
||||
await tester.pump();
|
||||
});
|
||||
}
|
||||
|
||||
@ -48,8 +48,8 @@ void main() {
|
||||
),
|
||||
now,
|
||||
);
|
||||
expect(copy.title, 'Workout A · break 1:47');
|
||||
expect(copy.body, 'Next: Squat — set 3/5\n5 reps @ 40 kg');
|
||||
expect(copy.title, '🏋 A · ⏱ 1:47');
|
||||
expect(copy.body, '→ Squat 3/5\n5 × 40 kg');
|
||||
});
|
||||
|
||||
test('says the break is over once the deadline has passed', () {
|
||||
@ -62,11 +62,11 @@ void main() {
|
||||
),
|
||||
now,
|
||||
);
|
||||
expect(copy.title, 'Workout A · break over');
|
||||
expect(copy.title, '🏋 A · ${BreakNotificationText.breakOverGlyph}');
|
||||
});
|
||||
|
||||
test('says lift when no rest is running', () {
|
||||
expect(BreakNotificationText.render(snap(), now).title, 'Workout A · lift');
|
||||
test('shows only the lift glyph when no rest is running', () {
|
||||
expect(BreakNotificationText.render(snap(), now).title, '🏋 A');
|
||||
});
|
||||
|
||||
test('says so when every set is recorded', () {
|
||||
@ -74,13 +74,13 @@ void main() {
|
||||
snap(nextExIdx: -1, nextSetIdx: -1, setsRemaining: 0),
|
||||
now,
|
||||
);
|
||||
expect(copy.body, 'Every set is done — finish the workout.');
|
||||
expect(copy.body, BreakNotificationText.doneGlyph);
|
||||
});
|
||||
|
||||
test('a finished workout asks to be saved', () {
|
||||
test('a finished workout shows the done glyph and no body', () {
|
||||
final copy = BreakNotificationText.render(snap(finished: true), now);
|
||||
expect(copy.title, 'Workout complete');
|
||||
expect(copy.body, 'Open the app to save it.');
|
||||
expect(copy.title, BreakNotificationText.doneGlyph);
|
||||
expect(copy.body, isEmpty);
|
||||
});
|
||||
|
||||
test('drops a pointless trailing zero from the weight', () {
|
||||
|
||||
@ -5,6 +5,7 @@ import 'package:workout_app/main.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
import 'package:workout_app/services/http_server_service.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
@ -45,7 +46,10 @@ void main() {
|
||||
});
|
||||
await tester.pump();
|
||||
expect(find.byType(Banner), findsOneWidget);
|
||||
expect(tester.widget<Banner>(find.byType(Banner)).message, 'SANDBOX');
|
||||
// A bare red ribbon: the colour and the corner are the cue, no word.
|
||||
final banner = tester.widget<Banner>(find.byType(Banner));
|
||||
expect(banner.message, isEmpty);
|
||||
expect(banner.color, buildAppTheme().colorScheme.error);
|
||||
});
|
||||
|
||||
testWidgets('the daily build has no ribbon', (tester) async {
|
||||
|
||||
@ -3,6 +3,8 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
import 'package:workout_app/widgets/break_banner.dart';
|
||||
|
||||
import '../icon_finders.dart';
|
||||
|
||||
Widget _wrap(Widget child) => MaterialApp(
|
||||
theme: buildAppTheme(),
|
||||
home: Scaffold(body: child),
|
||||
@ -28,7 +30,7 @@ BreakBanner _banner({
|
||||
|
||||
Visibility _slotOf(WidgetTester tester, String text) =>
|
||||
tester.widget<Visibility>(
|
||||
find.ancestor(of: find.text(text), matching: find.byType(Visibility)),
|
||||
find.ancestor(of: iconLabeled(text), matching: find.byType(Visibility)),
|
||||
);
|
||||
|
||||
void main() {
|
||||
@ -47,7 +49,7 @@ void main() {
|
||||
testWidgets('skip button calls onSkip', (tester) async {
|
||||
var skipped = false;
|
||||
await tester.pumpWidget(_wrap(_banner(onSkip: () => skipped = true)));
|
||||
await tester.tap(find.text('Skip'));
|
||||
await tester.tap(iconLabeled('Skip'));
|
||||
expect(skipped, isTrue);
|
||||
});
|
||||
|
||||
@ -68,20 +70,20 @@ void main() {
|
||||
testWidgets('Reset calls onReset', (tester) async {
|
||||
var reset = false;
|
||||
await tester.pumpWidget(_wrap(_banner(onReset: () => reset = true)));
|
||||
await tester.tap(find.text('Reset'));
|
||||
await tester.tap(iconLabeled('Reset'));
|
||||
expect(reset, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('Finish is disabled until every set is done', (tester) async {
|
||||
var finished = 0;
|
||||
await tester.pumpWidget(_wrap(_banner(onFinish: () => finished++)));
|
||||
await tester.tap(find.text('Finish'));
|
||||
await tester.tap(iconLabeled('Finish'));
|
||||
expect(finished, 0);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_wrap(_banner(canFinish: true, onFinish: () => finished++)),
|
||||
);
|
||||
await tester.tap(find.text('Finish'));
|
||||
await tester.tap(iconLabeled('Finish'));
|
||||
expect(finished, 1);
|
||||
});
|
||||
|
||||
@ -89,14 +91,14 @@ void main() {
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_wrap(_banner()));
|
||||
final finishAt = tester.getRect(find.text('Finish'));
|
||||
final finishAt = tester.getRect(iconLabeled('Finish'));
|
||||
await tester.pumpWidget(_wrap(_banner(finished: true)));
|
||||
for (final label in ['Reset', 'Finish']) {
|
||||
final slot = _slotOf(tester, label);
|
||||
expect(slot.visible, isFalse, reason: label);
|
||||
expect(slot.maintainSize, isTrue, reason: label);
|
||||
}
|
||||
expect(tester.getRect(find.text('Finish')), finishAt);
|
||||
expect(tester.getRect(iconLabeled('Finish')), finishAt);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -6,6 +6,8 @@ import 'package:workout_app/models/workout_plan.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
import 'package:workout_app/widgets/exercise_settings_sheet.dart';
|
||||
|
||||
import '../icon_finders.dart';
|
||||
|
||||
final _bench = ExerciseState.initial(workoutA[1]); // 22.5 kg x 12
|
||||
|
||||
Future<void> _pump(
|
||||
@ -25,23 +27,23 @@ Future<void> _pump(
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.ensureVisible(find.text('Deload now'));
|
||||
await tester.ensureVisible(iconLabeled('Deload now'));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
TextButton _button(WidgetTester tester) =>
|
||||
tester.widget<TextButton>(find.widgetWithText(TextButton, 'Deload now'));
|
||||
tester.widget<TextButton>(buttonLabeled<TextButton>('Deload now'));
|
||||
|
||||
void main() {
|
||||
testWidgets('the row names the current and the next target', (tester) async {
|
||||
await _pump(tester, _bench, () async => null);
|
||||
expect(find.text('Deload 22.5 kg × 12 → 20 kg'), findsOneWidget);
|
||||
expect(find.text('22.5 kg × 12 → 20 kg'), findsOneWidget);
|
||||
expect(_button(tester).onPressed, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('a whole-kilo weight prints without a decimal', (tester) async {
|
||||
await _pump(tester, _bench.copyWith(weight: 20), () async => null);
|
||||
expect(find.text('Deload 20 kg × 12 → 17.5 kg'), findsOneWidget);
|
||||
expect(find.text('20 kg × 12 → 17.5 kg'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('confirming deloads and the row shows the next step down', (
|
||||
@ -52,17 +54,17 @@ void main() {
|
||||
calls++;
|
||||
return _bench.copyWith(weight: 20);
|
||||
});
|
||||
await tester.tap(find.text('Deload now'));
|
||||
await tester.tap(iconLabeled('Deload now'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Deload Dumbbell Bench Press?'), findsOneWidget);
|
||||
expect(iconLabeled('Deload Dumbbell Bench Press?'), findsOneWidget);
|
||||
expect(find.textContaining('22.5 kg × 12 → 20 kg'), findsWidgets);
|
||||
expect(calls, 0);
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Deload'));
|
||||
await tester.tap(buttonLabeled<TextButton>('Deload'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(calls, 1);
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(find.text('Deload 20 kg × 12 → 17.5 kg'), findsOneWidget);
|
||||
expect(find.text('20 kg × 12 → 17.5 kg'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('cancelling leaves the host and the row alone', (tester) async {
|
||||
@ -71,13 +73,13 @@ void main() {
|
||||
calls++;
|
||||
return null;
|
||||
});
|
||||
await tester.tap(find.text('Deload now'));
|
||||
await tester.tap(iconLabeled('Deload now'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.tap(iconLabeled('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(calls, 0);
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(find.text('Deload 22.5 kg × 12 → 20 kg'), findsOneWidget);
|
||||
expect(find.text('22.5 kg × 12 → 20 kg'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a refused deload (null) keeps the shown state', (tester) async {
|
||||
@ -86,17 +88,19 @@ void main() {
|
||||
calls++;
|
||||
return null;
|
||||
});
|
||||
await tester.tap(find.text('Deload now'));
|
||||
await tester.tap(iconLabeled('Deload now'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Deload'));
|
||||
await tester.tap(buttonLabeled<TextButton>('Deload'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(calls, 1);
|
||||
expect(find.text('Deload 22.5 kg × 12 → 20 kg'), findsOneWidget);
|
||||
expect(find.text('22.5 kg × 12 → 20 kg'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('at the floor the row is disabled and says why', (tester) async {
|
||||
testWidgets('at the floor the row is disabled and shows a dash', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, _bench.copyWith(weight: 0), () async => null);
|
||||
expect(find.text('Deload nothing lower to go to'), findsOneWidget);
|
||||
expect(find.text('—'), findsOneWidget);
|
||||
expect(_button(tester).onPressed, isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@ -6,6 +6,8 @@ import 'package:workout_app/models/workout_plan.dart';
|
||||
import 'package:workout_app/ui/theme.dart';
|
||||
import 'package:workout_app/widgets/exercise_settings_sheet.dart';
|
||||
|
||||
import '../icon_finders.dart';
|
||||
|
||||
final _base = ExerciseState.initial(workoutA.first);
|
||||
|
||||
Future<List<ExerciseState>> _pump(
|
||||
@ -39,17 +41,17 @@ void main() {
|
||||
) async {
|
||||
final edits = await _pump(tester);
|
||||
final before = tester.getSize(find.byType(ExerciseSettingsSheet));
|
||||
expect(find.textContaining('+2.5 kg per step'), findsOneWidget);
|
||||
expect(find.textContaining('+2.5 kg ·'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Reps'));
|
||||
await tester.tap(iconLabeled('Reps'));
|
||||
await tester.pump();
|
||||
expect(edits.last.mode, ProgressionMode.reps);
|
||||
expect(find.textContaining('the weight never changes'), findsOneWidget);
|
||||
expect(find.text('+1'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Reps→kg'));
|
||||
await tester.tap(iconLabeled('Reps→kg'));
|
||||
await tester.pump();
|
||||
expect(edits.last.mode, ProgressionMode.doubleProgression);
|
||||
expect(find.textContaining('up to 12, then'), findsOneWidget);
|
||||
expect(find.textContaining('+1 … 12 ⇒'), findsOneWidget);
|
||||
expect(tester.getSize(find.byType(ExerciseSettingsSheet)), before);
|
||||
});
|
||||
|
||||
@ -58,7 +60,7 @@ void main() {
|
||||
) async {
|
||||
await _pump(tester);
|
||||
expect(find.bySemanticsLabel('Top reps up'), findsNothing);
|
||||
await tester.tap(find.text('Reps→kg'));
|
||||
await tester.tap(iconLabeled('Reps→kg'));
|
||||
await tester.pump();
|
||||
expect(find.bySemanticsLabel('Top reps up'), findsOneWidget);
|
||||
});
|
||||
@ -89,7 +91,7 @@ void main() {
|
||||
|
||||
testWidgets('warmup switch and thresholds are reported', (tester) async {
|
||||
final edits = await _pump(tester);
|
||||
await tester.tap(find.text('Warmup set'));
|
||||
await tester.tap(iconLabeled('Warmup set'));
|
||||
await tester.pump();
|
||||
expect(edits.last.hasWarmup, isFalse);
|
||||
|
||||
@ -133,7 +135,7 @@ void main() {
|
||||
final before = tester.getSize(find.byType(ExerciseSettingsSheet));
|
||||
final warmupRest = find.bySemanticsLabel('Warmup rest up').hitTestable();
|
||||
expect(warmupRest, findsOneWidget);
|
||||
await tester.tap(find.text('Warmup set'));
|
||||
await tester.tap(iconLabeled('Warmup set'));
|
||||
await tester.pump();
|
||||
expect(warmupRest, findsNothing);
|
||||
expect(tester.getSize(find.byType(ExerciseSettingsSheet)), before);
|
||||
@ -147,7 +149,7 @@ void main() {
|
||||
await _tapLabel(tester, 'Pause days up');
|
||||
await _tapLabel(tester, 'Pause days up');
|
||||
await _tapLabel(tester, 'Pause days down');
|
||||
await tester.tap(find.text('Pause'));
|
||||
await tester.tap(iconLabeled('Pause'));
|
||||
await tester.pump();
|
||||
expect(
|
||||
edits.last.pausedUntil,
|
||||
@ -155,10 +157,10 @@ void main() {
|
||||
);
|
||||
expect(find.textContaining('Paused — back on'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Resume'));
|
||||
await tester.tap(iconLabeled('Resume'));
|
||||
await tester.pump();
|
||||
expect(edits.last.pausedUntil, isNull);
|
||||
expect(find.text('Pause'), findsOneWidget);
|
||||
expect(iconLabeled('Pause'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('showExerciseSettingsSheet opens it as a bottom sheet', (
|
||||
|
||||
@ -8,6 +8,8 @@ import 'package:workout_app/widgets/exercise_settings_sheet.dart';
|
||||
import 'package:workout_app/widgets/exercise_tile.dart';
|
||||
import 'package:workout_app/widgets/rep_circle.dart';
|
||||
|
||||
import '../icon_finders.dart';
|
||||
|
||||
Widget _wrap(Widget child) => MaterialApp(
|
||||
theme: buildAppTheme(),
|
||||
home: Scaffold(body: child),
|
||||
@ -159,7 +161,7 @@ void main() {
|
||||
await tester.tap(find.bySemanticsLabel('Squat progression settings'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(ExerciseSettingsSheet), findsOneWidget);
|
||||
await tester.tap(find.text('Reps'));
|
||||
await tester.tap(iconLabeled('Reps'));
|
||||
await tester.pump();
|
||||
expect(edits.single.mode, ProgressionMode.reps);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user