diff --git a/docs/superpowers/plans/2026-08-18-recommendation-engine-upgrade.md b/docs/superpowers/plans/2026-08-18-recommendation-engine-upgrade.md new file mode 100644 index 0000000..14d2c5e --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-recommendation-engine-upgrade.md @@ -0,0 +1,205 @@ +# Recommendation Engine Upgrade Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the gap between what `MLService` can already compute and what actually reaches the user during a workout, then extend the model with the inputs it's currently blind to (readiness, effort, intra-session fatigue) — all estimated from data already collected, with **zero additional per-set user input** — without breaking the offline, zero-latency requirement of the in-workout recommendation card. Along the way, split `MLService`'s accumulated responsibilities to match this codebase's existing SOLID conventions (`managers/`, `strategies/`, `interfaces/`). + +**Non-goal:** Replacing the classical model with a cloud LLM call on the hot path. The recommendation must render synchronously while the user is mid-set with no network. An LLM-backed layer is scoped as an optional *secondary* explanation feature (Phase 6), not a replacement for the deterministic engine. + +**Product decisions locked in for this plan:** +- No manual per-set RPE picker — the user logs weight/reps only, same as today. Effort is *estimated*, never asked, per set. +- One optional once-per-workout question ("How did that session feel?" — 3 chips) is acceptable since it's asked once, not per set, and it's the only real calibration anchor the estimator has. +- Heart-rate data is optional and frequently absent (sync gaps) — every HR-dependent signal must degrade to a fully HR-less computation with no behavior change when HR is missing, never block or error. +- Flutter SDK is `3.44.8` (`workout-logger/pubspec.yaml:23`) — `CLAUDE.md`'s "3.41.5" is stale; no action needed here, just don't trust that doc for the SDK version. + +--- + +## Audit findings (why each task exists) + +Confirmed by reading `lib/services/ml_service.dart`, `lib/services/workout_provider.dart`, `lib/services/managers/analytics_manager.dart`, `lib/services/managers/readiness_manager.dart`, `lib/services/utils/readiness_calculator.dart`, `lib/services/ai/coach_tool_service.dart`, and `lib/models/models.dart`: + +1. **Recovery-aware branch is dead code in production.** `MLService.recommendSets()` has a fully-implemented "under-recovered primary muscle → hold, don't progress" path (`ml_service.dart:433-476`), gated on `recoveryScores` + `primaryMuscleIds` params. **Neither live caller passes them** — not `WorkoutProvider.getRecommendations` (`workout_provider.dart:666`, the one the workout screen actually uses) nor `AnalyticsManager.getRecommendations` (`analytics_manager.dart:135`). +2. **No intra-session fatigue.** Zero awareness of what the user already did *earlier in today's session*. Deload/plateau detection only looks at day-to-day history of the *same* exercise. +3. **Readiness (sleep/RHR/HRV) is display-only.** `ReadinessManager`/`ReadinessCalculator` compute a real 0–100 score, cached and shown on `readiness_card.dart`, never read by `MLService`. +4. **No effort signal at all**, estimated or otherwise. `WorkoutSet` has no RPE-like field; the model can't tell an easy top set from a grinder. +5. **Per-exercise, not program-aware.** Each exercise's recommendation is computed in isolation. +6. **Two call sites, diverging behavior.** `WorkoutProvider.getRecommendations` passes `pastSessions` (deload detection works there) but not recovery data. `AnalyticsManager.getRecommendations` passes neither (deload detection dead there too). Every future param has to be added in both places or they drift further. +7. **`MLService` violates SRP.** One 640-line class does curve-fitting math, recovery scoring, recommendation heuristics, and target-date prediction — unlike the rest of the codebase's `managers/`/`strategies/`/`interfaces/` split. +8. **A second "brain" already exists and is underused.** The Gemini-backed AI Coach (`coach_tool_service.dart`) already has tool-calling access to muscle recovery, targets, and history, but it's a separate on-demand chat feature, not wired into the automatic per-set card. + +--- + +## Global Constraints + +- Flutter SDK `3.44.8`. All commands run from `workout-logger/`. +- `flutter analyze` reports 0 issues at the end of every task. +- The in-workout recommendation call (`WorkoutProvider.getRecommendations`) stays **synchronous and offline** — no `await`, no network, no fresh Health Connect reads. Anything needing I/O (readiness, HR) must already be cached in memory before the card renders, exactly as `ReadinessManager` caches its snapshot today. +- Preserve `IMLService`'s role as the stable facade every call site depends on — see Phase 2's "keep the facade" decision. `MockMLService` (`test/test_utils/mock_ml_service.dart`) and Mockito-generated mocks must be updated in lockstep with any interface signature change (`dart run build_runner build --delete-conflicting-outputs`). +- No behavior change ships without a test in `test/ml_service_test.dart` (or a new file) demonstrating the new branch fires and every existing branch's output is byte-identical to before. +- New tunable constants (thresholds, caps) go in as named `static const` near the existing ones (e.g. `_plateauWeeklyPct` in `ml_service.dart:341`), never as magic numbers inline. +- Commit after every task. Conventional commit prefixes (`feat:`, `fix:`, `test:`, `refactor:`). + +--- + +## Phase 1 — Wire up what already exists (ship first, independent of everything else) + +Pure bug fix: connect signals that are already computed but discarded. No new logic. + +- [x] **1.1** Add `recoveryScores` + `primaryMuscleIds` to the call in `WorkoutProvider.getRecommendations` (`workout_provider.dart:666`). Source: `computeMuscleRecoveryScores(_sessions, exerciseMap)` (already used at `workout_provider.dart:1053`) + `Exercise.primaryMuscle` (the existing single-highest-activation getter, reused as-is rather than inventing a new "≥50%" threshold not used elsewhere in the codebase). +- [x] **1.2** Did the same for `AnalyticsManager.getRecommendations` (`analytics_manager.dart:135`), plus `pastSessions` (was missing — its deload detection was dead too). Note: `AnalyticsManager` currently has **zero live callers** anywhere in the app (verified — not wired into `main.dart` or any screen); this fix is precautionary/for whenever it's wired up, not a live-bug fix like 1.1. Signature follows the existing `exercises`/`exerciseMap` optional-param convention already used by `getWeeklyVolumeByMuscle` in the same file, so old 2-arg call sites stay valid. +- [x] **1.3** Tests added: `workout_provider_test.dart` ("holds load when the primary muscle is still under-recovered...", exercised through `WorkoutProvider`, not `MLService` directly — the test that would've caught the original gap) and two in `analytics_manager_test.dart` (params reach the mock when exercise data is supplied; omitted when it isn't, for backward compatibility). `MockMLService` extended with `lastPastSessions`/`lastRecoveryScores`/`lastPrimaryMuscleIds` tracking fields. +- [x] **1.4** `flutter analyze` clean (5 files), `flutter test` green (103/103, including the 3 new tests). Not committed — awaiting the user's go-ahead per this repo's "only commit when explicitly asked" policy. + +## Phase 2 — MLService refactor into SOLID collaborators + +**Decision:** keep `IMLService` as a stable facade; split the *implementation* into four collaborators, so DI wiring (`main.dart`) and every call site are untouched while the internals become testable independently. Land this **before** Phases 3–5 so the new signals arrive as additive rules, not more edits to a 70-line if/else. + +| New file | Owns | Moved from | +|---|---|---| +| `lib/services/strategies/growth_curve_fitter.dart` (`IGrowthCurveFitter`) | WLS fit, Tukey re-weighting, linear/log selection, `predictTargetCompletion`/`predictTargetWithConfidence` (prediction is the curve's inverse — belongs with the fit) | `ml_service.dart:17-216, 545-621` | +| `lib/services/utils/recovery_calculator.dart` (`const RecoveryCalculator()`) | `_tauHours`, `computeMuscleRecoveryScores`, shared `muscleVolumes` helper | `ml_service.dart:34-53, 269-334` | +| `lib/services/utils/effort_estimator.dart` | Estimated-RPE logic — see Phase 3 | new | +| `lib/services/strategies/progression_rules.dart` | `ProgressionRule.apply(ProgressionContext) → SetRecommendation?` (null = "not mine, try next") + an ordered `ProgressionRuleChain`, registry shaped like `TargetCalculatorFactory` (`target_calculator.dart:88-125`) with a `reset()` for test isolation | `ml_service.dart:459-529` | + +Recovery goes in `utils/`, not `managers/` — managers in this repo are `ChangeNotifier` state owners (`managers.dart`); recovery scoring is stateless and pure, same shape as `ReadinessCalculator`. + +`MLService` shrinks to ~120 lines of delegation, collaborators injected as optional constructor params defaulting to concretes (the same idiom `WorkoutProvider(..., IMLService? mlService)` already uses at `workout_provider.dart:91-94`). `extractExerciseDataPoints`/`extractMuscleDataPoints` stay put (thin model→`DataPoint` adapters). `trainGrowthModelStatic` (`ml_service.dart:65`) has no external callers — delete it, don't migrate it. + +Call-site convergence for the Phase 1 fix and future params: one shared parameter-assembly function in `lib/services/utils/exercise_history.dart` (already imported by `AnalyticsManager`), so `WorkoutProvider.getRecommendations` and `AnalyticsManager.getRecommendations` can no longer drift apart (audit item 6). + +- [x] **2.1** Extracted `GrowthCurveFitter` + `IGrowthCurveFitter` into `lib/services/strategies/growth_curve_fitter.dart`; `MLService` delegates via an injected `_curveFitter`. Deleted `trainGrowthModelStatic`. `predictTargetWithConfidence` moved to `GrowthCurveFitter.predictTargetWithConfidence` (static) — the one test call site (`ml_service_test.dart`) updated to match. `MLService`/`IGrowthCurveFitter`/`GrowthCurveFitter` re-exported from `ml_service.dart` so existing `import 'ml_service.dart'` call sites needed no changes. +- [x] **2.2** Extracted `RecoveryCalculator` (incl. public `muscleVolumes`, reusable by Phase 5's fatigue accumulator) into `lib/services/utils/recovery_calculator.dart`; `MLService` delegates via an injected `_recoveryCalculator`. +- [x] **2.3** Introduced `ProgressionContext` + `ProgressionRule` + `ProgressionRuleFactory` in `lib/services/strategies/progression_rules.dart`; ported all five branches (under-recovered, post-deload, decline, plateau, double-progression) verbatim as `UnderRecoveredRule`/`PostDeloadRecoveryRule`/`DeclineDeloadRule`/`PlateauRule`/`DoubleProgressionRule`. `MLService.recommendSets` now builds a `ProgressionContext` and calls `ProgressionRuleFactory.apply`. +- [x] **2.4** Added `ProgressionRuleFactory.reset()` + `registerRuleAtHead()`; new `test/progression_rules_test.dart` (15 tests) covers every rule in isolation plus chain ordering, override, and reset — mirroring `target_calculator_test.dart`'s pattern. +- [x] **2.5** Added `recoveryRecommendationInputs()` to `lib/services/utils/exercise_history.dart`; both `WorkoutProvider.getRecommendations` and `AnalyticsManager.getRecommendations` now call it instead of duplicating the recovery-score/primary-muscle assembly, so they can't drift apart again. + +**Verification:** whole-project `flutter analyze` clean; full `flutter test` run is 963/964 green — the 1 failure (`readiness_manager_test.dart`, an HR-fallback test) reproduces only in the full-suite run and passes standalone, and touches no file this refactor changed (confirmed unrelated pre-existing flakiness, not investigated further as out of scope). All pre-existing `ml_service_test.dart`/`workout_provider_test.dart`/`analytics_manager_test.dart`/`target_calculator_test.dart` assertions pass unmodified except the one `predictTargetWithConfidence` call-site rename noted in 2.1 — confirming the refactor is behavior-preserving. Not committed (repo policy: commit only on explicit request). + +## Phase 3 — Estimated effort (RPE), no per-set input + +**Decision:** a pure `EffortEstimator` (`lib/services/utils/effort_estimator.dart`, `const`-constructible, injected like `ReadinessCalculator` into `ReadinessManager`) producing a continuous **0–10 estimated RPE plus an explicit source + confidence** — never a bare number, so a future real RPE picker is a drop-in `EffortSource.userReported, confidence: 1.0` with zero call-site churn. + +New model in `lib/models/models.dart` near `SetRecommendation` (`models.dart:469`): `EffortEstimate { double rpe; EffortSource source; double confidence; }`, `enum EffortSource { userReported, estimatedWithHr, estimatedHrless }`. + +Anchor at RPE 8 (double progression already assumes near-failure working sets), four additive terms, all using data already logged today: + +1. **Trend deviation** — `z = (actualVolume − growthModel.predict(x)) / max(stdError, 0.05·predict(x))`, gated on `r2 > 0.2` (reuses `_minR2ForTrendSignal`, `ml_service.dart:343`). Term `−0.8·clamp(z, −2, 2)`. +2. **Intra-session decline** — volume ratio of last set vs. first set of the same exercise this session (via `WorkoutSet.volume`, not rep count, so it absorbs dropsets/assisted-weight sets correctly). Deadband ≤5%. Term `+3.0·clamp(declineFrac − 0.05, 0, 0.5)`. +3. **Rest/tempo (free, already logged, currently unused)** — `WorkoutSet.timestamp` (`models.dart:134`) gives inter-set gaps, `timeTaken` (`models.dart:133`) gives per-set duration. Longer-than-usual gaps or slower-than-usual reps vs. this exercise's own history both push RPE up. +4. **HR — optional, additive only.** Only used if Health Connect returns ≥3 samples inside the set window; normalized *within the session* (`hrFrac = (setPeak − sessionFloor)/(sessionPeak − sessionFloor)`). Absent → term 0, confidence unaffected downward, never blocks the estimate. + +`rpe = clamp(8.0 + calibrationOffset + Σterms, 5.0, 10.0)`. + +**Confidence:** base 0.35, +0.20 trend gate passed, +0.20 if ≥3 sets logged, +0.15 if HR term fired, +0.10 if ≥6 sessions history — **capped at 0.85** (real user-reported RPE, if ever added, is the only thing that reaches 1.0). Consumers may only let effort change a *branch* at `confidence ≥ 0.6`; below that it only enriches `SetRecommendation.reasoning` text. + +**Once-per-workout chip (Easy / Solid / Brutal) — include it.** It's the only real calibration signal available: rolling `calibrationOffset = mean(chipValue − estimatedSessionMeanRpe)` over the last 10 answered sessions (Easy→6.5, Solid→8, Brutal→9.5), clamped ±1.0. Store as nullable `WorkoutSession.sessionEffort` (int 1–3) — nullable means no Hive migration, and skipping it is a first-class path with `calibrationOffset = 0`. + +- [x] **3.1** Added `EffortEstimate`/`EffortSource` to `models.dart` (next to `SetRecommendation`); added nullable `WorkoutSession.sessionEffort` with `toJson`/`fromJson`/`copyWith` round-trip. +- [x] **3.2** Created `lib/services/utils/effort_estimator.dart` with terms 1–3 (trend deviation, intra-session decline, rest/tempo drift — all HR-less, pure, synchronous). 19 unit tests in `test/effort_estimator_test.dart` covering every term in isolation, the confidence ladder, and RPE clamping. +- [x] **3.3 (revised design):** Rather than accepting `IHealthConnectService?` and fetching samples itself, `EffortEstimator.estimate()` takes an optional **pre-resolved** `HrEffortSignal?` (peak bpm in the set's window + session floor/ceiling). Reason: the in-workout recommendation path must stay synchronous/offline (a hard constraint from this plan's Global Constraints), so `EffortEstimator` cannot do Health Connect I/O itself. It's a pure function ready to receive HR data whenever an upstream caller resolves it — tested directly with synthetic `HrEffortSignal` values (4 tests: null, too-few-samples, near-ceiling, well-below-ceiling). **Not done in this pass:** actually wiring a live Health Connect read into the hot path — that needs a caching layer analogous to `ReadinessManager`'s snapshot, which doesn't exist yet for per-set HR. Flagged as a natural follow-up, not built speculatively (no consumer ready to use it yet). +- [x] **3.4** Added the post-workout chip row (`_EffortChipRow` in `workout_summary_screen.dart`, Easy/Solid/Brutal) + `EffortCalibration` (`lib/services/utils/effort_calibration.dart`) implementing the rolling offset as an exponential moving average (α≈0.1, approximates a ~10-session rolling mean without persisting per-session history) rather than storing raw history. `WorkoutProvider.recordSessionEffort()` persists both the session's `sessionEffort` and the offset via `IStorageService.saveSetting`. Tests: `test/effort_calibration_test.dart` (7, incl. convergence/clamping) + 4 in `workout_provider_test.dart` (default 0.0, records + updates offset, persists across reload, no-op for unknown session). + +## Phase 4 — Readiness-aware modulation + +- [x] **4.1** `WorkoutProvider.getRecommendations` now takes an optional `ReadinessBand? readinessBand` (not the full snapshot — the rule chain only ever needed the band). Wired from `WorkoutFlowScreen` via `context.watch()?.snapshot?.band` — the **nullable-typed** lookup was required: an earlier attempt with `context.watch()` (non-nullable) threw `ProviderNotFoundException` in `userflow_workout_logging_test.dart`, which mounts `WorkoutFlowScreen` without the full `main.dart` provider tree. Nullable lookup resolves to `null` gracefully instead, preserving the screen's existing testability. +- [x] **4.2** Added `ReadinessRule` to `progression_rules.dart` at priority 3 (after under-recovered/post-deload, before the new `SessionFatigueRule` and decline/plateau) — reasoning: readiness is a whole-day physiological signal like under-recovery, so it outranks same-session-local fatigue and multi-session trend holds. `ProgressionContext` gained `bool isLowReadiness = false` (defaulted, so the one existing `ml_service.dart` call site needed no changes for this field alone). +- [x] **4.3** No explicit check needed in `MLService`/`WorkoutProvider` — `ReadinessManager.refresh()` already gates on `_settings.readinessEnabled` internally (`readiness_manager.dart:66`) and leaves `snapshot` `null` when disabled/no Health Connect permission, so a disabled/unavailable readiness feature naturally degrades to `readinessBand: null` → `isLowReadiness: false` with zero behavior change. +- [x] **4.4** Tests in `workout_provider_test.dart`: low band holds load even when reps are at the ceiling (verified against a same-setup baseline that *does* progress, to prove the suppression is real); moderate band is a no-op. + +## Phase 5 — Intra-session fatigue model + +**Decision: per-muscle *hard-set-equivalent* accumulation with a continuous dampening factor**, using the Phase 3 effort estimate — not raw tonnage (a leg-press set and a lateral-raise set aren't comparable in kg), not relative-to-typical-volume (would need a history query on the synchronous hot path). + +```text +hardSets[muscleId] = Σ over sets already logged today, excluding the current exercise + (activation% / 100) × clamp((estimatedRpe − 6) / 3, 0, 1) +``` +Activation weighting reuses the Phase 2 `RecoveryCalculator.muscleVolumes` helper rather than a second copy of that loop. + +```text +f = clamp((hardSets − softCap) / (hardCap − softCap), 0, 1) // softCap = 6, hardCap = 12 +``` +- `f = 0` → untouched. +- `0 < f < 1` → **scales, doesn't block**: weight-progression increment × `(1 − f)`, snapped to the nearest 2.5 kg plate (so `f > 0.5` naturally becomes "hold weight, reset reps"), confidence drops `high → medium`. +- `f ≥ 1` → early exit, same output shape as the under-recovered rule, distinct reasoning ("≈N hard sets for chest already this session — hold and finish strong"). + +**Priority in the Phase 2 rule chain:** the `f ≥ 1` exit sits *after* under-recovered and post-deload (those are multi-day physiological/protocol states and dominate), but *before* decline/plateau — deloading 10% while also mid-session-fatigued double-penalizes, and "rebuild next session" isn't the right advice for a fatigue state that resolves by tomorrow. `0 < f < 1` doesn't short-circuit; it flows through as a multiplier on whatever the later rules decide. + +Data source: `WorkoutProvider`'s in-progress session state, already in memory — zero I/O, preserves the offline constraint. + +- [x] **5.1** Added `SessionFatigueAccumulator` (`lib/services/utils/session_fatigue.dart`): `hardSetEquivalents({exerciseLogs, exerciseMap, excludeExerciseId})`, using `EffortEstimator` (HR-less, no growth model — just the decline/tempo terms plus the RPE-8 base anchor) per set, `clamp((rpe-6)/3, 0, 1)` for hardness, weighted by each exercise's muscle-activation percentages. 7 tests in `test/session_fatigue_test.dart`. +- [x] **5.2** Added `readinessBand`/`sessionHardSets` params to `recommendSets` on both `IMLService` and `MLService` (no separate `primaryMuscleIds` needed — it already existed and is reused for the fatigue lookup too, same as recovery). Updated `MockMLService` with `lastReadinessBand`/`lastSessionHardSets` tracking. No Mockito-generated mock exists for `IMLService` (verified via search) — only the hand-written `MockMLService` needed updating. +- [x] **5.3** Added `SessionFatigueRule` (hard-hold at `f ≥ 1.0`) to the chain, plus modified `DoubleProgressionRule` itself to scale its weight-progression increment by `(1 − f)` for `0 < f < 1`, snapped to the nearest 2.5kg plate — this couldn't be a separate rule since partial dampening isn't a "hold, stop the chain" decision, it has to modify what the terminal rule would otherwise do. Verified byte-identical output at `f = 0` (the default) with a dedicated test. `_fatigueSoftCap`/`_fatigueHardCap` (6.0/12.0) added as named constants in `ml_service.dart` beside `_plateauWeeklyPct`; the `recommendSets` priority-order doc comment updated to the full 8-tier list. +- [x] **5.4** Wired from `WorkoutProvider.getRecommendations` via a `SessionFatigueAccumulator` field, computed from `_currentExerciseLogs` (already in memory — zero I/O) excluding the exercise being recommended. 4 integration tests in `workout_provider_test.dart` using real `startWorkout`/`addSet`/`nextExercise` flows: same-primary-muscle exercise dampened after heavy prior sets (compared against a fresh provider with no in-progress session, to isolate the effect); a different, unrelated muscle group is unaffected. + +## Phase 6 — "Bigger model" evaluation (spike, completed 2026-08-18) + +Executed as a code-grounded architecture review (Opus subagent) plus an actual backtest of the shipped engine against a real 74-session export (`repforge_backup_2026-08-02_221350.json`, 832 simulated set-recommendations). No code changed in this phase — findings only. + +### Q1: Keep the ordered rule chain, or convert to a scored/weighted composite? + +**Decision: keep the chain.** Four of the seven rules (`UnderRecoveredRule`, `ReadinessRule`, `SessionFatigueRule`, `PlateauRule`) return numerically identical output (`weight: c.set.weight, reps: c.set.reps`) — they differ only in which *reasoning string* wins, i.e. the chain isn't blending four numbers, it's selecting an explanation for one shared "hold" outcome. A scored composite would have to sum contributors into a pressure number and then separately argmax them back apart to pick a sentence — that's the chain, reimplemented with extra state. Worse, `isUnderRecovered`'s veto (it must win outright regardless of how good the trend looks) isn't expressible as a smooth weight without a sentinel/infinite term, i.e. a hard gate wearing a score's clothing. Where the code genuinely needed continuity (partial same-session fatigue), it already lives *inside* the chain as a multiplier on the one rule that computes a delta (`DoubleProgressionRule`), not as a competing score. That's the correct shape: hard gates for veto states, one continuous modulator on the terminal rule. + +### Q2: Give the Gemini AI Coach a `_setRecommendation`/`_explainRecommendation` tool? + +**Decision: no — and the actual blocker is upstream of the LLM question.** `SetRecommendation.reasoning` — the carefully-worded string every rule produces — has exactly one reference in all of `lib/`: its own declaration. `_RecommendationCard` (`exercise_input_section.dart`) renders only the weight/reps and an unlabelled confidence dot; the reasoning text is asserted in tests but never shown to a user. Building a Coach tool to *explain* a decision nobody has been shown a single word about is solving the wrong layer. Do the free fix first: render `rec.reasoning` in the card (and label the confidence dot). Revisit the Coach tool only if, after reasoning is visible, chat transcripts or a "why?" tap-through show real demand for something richer than what's already computed and sitting unused. + +### Real-data backtest — the second most useful finding of this phase + +Backtested the exact shipped `MLService`/`ProgressionRuleFactory` against 74 real sessions (832 simulated recommendations, mirroring `WorkoutProvider.getRecommendations`'s exact inputs at each historical point in time — no data leakage from future sessions): + +| Rule | Fired | +|---|---| +| `DoubleProgressionRule` (add rep) | 420 (50.5%) | +| `DoubleProgressionRule` (weight bump) | 137 (16.5%) | +| `UnderRecoveredRule` | 119 (14.3%) | +| `PostDeloadRecoveryRule` | 77 (9.3%) | +| `DeclineDeloadRule` | 50 (6.0%) | +| `PlateauRule` | 29 (3.5%) | +| `SessionFatigueRule` / fatigue-scaled progression | **0 (0.0%)** | + +The Phase 1 recovery-gate fix (`UnderRecoveredRule` + `PostDeloadRecoveryRule`) is doing real, substantial work for this user — nearly a quarter of all recommendations. **Phase 5's same-session fatigue signal never fired once.** 159 of 832 sets had *some* non-zero same-session hard-set accumulation for their primary muscle, but the highest value ever reached across all 74 sessions was **5.13** — under the `_fatigueSoftCap = 6.0` threshold every time. This user's actual training pattern (rotating through many distinct exercises per session rather than stacking several heavy sets on overlapping muscles back-to-back) never generates enough same-session overlap to cross into the dampening zone as calibrated. + +This doesn't mean Phase 5 is wrong — it means `_fatigueSoftCap`/`_fatigueHardCap` (6.0/12.0, always flagged as unvalidated guesses) are calibrated for a different training style than this real user's. **Action for whenever this gets tuned:** lower `_fatigueSoftCap` toward ~4–5 and re-backtest, or gather a session with genuinely stacked same-muscle work (e.g. a push-day superset) to find a real threshold instead of guessing again. Also confirmed separately: `readiness.snapshot` in this export was cached once at `band: moderate` — never `low` — so `ReadinessRule` had no opportunity to fire in this dataset either; no conclusion possible from this backup about its calibration. + +**Also surfaced, unrelated to the two questions but found while reading the export:** the backup's `settings` block contains a plaintext `geminiApiKey`. Flagged to the user directly — recommend rotating that key and confirming this export file isn't synced/committed anywhere. + +--- + +## Phase 7 — Remove `muscleActivations` from the fatigue path (completed 2026-08-19) + +The user pointed out a concrete real case the Phase 6 backtest's calibration missed: their back/bicep routine trains Lat Pulldown, Seated Cable Row, and Pull-ups back to back, and order visibly affects performance — but investigating found `seated_cable_row`'s highest-activation muscle is `'back'` (70%) while `lat_pulldown`/`pull_ups`' is `'lats'` (75–80%) — **separate, non-aliased ids** in this app's hand-authored taxonomy (`exercise_database.dart:9-16`). Since Phase 5's fatigue gate only checked `[exercise.primaryMuscle]` (a single label) against the accumulated map, cross-talk between these three near-identical pulling movements depended on accidents of which label the exercise database's author happened to pick as "primary" for each. The user's explicit instruction: *"Don't rely much on this muscle activation data (as it's hardcoded, and I don't think any medical reference backs this), just make relationships based on the data."* + +Rather than patch the taxonomy (e.g. broadening to "all significantly-activated muscles"), a deeper spike (Opus subagent, grounded in the real 74-session export) tested whether a same-session order effect is statistically detectable **at all**, independent of muscle labels: + +- **Pooled effect across all exercises: r = 0.005, t = 0.09, n = 309** (session-demeaned regression of trend-residual performance against prior in-session hard-set count). Effectively zero. +- **Per-exercise coefficients that looked real didn't replicate** on a split-half check (e.g. `hammer_curl`: −0.52 in the first half of history, +0.11 in the second). +- **Order barely varies in this user's real routine** — 73 of 85 logged exercise pairs have *zero* counterfactual observations (always trained in the same relative order), and a plausible 3–5% order effect is smaller than the 2.6–13.8% session-to-session noise on most exercises' top-set performance. No model — linear, GRU, or transformer — can be fit reliably against noise this size with this little contrastive data; a user-proposed LSTM/attention approach was evaluated and rejected on the same grounds (far more parameters than the 1-parameter linear model that already failed to generalize, against the same starved, noisy dataset). +- **The only identifiable unit was a single exercise-agnostic scalar** — total prior same-session hard-set-equivalents, not attributed to any muscle or specific prior exercise. + +**Implemented:** `SessionFatigueAccumulator.factorFor()` (`lib/services/utils/session_fatigue.dart`) now sums RPE-weighted hardness across all of today's earlier sets with **no `Exercise.muscleActivations` dependency at all** — it no longer even takes an `exerciseMap` parameter. `IMLService.recommendSets`/`MLService.recommendSets` replaced `Map? sessionHardSets` with a single `double sessionFatigueFactor = 0.0`, computed upstream by `WorkoutProvider` and passed straight through — `ml_service.dart` no longer does any per-muscle lookup or cap math itself (`primaryMuscleIds` now serves the recovery gate only). `ProgressionContext`, `SessionFatigueRule`, and `DoubleProgressionRule` needed **zero changes** — they already consumed a plain `double`. + +**Calibration, not a guess:** re-ran a backtest of the new formula against the same 74-session export (254 real same-session contexts) and found real-set totals up to **11.87**, median 4.56. The new `_softCap`/`_hardCap` (16.0/24.0) sit comfortably above the observed max, so the factor evaluates to exactly `0.0` on every real historical case — an explicit, verified "must not ship as a behavior change" guarantee rather than an assumption. This is an intentionally inert placeholder, documented as such in the accumulator's file header, ready to be recalibrated downward once genuine order-variation data exists (the deferred "occasionally suggest swapping exercise order" data-collection nudge from the Phase 6 spike is what would generate that data). + +Tests: `test/session_fatigue_test.dart` fully rewritten for the new scalar API (no `Exercise` fixtures needed at all now); two `workout_provider_test.dart` integration tests updated — one confirms realistic session sizes produce zero behavior change (verified against a fresh-provider baseline), one confirms an extreme, unrealistic session (40 sets) still dampens, proving the mechanism is wired end-to-end even though real sessions never reach it. Full suite verified green. + +--- + +## Suggested sequencing + +**1 → 2 → 3 → (4 and 5 in either order, 5 depends on 3) → 6.** Phase 1 is a same-day bug fix. Phase 2 is a pure refactor with no behavior change — verify with byte-identical-output tests before adding anything new on top of it. Phases 3–5 are each independently shippable; 5 needs 3's effort estimate as an input, so it can't land first. Ship and observe after each phase rather than batching — the thresholds introduced in Phases 3 and 5 in particular (the 8.0 RPE anchor, `softCap`/`hardCap`) are unvalidated constants that will need tuning against real user feedback, and that's much easier to isolate one phase at a time. + +--- + +## Status: Phases 1–5 implemented (2026-08-18) + +All tasks above are done. Final verification: whole-project `flutter analyze` — 0 issues. Full `flutter test` — **1014/1014 passing** (the 1 pre-existing flaky failure noted after Phase 2 did not reproduce in the final full run, consistent with it being unrelated ordering/timing flakiness in `readiness_manager_test.dart`, not a regression from this work). + +**New files:** `strategies/growth_curve_fitter.dart`, `strategies/progression_rules.dart`, `utils/recovery_calculator.dart`, `utils/effort_estimator.dart`, `utils/effort_calibration.dart`, `utils/session_fatigue.dart`, plus 6 new test files (`progression_rules_test.dart`, `effort_estimator_test.dart`, `effort_calibration_test.dart`, `session_fatigue_test.dart`, and additions to `model_serialization_test.dart`/`workout_provider_test.dart`/`analytics_manager_test.dart`). + +**Known gaps, called out honestly rather than papered over:** +- The HR term in `EffortEstimator` is fully implemented and tested but not wired to a live Health Connect read anywhere — it needs a caching layer that doesn't exist yet (see Phase 3.3's note). Every constant fed to it in production today is `null`, so it never fires outside of tests. +- All the new thresholds (RPE-8 anchor, `_fatigueSoftCap`/`_fatigueHardCap`, `EffortCalibration`'s α=0.1) are engineering estimates, not tuned against real user data. Expect to revisit them after Phase 6's usage-data checkpoint. +- `AnalyticsManager.getRecommendations` received the Phase 1 recovery-data fix for consistency but was **not** extended with `readinessBand`/`sessionHardSets` — it still has zero live callers in the app, so this was judged out of scope rather than speculative work. + +**Not committed.** Every phase's changes are sitting in the working tree, verified but uncommitted, per this repo's "only commit when explicitly asked" policy. diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 256241e..a6d2f4f 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -37,6 +37,7 @@ import 'genui/a2ui.dart'; import 'theme/a2ui_app_theme.dart'; import 'screens/home_screen.dart'; import 'screens/onboarding_screen.dart'; +import 'screens/widgets/rf_widgets.dart'; /// Resolved once in main() before runApp(). Read lazily by /// WorkoutLoggerApp._storageService's static initializer, which only runs @@ -46,9 +47,8 @@ IStorageService? _resolvedStorageService; /// One-time, flag-gated, reversible Hive -> SQLite cutover. See /// docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md §6. Future _resolveStorageBackend() async { - // Hive stays initialized unconditionally: the cutover flag itself lives in - // this settings box, so it has to be readable before we know which backend - // to build — and the pre-migration path still runs on Hive outright. + // Hive must be initialized before the cutover check itself, since the + // migration flag below is read directly from the Hive 'settings' box. await Hive.initFlutter(); final settingsBox = await Hive.openBox('settings'); final alreadyMigrated = settingsBox.get(storageMigratedFlagKey) == 'true'; @@ -220,6 +220,9 @@ class WorkoutLoggerApp extends StatelessWidget { title: 'Workout Logger', debugShowCheckedModeBanner: false, theme: AppTheme.darkTheme, + // Above the Navigator, so every route feeds the ambient glow. + builder: (context, child) => + AmbientMotionScope(child: child ?? const SizedBox.shrink()), home: const AppInitializer(), ), ), @@ -262,6 +265,7 @@ class _AppInitializerState extends State { settings.geminiApiKey, model: settings.geminiModel, maxToolRounds: settings.geminiMaxToolRounds, + thinkingLevel: settings.geminiThinkingLevel, ); try { await gemini.loadUsage(); diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index cc734d2..3b2f04b 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -315,6 +315,10 @@ class WorkoutSession { final String? notes; /// Non-null when this session was successfully synced to Health Connect. final DateTime? hcSyncedAt; + /// Optional once-per-workout subjective effort (1 = Easy, 2 = Solid, + /// 3 = Brutal), captured on the post-workout summary screen. Used only to + /// calibrate [EffortEstimator]'s per-set RPE anchor — never required. + final int? sessionEffort; WorkoutSession({ required this.id, @@ -324,6 +328,7 @@ class WorkoutSession { required this.duration, this.notes, this.hcSyncedAt, + this.sessionEffort, }); double get totalVolume => @@ -337,6 +342,7 @@ class WorkoutSession { 'duration': duration, 'notes': notes, 'hcSyncedAt': hcSyncedAt?.toIso8601String(), + 'sessionEffort': sessionEffort, }; factory WorkoutSession.fromJson(Map json) => WorkoutSession( @@ -351,6 +357,7 @@ class WorkoutSession { hcSyncedAt: json['hcSyncedAt'] != null ? DateTime.parse(json['hcSyncedAt'] as String) : null, + sessionEffort: json['sessionEffort'] as int?, ); WorkoutSession copyWith({ @@ -361,6 +368,7 @@ class WorkoutSession { Object? duration = _sentinel, Object? notes = _sentinel, Object? hcSyncedAt = _sentinel, + Object? sessionEffort = _sentinel, }) => WorkoutSession( id: id == _sentinel ? this.id : id as String, date: date == _sentinel ? this.date : date as DateTime, @@ -373,6 +381,9 @@ class WorkoutSession { hcSyncedAt: hcSyncedAt == _sentinel ? this.hcSyncedAt : hcSyncedAt as DateTime?, + sessionEffort: sessionEffort == _sentinel + ? this.sessionEffort + : sessionEffort as int?, ); } @@ -480,6 +491,34 @@ class SetRecommendation { }); } +// ==================== Effort Estimate ==================== + +/// Where an [EffortEstimate]'s RPE value came from. +/// +/// No per-set RPE picker exists in this app — [userReported] is reserved +/// for if one is ever added, so estimated values can never be confused with +/// a real logged one. +enum EffortSource { userReported, estimatedWithHr, estimatedHrless } + +/// A 0–10 RPE (rate of perceived exertion) estimate for one set, inferred +/// from data already logged rather than asked of the user. See +/// [EffortEstimator] for how it's computed. +class EffortEstimate { + final double rpe; + final EffortSource source; + + /// 0–1. Capped below 1.0 (see [EffortEstimator.maxEstimatedConfidence]) — + /// an estimate can never claim the certainty a real user-reported value + /// would have. + final double confidence; + + const EffortEstimate({ + required this.rpe, + required this.source, + required this.confidence, + }); +} + // ==================== Growth Model ==================== /// Functional form of a fitted growth curve. diff --git a/workout-logger/lib/screens/ai_coach_screen.dart b/workout-logger/lib/screens/ai_coach_screen.dart index 48bc5da..41bc8c3 100644 --- a/workout-logger/lib/screens/ai_coach_screen.dart +++ b/workout-logger/lib/screens/ai_coach_screen.dart @@ -18,8 +18,12 @@ import '../services/managers/conversation_manager.dart'; import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; import 'widgets/rf_widgets.dart'; +import 'widgets/rf_shell.dart'; import 'profile_screen.dart'; +/// Bubbles stop short of the far edge; full-bleed ones read as banners. +const double _kBubbleMaxWidthFactor = 0.82; + /// Public entry point. Owns the screen-scoped [AiCoachViewModel]. class AiCoachScreen extends StatelessWidget { const AiCoachScreen({super.key, this.seedPrompt}); @@ -103,7 +107,7 @@ class _AiCoachViewState extends State<_AiCoachView> { if (_scrollCtrl.hasClients) { _scrollCtrl.animateTo( _scrollCtrl.position.maxScrollExtent, - duration: const Duration(milliseconds: 250), + duration: AppDurations.moderate, curve: Curves.easeOut, ); } @@ -119,18 +123,17 @@ class _AiCoachViewState extends State<_AiCoachView> { body: Stack( children: [ const AmbientGlow(), - SafeArea( - child: Column( - children: [ - _buildHeader(context, vm), - Expanded( - child: vm.isConfigured - ? _buildChatArea(vm) - : _buildNoKeyState(context), - ), - if (vm.isConfigured) _buildInputBar(vm), - ], - ), + // No wrapping SafeArea: header takes the top inset, input bar the bottom. + Column( + children: [ + _buildHeader(context, vm), + Expanded( + child: vm.isConfigured + ? _buildChatArea(vm) + : _buildNoKeyState(context), + ), + if (vm.isConfigured) _buildInputBar(vm), + ], ), ], ), @@ -138,91 +141,25 @@ class _AiCoachViewState extends State<_AiCoachView> { } Widget _buildHeader(BuildContext context, AiCoachViewModel vm) { - return Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.sm, - AppSpacing.md, - 0, - ), - child: Row( - children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppColors.glass3, - borderRadius: BorderRadius.circular(AppRadius.sm), - border: Border.all(color: AppColors.glassBorder), - ), - child: const Icon( - Icons.arrow_back_rounded, - color: AppColors.textSoft, - size: 18, - ), - ), + return RFScreenHeader( + title: 'AI Coach', + subtitle: 'Powered by Gemini', + badgeIcon: Icons.auto_awesome_rounded, + onBack: () => Navigator.pop(context), + actions: [ + if (vm.isConfigured) ...[ + RFIconButton( + icon: Icons.history_rounded, + tooltip: 'Past conversations', + onTap: () => _openHistory(context, vm), ), - const SizedBox(width: AppSpacing.md), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppColors.primary, Color(0xFF5B21B6)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.sm), - boxShadow: [ - BoxShadow( - color: AppColors.primaryGlow(0.4), - blurRadius: 12, - spreadRadius: -4, - ), - ], - ), - child: const Icon(Icons.auto_awesome_rounded, color: Colors.white, size: 16), + RFIconButton( + icon: Icons.add_rounded, + tooltip: 'New chat', + onTap: vm.newConversation, ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'AI Coach', - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textPrimary, - fontSize: 16, - fontWeight: FontWeight.w700, - letterSpacing: -0.3, - ), - ), - Text( - 'Powered by Gemini', - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textMuted, - fontSize: 11, - ), - ), - ], - ), - ), - if (vm.isConfigured) ...[ - _HeaderIconButton( - icon: Icons.history_rounded, - onTap: () => _openHistory(context, vm), - ), - const SizedBox(width: AppSpacing.sm), - _HeaderIconButton( - icon: Icons.add_rounded, - onTap: () { - HapticFeedback.lightImpact(); - vm.newConversation(); - }, - ), - ], ], - ), + ], ); } @@ -246,11 +183,13 @@ class _AiCoachViewState extends State<_AiCoachView> { return ListView.builder( controller: _scrollCtrl, + physics: const BouncingScrollPhysics(), + // Extra bottom room so the last turn settles clear of the input bar. padding: const EdgeInsets.fromLTRB( AppSpacing.md, AppSpacing.md, AppSpacing.md, - AppSpacing.sm, + AppSpacing.lg, ), itemCount: messages.length + (vm.isLoading ? 1 : 0), itemBuilder: (_, i) { @@ -273,34 +212,19 @@ class _AiCoachViewState extends State<_AiCoachView> { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 72, - height: 72, - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppColors.primary, Color(0xFF5B21B6)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.xl), - boxShadow: [ - BoxShadow( - color: AppColors.primaryGlow(0.45), - blurRadius: 28, - spreadRadius: -4, - ), - ], - ), - child: const Icon( - Icons.auto_awesome_rounded, - color: Colors.white, - size: 32, - ), + const RFGradientBadge( + icon: Icons.auto_awesome_rounded, + size: 72, + radius: AppRadius.xl, + glow: 0.45, ), const SizedBox(height: AppSpacing.lg), Text( - name != null && name.isNotEmpty ? 'Hey $name 👋' : 'Your AI Coach', - style: TextStyle(fontFamily: 'Geist', + name != null && name.isNotEmpty + ? 'Hey $name 👋' + : 'Your AI Coach', + style: TextStyle( + fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 22, fontWeight: FontWeight.w700, @@ -311,7 +235,8 @@ class _AiCoachViewState extends State<_AiCoachView> { Text( 'Ask me anything — what to train today, how to break a plateau, reading your progress, anything.', textAlign: TextAlign.center, - style: TextStyle(fontFamily: 'Geist', + style: TextStyle( + fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 14, height: 1.5, @@ -329,7 +254,7 @@ class _AiCoachViewState extends State<_AiCoachView> { 'Am I progressing on bench?', 'Suggest a deload week', ]) - _SuggestionChip( + RFOptionChip( label: s, onTap: () { _controller.text = s; @@ -354,7 +279,8 @@ class _AiCoachViewState extends State<_AiCoachView> { const RFEmptyState( icon: Icons.key_rounded, title: 'API Key Required', - subtitle: 'Add your Gemini API key in\nProfile → AI Features to start chatting', + subtitle: + 'Add your Gemini API key in\nProfile → AI Features to start chatting', ), const SizedBox(height: AppSpacing.lg), GlowButton( @@ -373,44 +299,46 @@ class _AiCoachViewState extends State<_AiCoachView> { } Widget _buildInputBar(AiCoachViewModel vm) { - final loading = vm.isLoading; - return Container( - padding: EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.sm, - AppSpacing.md, - AppSpacing.md + MediaQuery.of(context).padding.bottom, - ), - decoration: BoxDecoration( - color: AppColors.surface.withValues(alpha: 0.9), - border: const Border(top: BorderSide(color: AppColors.glassBorder)), - ), + return RFBottomBar( child: Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: Container( + constraints: const BoxConstraints(minHeight: 44), + alignment: Alignment.centerLeft, decoration: BoxDecoration( - color: AppColors.glass3, + color: AppColors.glass2, borderRadius: BorderRadius.circular(AppRadius.xl), border: Border.all(color: AppColors.glassBorderStrong), ), child: TextField( controller: _controller, - style: TextStyle(fontFamily: 'Geist', + style: const TextStyle( + fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, + height: 1.4, ), - maxLines: 4, + maxLines: 5, minLines: 1, textCapitalization: TextCapitalization.sentences, - decoration: InputDecoration( - hintText: 'Ask your coach...', - hintStyle: TextStyle(fontFamily: 'Geist', + cursorColor: AppColors.primary, + decoration: const InputDecoration( + hintText: 'Ask your coach…', + hintStyle: TextStyle( + fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 14, ), + // The container draws the only frame; the theme otherwise nests + // a filled 12-radius box and focus ring inside this 18-radius pill. + filled: false, border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.symmetric( horizontal: AppSpacing.md, vertical: AppSpacing.sm + 4, ), @@ -420,48 +348,13 @@ class _AiCoachViewState extends State<_AiCoachView> { ), ), const SizedBox(width: AppSpacing.sm), - GestureDetector( - onTap: loading ? null : _send, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - width: 44, - height: 44, - decoration: BoxDecoration( - gradient: loading - ? null - : const LinearGradient( - colors: [AppColors.primary, Color(0xFF5B21B6)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - color: loading ? AppColors.glass3 : null, - borderRadius: BorderRadius.circular(AppRadius.xl), - boxShadow: loading - ? null - : [ - BoxShadow( - color: AppColors.primaryGlow(0.4), - blurRadius: 12, - spreadRadius: -4, - ), - ], - ), - child: loading - ? const Center( - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 1.5, - valueColor: AlwaysStoppedAnimation(AppColors.primary), - ), - ), - ) - : const Icon( - Icons.arrow_upward_rounded, - color: Colors.white, - size: 20, - ), + // Keystrokes rebuild only this button, not the whole transcript. + ValueListenableBuilder( + valueListenable: _controller, + builder: (_, value, _) => _SendButton( + loading: vm.isLoading, + enabled: value.text.trim().isNotEmpty, + onTap: _send, ), ), ], @@ -470,25 +363,75 @@ class _AiCoachViewState extends State<_AiCoachView> { } } -// ── Header icon button ────────────────────────────────────────────────────── +// ── Send button ────────────────────────────────────────────────────────────── + +/// Three distinguishable states: sending, ready, and nothing-to-send. +class _SendButton extends StatelessWidget { + const _SendButton({ + required this.loading, + required this.enabled, + required this.onTap, + }); -class _HeaderIconButton extends StatelessWidget { - const _HeaderIconButton({required this.icon, required this.onTap}); - final IconData icon; + final bool loading; + final bool enabled; final VoidCallback onTap; @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppColors.glass, - borderRadius: BorderRadius.circular(AppRadius.sm), - border: Border.all(color: AppColors.glassBorder), + final active = enabled && !loading; + return Semantics( + button: true, + enabled: active, + label: loading ? 'Sending' : 'Send message', + // InkWell, not a bare GestureDetector: the button needs to sit in the + // focus traversal order and activate from the keyboard. Enter already + // sends from the text field, so this is about reaching the control + // itself, not about the action being unavailable. + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: active ? onTap : null, + borderRadius: BorderRadius.circular(AppRadius.xl), + focusColor: AppColors.primaryGlow(0.35), + child: AnimatedContainer( + duration: AppDurations.fast, + curve: Curves.easeOut, + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: active ? AppColors.primaryGradient : null, + color: active ? null : AppColors.glass2, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: active ? null : Border.all(color: AppColors.glassBorder), + boxShadow: active + ? [ + BoxShadow( + color: AppColors.primaryGlow(0.4), + blurRadius: 12, + spreadRadius: -4, + ), + ] + : null, + ), + child: Center( + child: loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ) + : Icon( + Icons.arrow_upward_rounded, + color: active ? Colors.white : AppColors.textFaint, + size: 20, + ), + ), + ), ), - child: Icon(icon, color: AppColors.textSoft, size: 18), ), ); } @@ -518,7 +461,8 @@ class _ConversationsSheet extends StatelessWidget { children: [ Text( 'Conversations', - style: TextStyle(fontFamily: 'Geist', + style: TextStyle( + fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 16, fontWeight: FontWeight.w700, @@ -532,12 +476,16 @@ class _ConversationsSheet extends StatelessWidget { }, child: Row( children: [ - const Icon(Icons.add_rounded, - color: AppColors.primary, size: 18), + const Icon( + Icons.add_rounded, + color: AppColors.primary, + size: 18, + ), const SizedBox(width: 4), Text( 'New chat', - style: TextStyle(fontFamily: 'Geist', + style: TextStyle( + fontFamily: 'Geist', color: AppColors.primary, fontSize: 13, fontWeight: FontWeight.w600, @@ -551,10 +499,13 @@ class _ConversationsSheet extends StatelessWidget { const SizedBox(height: AppSpacing.md), if (conversations.isEmpty) Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg), + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.lg, + ), child: Text( 'No saved conversations yet.', - style: TextStyle(fontFamily: 'Geist', + style: TextStyle( + fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 13, ), @@ -617,23 +568,31 @@ class _ConversationTile extends StatelessWidget { vertical: AppSpacing.sm + 2, ), decoration: BoxDecoration( - color: isActive ? AppColors.primary.withValues(alpha: 0.12) : AppColors.glass3, + color: isActive + ? AppColors.primary.withValues(alpha: 0.12) + : AppColors.glass3, borderRadius: BorderRadius.circular(AppRadius.md), border: Border.all( - color: isActive ? AppColors.primary.withValues(alpha: 0.4) : AppColors.glassBorder, + color: isActive + ? AppColors.primary.withValues(alpha: 0.4) + : AppColors.glassBorder, ), ), child: Row( children: [ - const Icon(Icons.chat_bubble_outline_rounded, - color: AppColors.textMuted, size: 16), + const Icon( + Icons.chat_bubble_outline_rounded, + color: AppColors.textMuted, + size: 16, + ), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( conversation.title.isEmpty ? 'New chat' : conversation.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontFamily: 'Geist', + style: TextStyle( + fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 13, fontWeight: FontWeight.w500, @@ -644,8 +603,11 @@ class _ConversationTile extends StatelessWidget { onTap: onDelete, child: const Padding( padding: EdgeInsets.only(left: AppSpacing.sm), - child: Icon(Icons.delete_outline_rounded, - color: AppColors.textFaint, size: 18), + child: Icon( + Icons.delete_outline_rounded, + color: AppColors.textFaint, + size: 18, + ), ), ), ], @@ -655,37 +617,6 @@ class _ConversationTile extends StatelessWidget { } } -// ── Suggestion chip ─────────────────────────────────────────────────────────── - -class _SuggestionChip extends StatelessWidget { - const _SuggestionChip({required this.label, required this.onTap}); - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all(color: AppColors.primary.withValues(alpha: 0.30)), - ), - child: Text( - label, - style: TextStyle(fontFamily: 'Geist', - color: AppColors.primary, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - ); - } -} - // ── Message bubble ──────────────────────────────────────────────────────────── class _MessageBubble extends StatelessWidget { @@ -695,76 +626,20 @@ class _MessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { final isUser = message.role == 'user'; - return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.md), - child: Row( - mainAxisAlignment: - isUser ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (!isUser) ...[ - _AiAvatar(), - const SizedBox(width: AppSpacing.sm), - ], - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (!isUser && (message.toolCalls?.isNotEmpty ?? false)) - Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.xs), - child: _ToolCallChips(toolNames: message.toolCalls!), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm + 2, - ), - decoration: BoxDecoration( - gradient: isUser - ? const LinearGradient( - colors: [AppColors.primary, Color(0xFF5B21B6)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ) - : null, - color: isUser ? null : AppColors.glass3, - borderRadius: BorderRadius.only( - topLeft: const Radius.circular(AppRadius.lg), - topRight: const Radius.circular(AppRadius.lg), - bottomLeft: Radius.circular(isUser ? AppRadius.lg : 4), - bottomRight: Radius.circular(isUser ? 4 : AppRadius.lg), - ), - border: isUser - ? null - : Border.all(color: AppColors.glassBorder), - boxShadow: isUser - ? [ - BoxShadow( - color: AppColors.primaryGlow(0.25), - blurRadius: 12, - spreadRadius: -4, - ), - ] - : null, - ), - child: isUser - ? Text( - message.text, - style: const TextStyle(fontFamily: 'Geist', - color: AppColors.textPrimary, - fontSize: 14, - height: 1.55, - ), - ) - : CoachMessageContent(text: message.text), - ), - ], - ), - ), - ], - ), + return _Turn( + isUser: isUser, + toolCalls: isUser ? const [] : (message.toolCalls ?? const []), + child: isUser + ? Text( + message.text, + style: const TextStyle( + fontFamily: 'Geist', + color: Colors.white, + fontSize: 14, + height: 1.55, + ), + ) + : CoachMessageContent(text: message.text), ); } } @@ -776,45 +651,123 @@ class _StreamingBubble extends StatelessWidget { @override Widget build(BuildContext context) { + return _Turn( + isUser: false, + toolCalls: toolCalls, + toolCallsActive: true, + // An empty stream is still a bubble — the dots need somewhere to sit. + forceBubble: text.isEmpty, + child: text.isEmpty + ? const RFLoadingDots() + : CoachMessageContent(text: text, streaming: true), + ); + } +} + +// ── Turn layout ────────────────────────────────────────────────────────────── + +/// One turn: avatar, tool-call chips, and content — bubbled for prose, bare +/// for a dashboard, whose own cards would otherwise sit in a second frame. +class _Turn extends StatelessWidget { + const _Turn({ + required this.isUser, + required this.child, + this.toolCalls = const [], + this.toolCallsActive = false, + this.forceBubble = false, + }); + + final bool isUser; + final Widget child; + final List toolCalls; + final bool toolCallsActive; + final bool forceBubble; + + @override + Widget build(BuildContext context) { + // A dashboard takes the full column; prose takes a bubble. + final isDashboard = + !forceBubble && + !isUser && + child is CoachMessageContent && + CoachMessageContent.rendersAsDashboard( + (child as CoachMessageContent).text, + ); + + final content = isDashboard + ? child + : Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + gradient: isUser ? AppColors.primaryGradient : null, + color: isUser ? null : AppColors.glass2, + // The square corner marks the speaker's side. + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(AppRadius.lg), + topRight: const Radius.circular(AppRadius.lg), + bottomLeft: Radius.circular(isUser ? AppRadius.lg : 4), + bottomRight: Radius.circular(isUser ? 4 : AppRadius.lg), + ), + border: isUser ? null : Border.all(color: AppColors.glassBorder), + boxShadow: isUser + ? [ + BoxShadow( + color: AppColors.primaryGlow(0.25), + blurRadius: 12, + spreadRadius: -4, + ), + ] + : null, + ), + child: child, + ); + + final column = Column( + crossAxisAlignment: isUser + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (toolCalls.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: _ToolCallChips( + toolNames: toolCalls, + active: toolCallsActive, + ), + ), + content, + ], + ); + return Padding( padding: const EdgeInsets.only(bottom: AppSpacing.md), child: Row( - crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: isUser + ? MainAxisAlignment.end + : MainAxisAlignment.start, + // Top, so a tall turn's avatar sits beside its first line, not its last. + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _AiAvatar(), - const SizedBox(width: AppSpacing.sm), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (toolCalls.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.xs), - child: _ToolCallChips(toolNames: toolCalls, active: true), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm + 2, - ), - decoration: BoxDecoration( - color: AppColors.glass3, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(AppRadius.lg), - topRight: Radius.circular(AppRadius.lg), - bottomLeft: Radius.circular(4), - bottomRight: Radius.circular(AppRadius.lg), - ), - border: Border.all(color: AppColors.glassBorder), - ), - child: text.isEmpty - ? const RFLoadingDots() - : CoachMessageContent(text: text, streaming: true), + if (!isUser) ...[ + const _AiAvatar(), + const SizedBox(width: AppSpacing.sm), + ], + if (isDashboard) + Expanded(child: column) + else + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: + MediaQuery.sizeOf(context).width * _kBubbleMaxWidthFactor, ), - ], + child: column, + ), ), - ), ], ), ); @@ -852,7 +805,9 @@ class _ToolCallChips extends StatelessWidget { decoration: BoxDecoration( color: AppColors.secondary.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all(color: AppColors.secondary.withValues(alpha: 0.3)), + border: Border.all( + color: AppColors.secondary.withValues(alpha: 0.3), + ), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -865,7 +820,8 @@ class _ToolCallChips extends StatelessWidget { const SizedBox(width: 4), Text( _label(name), - style: const TextStyle(fontFamily: 'GeistMono', + style: const TextStyle( + fontFamily: 'GeistMono', color: AppColors.secondary, fontSize: 10, fontWeight: FontWeight.w600, @@ -892,6 +848,27 @@ class CoachMessageContent extends StatefulWidget { this.streaming = false, }); + static final _parser = A2UiParser(defaultA2UiRegistry); + + /// Parsed nodes by source text. Shared because the turn layout needs the + /// result before this widget builds; bounded so transcripts don't accumulate. + static final Map _nodeCache = {}; + static const _nodeCacheLimit = 32; + + static A2UiNode? nodeFor(String text) { + if (_nodeCache.containsKey(text)) return _nodeCache[text]; + if (_nodeCache.length >= _nodeCacheLimit) { + _nodeCache.remove(_nodeCache.keys.first); + } + return _nodeCache[text] = _parser.parse(text); + } + + /// True when [text] is a complete A2UI payload, so renders full-width. + static bool rendersAsDashboard(String text) => nodeFor(text) != null; + + /// True when [text] is a partial A2UI payload still arriving. + static bool looksLikeUi(String text) => _parser.looksLikeUi(text); + final String text; /// True while tokens are still arriving, so a half-written JSON payload @@ -903,33 +880,14 @@ class CoachMessageContent extends StatefulWidget { } class _CoachMessageContentState extends State { - static final _parser = A2UiParser(defaultA2UiRegistry); - - A2UiNode? _node; - String? _parsedFrom; - - @override - void didUpdateWidget(CoachMessageContent oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.text != widget.text) _parsedFrom = null; - } - - A2UiNode? get _resolved { - if (_parsedFrom != widget.text) { - _parsedFrom = widget.text; - _node = _parser.parse(widget.text); - } - return _node; - } - @override Widget build(BuildContext context) { - final node = _resolved; + final node = CoachMessageContent.nodeFor(widget.text); if (node != null) return A2UiRenderer(node: node); // Mid-stream JSON: hide the braces behind a progress row rather than // letting the Markdown renderer spill raw payload into the bubble. - if (widget.streaming && _parser.looksLikeUi(widget.text)) { + if (widget.streaming && CoachMessageContent.looksLikeUi(widget.text)) { return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -963,7 +921,8 @@ class _CoachMarkdown extends StatelessWidget { Widget build(BuildContext context) { return GptMarkdown( text, - style: TextStyle(fontFamily: 'Geist', + style: TextStyle( + fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, height: 1.55, @@ -973,27 +932,14 @@ class _CoachMarkdown extends StatelessWidget { } class _AiAvatar extends StatelessWidget { + const _AiAvatar(); + @override Widget build(BuildContext context) { - return Container( - width: 28, - height: 28, - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppColors.primary, Color(0xFF5B21B6)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.sm), - boxShadow: [ - BoxShadow( - color: AppColors.primaryGlow(0.35), - blurRadius: 8, - spreadRadius: -2, - ), - ], - ), - child: const Icon(Icons.auto_awesome_rounded, color: Colors.white, size: 14), + return const RFGradientBadge( + icon: Icons.auto_awesome_rounded, + size: 28, + radius: AppRadius.sm, ); } } diff --git a/workout-logger/lib/screens/widgets/exercise_input_section.dart b/workout-logger/lib/screens/widgets/exercise_input_section.dart index 23a25fb..923d222 100644 --- a/workout-logger/lib/screens/widgets/exercise_input_section.dart +++ b/workout-logger/lib/screens/widgets/exercise_input_section.dart @@ -5,14 +5,14 @@ import 'package:flutter/services.dart'; import '../../models/models.dart'; import '../../services/settings_provider.dart'; import '../../theme/app_theme.dart'; -import 'rf_widgets.dart'; +import 'rf_shell.dart'; // ── ExerciseInputSection ────────────────────────────────────────────────────── -// Renders: AI suggestion card, weight/reps inputs, dropset section, -// LOG SET button, previous sets, last session info, program metadata banner. +// Suggestion card, weight/reps inputs, dropset, session history, program meta. class ExerciseInputSection extends StatelessWidget { const ExerciseInputSection({ super.key, + required this.contentWidth, required this.currentWeight, required this.currentReps, required this.isDropset, @@ -32,7 +32,6 @@ class ExerciseInputSection extends StatelessWidget { required this.onDropRemoved, required this.onDropWeightChanged, required this.onDropRepsChanged, - required this.onLogSet, required this.onApplyRecommendation, this.programSlot, this.programWeek, @@ -42,6 +41,9 @@ class ExerciseInputSection extends StatelessWidget { this.onHandleChanged, }); + /// Layout width minus padding. Passed in, not measured: the host screen's [IntrinsicHeight] (which [Spacer] needs) forbids a [LayoutBuilder] under it. + final double contentWidth; + final double currentWeight; final int currentReps; final bool isDropset; @@ -61,7 +63,6 @@ class ExerciseInputSection extends StatelessWidget { final ValueChanged onDropRemoved; final void Function(int index, double weight) onDropWeightChanged; final void Function(int index, int reps) onDropRepsChanged; - final VoidCallback onLogSet; final VoidCallback onApplyRecommendation; final ProgramExerciseSlot? programSlot; final ProgramWeek? programWeek; @@ -114,6 +115,7 @@ class ExerciseInputSection extends StatelessWidget { // Weight + reps inputs if (!isDropset) ...[ _InputRow( + contentWidth: contentWidth, currentWeight: currentWeight, currentReps: currentReps, settings: settings, @@ -134,9 +136,12 @@ class ExerciseInputSection extends StatelessWidget { children: [ const Icon(Icons.fitness_center_rounded, size: 14, color: AppColors.primary), const SizedBox(width: 6), - Text( - 'Effective Volume Load: ${effectiveWeightDisplay.toStringAsFixed(1)} ${settings.unitLabel} (${bodyWeightDisplay.toStringAsFixed(1)} BW − ${currentWeightDisplay.toStringAsFixed(1)} Assist) × $currentReps reps', - style: const TextStyle(fontSize: 11, color: AppColors.textSoft, fontWeight: FontWeight.w500), + // Long enough to wrap on a narrow phone, more so at a large text scale. + Expanded( + child: Text( + 'Effective Volume Load: ${effectiveWeightDisplay.toStringAsFixed(1)} ${settings.unitLabel} (${bodyWeightDisplay.toStringAsFixed(1)} BW − ${currentWeightDisplay.toStringAsFixed(1)} Assist) × $currentReps reps', + style: const TextStyle(fontSize: 11, color: AppColors.textSoft, fontWeight: FontWeight.w500), + ), ), ], ), @@ -165,21 +170,13 @@ class ExerciseInputSection extends StatelessWidget { const SizedBox(height: AppSpacing.lg), - // LOG SET button - GlowButton( - label: 'LOG SET', - icon: Icons.check_rounded, - onPressed: onLogSet, - ), + // Absorbs leftover height so the history below reads as a footer. + const Spacer(), - // Previous sets if (previousSets.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.lg), _PreviousSetsSection(sets: previousSets, settings: settings), + const SizedBox(height: AppSpacing.lg), ], - - // Last session - const SizedBox(height: AppSpacing.lg), _LastSessionSection(lastSession: lastSession, settings: settings), ], ); @@ -208,44 +205,21 @@ class _HandleSelector extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'ATTACHMENT / HANDLE VARIATION', - style: TextStyle( - color: AppColors.textMuted, - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - ), - ), - const SizedBox(height: 6), + const RFLabel('Attachment'), + const SizedBox(height: AppSpacing.sm), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: availableHandles.map((handle) { - final isSelected = selectedHandle == handle; return Padding( padding: const EdgeInsets.only(right: 6), - child: FilterChip( - label: Text(handle), - selected: isSelected, - onSelected: locked + child: RFOptionChip( + label: handle, + selected: selectedHandle == handle, + inMutuallyExclusiveGroup: true, + onTap: locked || onChanged == null ? null - : (selected) { - if (selected && onChanged != null) { - onChanged!(handle); - } - }, - selectedColor: AppColors.primary.withValues(alpha: 0.25), - backgroundColor: AppColors.surface, - checkmarkColor: AppColors.primary, - labelStyle: TextStyle( - color: isSelected ? AppColors.primary : AppColors.textSoft, - fontSize: 12, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - ), - side: BorderSide( - color: isSelected ? AppColors.primary : AppColors.glassBorder, - ), + : () => onChanged!(handle), ), ); }).toList(), @@ -315,13 +289,17 @@ class _RecommendationCard extends StatelessWidget { children: [ Row( children: [ - const Text( - 'AI Suggestion', - style: TextStyle( - color: AppColors.textSoft, - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, + const Flexible( + child: Text( + 'AI Suggestion', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: AppColors.textSoft, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), ), ), const SizedBox(width: 6), @@ -371,6 +349,7 @@ class _RecommendationCard extends StatelessWidget { // ── Input Row ──────────────────────────────────────────────────────────────── class _InputRow extends StatelessWidget { const _InputRow({ + required this.contentWidth, required this.currentWeight, required this.currentReps, required this.settings, @@ -379,6 +358,7 @@ class _InputRow extends StatelessWidget { this.isAssistedBW = false, }); + final double contentWidth; final double currentWeight; final int currentReps; final SettingsProvider settings; @@ -392,27 +372,42 @@ class _InputRow extends StatelessWidget { isAssistedBW ? 'Assist (${settings.unitLabel})' : settings.unitLabel; final displayWeight = settings.toDisplay(currentWeight); + // Once a large system font squeezes the value past legibility, stack rather than shrink the digits further. + final pairedWidth = (contentWidth - AppSpacing.md) / 2; + final stacked = !_NumberInputCard.valueFits(context, pairedWidth); + final cardWidth = stacked ? contentWidth : pairedWidth; + + final weightCard = _NumberInputCard( + cardWidth: cardWidth, + label: weightLabel, + value: displayWeight, + step: settings.weightIncrement, + decimals: 1, + onChanged: (v) => onWeightChanged(settings.toStorage(v)), + ); + final repsCard = _NumberInputCard( + cardWidth: cardWidth, + label: 'Reps', + value: currentReps.toDouble(), + step: 1, + decimals: 0, + onChanged: (v) => onRepsChanged(v.toInt()), + ); + + if (stacked) { + return Column( + children: [ + weightCard, + const SizedBox(height: AppSpacing.md), + repsCard, + ], + ); + } return Row( children: [ - Expanded( - child: _NumberInputCard( - label: weightLabel, - value: displayWeight, - step: settings.weightIncrement, - decimals: 1, - onChanged: (v) => onWeightChanged(settings.toStorage(v)), - ), - ), + Expanded(child: weightCard), const SizedBox(width: AppSpacing.md), - Expanded( - child: _NumberInputCard( - label: 'Reps', - value: currentReps.toDouble(), - step: 1, - decimals: 0, - onChanged: (v) => onRepsChanged(v.toInt()), - ), - ), + Expanded(child: repsCard), ], ); } @@ -421,6 +416,7 @@ class _InputRow extends StatelessWidget { // ── Number Input Card ───────────────────────────────────────────────────────── class _NumberInputCard extends StatefulWidget { const _NumberInputCard({ + required this.cardWidth, required this.label, required this.value, required this.step, @@ -428,12 +424,49 @@ class _NumberInputCard extends StatefulWidget { required this.onChanged, }); + /// Laid-out width; passed for the reason [ExerciseInputSection.contentWidth] gives. + final double cardWidth; + final String label; final double value; final double step; final int decimals; final ValueChanged onChanged; + /// Tighter than the usual `md`, to leave the value more of the row. + static const double hPadding = AppSpacing.sm + 2; + + static const double maxValueFontSize = 36; + static const double minValueFontSize = 18; + + /// Room left for the value between the two steppers in a card [cardWidth] wide. + static double valueSlotWidth(BuildContext context, double cardWidth) => + cardWidth - hPadding * 2 - _StepBtn.sizeOf(context) * 2; + + /// Whether a card [cardWidth] wide still shows `100.0` legibly — [_InputRow] stacks when it does not. + static bool valueFits(BuildContext context, double cardWidth) => + valueSlotWidth(context, cardWidth) >= + measureValue(context, '100.0', minValueFontSize); + + static TextStyle valueStyle(double fontSize) => TextStyle( + fontFamily: 'GeistMono', + color: AppColors.textPrimary, + fontSize: fontSize, + fontWeight: FontWeight.w700, + ); + + /// Width [text] paints at, honouring the reader's text scale. + static double measureValue( + BuildContext context, String text, double fontSize) { + final painter = TextPainter( + text: TextSpan(text: text, style: valueStyle(fontSize)), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + maxLines: 1, + )..layout(); + return painter.width; + } + @override State<_NumberInputCard> createState() => _NumberInputCardState(); } @@ -469,10 +502,27 @@ class _NumberInputCardState extends State<_NumberInputCard> { ? widget.value.toStringAsFixed(widget.decimals) : widget.value.toInt().toString(); + /// Largest size that paints [text] inside the value slot, floored at [_NumberInputCard.minValueFontSize]. + double _fitFontSize(BuildContext context, String text) { + const maxSize = _NumberInputCard.maxValueFontSize; + final slot = _NumberInputCard.valueSlotWidth(context, widget.cardWidth); + if (!slot.isFinite) return maxSize; + // A few pixels for the caret, which sits past the last glyph. + final available = slot - 4; + if (available <= 0) return _NumberInputCard.minValueFontSize; + final natural = _NumberInputCard.measureValue(context, text, maxSize); + if (natural <= available || natural <= 0) return maxSize; + return (maxSize * available / natural) + .clamp(_NumberInputCard.minValueFontSize, maxSize); + } + @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.all(AppSpacing.md), + padding: const EdgeInsets.symmetric( + horizontal: _NumberInputCard.hPadding, + vertical: AppSpacing.md, + ), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(AppRadius.lg), @@ -480,14 +530,10 @@ class _NumberInputCardState extends State<_NumberInputCard> { ), child: Column( children: [ - Text( - widget.label, - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textMuted, - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - ), + // Kept to one line so the two cards in a row stay the same height. + FittedBox( + fit: BoxFit.scaleDown, + child: RFLabel(widget.label), ), const SizedBox(height: AppSpacing.sm), Row( @@ -500,41 +546,49 @@ class _NumberInputCardState extends State<_NumberInputCard> { ), ), Expanded( - child: TextField( - controller: _controller, - focusNode: _focusNode, - style: TextStyle(fontFamily: 'GeistMono', - color: AppColors.textPrimary, - fontSize: 36, - fontWeight: FontWeight.w700, - ), - textAlign: TextAlign.center, - keyboardType: TextInputType.numberWithOptions( - decimal: widget.decimals > 0, - ), - inputFormatters: widget.decimals > 0 - ? [ - FilteringTextInputFormatter.allow( - RegExp(r'^\d*\.?\d*$'), - ), - ] - : [FilteringTextInputFormatter.digitsOnly], - decoration: const InputDecoration( - border: InputBorder.none, - contentPadding: EdgeInsets.zero, - isDense: true, + // Re-fitted per keystroke: a 3-digit weight or a large system font would otherwise be clipped. + child: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, _) => TextField( + controller: _controller, + focusNode: _focusNode, + style: _NumberInputCard.valueStyle( + _fitFontSize( + context, + value.text.isEmpty ? _format() : value.text, + ), + ), + maxLines: 1, + textAlign: TextAlign.center, + keyboardType: TextInputType.numberWithOptions( + decimal: widget.decimals > 0, + ), + inputFormatters: widget.decimals > 0 + ? [ + FilteringTextInputFormatter.allow( + RegExp(r'^\d*\.?\d*$'), + ), + ] + : [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + ), + onChanged: (text) { + final parsed = double.tryParse(text); + if (parsed != null) { + widget.onChanged(parsed.clamp(0, 999).toDouble()); + } + }, + onEditingComplete: () { + final formatted = _format(); + if (_controller.text != formatted) { + _controller.text = formatted; + } + _focusNode.unfocus(); + }, ), - onChanged: (text) { - final parsed = double.tryParse(text); - if (parsed != null) { - widget.onChanged(parsed.clamp(0, 999).toDouble()); - } - }, - onEditingComplete: () { - final formatted = _format(); - if (_controller.text != formatted) _controller.text = formatted; - _focusNode.unfocus(); - }, ), ), _StepBtn( @@ -556,9 +610,13 @@ class _StepBtn extends StatelessWidget { final IconData icon; final VoidCallback onTap; + /// Fixed: a stepper stays a thumb target, so the value beside it is what gives. + static double sizeOf(BuildContext context) => + MediaQuery.sizeOf(context).width < AppBreakpoints.narrow ? 36.0 : 40.0; + @override Widget build(BuildContext context) { - final size = MediaQuery.sizeOf(context).width < AppBreakpoints.narrow ? 36.0 : 40.0; + final size = sizeOf(context); return GestureDetector( onTap: () { onTap(); @@ -632,15 +690,16 @@ class _DropsetSection extends StatelessWidget { size: 18, ), const SizedBox(width: 8), - const Text( - 'Dropset', - style: TextStyle( - color: AppColors.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w600, + const Expanded( + child: Text( + 'Dropset', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), ), - const Spacer(), Switch( value: isDropset, onChanged: onToggled, @@ -799,15 +858,7 @@ class _PreviousSetsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'THIS SESSION', - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textFaint, - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 1.2, - ), - ), + const RFLabel('This session', dim: true), const SizedBox(height: AppSpacing.sm), Wrap( spacing: 6, @@ -920,9 +971,11 @@ class _LastSessionSection extends StatelessWidget { Icon(Icons.star_outline_rounded, color: AppColors.textMuted, size: 16), SizedBox(width: 8), - Text( - 'First time doing this exercise!', - style: TextStyle(color: AppColors.textMuted, fontSize: 13), + Expanded( + child: Text( + 'First time doing this exercise!', + style: TextStyle(color: AppColors.textMuted, fontSize: 13), + ), ), ], ), @@ -932,15 +985,7 @@ class _LastSessionSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'LAST SESSION', - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textFaint, - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 1.2, - ), - ), + const RFLabel('Last session', dim: true), const SizedBox(height: AppSpacing.sm), Wrap( spacing: 6, @@ -1005,12 +1050,14 @@ class _ProgramMetaBanner extends StatelessWidget { child: Icon(Icons.battery_charging_full_rounded, size: 14, color: Colors.amber), ), - Text( - 'Target: $displaySets × $repRange', - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 13, - fontWeight: FontWeight.w600, + Flexible( + child: Text( + 'Target: $displaySets × $repRange', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), ), ), ], diff --git a/workout-logger/lib/screens/widgets/floating_nav_bar.dart b/workout-logger/lib/screens/widgets/floating_nav_bar.dart index d3e9af8..fb59dc8 100644 --- a/workout-logger/lib/screens/widgets/floating_nav_bar.dart +++ b/workout-logger/lib/screens/widgets/floating_nav_bar.dart @@ -95,6 +95,8 @@ class FloatingNavBarTheme { this.inactiveIconColor, this.outerShadowColor, this.outerGlowColor, + /// Colour of the unread-indicator dot on a badged item. + this.badgeColor = const Color(0xFFE05040), // ── Sizes ──────────────────────────────────────────────────────────────── this.navHeight = 60.0, this.chipHeight = 46.0, @@ -130,8 +132,10 @@ class FloatingNavBarTheme { // ── Scroll behaviour ───────────────────────────────────────────────────── /// Set to false to keep the nav bar permanently visible. this.hideOnScroll = true, - this.scrollDownThreshold = 2.0, - this.scrollUpThreshold = 2.0, + /// Cumulative downward travel, in pixels, before the bar hides. + this.scrollDownThreshold = 48.0, + /// Cumulative upward travel before it returns; smaller, so it comes back fast. + this.scrollUpThreshold = 16.0, // ── Misc ───────────────────────────────────────────────────────────────── this.bottomMargin = 16.0, this.hapticFeedback = true, @@ -167,6 +171,7 @@ class FloatingNavBarTheme { final Color? inactiveIconColor; final Color? outerShadowColor; final Color? outerGlowColor; + final Color badgeColor; // ── Sizes ──────────────────────────────────────────────────────────────────── final double navHeight; @@ -260,7 +265,10 @@ class FloatingNavBar extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final bottomPad = MediaQuery.of(context).padding.bottom; + final bottomPad = MediaQuery.paddingOf(context).bottom; + // Never below bottomMargin, and always a visible gap above any inset. + final bottomInset = + bottomPad + 8 > theme.bottomMargin ? bottomPad + 8 : theme.bottomMargin; // ── Resolve colours ────────────────────────────────────────────────────── final bg = theme.backgroundColor ?? @@ -280,9 +288,8 @@ class FloatingNavBar extends StatelessWidget { return Align( alignment: Alignment.bottomCenter, child: Padding( - padding: EdgeInsets.only( - bottom: bottomPad > 0 ? bottomPad : theme.bottomMargin, - ), + // Clears the system inset and keeps a margin off the gesture handle. + padding: EdgeInsets.only(bottom: bottomInset), child: _ShadowWrapper( outerShadow: outerShadow, outerGlow: outerGlow, @@ -567,7 +574,7 @@ class _NavCellState extends State<_NavCell> width: 8, height: 8, decoration: BoxDecoration( - color: Colors.red, + color: t.badgeColor, shape: BoxShape.circle, border: Border.all( // border matches the chip bg for a @@ -688,26 +695,49 @@ class _FloatingNavBarScaffoldState extends State { // ── Tab change — always restore visibility ──────────────────────────────── void _handleTabChange(int index) { + _travel = 0; if (!_visible) setState(() => _visible = true); widget.onTabChanged(index); } // ── Scroll detection ────────────────────────────────────────────────────── + /// Travel since the last direction change; positive is down, reset on reversal. + double _travel = 0; + bool _handleScrollNotification(ScrollNotification n) { if (!widget.theme.hideOnScroll) return false; + // A settled scroll starts a fresh gesture — don't carry momentum across. + if (n is ScrollEndNotification) { + _travel = 0; + return false; + } + if (n is ScrollUpdateNotification) { + // Ignore overscroll bounce: rubber-banding reads as a drag it isn't. + final m = n.metrics; + if (m.pixels < m.minScrollExtent || m.pixels > m.maxScrollExtent) { + return false; + } + final delta = n.scrollDelta ?? 0; + // Direction reversal restarts the count. + if (delta.sign != _travel.sign) _travel = 0; + _travel += delta; - if (delta > widget.theme.scrollDownThreshold && _visible) { + if (_travel > widget.theme.scrollDownThreshold && _visible) { + _travel = 0; setState(() => _visible = false); - } else if (delta < -widget.theme.scrollUpThreshold && !_visible) { + } else if (-_travel > widget.theme.scrollUpThreshold && !_visible) { + _travel = 0; setState(() => _visible = true); } - // At the very top → always show. - if (n.metrics.pixels <= 0 && !_visible) { + // Near the very top → always show. A small band rather than an exact + // zero, so the bar is already back by the time the bounce settles. + if (m.pixels <= m.minScrollExtent + 8 && !_visible) { + _travel = 0; setState(() => _visible = true); } } diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart index 8e27f06..dd9c7c9 100644 --- a/workout-logger/lib/screens/widgets/profile_sections.dart +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -8,6 +8,7 @@ import '../../services/debug_log_buffer.dart'; import '../../services/settings_provider.dart'; import '../../services/ai/gemini_ai_service.dart'; import '../../theme/app_theme.dart'; +import 'rf_dialogs.dart'; import 'rf_widgets.dart'; const String _createdBy = 'Devasy Patel'; @@ -716,6 +717,7 @@ class _AiSettingsSectionState extends State { // Live value shown while dragging the slider; null when not dragging (in // which case the persisted settings value is shown instead). double? _draggingMaxToolRounds; + double? _draggingThinkingLevelIndex; @override void initState() { @@ -731,6 +733,22 @@ class _AiSettingsSectionState extends State { super.dispose(); } + /// Reports the outcome of a settings write. Success is only worth a toast + /// for the deliberate actions (Save, picking a model) — the thinking + /// slider commits on every drag-release, so it stays quiet unless it fails. + void _reportSaved(String message) { + if (!mounted) return; + context.showRFSnackBar(message, type: RFSnackBarType.success); + } + + void _reportSaveFailed(String what) { + if (!mounted) return; + context.showRFSnackBar( + "Couldn't save $what — the change wasn't applied.", + type: RFSnackBarType.error, + ); + } + Future _save() async { setState(() => _saving = true); final key = _ctrl.text.trim(); @@ -739,26 +757,47 @@ class _AiSettingsSectionState extends State { try { await settings.setGeminiApiKey(key); gemini.updateApiKey(key); + _reportSaved(key.isEmpty ? 'API key cleared' : 'API key saved'); + } catch (e, st) { + debugPrint('Failed to save Gemini API key: $e\n$st'); + _reportSaveFailed('your API key'); } finally { if (mounted) setState(() => _saving = false); } } Future _selectModel(String modelId) async { + // Same contract as _commitThinkingLevel below: the dropdown's onChanged + // drops this Future, so a storage failure would otherwise surface as an + // unhandled error. On failure the provider keeps the previous model and + // the dropdown rebuilds back onto it, so there's nothing to undo here. final settings = context.read(); final gemini = context.read(); - await settings.setGeminiModel(modelId); - gemini.updateModel(modelId); + try { + await settings.setGeminiModel(modelId); + gemini.updateModel(modelId); + _reportSaved('Model saved'); + } catch (e, st) { + debugPrint('Failed to save Gemini model: $e\n$st'); + _reportSaveFailed('the model'); + } } Future _commitMaxToolRounds(int rounds) async { // onChangeEnd discards this Future, so a storage failure would otherwise // surface as an unhandled error; and the await means the widget can be // disposed before setState runs. + final settings = context.read(); + final gemini = context.read(); try { - await context.read().setGeminiMaxToolRounds(rounds); + await settings.setGeminiMaxToolRounds(rounds); } catch (e, st) { debugPrint('Failed to save max tool rounds: $e\n$st'); + // The drag already pushed each intermediate value into the live + // service, so put it back to what was actually stored — otherwise + // requests keep using a limit the user was just told wasn't saved. + gemini.updateMaxToolRounds(settings.geminiMaxToolRounds); + _reportSaveFailed('the tool-round limit'); } finally { if (mounted) { setState(() => _draggingMaxToolRounds = null); @@ -766,6 +805,28 @@ class _AiSettingsSectionState extends State { } } + Future _commitThinkingLevel(String level) async { + // Same contract as _commitMaxToolRounds above: onChangeEnd drops the + // Future, and the widget may be gone by the time the await returns. + final settings = context.read(); + final gemini = context.read(); + try { + await settings.setGeminiThinkingLevel(level); + gemini.updateThinkingLevel(level); + } catch (e, st) { + debugPrint('Failed to save thinking level: $e\n$st'); + // Same reason as _commitMaxToolRounds: the drag updated the live + // service on every tick, so restore the stored level rather than + // leaving the service on one that was never written. + gemini.updateThinkingLevel(settings.geminiThinkingLevel); + _reportSaveFailed('the thinking level'); + } finally { + if (mounted) { + setState(() => _draggingThinkingLevelIndex = null); + } + } + } + @override Widget build(BuildContext context) { final gemini = context.watch(); @@ -857,41 +918,116 @@ class _AiSettingsSectionState extends State { const SizedBox(height: AppSpacing.md), const _SectionLabel('GEMINI MODEL'), const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: 8, - runSpacing: 8, - children: kGeminiModels.map(((String, String) entry) { - final (id, label) = entry; - final selected = settings.geminiModel == id; - return GestureDetector( - onTap: () => _selectModel(id), - child: AnimatedContainer( - duration: AppDurations.fast, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), - decoration: BoxDecoration( - color: selected - ? AppColors.primary.withValues(alpha: 0.15) - : AppColors.glass, - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all( - color: selected - ? AppColors.primary.withValues(alpha: 0.5) - : AppColors.glassBorder, - width: selected ? 1.5 : 1, + Container( + decoration: BoxDecoration( + color: AppColors.glass, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorderStrong), + ), + child: DropdownButtonHideUnderline( + child: DropdownButtonFormField( + key: ValueKey(settings.geminiModel), + initialValue: settings.geminiModel, + isExpanded: true, + isDense: false, + dropdownColor: AppColors.card, + icon: const Icon(Icons.keyboard_arrow_down_rounded, color: AppColors.textFaint), + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + ), + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textPrimary, + fontSize: 13, + ), + items: kGeminiModels.map(((String, String) entry) { + final (id, label) = entry; + return DropdownMenuItem(value: id, child: Text(label)); + }).toList(), + onChanged: (id) { + if (id != null) _selectModel(id); + }, + ), + ), + ), + if (supportedThinkingLevels(settings.geminiModel).isNotEmpty) ...[ + const SizedBox(height: AppSpacing.md), + const _SectionLabel('THINKING LEVEL'), + const SizedBox(height: AppSpacing.sm), + Text( + 'How much the model reasons before replying. Lower is faster and cheaper; higher is more capable on hard problems.', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: AppSpacing.xs), + Builder(builder: (context) { + final levels = supportedThinkingLevels(settings.geminiModel); + final currentIndex = levels.indexOf(settings.geminiThinkingLevel); + // Clamped because a drag in progress can outlive the level list + // it was started against: picking a model with fewer levels + // leaves _draggingThinkingLevelIndex past the new max, which + // Slider asserts on. + final liveIndex = (_draggingThinkingLevelIndex ?? + (currentIndex >= 0 ? currentIndex.toDouble() : 0.0)) + .clamp(0.0, (levels.length - 1).toDouble()); + final liveLevel = levels[liveIndex.round().clamp(0, levels.length - 1)]; + final liveLevelLabel = liveLevel[0].toUpperCase() + liveLevel.substring(1); + return Row( + children: [ + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: AppColors.primary, + inactiveTrackColor: AppColors.glassBorderStrong, + thumbColor: AppColors.primary, + overlayColor: AppColors.primary.withValues(alpha: 0.15), + valueIndicatorColor: AppColors.primary, + trackHeight: 3, + ), + child: Slider( + value: liveIndex, + min: 0, + max: (levels.length - 1).toDouble(), + divisions: levels.length > 1 ? levels.length - 1 : null, + label: liveLevelLabel, + onChanged: (v) { + setState(() => _draggingThinkingLevelIndex = v); + context.read().updateThinkingLevel( + levels[v.round().clamp(0, levels.length - 1)], + ); + }, + onChangeEnd: (v) => _commitThinkingLevel( + levels[v.round().clamp(0, levels.length - 1)], + ), + ), ), ), - child: Text( - label, - style: TextStyle(fontFamily: 'GeistMono', - color: selected ? AppColors.primary : AppColors.textSoft, - fontWeight: selected ? FontWeight.w700 : FontWeight.w400, - fontSize: 11, + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.35)), + ), + child: Text( + liveLevelLabel, + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.primary, + fontWeight: FontWeight.w700, + fontSize: 12, + ), ), ), - ), + ], ); - }).toList(), - ), + }), + ], const SizedBox(height: AppSpacing.md), const _SectionLabel('MAX TOOL-CALL STEPS'), const SizedBox(height: AppSpacing.sm), diff --git a/workout-logger/lib/screens/widgets/rest_timer_view.dart b/workout-logger/lib/screens/widgets/rest_timer_view.dart index b614e6a..687f15f 100644 --- a/workout-logger/lib/screens/widgets/rest_timer_view.dart +++ b/workout-logger/lib/screens/widgets/rest_timer_view.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../theme/app_theme.dart'; import 'rf_widgets.dart'; +import 'rf_shell.dart'; class RestTimerView extends StatelessWidget { const RestTimerView({ @@ -31,15 +32,7 @@ class RestTimerView extends StatelessWidget { // Top hint Padding( padding: const EdgeInsets.only(top: AppSpacing.lg), - child: Text( - 'REST', - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 2, - ), - ), + child: const RFLabel('Rest'), ), // Ring + time fills most of the screen Expanded( @@ -66,15 +59,8 @@ class RestTimerView extends StatelessWidget { ), if (nextExerciseName != null) ...[ const SizedBox(height: AppSpacing.lg), - Text( - 'Next up', - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 11, - letterSpacing: 0.5, - ), - ), - const SizedBox(height: 4), + const RFLabel('Next up', dim: true), + const SizedBox(height: AppSpacing.sm), Text( nextExerciseName!, style: const TextStyle( @@ -98,7 +84,7 @@ class RestTimerView extends StatelessWidget { AppSpacing.xl, ), child: OutlineGlowButton( - label: 'SKIP REST', + label: 'Skip rest', onPressed: onSkip, color: AppColors.textSoft, fullWidth: true, diff --git a/workout-logger/lib/screens/widgets/rf_dialogs.dart b/workout-logger/lib/screens/widgets/rf_dialogs.dart index 15b5e1a..74d8729 100644 --- a/workout-logger/lib/screens/widgets/rf_dialogs.dart +++ b/workout-logger/lib/screens/widgets/rf_dialogs.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; /// Types of snackbar toast notifications. enum RFSnackBarType { info, success, warning, error } @@ -71,6 +72,180 @@ extension RFSnackBarContext on BuildContext { } } +/// One choice in an [showRFActionSheet]. +class RFAction { + const RFAction({ + required this.label, + required this.value, + this.description, + this.icon, + this.isPrimary = false, + this.isDanger = false, + }); + + final String label; + final T value; + + /// One short line under the label saying what the choice does. + final String? description; + final IconData? icon; + + /// Renders as the filled brand button. At most one per sheet. + final bool isPrimary; + final bool isDanger; +} + +/// Bottom sheet for three or more choices, where a dialog's action row wraps. +/// Returns null if dismissed without a choice. +Future showRFActionSheet( + BuildContext context, { + required String title, + String? message, + required List> actions, +}) { + return showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + barrierColor: Colors.black.withValues(alpha: 0.6), + // Without this the sheet is capped at 9/16 of the viewport. Three actions + // with descriptions already fill most of that, so at a large text scale + // the bottom action would be clipped with no way to scroll to it. + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (ctx) => SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.md, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Grabber + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.glassBorderStrong, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + ), + Text( + title, + style: const TextStyle( + fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + if (message != null) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + message, + style: const TextStyle( + fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 14, + height: 1.4, + ), + ), + ], + const SizedBox(height: AppSpacing.lg), + for (final action in actions) ...[ + if (action.isPrimary) + GlowButton( + label: action.label, + icon: action.icon, + onPressed: () => Navigator.pop(ctx, action.value), + ) + else + _SheetChoice(action: action, ctx: ctx), + if (action != actions.last) const SizedBox(height: AppSpacing.sm), + ], + ], + ), + ), + ), + ); +} + +class _SheetChoice extends StatelessWidget { + const _SheetChoice({required this.action, required this.ctx}); + + final RFAction action; + final BuildContext ctx; + + @override + Widget build(BuildContext context) { + final fg = action.isDanger ? AppColors.error : AppColors.textPrimary; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.pop(ctx, action.value), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md - 2, + ), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(AppRadius.button), + border: Border.all( + color: action.isDanger + ? AppColors.error.withValues(alpha: 0.35) + : AppColors.glassBorder, + ), + ), + child: Row( + children: [ + if (action.icon != null) ...[ + Icon(action.icon, size: 18, color: fg), + const SizedBox(width: AppSpacing.sm + 2), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + action.label, + style: TextStyle( + fontFamily: 'Geist', + color: fg, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + if (action.description != null) ...[ + const SizedBox(height: 2), + Text( + action.description!, + style: const TextStyle( + fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} + /// Displays a standardized glassmorphic confirm dialog. Future showRFConfirmDialog( BuildContext context, { diff --git a/workout-logger/lib/screens/widgets/rf_shell.dart b/workout-logger/lib/screens/widgets/rf_shell.dart new file mode 100644 index 0000000..150f3fd --- /dev/null +++ b/workout-logger/lib/screens/widgets/rf_shell.dart @@ -0,0 +1,440 @@ +// rf_shell.dart — Screen chrome shared by every RepForge screen. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../theme/app_theme.dart'; + +// ── RFIconButton ───────────────────────────────────────────────────────────── +// The glass square icon button used in every screen header. One fill, one size. +class RFIconButton extends StatelessWidget { + const RFIconButton({ + super.key, + required this.icon, + required this.onTap, + this.tooltip, + this.color, + this.size = standardSize, + }); + + /// The painted box size for every header button. [RFScreenHeader]'s + /// centred-title counterweight assumes each action is this wide. + static const double standardSize = 38; + + /// Material's minimum touch target. The painted box stays [size]; the + /// gesture area is expanded to this when [size] is smaller. + static const double minTapTarget = 48; + + /// The width a default-size button actually occupies once the tap target is + /// applied — what a caller laying out around it needs, not [standardSize]. + static const double standardExtent = standardSize > minTapTarget + ? standardSize + : minTapTarget; + + final IconData icon; + final VoidCallback? onTap; + + /// Screen-reader label and tooltip. Always supply one: these are icon-only. + final String? tooltip; + + /// Tints the icon (e.g. destructive actions). Defaults to soft text. + final Color? color; + final double size; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + final target = size < minTapTarget ? minTapTarget : size; + // InkWell, not a bare GestureDetector: this is the back/close control in + // every header, so it has to be reachable by keyboard and switch access — + // the same requirement RFOptionChip states below. The Material is there + // for headers that sit outside a Scaffold, where InkWell has no ancestor + // to paint its splash into. + final button = Semantics( + button: true, + enabled: enabled, + label: tooltip, + child: Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(AppRadius.md), + onTap: enabled + ? () { + HapticFeedback.lightImpact(); + onTap!(); + } + : null, + // The gesture area is the full 48pt target; only the decorated box + // inside it is painted at `size`. + child: SizedBox( + width: target, + height: target, + child: Center( + child: Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Icon( + icon, + size: 18, + color: enabled + ? (color ?? AppColors.textSoft) + : AppColors.textFaint, + ), + ), + ), + ), + ), + ), + ); + return tooltip == null ? button : Tooltip(message: tooltip!, child: button); + } +} + +// ── RFGradientBadge ────────────────────────────────────────────────────────── +// Small brand-gradient tile — the AI mark in headers, avatars and empty states. +class RFGradientBadge extends StatelessWidget { + const RFGradientBadge({ + super.key, + required this.icon, + this.size = standardSize, + this.radius = AppRadius.md, + this.glow = 0.35, + }); + + /// Painted extent of a default-size badge. Exposed so callers laying out + /// around one — the centred-title counterweight — need not construct it. + static const double standardSize = 34; + + final IconData icon; + final double size; + final double radius; + final double glow; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(radius), + boxShadow: [ + BoxShadow( + color: AppColors.primaryGlow(glow), + blurRadius: size * 0.4, + spreadRadius: -size * 0.12, + ), + ], + ), + child: Icon(icon, color: Colors.white, size: size * 0.5), + ); + } +} + +// ── RFScreenHeader ─────────────────────────────────────────────────────────── +// Leading affordance · optional badge · title/subtitle · actions. +class RFScreenHeader extends StatelessWidget { + const RFScreenHeader({ + super.key, + required this.title, + this.subtitle, + this.badgeIcon, + this.onBack, + this.leadingIcon = Icons.arrow_back_rounded, + this.leadingTooltip = 'Back', + this.actions = const [], + this.centreTitle = false, + this.bottom, + }); + + final String title; + final String? subtitle; + + /// When set, a brand-gradient badge sits between the back button and title. + final IconData? badgeIcon; + + /// Omit to hide the leading button entirely (root-level screens). + final VoidCallback? onBack; + final IconData leadingIcon; + final String leadingTooltip; + final List actions; + final bool centreTitle; + + /// Rendered full-bleed under the header row — e.g. a progress bar. + final Widget? bottom; + + @override + Widget build(BuildContext context) { + // One action = one default-size button plus its 8pt gap. + // + // This counterweight is only correct while every action is an + // RFIconButton at the default size: the header cannot measure its actions + // before layout, so a caller passing differently-sized actions (as + // workout_header.dart does) with centreTitle: true will see the title sit + // off centre. Measure the trailing cluster if that case ever needs to work. + const cellWidth = RFIconButton.standardExtent + AppSpacing.sm; + // The badge shares the leading run with the back button, so it carries + // its own weight on that side too. + const badgeWidth = RFGradientBadge.standardSize + AppSpacing.sm; + final leadingWidth = + (onBack != null ? cellWidth : 0.0) + + (badgeIcon != null ? badgeWidth : 0.0); + final trailingWidth = actions.length * cellWidth; + + final titleBlock = Column( + crossAxisAlignment: centreTitle + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: centreTitle ? TextAlign.center : TextAlign.start, + style: const TextStyle( + fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: centreTitle ? TextAlign.center : TextAlign.start, + style: const TextStyle( + fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ], + ); + + return Container( + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: AppColors.glassBorder)), + ), + child: SafeArea( + bottom: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.sm, + ), + child: Row( + children: [ + if (onBack != null) ...[ + RFIconButton( + icon: leadingIcon, + tooltip: leadingTooltip, + onTap: onBack, + ), + const SizedBox(width: AppSpacing.sm), + ], + if (badgeIcon != null) ...[ + RFGradientBadge(icon: badgeIcon!), + const SizedBox(width: AppSpacing.sm), + ], + // Counterweight, so a centred title lands on true centre. + if (centreTitle && trailingWidth > leadingWidth) + SizedBox(width: trailingWidth - leadingWidth), + Expanded(child: titleBlock), + if (centreTitle && leadingWidth > trailingWidth) + SizedBox(width: leadingWidth - trailingWidth), + for (final action in actions) ...[ + const SizedBox(width: AppSpacing.sm), + action, + ], + ], + ), + ), + if (bottom != null) + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.sm, + ), + child: bottom!, + ), + ], + ), + ), + ); + } +} + +// ── RFBottomBar ────────────────────────────────────────────────────────────── +// Sticky foot-of-screen action strip. Owns its safe-area inset; never add one. +class RFBottomBar extends StatelessWidget { + const RFBottomBar({super.key, required this.child}); + + final Widget child; + + /// Floor for the space a scroll view must reserve to clear a one-row bar. + static double clearance(BuildContext context) => + 72 + MediaQuery.paddingOf(context).bottom; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm + 4, + AppSpacing.md, + AppSpacing.sm + 4 + MediaQuery.paddingOf(context).bottom, + ), + decoration: const BoxDecoration( + color: AppColors.surface, + border: Border(top: BorderSide(color: AppColors.glassBorder)), + ), + child: child, + ); + } +} + +// ── RFLabel ────────────────────────────────────────────────────────────────── +// The one uppercase micro-label: headings, captions and overlines share it. +class RFLabel extends StatelessWidget { + const RFLabel(this.text, {super.key, this.color, this.dim = false}); + + final String text; + final Color? color; + + /// Drops to the faintest text tier — for labels over already-quiet content. + final bool dim; + + @override + Widget build(BuildContext context) { + return Text( + text.toUpperCase(), + style: TextStyle( + fontFamily: 'Geist', + color: color ?? (dim ? AppColors.textFaint : AppColors.textMuted), + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ); + } +} + +// ── RFOptionChip ───────────────────────────────────────────────────────────── +// A chip the user picks, as opposed to RFChip which only labels. +class RFOptionChip extends StatelessWidget { + const RFOptionChip({ + super.key, + required this.label, + required this.onTap, + this.selected = false, + this.color, + this.icon, + this.inMutuallyExclusiveGroup = false, + }); + + final String label; + + /// Null renders the chip inert (shown, but not selectable). + final VoidCallback? onTap; + final bool selected; + + /// Accent for the selected state. Defaults to the brand violet. + final Color? color; + final IconData? icon; + + /// Set when this chip is one of a single-choice group (handle picker, + /// effort rating) so a screen reader announces it as a radio-style choice + /// rather than a standalone button. Left false for chips that are just + /// actions, e.g. the coach's suggested prompts. + final bool inMutuallyExclusiveGroup; + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.primary; + final enabled = onTap != null; + final fg = selected + ? c + : enabled + ? AppColors.textSoft + : AppColors.textFaint; + + return Semantics( + button: enabled, + enabled: enabled, + inMutuallyExclusiveGroup: inMutuallyExclusiveGroup, + selected: selected, + label: label, + // InkWell, not a bare GestureDetector: these need to be reachable by + // keyboard/switch access and to show a focus + press response, not just + // fire a haptic. + child: InkWell( + onTap: enabled + ? () { + HapticFeedback.selectionClick(); + onTap!(); + } + : null, + borderRadius: BorderRadius.circular(AppRadius.full), + child: AnimatedContainer( + duration: AppDurations.fast, + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: AppSpacing.sm + 1, + ), + decoration: BoxDecoration( + color: selected ? c.withValues(alpha: 0.16) : AppColors.glass2, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: selected + ? c.withValues(alpha: 0.55) + : AppColors.glassBorder, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 13, color: fg), + const SizedBox(width: 6), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Geist', + color: fg, + fontSize: 13, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/rf_widgets.dart b/workout-logger/lib/screens/widgets/rf_widgets.dart index a08d842..b3efb5c 100644 --- a/workout-logger/lib/screens/widgets/rf_widgets.dart +++ b/workout-logger/lib/screens/widgets/rf_widgets.dart @@ -5,6 +5,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../theme/app_theme.dart'; +import 'rf_shell.dart'; // ── Route helper ────────────────────────────────────────────────────────────── // Right-to-left slide push, shared by the home screen and detail entry points. @@ -44,6 +45,7 @@ class GlassCard extends StatelessWidget { final BorderRadius? borderRadius; final Color? glowColor; final Color? borderColor; + /// When true, uses accent colour border (e.g. Analytics exercise selector). final bool accentBorder; final VoidCallback? onTap; @@ -52,18 +54,22 @@ class GlassCard extends StatelessWidget { @override Widget build(BuildContext context) { final radius = borderRadius ?? BorderRadius.circular(AppRadius.xl); - final effectiveBorderColor = accentBorder - ? AppColors.primary - : (borderColor ?? AppColors.glassBorder); + + // An explicit border is a state signal (selected, accented), so it stays a + // flat ring at full strength. The graded ring is the default *material*, + // and grading it would mute the signal. + final overrideColor = accentBorder ? AppColors.primary : borderColor; final decoration = BoxDecoration( gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0x09FFFFFF), Color(0x04FFFFFF)], + colors: [AppColors.glassFillTop, AppColors.glassFillBottom], ), borderRadius: radius, - border: Border.all(color: effectiveBorderColor, width: 1), + border: overrideColor != null + ? Border.all(color: overrideColor, width: 1) + : null, boxShadow: glowColor != null ? [ BoxShadow( @@ -75,59 +81,365 @@ class GlassCard extends StatelessWidget { : null, ); - final content = Container( + Widget content = Container( padding: padding ?? const EdgeInsets.all(AppSpacing.md), - margin: margin, decoration: decoration, child: child, ); + if (overrideColor == null) { + content = CustomPaint( + foregroundPainter: _GradedRingPainter(radius: radius), + child: content, + ); + } + if (margin != null) { + content = Padding(padding: margin!, child: content); + } + if (onTap == null) return content; return Semantics( button: true, label: semanticsLabel, - child: GestureDetector( - onTap: onTap, - child: content, - ), + child: GestureDetector(onTap: onTap, child: content), + ); + } +} + +/// A 1px border that grades from [AppColors.glassEdgeTop] down to +/// [AppColors.glassEdgeBottom]. +/// +/// Real glass catches light on the edge facing the source; a flat ring on all +/// four sides is the thing that made these panels read as outlines. Flutter's +/// [Border] takes a single colour per side, so the ring is stroked by hand. +class _GradedRingPainter extends CustomPainter { + const _GradedRingPainter({required this.radius}); + + final BorderRadius radius; + + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + // A stroke straddles its path, so pull in by half the width to keep the + // full pixel inside the card rather than bleeding over the neighbour. + final rrect = radius.toRRect(rect).deflate(0.5); + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.glassEdgeTop, AppColors.glassEdgeBottom], + ).createShader(rect); + canvas.drawRRect(rrect, paint); + } + + @override + bool shouldRepaint(_GradedRingPainter oldDelegate) => + oldDelegate.radius != radius; +} + +// ── AmbientMotion ──────────────────────────────────────────────────────────── + +/// App-wide vertical scroll position, published for [AmbientGlow] to lean against. +class AmbientMotion extends InheritedNotifier> { + const AmbientMotion({ + super.key, + required ValueNotifier super.notifier, + required super.child, + }); + + /// Reads the current offset without subscribing. + static double read(BuildContext context) { + final element = context + .getElementForInheritedWidgetOfExactType(); + final widget = element?.widget as AmbientMotion?; + return widget?.notifier?.value ?? 0; + } +} + +/// Installs the [AmbientMotion] signal. Mount once, above the app's Navigator. +class AmbientMotionScope extends StatefulWidget { + const AmbientMotionScope({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _AmbientMotionScopeState(); +} + +class _AmbientMotionScopeState extends State { + final _offset = ValueNotifier(0); + + @override + void dispose() { + _offset.dispose(); + super.dispose(); + } + + bool _onScroll(ScrollNotification n) { + if (n is ScrollUpdateNotification && n.metrics.axis == Axis.vertical) { + _offset.value = n.metrics.pixels; + } + return false; + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: _onScroll, + child: AmbientMotion(notifier: _offset, child: widget.child), ); } } // ── AmbientGlow ────────────────────────────────────────────────────────────── -// Matches the design's rf-ambient pseudo-elements. -class AmbientGlow extends StatelessWidget { + +/// Violet wash behind every screen: a full-height floor plus three radial +/// pools that drift slowly and lean against the user's scroll. Only transforms +/// and opacity animate. +/// +/// The floor is not decorative. Pools are finite and a scrolling column is not, +/// so pools alone can only ever light the top of a screen — the previous rig +/// lit the content column from 0 to 264dp and left the remaining 70% of a +/// Pixel flat. The floor guarantees light everywhere; the pools give it a +/// direction. +class AmbientGlow extends StatefulWidget { const AmbientGlow({super.key}); + /// Set false to render the wash static. The drift loop never ends, so under + /// the test binding it would hold `pumpAndSettle` open forever. + static bool motionEnabled = true; + + @override + State createState() => _AmbientGlowState(); +} + +class _AmbientGlowState extends State + with SingleTickerProviderStateMixin { + /// Every drift period divides this evenly, so the loop closes without a snap. + static const _cycle = Duration(seconds: 120); + + /// Scroll travel that maps to the full counter-offset. + static const _parallaxRange = 640.0; + + /// Peak counter-offset, in logical pixels. + static const _parallaxDepthNear = 34.0; + static const _parallaxDepthFar = 14.0; + + /// Pool boxes are a fraction of viewport height, not fixed dp. The old fixed + /// sizes meant coverage degraded as phones got taller — the same 480dp pool + /// lit 37% of a 720dp screen but only 28% of a 956dp one. + static const _keyScale = 0.82; + static const _counterScale = 0.97; + + /// Counterweight centre, as a fraction of viewport height, and its horizontal + /// offset from centre. Offset rather than centred so the pair reads as two + /// sources instead of a symmetric vignette. + static const _counterCentreY = 0.74; + static const _counterOffsetX = 58.0; + + /// The far pool hugs the right bezel: its centre sits 166dp from the content + /// column with a 108dp reach, so it never touches the cards. It is edge + /// atmosphere, and sized in fixed dp on purpose. + static const _farBox = 360.0; + + late final AnimationController _ctrl; + + double _parallax = 0; + + @override + void initState() { + super.initState(); + // Constructed eagerly: a lazy late-final would build its Ticker inside + // dispose(), when ancestor lookup is already unsafe. + _ctrl = AnimationController(vsync: this, duration: _cycle); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_animates) { + if (!_ctrl.isAnimating) _ctrl.repeat(); + } else if (_ctrl.isAnimating) { + _ctrl.stop(); + } + } + + bool get _animates => + AmbientGlow.motionEnabled && !MediaQuery.disableAnimationsOf(context); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + /// Sine at [harmonic] cycles per loop; coprime harmonics never resync. + double _wave(double t, int harmonic) => math.sin(2 * math.pi * harmonic * t); + + /// Eased toward the live scroll position so route changes glide, not jump. + double _sampleParallax(BuildContext context) { + final target = (AmbientMotion.read(context) / _parallaxRange).clamp( + 0.0, + 1.0, + ); + _parallax += (target - _parallax) * 0.08; + return _parallax; + } + + /// One pool. When the rig is static the drift and fade are skipped entirely + /// rather than sampled at rest, so nothing wraps the wash that need not. + Widget _pool({ + required double box, + required double left, + required double top, + required double opacity, + required bool animate, + Offset drift = Offset.zero, + double fade = 1, + }) { + // The fade is folded into the gradient's own alpha rather than wrapped in + // an Opacity: these boxes are a large fraction of the viewport and the + // drift loop never stops, so an Opacity here would mean three + // near-fullscreen saveLayers on every frame, forever. Equivalent output — + // the gradient's far stop is fully transparent, so scaling the near stop + // scales the whole ramp. + return Positioned( + left: left, + top: top, + child: animate + ? Transform.translate( + offset: drift, + child: _Wash(size: box, opacity: opacity * fade), + ) + : _Wash(size: box, opacity: opacity), + ); + } + + Widget _rig(Size size, {required bool animate, double t = 0, double p = 0}) { + final w = size.width; + final h = size.height; + final keyBox = h * _keyScale; + final counterBox = h * _counterScale; + + return Stack( + children: [ + const Positioned.fill(child: _WashFloor()), + // Key: top-anchored and centred. Establishes the light direction. + _pool( + box: keyBox, + left: (w - keyBox) / 2, + top: -120, + opacity: 0.32, + animate: animate, + drift: Offset( + _wave(t, 2) * 20, + _wave(t, 3) * 13 - p * _parallaxDepthNear, + ), + fade: 0.86 + 0.14 * (0.5 + 0.5 * _wave(t, 5)), + ), + // Far: right bezel only. Less travel — the gap is the parallax. + _pool( + box: _farBox, + left: w + 140 - _farBox, + top: 40, + opacity: 0.16, + animate: animate, + drift: Offset( + _wave(t, 3) * -9, + _wave(t, 2) * 7 - p * _parallaxDepthFar, + ), + fade: 0.80 + 0.20 * (0.5 + 0.5 * _wave(t, 3)), + ), + // Counterweight: lower third, off-axis, and quiet. It gives the bottom + // of the screen a source rather than a flat tint. + _pool( + box: counterBox, + left: (w - counterBox) / 2 + _counterOffsetX, + top: h * _counterCentreY - counterBox / 2, + opacity: 0.14, + animate: animate, + drift: Offset( + _wave(t, 2) * -11, + _wave(t, 3) * 8 - p * _parallaxDepthFar, + ), + fade: 0.84 + 0.16 * (0.5 + 0.5 * _wave(t, 2)), + ), + ], + ); + } + @override Widget build(BuildContext context) { return Positioned.fill( child: IgnorePointer( - child: Stack( - children: [ - // Top violet wash - Positioned( - top: -120, - left: 0, - right: 0, - child: Center( - child: Container( - width: 480, - height: 480, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - const Color(0xFF5B21B6).withValues(alpha: 0.35), - Colors.transparent, - ], - stops: const [0, 0.6], - ), - ), + child: RepaintBoundary( + child: LayoutBuilder( + builder: (context, constraints) { + final size = constraints.biggest; + // An always-moving backdrop is what this setting exists to stop. + if (!_animates) return _rig(size, animate: false); + return AnimatedBuilder( + animation: _ctrl, + builder: (context, _) => _rig( + size, + animate: true, + t: _ctrl.value, + p: _sampleParallax(context), ), - ), - ), + ); + }, + ), + ), + ), + ); + } +} + +/// The floor: a full-height grade that keeps every pixel of canvas fractionally +/// above flat black, so glass always has something behind it to sit on. +class _WashFloor extends StatelessWidget { + const _WashFloor(); + + @override + Widget build(BuildContext context) { + return const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.washFloorTop, + AppColors.washFloorMid, + AppColors.washFloorBottom, ], + stops: [0, 0.45, 1], + ), + ), + ); + } +} + +class _Wash extends StatelessWidget { + const _Wash({required this.size, required this.opacity}); + + final double size; + final double opacity; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.primaryDeep.withValues(alpha: opacity), + Colors.transparent, + ], + stops: const [0, 0.6], ), ), ); @@ -138,7 +450,6 @@ class AmbientGlow extends StatelessWidget { // Moved to floating_nav_bar.dart (zero-dependency, drop-in portable widget). // Import and use FloatingNavBar / FloatingNavBarScaffold / FloatingNavItem. - // ── GlowButton ────────────────────────────────────────────────────────────── // Full-width primary action button with glow shadow + haptic feedback. class GlowButton extends StatefulWidget { @@ -212,10 +523,8 @@ class _GlowButtonState extends State return AnimatedBuilder( animation: _scale, - builder: (context, child) => Transform.scale( - scale: _scale.value, - child: child, - ), + builder: (context, child) => + Transform.scale(scale: _scale.value, child: child), child: Semantics( button: true, label: widget.label, @@ -249,8 +558,9 @@ class _GlowButtonState extends State ], ), child: Row( - mainAxisSize: - widget.fullWidth ? MainAxisSize.max : MainAxisSize.min, + mainAxisSize: widget.fullWidth + ? MainAxisSize.max + : MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ if (widget.icon != null) ...[ @@ -261,13 +571,17 @@ class _GlowButtonState extends State ), const SizedBox(width: AppSpacing.sm), ], - Text( - widget.label, - style: TextStyle( - color: disabled ? AppColors.textMuted : Colors.white, - fontSize: widget.small ? 14 : 16, - fontWeight: FontWeight.w700, - letterSpacing: 0.5, + Flexible( + child: Text( + widget.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: disabled ? AppColors.textMuted : Colors.white, + fontSize: widget.small ? 14 : 16, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), ), ), ], @@ -391,18 +705,7 @@ class RFSectionHeader extends StatelessWidget { padding: EdgeInsets.only(bottom: bottomPad ? AppSpacing.sm : 0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - title.toUpperCase(), - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 1.2, - ), - ), - ?trailing, - ], + children: [RFLabel(title), ?trailing], ), ); } @@ -498,7 +801,8 @@ class AnimatedCounter extends StatelessWidget { : v.toInt().toString(); return Text( '$display$suffix', - style: style ?? + style: + style ?? const TextStyle( color: AppColors.textPrimary, fontSize: 22, @@ -732,10 +1036,17 @@ class RFProgressBar extends StatelessWidget { curve: Curves.easeOutCubic, width: constraints.maxWidth * clamped, decoration: BoxDecoration( - gradient: LinearGradient(colors: [c, Color.lerp(c, Colors.white, 0.2)!]), + gradient: LinearGradient( + colors: [c, Color.lerp(c, Colors.white, 0.2)!], + ), borderRadius: BorderRadius.circular(AppRadius.full), boxShadow: showGlow - ? [BoxShadow(color: c.withValues(alpha: 0.5), blurRadius: 8)] + ? [ + BoxShadow( + color: c.withValues(alpha: 0.5), + blurRadius: 8, + ), + ] : null, ), ), @@ -766,8 +1077,9 @@ class RestTimerRing extends StatelessWidget { final progress = total > 0 ? (remaining / total).clamp(0.0, 1.0) : 0.0; final mins = remaining ~/ 60; final secs = remaining % 60; - final label = - mins > 0 ? '$mins:${secs.toString().padLeft(2, '0')}' : '$secs'; + final label = mins > 0 + ? '$mins:${secs.toString().padLeft(2, '0')}' + : '$secs'; return SizedBox( width: size, @@ -987,7 +1299,10 @@ class _RFTextFieldState extends State { style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), decoration: InputDecoration( hintText: widget.hint, - hintStyle: const TextStyle(color: AppColors.textMuted, fontSize: 14), + hintStyle: const TextStyle( + color: AppColors.textMuted, + fontSize: 14, + ), prefixIcon: widget.prefixIcon != null ? Icon(widget.prefixIcon, color: AppColors.textSoft, size: 20) : null, @@ -1004,4 +1319,3 @@ class _RFTextFieldState extends State { ); } } - diff --git a/workout-logger/lib/screens/widgets/workout_header.dart b/workout-logger/lib/screens/widgets/workout_header.dart index 729ae49..844802a 100644 --- a/workout-logger/lib/screens/widgets/workout_header.dart +++ b/workout-logger/lib/screens/widgets/workout_header.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../theme/app_theme.dart'; import 'rf_widgets.dart'; +import 'rf_shell.dart'; // ── WorkoutHeader ───────────────────────────────────────────────────────────── // Shows exercise name, set/exercise progress, elapsed timer, and nav actions. @@ -16,11 +17,7 @@ class WorkoutHeader extends StatefulWidget { required this.setNumber, required this.workoutStartTime, required this.progress, - required this.isFirst, - required this.isLast, required this.onClose, - required this.onPrevious, - required this.onNext, required this.onFinish, required this.onRemoveLastSet, required this.onSetRestTime, @@ -33,11 +30,7 @@ class WorkoutHeader extends StatefulWidget { final int setNumber; final DateTime? workoutStartTime; final double progress; - final bool isFirst; - final bool isLast; final VoidCallback onClose; - final VoidCallback onPrevious; - final VoidCallback onNext; final VoidCallback onFinish; final VoidCallback onRemoveLastSet; final void Function(int seconds) onSetRestTime; @@ -80,113 +73,70 @@ class _WorkoutHeaderState extends State { @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF0C0C12), Color(0x000C0C12)], + // Left-aligned title, matching every other screen header in the app. The + // title used to be centred while the leading and trailing clusters had + // very different widths, which pushed it visibly off the optical centre. + return RFScreenHeader( + title: widget.exerciseName, + subtitle: + 'Exercise ${widget.currentExerciseIndex + 1} of ${widget.totalExercises} · Set ${widget.setNumber}', + onBack: widget.onClose, + leadingIcon: Icons.close_rounded, + leadingTooltip: 'Cancel workout', + actions: [ + _ElapsedChip(label: _elapsedLabel), + _OptionsMenu( + restSeconds: widget.restSeconds, + onRemoveLastSet: widget.onRemoveLastSet, + onSetRestTime: widget.onSetRestTime, + onFinish: widget.onFinish, ), - border: Border(bottom: BorderSide(color: AppColors.glassBorder)), + ], + bottom: RFProgressBar( + value: widget.progress, + height: 3, + showGlow: false, ), - child: SafeArea( - bottom: false, - child: Column( + ); + } +} + +// ── Elapsed chip ────────────────────────────────────────────────────────────── +class _ElapsedChip extends StatelessWidget { + const _ElapsedChip({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Semantics( + label: 'Elapsed time', + value: label, + child: Container( + // A floor, not a fixed height: a large text scale needs more than 38pt. + constraints: const BoxConstraints(minHeight: 38), + alignment: Alignment.center, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm + 2, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(4, 4, 4, 0), - child: Row( - children: [ - // Close button - IconButton( - icon: const Icon(Icons.close_rounded, size: 22), - color: AppColors.textSoft, - onPressed: widget.onClose, - ), - // Exercise info - Expanded( - child: Column( - children: [ - Text( - widget.exerciseName, - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textPrimary, - fontSize: 17, - fontWeight: FontWeight.w600, - letterSpacing: -0.3, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Exercise ${widget.currentExerciseIndex + 1} of ${widget.totalExercises} · Set ${widget.setNumber}', - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textMuted, - fontSize: 11, - ), - ), - ], - ), - ], - ), - ), - // Timer chip + menu - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppRadius.full), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.timer_outlined, size: 12, color: AppColors.textMuted), - const SizedBox(width: 4), - Text( - _elapsedLabel, - style: TextStyle(fontFamily: 'GeistMono', - color: AppColors.textSoft, - fontSize: 12, - ), - ), - ], - ), - ), - _OptionsMenu( - restSeconds: widget.restSeconds, - onRemoveLastSet: widget.onRemoveLastSet, - onSetRestTime: widget.onSetRestTime, - onFinish: widget.onFinish, - ), - ], - ), - ], - ), - ), - // Progress bar - Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.sm, - AppSpacing.md, - AppSpacing.sm, - ), - child: RFProgressBar( - value: widget.progress, - height: 4, - showGlow: false, + const Icon(Icons.timer_outlined, size: 13, color: AppColors.textMuted), + const SizedBox(width: 5), + Text( + label, + style: const TextStyle( + fontFamily: 'GeistMono', + color: AppColors.textSoft, + fontSize: 12, + fontFeatures: [FontFeature.tabularFigures()], ), ), ], diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 56ac25c..3e85ac3 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -10,10 +10,14 @@ import '../models/models.dart'; import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; import '../services/managers/pr_manager.dart'; +import '../services/managers/readiness_manager.dart'; import '../theme/app_theme.dart'; import 'add_custom_exercise_screen.dart'; import 'exercise_library_screen.dart'; import 'workout_summary_screen.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/rf_shell.dart'; +import 'widgets/rf_dialogs.dart'; import 'widgets/workout_header.dart'; import 'widgets/exercise_input_section.dart'; import 'widgets/rest_timer_view.dart'; @@ -270,8 +274,17 @@ class _WorkoutFlowScreenState extends State { final isLast = idx >= totalExercises - 1; final selectedHandle = log?.handle; + // Nullable-typed lookup: resolves to null (no readiness signal) instead + // of throwing when no ReadinessManager is above this screen in the + // widget tree — keeps this screen usable without the full app's + // provider tree (e.g. in isolated widget tests). + final readinessBand = context.watch()?.snapshot?.band; final recommendations = exercise != null - ? provider.getRecommendations(exercise.id, handle: selectedHandle) + ? provider.getRecommendations( + exercise.id, + handle: selectedHandle, + readinessBand: readinessBand, + ) : []; final lastSession = exercise != null @@ -287,27 +300,26 @@ class _WorkoutFlowScreenState extends State { setNumber: (log?.sets.length ?? 0) + 1, workoutStartTime: provider.workoutStartTime, progress: totalExercises > 0 ? (idx + 1) / totalExercises : 0, - isFirst: isFirst, - isLast: isLast, onClose: _showCancelDialog, - onPrevious: () { - provider.previousExercise(); - _loadLastSessionData(); - }, - onNext: () { - provider.nextExercise(); - _loadLastSessionData(); - }, onFinish: _finishWorkout, onRemoveLastSet: provider.removeLastSet, onSetRestTime: (s) => setState(() => _restSeconds = s), restSeconds: _restSeconds, ), Expanded( - child: SingleChildScrollView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.all(AppSpacing.md), - child: ExerciseInputSection( + // minHeight + IntrinsicHeight let the section's Spacer do its work. + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(AppSpacing.md), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - AppSpacing.md * 2, + ), + child: IntrinsicHeight( + child: ExerciseInputSection( + // The section cannot measure itself under IntrinsicHeight. + contentWidth: constraints.maxWidth - AppSpacing.md * 2, currentWeight: _currentWeight, currentReps: _currentReps, isDropset: _isDropset, @@ -348,7 +360,6 @@ class _WorkoutFlowScreenState extends State { _drops[i] = DropsetEntry(weight: _drops[i].weight, reps: r); } }, - onLogSet: _completeSet, onApplyRecommendation: () { if (recommendations.isEmpty) return; final setIdx = (log?.sets.length ?? 0) @@ -365,6 +376,9 @@ class _WorkoutFlowScreenState extends State { _mainRepsCtrl.text = rec.reps.toString(); }); }, + ), + ), + ), ), ), ), @@ -389,98 +403,44 @@ class _WorkoutFlowScreenState extends State { ); } + /// Log set is tapped twenty-odd times a session and exercise nav a handful, + /// so the log action owns the width and the thumb zone. It used to be inverted. Widget _buildBottomNav(WorkoutProvider provider, bool isFirst, bool isLast) { - final bottomPad = MediaQuery.of(context).padding.bottom; - return Container( - padding: EdgeInsets.fromLTRB(16, 12, 16, 12 + bottomPad), - decoration: BoxDecoration( - color: AppColors.surface.withValues(alpha: 0.95), - border: Border(top: BorderSide(color: AppColors.glassBorder)), - ), + return RFBottomBar( child: Row( children: [ - if (!isFirst) - Flexible( - child: GestureDetector( - onTap: () { - provider.previousExercise(); - _loadLastSessionData(); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration( - color: AppColors.glass2, - borderRadius: BorderRadius.circular(AppRadius.button), - border: Border.all(color: AppColors.glassBorderStrong), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.arrow_back_rounded, size: 16, color: AppColors.textMuted), - const SizedBox(width: 6), - Text( - 'Prev', - style: TextStyle(fontFamily: 'Geist', - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppColors.textMuted, - ), - ), - ], - ), - ), - ), - ) - else - const SizedBox.shrink(), - const Spacer(), - Flexible( - child: GestureDetector( - onTap: isLast - ? _finishWorkout - : () { - provider.nextExercise(); - _loadLastSessionData(); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), - decoration: BoxDecoration( - color: isLast ? AppColors.success : AppColors.primary, - borderRadius: BorderRadius.circular(AppRadius.button), - boxShadow: [ - BoxShadow( - color: (isLast ? AppColors.success : AppColors.primary) - .withValues(alpha: 0.35), - blurRadius: 16, - offset: const Offset(0, 4), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - isLast ? 'Finish' : 'Next exercise', - overflow: TextOverflow.ellipsis, - style: TextStyle(fontFamily: 'Geist', - fontSize: 13, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ), - const SizedBox(width: 6), - Icon( - isLast ? Icons.check_rounded : Icons.arrow_forward_rounded, - size: 16, - color: Colors.white, - ), - ], - ), - ), + RFIconButton( + icon: Icons.arrow_back_rounded, + tooltip: 'Previous exercise', + onTap: isFirst + ? null + : () { + provider.previousExercise(); + _loadLastSessionData(); + }, + size: 46, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: GlowButton( + label: 'Log set', + icon: Icons.check_rounded, + onPressed: _completeSet, ), ), + const SizedBox(width: AppSpacing.sm), + RFIconButton( + icon: isLast ? Icons.flag_rounded : Icons.arrow_forward_rounded, + tooltip: isLast ? 'Finish workout' : 'Next exercise', + color: isLast ? AppColors.success : AppColors.textSoft, + onTap: isLast + ? _finishWorkout + : () { + provider.nextExercise(); + _loadLastSessionData(); + }, + size: 46, + ), ], ), ); @@ -656,98 +616,69 @@ class _WorkoutFlowScreenState extends State { // ── Dialogs ───────────────────────────────────────────────────────────────── - void _showCancelDialog() { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppColors.cardHigh, - title: const Text('Cancel Workout?'), - content: const Text('Your progress will not be saved.'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Continue'), - ), - TextButton( - onPressed: () async { - final nav = Navigator.of(context); - final ctxNav = Navigator.of(ctx); - await context.read().cancelWorkout(); - if (!mounted) return; - ctxNav.pop(); - nav.pop(); - }, - style: TextButton.styleFrom(foregroundColor: AppColors.error), - child: const Text('Discard'), - ), - ], - ), + Future _showCancelDialog() async { + final discard = await showRFConfirmDialog( + context, + title: 'Discard this workout?', + content: 'Nothing from this session will be saved.', + cancelText: 'Keep going', + confirmText: 'Discard', + isDanger: true, ); + if (discard != true || !mounted) return; + final nav = Navigator.of(context); + await context.read().cancelWorkout(); + if (!mounted) return; + nav.pop(); } - void _finishWorkout() { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppColors.cardHigh, - title: const Text('Finish Workout?'), - content: const Text('Ready to save this session?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Continue'), - ), - ElevatedButton( - onPressed: () async { - final nav = Navigator.of(context); - final prManager = context.read(); - Navigator.of(ctx).pop(); - final session = - await context.read().finishWorkout(); - final newPRs = await prManager.checkAndUpdatePRs(session); - if (!mounted) return; - nav.pushReplacement(MaterialPageRoute( - builder: (_) => WorkoutSummaryScreen( - session: session, - newPRs: newPRs, - ), - )); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.success, - ), - child: const Text('Save & Finish'), - ), - ], - ), + Future _finishWorkout() async { + final confirmed = await showRFConfirmDialog( + context, + title: 'Finish workout?', + content: 'This session will be saved to your history.', + cancelText: 'Keep going', + confirmText: 'Save & finish', ); + if (confirmed != true || !mounted) return; + + final nav = Navigator.of(context); + final prManager = context.read(); + final session = await context.read().finishWorkout(); + final newPRs = await prManager.checkAndUpdatePRs(session); + if (!mounted) return; + nav.pushReplacement(MaterialPageRoute( + builder: (_) => WorkoutSummaryScreen(session: session, newPRs: newPRs), + )); } Future _handleBack() async { - final action = await showDialog<_LeaveAction>( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppColors.cardHigh, - title: const Text('Leave workout?'), - content: const Text( - 'Progress is saved. You can resume next time.', + // A sheet, not a dialog: three dialog actions wrap into a ragged column. + final action = await showRFActionSheet<_LeaveAction>( + context, + title: 'Leave this workout?', + message: 'Your sets so far are already saved.', + actions: const [ + RFAction( + label: 'Keep & exit', + value: _LeaveAction.keep, + description: 'Resume where you left off next time', + icon: Icons.bookmark_outline_rounded, + isPrimary: true, ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, _LeaveAction.discard), - style: TextButton.styleFrom(foregroundColor: AppColors.error), - child: const Text('Discard'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, _LeaveAction.keep), - child: const Text('Keep & exit'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, _LeaveAction.cancel), - child: const Text('Cancel'), - ), - ], - ), + RFAction( + label: 'Stay here', + value: _LeaveAction.cancel, + icon: Icons.arrow_back_rounded, + ), + RFAction( + label: 'Discard workout', + value: _LeaveAction.discard, + description: 'Delete this session for good', + icon: Icons.delete_outline_rounded, + isDanger: true, + ), + ], ); if (!mounted) return; diff --git a/workout-logger/lib/screens/workout_summary_screen.dart b/workout-logger/lib/screens/workout_summary_screen.dart index 0bd87e4..8d9f640 100644 --- a/workout-logger/lib/screens/workout_summary_screen.dart +++ b/workout-logger/lib/screens/workout_summary_screen.dart @@ -11,6 +11,7 @@ import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; import 'widgets/rf_widgets.dart'; import 'widgets/rf_cards.dart'; +import 'widgets/rf_shell.dart'; class WorkoutSummaryScreen extends StatelessWidget { const WorkoutSummaryScreen({ @@ -48,50 +49,70 @@ class WorkoutSummaryScreen extends StatelessWidget { return Scaffold( backgroundColor: AppColors.background, - body: SafeArea( - child: CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(height: AppSpacing.lg), - _buildTrophyHeader(context), - const SizedBox(height: AppSpacing.xl), - _buildStatGrid( - session.duration, - volStr, - totalSets, - session.exercises.length, - settings.unitLabel, - ), - if (newPRs.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.lg), - _buildPRSection(newPRs, provider), - ], - if (muscles.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.lg), - _buildMusclesSection(muscles, provider), + body: Stack( + children: [ + const AmbientGlow(), + Column( + children: [ + Expanded( + child: SafeArea( + bottom: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: AppSpacing.lg), + _buildTrophyHeader(context), + const SizedBox(height: AppSpacing.xl), + _buildStatGrid( + session.duration, + volStr, + totalSets, + session.exercises.length, + settings.unitLabel, + ), + // The only ask on this screen, above the read-only sections. + const SizedBox(height: AppSpacing.lg), + _EffortChipRow(session: session), + if (newPRs.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.lg), + _buildPRSection(newPRs, provider), + ], + if (muscles.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.lg), + _buildMusclesSection(muscles, provider), + ], + const SizedBox(height: AppSpacing.lg), + _buildExerciseSummary( + session: session, + provider: provider, + settings: settings, + ), + const SizedBox(height: AppSpacing.md), + ], + ), + ), + ), ], - const SizedBox(height: AppSpacing.lg), - _buildExerciseSummary(session, provider), - const SizedBox(height: AppSpacing.xl), - GlowButton( - label: 'Done', - icon: Icons.check_rounded, - onPressed: () => Navigator.of(context) - .popUntil((r) => r.isFirst), - ), - const SizedBox(height: AppSpacing.lg), - ], + ), ), ), - ), - ], - ), + RFBottomBar( + child: GlowButton( + label: 'Done', + icon: Icons.check_rounded, + onPressed: () => + Navigator.of(context).popUntil((r) => r.isFirst), + ), + ), + ], + ), + ], ), ); } @@ -139,10 +160,7 @@ class WorkoutSummaryScreen extends StatelessWidget { const SizedBox(height: 4), Text( dateStr, - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 13, - ), + style: const TextStyle(color: AppColors.textMuted, fontSize: 13), ), ], ); @@ -208,7 +226,7 @@ class WorkoutSummaryScreen extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const RFSectionHeader('New Personal Records'), + const RFSectionHeader('New personal records'), const SizedBox(height: AppSpacing.sm), ...prs.map((pr) { final name = provider.getExerciseName(pr.exerciseId); @@ -271,34 +289,36 @@ class WorkoutSummaryScreen extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const RFSectionHeader('Muscles Trained'), + const RFSectionHeader('Muscles trained'), const SizedBox(height: AppSpacing.sm), Wrap( spacing: 6, runSpacing: 6, children: muscles.map((m) { final name = provider.getMuscleGroupName(m); - return RFChip( - label: name, - color: AppColors.muscle(m), - ); + return RFChip(label: name, color: AppColors.muscle(m)); }).toList(), ), ], ); } - Widget _buildExerciseSummary( - WorkoutSession session, - WorkoutProvider provider, - ) { + Widget _buildExerciseSummary({ + required WorkoutSession session, + required WorkoutProvider provider, + required SettingsProvider settings, + }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const RFSectionHeader('Exercise Breakdown'), + const RFSectionHeader('Exercise breakdown'), const SizedBox(height: AppSpacing.sm), ...session.exercises.map( - (log) => _ExerciseSummaryRow(log: log, provider: provider), + (log) => _ExerciseSummaryRow( + log: log, + provider: provider, + settings: settings, + ), ), ], ); @@ -309,15 +329,18 @@ class _ExerciseSummaryRow extends StatelessWidget { const _ExerciseSummaryRow({ required this.log, required this.provider, + required this.settings, }); final ExerciseLog log; final WorkoutProvider provider; + final SettingsProvider settings; @override Widget build(BuildContext context) { final name = provider.getExerciseName(log.exerciseId); - final volume = log.totalVolume; + // Converted like every other figure here; this row used to print raw kg. + final volume = settings.toDisplay(log.totalVolume); final volStr = volume >= 1000 ? '${(volume / 1000).toStringAsFixed(1)}k' : volume.toStringAsFixed(0); @@ -356,7 +379,7 @@ class _ExerciseSummaryRow extends StatelessWidget { ), ), Text( - '$volStr kg', + '$volStr ${settings.unitLabel}', style: const TextStyle( color: AppColors.success, fontSize: 13, @@ -368,3 +391,69 @@ class _ExerciseSummaryRow extends StatelessWidget { ); } } + +/// Optional once-per-workout effort chip — "how did that feel?" — used only +/// to calibrate [EffortEstimator]'s RPE anchor. Never required: skipping it +/// leaves the rolling calibration offset unchanged. +class _EffortChipRow extends StatefulWidget { + const _EffortChipRow({required this.session}); + + final WorkoutSession session; + + @override + State<_EffortChipRow> createState() => _EffortChipRowState(); +} + +class _EffortChipRowState extends State<_EffortChipRow> { + static const _options = [ + (value: 1, label: 'Easy', color: AppColors.success), + (value: 2, label: 'Solid', color: AppColors.secondary), + (value: 3, label: 'Brutal', color: AppColors.accent), + ]; + + late int? _selected = widget.session.sessionEffort; + + Future _select(int value) async { + final previous = _selected; + setState(() => _selected = value); + try { + await context.read().recordSessionEffort( + widget.session.id, + value, + ); + } catch (e, st) { + // Don't leave the chip showing a value that was never persisted. + debugPrint('Failed to record session effort: $e\n$st'); + if (mounted) setState(() => _selected = previous); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const RFSectionHeader('How did that feel?'), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + for (final option in _options) ...[ + Expanded(child: _buildChip(option)), + if (option != _options.last) const SizedBox(width: AppSpacing.sm), + ], + ], + ), + ], + ); + } + + Widget _buildChip(({int value, String label, Color color}) option) { + return RFOptionChip( + label: option.label, + color: option.color, + selected: _selected == option.value, + inMutuallyExclusiveGroup: true, + onTap: () => _select(option.value), + ); + } +} diff --git a/workout-logger/lib/services/ai/gemini_ai_service.dart b/workout-logger/lib/services/ai/gemini_ai_service.dart index 1051481..b4acd65 100644 --- a/workout-logger/lib/services/ai/gemini_ai_service.dart +++ b/workout-logger/lib/services/ai/gemini_ai_service.dart @@ -28,8 +28,36 @@ const kGeminiModels = [ ('gemini-3.5-flash-lite', 'Gemini 3.5 Flash Lite'), ('gemini-3.5-flash', 'Gemini 3.5 Flash'), ('gemini-3.6-flash', 'Gemini 3.6 Flash'), + ('gemini-3.7-flash', 'Gemini 3.7 Flash'), ]; +// Ordered fastest/cheapest → most thorough. Matches the Gemini API's own +// thinkingLevel scale (gemini-2.x models don't use this — see +// supportedThinkingLevels). +const kThinkingLevels = ['minimal', 'low', 'medium', 'high']; + +// Fastest level available on the default model. +const kDefaultThinkingLevel = 'minimal'; + +/// Thinking levels [model] accepts, in kThinkingLevels order. Empty for +/// gemini-2.x models, which use the older thinkingBudget shape and don't +/// support thinkingLevel at all. gemini-3.7-flash is the first 3.x model +/// that doesn't support 'minimal' — sending it returns an API error. +List supportedThinkingLevels(String model) { + if (model.startsWith('gemini-2')) return const []; + if (model == 'gemini-3.7-flash') return const ['low', 'medium', 'high']; + return kThinkingLevels; +} + +/// Snaps [level] to a value [model] actually supports, falling back to the +/// model's fastest supported level if [level] isn't valid for it. Returns +/// [level] unchanged for models with no thinkingLevel support at all. +String clampThinkingLevel(String model, String level) { + final supported = supportedThinkingLevels(model); + if (supported.isEmpty || supported.contains(level)) return level; + return supported.first; +} + // Default to the latest GA model. const kDefaultGeminiModel = 'gemini-3.6-flash'; @@ -105,8 +133,10 @@ bool _isDailyQuotaExhausted(String body) { body.contains('free_tier_requests'); } -String? _getFallbackModel(String currentModel) { +String? getFallbackModel(String currentModel) { switch (currentModel) { + case 'gemini-3.7-flash': + return 'gemini-3.6-flash'; case 'gemini-3.6-flash': return 'gemini-3.5-flash'; case 'gemini-3.5-flash': @@ -144,6 +174,7 @@ class GeminiAiService extends ChangeNotifier implements IAiService { String _apiKey = ''; String _model = kDefaultGeminiModel; int _maxToolRounds = kDefaultMaxToolRounds; + String _thinkingLevel = kDefaultThinkingLevel; // Cumulative token usage across all AI calls (persisted). int _promptTokens = 0; @@ -160,6 +191,10 @@ class GeminiAiService extends ChangeNotifier implements IAiService { /// Upper bound on tool-resolution rounds per user turn. int get maxToolRounds => _maxToolRounds; + /// Current thinkingLevel sent with 3.x requests. Always valid for + /// [currentModel] — see [clampThinkingLevel]. + String get thinkingLevel => _thinkingLevel; + /// Cumulative input (prompt) tokens billed across all AI calls. int get promptTokensUsed => _promptTokens; @@ -175,10 +210,12 @@ class GeminiAiService extends ChangeNotifier implements IAiService { void init(String apiKey, { String model = kDefaultGeminiModel, int maxToolRounds = kDefaultMaxToolRounds, + String thinkingLevel = kDefaultThinkingLevel, }) { _apiKey = apiKey.trim(); _model = model; _maxToolRounds = maxToolRounds.clamp(kMinMaxToolRounds, kMaxMaxToolRounds); + _thinkingLevel = clampThinkingLevel(_model, thinkingLevel); } /// Load persisted cumulative token usage (call once at startup). @@ -252,6 +289,12 @@ class GeminiAiService extends ChangeNotifier implements IAiService { void updateModel(String model) { _model = model; + _thinkingLevel = clampThinkingLevel(_model, _thinkingLevel); + notifyListeners(); + } + + void updateThinkingLevel(String level) { + _thinkingLevel = clampThinkingLevel(_model, level); notifyListeners(); } @@ -285,15 +328,17 @@ class GeminiAiService extends ChangeNotifier implements IAiService { // Every gemini-2.x model predates the Gemini 3.x thinking-level enum and // only understands the older thinkingBudget (integer token budget) shape; - // 3.x models take thinkingLevel (minimal/medium/high). Matched by family, + // 3.x models take thinkingLevel (see kThinkingLevels). Matched by family, // not the single 'gemini-2.5-flash' id, so a persisted legacy model id // that isn't in kGeminiModels (e.g. from a since-removed picker entry) // still gets the right shape instead of failing with no fallback. Since // the daily quota fallback chain can land on either family mid-conversation, // the config shape must match whichever model is currently selected. + // _thinkingLevel is kept clamped to _model everywhere it's set, so it's + // always a value _model actually accepts by the time this is read. Map get _thinkingConfig => _model.startsWith('gemini-2') ? {'thinkingBudget': 0} - : {'thinkingLevel': 'minimal'}; + : {'thinkingLevel': _thinkingLevel}; // Extracts non-thought text strings from a candidate object. Iterable _textFromCandidate(Map candidate) sync* { @@ -332,9 +377,10 @@ class GeminiAiService extends ChangeNotifier implements IAiService { // Automatically fallback to next model when daily free quota limit is reached. if (_isDailyQuotaExhausted(err)) { - final fallback = _getFallbackModel(_model); + final fallback = getFallbackModel(_model); if (fallback != null) { _model = fallback; + _thinkingLevel = clampThinkingLevel(_model, _thinkingLevel); // gemini-2.5-flash and the 3.x family use different thinkingConfig // shapes (see _thinkingConfig) — rebuild it for the new model so the // retried request isn't rejected for the previous model's shape. @@ -404,9 +450,10 @@ class GeminiAiService extends ChangeNotifier implements IAiService { } if (_isDailyQuotaExhausted(response.body)) { - final fallback = _getFallbackModel(_model); + final fallback = getFallbackModel(_model); if (fallback != null) { _model = fallback; + _thinkingLevel = clampThinkingLevel(_model, _thinkingLevel); // See the matching comment in _streamSse — the thinkingConfig shape // must match whichever model this attempt is about to hit. (body['generationConfig'] as Map)['thinkingConfig'] = diff --git a/workout-logger/lib/services/interfaces/ml_service_interface.dart b/workout-logger/lib/services/interfaces/ml_service_interface.dart index 18c6f14..b63220f 100644 --- a/workout-logger/lib/services/interfaces/ml_service_interface.dart +++ b/workout-logger/lib/services/interfaces/ml_service_interface.dart @@ -75,12 +75,37 @@ abstract class IMLService { DateTime? asOf, }); + /// The history-dependent half of [computeMuscleRecoveryScores]: when each + /// muscle group was last trained. Sorts and walks all of [sessions], so + /// callers on a hot path cache this and pair it with + /// [recoveryScoresFrom] rather than recomputing per call. + Map lastTrainedPerMuscle( + List sessions, + Map exerciseMap, + ); + + /// The clock-dependent half: applies the recovery decay to a + /// [lastTrainedPerMuscle] result. O(muscle groups). + Map recoveryScoresFrom( + Map lastTrained, { + DateTime? asOf, + }); + /// Get recommended sets based on last session, recent-session trend, and growth model. /// [pastSessions], if provided, only has its first two entries read for /// deload/recovery detection: index 0 is the latest prior session, index 1 /// is the session immediately before that. Any further entries are ignored. /// [minReps]/[maxReps] define the double-progression rep range. /// Pass [recoveryScores] + [primaryMuscleIds] for recovery-aware advice. + /// Pass [readinessBand] (from the cached `ReadinessManager.snapshot` — this + /// method never fetches it itself, keeping this synchronous/offline) to + /// hold load on a low-readiness day. + /// Pass [sessionFatigueFactor] (a pre-computed 0.0–1.0 dampening factor — + /// see `SessionFatigueAccumulator`) for same-session fatigue awareness. + /// Deliberately exercise-agnostic, not muscle-specific: a backtest found no + /// statistically significant same-session order effect for this app's + /// hand-authored `muscleActivations` groupings, so this only tracks total + /// prior same-session training load, not which muscles it hit. List recommendSets({ required List lastSession, List>? pastSessions, @@ -89,6 +114,8 @@ abstract class IMLService { int maxReps = 12, Map? recoveryScores, List? primaryMuscleIds, + ReadinessBand? readinessBand, + double sessionFatigueFactor = 0.0, DateTime? asOf, }); diff --git a/workout-logger/lib/services/managers/analytics_manager.dart b/workout-logger/lib/services/managers/analytics_manager.dart index e234bba..151e9a3 100644 --- a/workout-logger/lib/services/managers/analytics_manager.dart +++ b/workout-logger/lib/services/managers/analytics_manager.dart @@ -33,6 +33,21 @@ class AnalyticsManager extends ChangeNotifier { // Rebuilt via [buildSessionIndex] whenever the session list changes. Map> _sessionIndex = {}; + // When each muscle was last trained, cached alongside _sessionIndex: the + // walk behind it sorts and scans all sessions, and it depends only on the + // history, not on the clock. Dropped by buildSessionIndex; _lastTrainedFor + // guards the case where a caller passes a different exerciseMap than the + // one this was built from. + Map? _lastTrained; + Map? _lastTrainedFor; + + // The map built from a caller's `exercises` list when it passes no + // exerciseMap. Held so repeated calls with the same list reuse one map + // instance — without this, the literal below is a fresh object every call + // and _lastTrainedFor's identity check never hits. + List? _fallbackLookupFor; + Map? _fallbackLookup; + // Callback to update targets with new growth models final void Function(String exerciseId, GrowthModel model)? onGrowthModelUpdated; @@ -66,6 +81,12 @@ class AnalyticsManager extends ChangeNotifier { } _sessionIndex = index; + // The recovery walk is keyed off the same "sessions changed" signal, so + // it's dropped here and rebuilt lazily on the next getRecommendations. + _lastTrained = null; + _lastTrainedFor = null; + _fallbackLookupFor = null; + _fallbackLookup = null; } /// Get growth model for a specific exercise @@ -130,14 +151,36 @@ class AnalyticsManager extends ChangeNotifier { /// Get set recommendations for an exercise. /// /// Uses the most-recently-dated session containing this exercise as the - /// basis for the recommendation. Reads from the pre-built session index - /// so no sort is required at call time — O(1). + /// basis for the recommendation, plus up to 2 sessions before that for + /// deload-recovery detection and (when [exercises]/[exerciseMap] are + /// supplied) the primary muscle's recovery status. + /// + /// Reads from the pre-built session index, so the log lookup is O(1) and + /// needs no sort at call time. The recovery walk behind the recovery + /// status is O(N sessions), but it is cached on the same + /// [buildSessionIndex] invalidation, so it costs that only on the first + /// call after a session change; subsequent calls pay O(muscle groups). + /// + /// [exerciseMap] is a pre-built id → Exercise map for O(1) lookups; falls + /// back to building one from [exercises] when omitted (see + /// [getWeeklyVolumeByMuscle] for the same convention). That fallback is + /// memoized on the [exercises] instance, because the last-trained cache + /// below keys off the map's identity. + /// + /// [readinessBand] and [sessionFatigueFactor] are forwarded straight to + /// [IMLService.recommendSets], matching what + /// `WorkoutProvider.getRecommendations` passes — the two entry points + /// have drifted apart before (see [recoveryRecommendationInputs]). List getRecommendations( String exerciseId, - List sessions, - ) { + List sessions, { + List exercises = const [], + Map? exerciseMap, + ReadinessBand? readinessBand, + double sessionFatigueFactor = 0.0, + }) { final isFresh = identical(_lastIndexedSessions, sessions); - + // Fast path: use pre-built index if available and fresh. final logs = isFresh ? _sessionIndex[exerciseId] : null; final lastLog = (logs != null && logs.isNotEmpty) @@ -148,12 +191,48 @@ class AnalyticsManager extends ChangeNotifier { return _mlService.getDefaultRecommendations(3); } + final pastSessions = (logs != null && logs.isNotEmpty) + ? logs.take(3).map((e) => e.log.sets).toList() + : [lastLog.sets]; + + final lookup = exerciseMap ?? _fallbackLookupFrom(exercises); + // Reuse the last-trained walk across calls — it's invalidated by + // buildSessionIndex, the same signal that invalidates _sessionIndex. + if (!isFresh || !identical(_lastTrainedFor, lookup)) { + _lastTrained = _mlService.lastTrainedPerMuscle(sessions, lookup); + _lastTrainedFor = lookup; + } + final recoveryInputs = recoveryRecommendationInputs( + exerciseId: exerciseId, + sessions: sessions, + exerciseMap: lookup, + mlService: _mlService, + lastTrained: _lastTrained, + ); + return _mlService.recommendSets( lastSession: lastLog.sets, + pastSessions: pastSessions, growthModel: _growthModels[exerciseId], + recoveryScores: recoveryInputs.recoveryScores, + primaryMuscleIds: recoveryInputs.primaryMuscleIds, + readinessBand: readinessBand, + sessionFatigueFactor: sessionFatigueFactor, ); } + /// id → Exercise map for [exercises], reusing the previously built one + /// while the caller keeps passing the same list instance. The identity + /// check in [getRecommendations] compares map instances, so rebuilding + /// this on every call would invalidate [_lastTrained] every time. + Map _fallbackLookupFrom(List exercises) { + if (!identical(_fallbackLookupFor, exercises) || _fallbackLookup == null) { + _fallbackLookup = {for (final e in exercises) e.id: e}; + _fallbackLookupFor = exercises; + } + return _fallbackLookup!; + } + /// Get volume progression for an exercise. /// /// Reads from the pre-built session index (sorted oldest-first by diff --git a/workout-logger/lib/services/ml_service.dart b/workout-logger/lib/services/ml_service.dart index d33654a..0d7da8c 100644 --- a/workout-logger/lib/services/ml_service.dart +++ b/workout-logger/lib/services/ml_service.dart @@ -1,219 +1,38 @@ import 'dart:math'; import '../models/models.dart'; import 'interfaces/ml_service_interface.dart'; +import 'strategies/growth_curve_fitter.dart'; +import 'strategies/progression_rules.dart'; +import 'utils/recovery_calculator.dart'; export 'interfaces/ml_service_interface.dart' show DataPoint, MuscleRecoveryStatus; +export 'strategies/growth_curve_fitter.dart' show IGrowthCurveFitter, GrowthCurveFitter; +export 'strategies/progression_rules.dart' + show ProgressionContext, ProgressionRule, ProgressionRuleFactory; +export 'utils/recovery_calculator.dart' show RecoveryCalculator; /// Growth modelling + double-progression recommendations + per-muscle /// recovery scoring. /// -/// Growth model: exponentially-weighted least squares fit of two candidate -/// curves — linear and logarithmic (saturating) — each refined with one -/// robust (Tukey bisquare) re-weighting pass so single outlier sessions -/// (deloads, cut-short workouts) don't tilt the trend. The better-fitting -/// curve wins; the logarithmic form captures the diminishing returns real -/// muscle growth follows, which a straight line systematically overshoots. +/// Growth-curve fitting and target-date projection are delegated to +/// [IGrowthCurveFitter] (see `strategies/growth_curve_fitter.dart`) and +/// recovery scoring to [RecoveryCalculator] (see `utils/recovery_calculator.dart`), +/// so this class only owns data extraction and the recommendation heuristics. class MLService implements IMLService { - // Decay constant for recency weights. At λ=0.15, a session 10 sessions ago - // carries exp(−1.5) ≈ 22 % of the weight of the most recent session. - static const _lambda = 0.15; - - // Logarithmic candidate is considered only with enough history for - // curvature to be identifiable; over short spans log ≈ linear. - static const _minPointsForLogCurve = 6; - static const _minSpanDaysForLogCurve = 14.0; - - // The log curve must beat linear by this fraction of weighted RSS to win, - // preventing flip-flopping between near-identical fits. - static const _logSelectionMargin = 0.02; - - // Robust pass: points beyond c·σ̂ get fully rejected by Tukey's bisquare. - static const _tukeyC = 4.685; - static const _minPointsForRobustPass = 5; - - // Recovery time constants τ (hours) per muscle group. - // Full recovery (~95 %) occurs at ≈ 3τ. - static const _tauHours = { - 'chest': 48.0, - 'back': 60.0, - 'lats': 60.0, - 'quads': 60.0, - 'hamstrings': 60.0, - 'glutes': 60.0, - 'legs': 60.0, - 'shoulders': 40.0, - 'traps': 40.0, - 'biceps': 36.0, - 'triceps': 36.0, - 'abs': 24.0, - 'core': 24.0, - 'calves': 24.0, - 'forearms': 24.0, - }; - static const _defaultTauHours = 48.0; + final IGrowthCurveFitter _curveFitter; + final RecoveryCalculator _recoveryCalculator; + + MLService({ + IGrowthCurveFitter? curveFitter, + RecoveryCalculator? recoveryCalculator, + }) : _curveFitter = curveFitter ?? GrowthCurveFitter(), + _recoveryCalculator = recoveryCalculator ?? const RecoveryCalculator(); // ==================== GROWTH MODEL ==================== @override - GrowthModel trainGrowthModel(List dataPoints) { - return MLService.trainGrowthModelStatic(dataPoints); - } - - /// Fits linear and logarithmic candidates with exponential recency weights - /// (weight for point i of n: exp(−λ·(n−1−i))) plus one robust re-weighting - /// pass each, then selects the better curve by weighted residual error. - static GrowthModel trainGrowthModelStatic(List dataPoints) { - if (dataPoints.isEmpty) { - return GrowthModel(slope: 0, intercept: 0, r2: 0, lastTrained: DateTime.now()); - } - if (dataPoints.length == 1) { - return GrowthModel( - slope: 0, - intercept: dataPoints.first.y, - r2: 1, - lastTrained: DateTime.now(), - lastX: dataPoints.first.x, - ); - } - - final n = dataPoints.length; - final recency = List.generate(n, (i) => exp(-_lambda * (n - 1 - i))); - final xs = dataPoints.map((p) => p.x).toList(); - final ys = dataPoints.map((p) => p.y).toList(); - final lastX = xs.reduce(max); - final spanDays = lastX - xs.reduce(min); - - final linear = _robustWeightedFit(xs, ys, recency); - - _Fit? logFit; - if (n >= _minPointsForLogCurve && spanDays >= _minSpanDaysForLogCurve) { - final logXs = xs.map((x) => log(1 + max(0.0, x))).toList(); - logFit = _robustWeightedFit(logXs, ys, recency); - } - - final useLog = logFit != null && - logFit.rss < linear.rss * (1 - _logSelectionMargin); - final fit = useLog ? logFit : linear; - final curve = useLog ? GrowthCurve.logarithmic : GrowthCurve.linear; - - // Instantaneous daily rate at the newest point: d/dx [a + b·ln(1+x)]. - final slope = useLog ? fit.slope / (1 + lastX) : fit.slope; - - return GrowthModel( - slope: slope, - intercept: fit.intercept, - r2: fit.r2.clamp(0.0, 1.0), - lastTrained: DateTime.now(), - curve: curve, - coefficient: fit.slope, - lastX: lastX, - stdError: fit.stdError, - ); - } - - /// Weighted least squares with one Tukey-bisquare re-weighting pass. - /// - /// The robust pass estimates residual scale via the weighted MAD, then - /// refits with outliers down-weighted by (1 − (r/cσ̂)²)², so a single - /// deload or cut-short session cannot tilt the trend. Skipped for tiny - /// samples or when residuals are too uniform to identify outliers. - static _Fit _robustWeightedFit( - List xs, - List ys, - List recency, - ) { - var fit = _weightedLeastSquares(xs, ys, recency); - - if (xs.length < _minPointsForRobustPass) return fit; - - final residuals = [ - for (var i = 0; i < xs.length; i++) - (ys[i] - (fit.intercept + fit.slope * xs[i])).abs(), - ]; - final mad = _median(residuals); - if (mad <= 0) return fit; - final scale = 1.4826 * mad; // MAD → σ̂ for normal residuals - - final robust = []; - for (var i = 0; i < xs.length; i++) { - final u = residuals[i] / (_tukeyC * scale); - final tukey = u >= 1 ? 0.0 : pow(1 - u * u, 2).toDouble(); - robust.add(recency[i] * tukey); - } - // Refit only if the pass actually rejected/damped something and enough - // effective weight survives to keep the fit identifiable. - final kept = robust.where((w) => w > 0).length; - if (kept < 3) return fit; - final refit = _weightedLeastSquares(xs, ys, robust); - return refit.degenerate ? fit : refit; - } - - static _Fit _weightedLeastSquares( - List xs, - List ys, - List weights, - ) { - final n = xs.length; - final wSum = weights.fold(0.0, (s, w) => s + w); - - double wSumX = 0, wSumY = 0, wSumXY = 0, wSumX2 = 0; - for (var i = 0; i < n; i++) { - final w = weights[i]; - wSumX += w * xs[i]; - wSumY += w * ys[i]; - wSumXY += w * xs[i] * ys[i]; - wSumX2 += w * xs[i] * xs[i]; - } - - final denom = wSum * wSumX2 - wSumX * wSumX; - if (denom.abs() < 1e-12 || wSum <= 0) { - final mean = wSum > 0 ? wSumY / wSum : 0.0; - return _Fit( - slope: 0, - intercept: mean, - r2: 0, - rss: double.infinity, - stdError: 0, - degenerate: true, - ); - } - - final slope = (wSum * wSumXY - wSumX * wSumY) / denom; - final intercept = (wSumY - slope * wSumX) / wSum; - - final yBar = wSumY / wSum; - double ssTotal = 0, ssResidual = 0, wSqSum = 0; - for (var i = 0; i < n; i++) { - final w = weights[i]; - final predicted = slope * xs[i] + intercept; - ssTotal += w * pow(ys[i] - yBar, 2); - ssResidual += w * pow(ys[i] - predicted, 2); - wSqSum += w * w; - } - - // Weighted mean squared residual, dof-corrected via the Kish effective - // sample size (recency weights make n optimistic). - final nEff = wSqSum > 0 ? (wSum * wSum) / wSqSum : 0.0; - final dof = max(1.0, nEff - 2); - final stdError = sqrt(max(0.0, ssResidual / wSum) * (nEff / dof)); - - return _Fit( - slope: slope, - intercept: intercept, - r2: ssTotal > 0 ? (1 - ssResidual / ssTotal).toDouble() : 0.0, - rss: ssResidual, - stdError: stdError, - degenerate: false, - ); - } - - static double _median(List values) { - final sorted = List.from(values)..sort(); - final mid = sorted.length ~/ 2; - return sorted.length.isOdd - ? sorted[mid] - : (sorted[mid - 1] + sorted[mid]) / 2; - } + GrowthModel trainGrowthModel(List dataPoints) => + _curveFitter.fit(dataPoints); // ==================== DATA EXTRACTION ==================== @@ -256,7 +75,7 @@ class MLService implements IMLService { DateTime? firstDate; for (final session in sorted) { - final volumes = _muscleVolumes(session, exerciseMap); + final volumes = _recoveryCalculator.muscleVolumes(session, exerciseMap); final vol = volumes[muscleGroupId]; if (vol == null || vol == 0) continue; firstDate ??= session.date; @@ -268,70 +87,31 @@ class MLService implements IMLService { // ==================== RECOVERY ==================== - /// Compute recovery scores for every muscle group trained in [sessions]. - /// - /// Model: recovery(t) = 1 − exp(−t / τ) - /// t = hours since last session that trained this muscle - /// τ = muscle-specific time constant (see [_tauHours]) - /// - /// Full recovery (≥ 95 %) occurs around t = 3τ. @override Map computeMuscleRecoveryScores( List sessions, Map exerciseMap, { DateTime? asOf, - }) { - final now = asOf ?? DateTime.now(); - final sorted = List.from(sessions) - ..sort((a, b) => a.date.compareTo(b.date)); - - // Walk sessions forward — each one updates the "last trained" record. - final lastTrained = {}; - for (final session in sorted) { - for (final muscleId in _muscleVolumes(session, exerciseMap).keys) { - lastTrained[muscleId] = session.date; - } - } - - final result = {}; - for (final entry in lastTrained.entries) { - final muscleId = entry.key; - final tau = _tauHours[muscleId] ?? _defaultTauHours; - final hours = now.difference(entry.value).inMinutes / 60.0; - final fraction = (1.0 - exp(-hours / tau)).clamp(0.0, 1.0); - // 95 % recovery ≈ 3τ; remaining = 3τ − elapsed. - final hoursRemaining = tau * 3 - hours; - - result[muscleId] = MuscleRecoveryStatus( - muscleGroupId: muscleId, - recoveryFraction: fraction, - timeSinceLastTrained: Duration(minutes: (hours * 60).round()), - estimatedTimeToFullRecovery: hoursRemaining > 0 - ? Duration(minutes: (hoursRemaining * 60).round()) - : null, + }) => + _recoveryCalculator.computeMuscleRecoveryScores( + sessions, + exerciseMap, + asOf: asOf, ); - } - return result; - } - /// Effective volume per muscle group for one session. - static Map _muscleVolumes( - WorkoutSession session, + @override + Map lastTrainedPerMuscle( + List sessions, Map exerciseMap, - ) { - final volumes = {}; - for (final log in session.exercises) { - final exercise = exerciseMap[log.exerciseId]; - if (exercise == null) continue; - final total = log.totalVolume; - for (final activation in exercise.muscleActivations) { - volumes[activation.muscleGroupId] = - (volumes[activation.muscleGroupId] ?? 0.0) + - total * activation.activationPercentage / 100.0; - } - } - return volumes; - } + ) => + _recoveryCalculator.lastTrainedPerMuscle(sessions, exerciseMap); + + @override + Map recoveryScoresFrom( + Map lastTrained, { + DateTime? asOf, + }) => + _recoveryCalculator.recoveryScoresFrom(lastTrained, asOf: asOf); // ==================== RECOMMENDATIONS ==================== @@ -350,18 +130,31 @@ class MLService implements IMLService { // as "active" — see the isRecent comment below. static const _deloadRecencyWindowDays = 21; - /// Double-progression with trend- and recovery-aware modulation. + /// Double-progression with trend-, recovery-, readiness-, and + /// same-session-fatigue-aware modulation. /// /// Priority order: /// 1. Under-recovered primary muscle → maintenance (hold weight & reps). - /// 2. Decline (weekly growth < −2 %, trustworthy fit) → 10 % deload. - /// 3. Plateau (weekly growth < 0.5 %, trustworthy fit) → maintenance. - /// 4. reps ≥ maxReps → bump weight, reset to minReps. - /// 5. Otherwise → add 1 rep, hold weight. + /// 2. Post-deload recovery → re-anchor on the pre-deload baseline. + /// 3. Low whole-day readiness → maintenance. + /// 4. Heavy same-session fatigue (`sessionFatigueFactor` ≥ 1.0) → maintenance; + /// partial fatigue instead scales the weight-progression increment. + /// 5. Decline (weekly growth < −2 %, trustworthy fit) → 10 % deload. + /// 6. Plateau (weekly growth < 0.5 %, trustworthy fit) → maintenance. + /// 7. reps ≥ maxReps → bump weight (fatigue-scaled), reset to minReps. + /// 8. Otherwise → add 1 rep, hold weight. /// /// Trend checks use [GrowthModel.weeklyGrowthPercent] — growth relative to /// the lifter's current volume — so the same thresholds work for a 60 kg /// novice bench and a 10 t weekly squat volume. + /// + /// [sessionFatigueFactor] is a pre-computed 0.0–1.0 dampening factor from + /// same-session earlier training (see `SessionFatigueAccumulator` — + /// deliberately *not* attributed to specific muscles here: an exercise's + /// hand-authored `muscleActivations` percentages proved untrustworthy for + /// this — e.g. Seated Cable Row's own top muscle is "back" while Lat + /// Pulldown/Pull-ups' is "lats", separate ids in this app's taxonomy, so + /// per-muscle attribution silently missed real overlap between them). @override List recommendSets({ required List lastSession, @@ -371,6 +164,8 @@ class MLService implements IMLService { int maxReps = 12, Map? recoveryScores, List? primaryMuscleIds, + ReadinessBand? readinessBand, + double sessionFatigueFactor = 0.0, DateTime? asOf, }) { final now = asOf ?? DateTime.now(); @@ -445,8 +240,10 @@ class MLService implements IMLService { .fold(100, (a, b) => a < b ? a : b) : null; + final isLowReadiness = readinessBand == ReadinessBand.low; + return refSets - .map((set) => _doubleProgression( + .map((set) => ProgressionRuleFactory.apply(ProgressionContext( set: set, minReps: minReps, maxReps: maxReps, @@ -455,82 +252,12 @@ class MLService implements IMLService { isUnderRecovered: isUnderRecovered, recoveryPercent: worstRecovery, isPostDeloadRecovery: isPostDeloadRecovery, - )) + isLowReadiness: isLowReadiness, + sessionFatigueFactor: sessionFatigueFactor, + ))) .toList(); } - static SetRecommendation _doubleProgression({ - required WorkoutSet set, - required int minReps, - required int maxReps, - required bool isPlateau, - required bool isDeclining, - required bool isUnderRecovered, - int? recoveryPercent, - bool isPostDeloadRecovery = false, - }) { - if (isUnderRecovered) { - return SetRecommendation( - weight: set.weight, - reps: set.reps, - confidence: 'low', - reasoning: - 'Muscle only $recoveryPercent% recovered — maintain load, skip progression', - ); - } - - if (isPostDeloadRecovery) { - return SetRecommendation( - weight: set.weight, - reps: set.reps, - confidence: 'high', - // No raw weight value embedded here — the recommended weight/unit - // is already surfaced via SetRecommendation.weight and formatted by - // the presentation layer according to the user's unit preference. - reasoning: - 'Resuming training after deload — anchored on pre-deload baseline (${set.reps} reps)', - ); - } - - if (isDeclining) { - // Round the deload to the plate increment users can actually load. - final deloaded = max(0.0, ((set.weight * 0.9) / 2.5).round() * 2.5); - return SetRecommendation( - weight: deloaded, - reps: set.reps, - confidence: 'medium', - reasoning: - 'Volume trending down — deload ~10% for a session or two, then rebuild', - ); - } - - if (isPlateau) { - return SetRecommendation( - weight: set.weight, - reps: set.reps, - confidence: 'medium', - reasoning: 'Plateau detected — maintain load and focus on form quality', - ); - } - - if (set.reps >= maxReps) { - final increment = set.weight < 40 ? 2.5 : 5.0; - return SetRecommendation( - weight: set.weight + increment, - reps: minReps, - confidence: 'high', - reasoning: 'Rep target hit — add ${increment}kg and reset to $minReps reps', - ); - } - - return SetRecommendation( - weight: set.weight, - reps: set.reps + 1, - confidence: 'high', - reasoning: 'Add 1 rep (${set.reps + 1}/$maxReps) — progressive overload', - ); - } - /// Fill in default recommendations when no history exists. @override List getDefaultRecommendations(int setCount) { @@ -547,98 +274,15 @@ class MLService implements IMLService { // ==================== TARGET PREDICTIONS ==================== - // Predictions further out than this are noise, not information. - static const _maxPredictionDays = 365 * 2; - - /// Projects the fitted curve forward to the target (x = days). - /// - /// Linear fits extrapolate at the constant rate; logarithmic fits invert - /// the curve, so the flattening trajectory honestly pushes the date out - /// instead of promising linear gains forever. Predictions beyond two years - /// return null — too uncertain to show. @override DateTime? predictTargetCompletion({ required double currentValue, required double targetValue, required GrowthModel growthModel, - double sessionsPerWeek = 3.0, - }) { - if (currentValue >= targetValue) return DateTime.now(); - if (growthModel.slope <= 0) return null; - - final double daysFromNow; - switch (growthModel.curve) { - case GrowthCurve.linear: - daysFromNow = (targetValue - currentValue) / growthModel.slope; - case GrowthCurve.logarithmic: - // Map the live current value and the target through the curve's - // inverse x(y) = exp((y−a)/b) − 1 and take the day difference, so - // drift between the live value and the fitted curve cancels out. - final b = growthModel.coefficient; - if (b <= 0) return null; - final xTarget = exp((targetValue - growthModel.intercept) / b) - 1; - final xCurrent = exp((currentValue - growthModel.intercept) / b) - 1; - daysFromNow = xTarget - xCurrent; - } - - if (daysFromNow <= 0) return DateTime.now(); - if (!daysFromNow.isFinite || daysFromNow > _maxPredictionDays) return null; - return DateTime.now().add(Duration(days: daysFromNow.ceil())); - } - - /// Confidence interval around the predicted completion date. - /// - /// Width comes from the model's residual standard error converted to days - /// at the current growth rate (± how long the typical session-to-session - /// scatter could shift the crossing point), falling back to an R²-scaled - /// margin for legacy models without a stored error. - static ({DateTime optimistic, DateTime expected, DateTime pessimistic})? - predictTargetWithConfidence({ - required double currentValue, - required double targetValue, - required GrowthModel growthModel, - double sessionsPerWeek = 3.0, - }) { - final expected = MLService().predictTargetCompletion( - currentValue: currentValue, - targetValue: targetValue, - growthModel: growthModel, - sessionsPerWeek: sessionsPerWeek, - ); - if (expected == null) return null; - - final daysToTarget = expected.difference(DateTime.now()).inDays; - final int uncertainty; - if (growthModel.stdError > 0 && growthModel.slope > 0) { - uncertainty = (growthModel.stdError / growthModel.slope) - .ceil() - .clamp(0, max(1, daysToTarget)); - } else { - uncertainty = ((1 - growthModel.r2) * daysToTarget * 0.5).ceil(); - } - return ( - optimistic: expected.subtract(Duration(days: uncertainty)), - expected: expected, - pessimistic: expected.add(Duration(days: uncertainty)), - ); - } -} - -/// Internal weighted-least-squares result for one candidate curve. -class _Fit { - final double slope; - final double intercept; - final double r2; - final double rss; // weighted residual sum of squares (selection criterion) - final double stdError; - final bool degenerate; - - const _Fit({ - required this.slope, - required this.intercept, - required this.r2, - required this.rss, - required this.stdError, - required this.degenerate, - }); + }) => + _curveFitter.predictTargetCompletion( + currentValue: currentValue, + targetValue: targetValue, + growthModel: growthModel, + ); } diff --git a/workout-logger/lib/services/settings_provider.dart b/workout-logger/lib/services/settings_provider.dart index c79660a..a3ca124 100644 --- a/workout-logger/lib/services/settings_provider.dart +++ b/workout-logger/lib/services/settings_provider.dart @@ -3,7 +3,9 @@ import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'ai/gemini_ai_service.dart' - show kDefaultMaxToolRounds, kMinMaxToolRounds, kMaxMaxToolRounds; + show kDefaultMaxToolRounds, kMinMaxToolRounds, kMaxMaxToolRounds, + kDefaultThinkingLevel, kDefaultGeminiModel, kGeminiModels, + clampThinkingLevel; import 'interfaces/storage_service_interface.dart'; enum WeightUnit { kg, lbs } @@ -18,8 +20,9 @@ class SettingsProvider extends ChangeNotifier { String? _userName; String? _lastSeenVersion; String _geminiApiKey = ''; - String _geminiModel = 'gemini-3.6-flash'; + String _geminiModel = kDefaultGeminiModel; int _geminiMaxToolRounds = kDefaultMaxToolRounds; + String _geminiThinkingLevel = kDefaultThinkingLevel; String _weeklyInsights = ''; DateTime? _weeklyInsightsDate; bool _showAdvancedMetrics = false; @@ -36,6 +39,7 @@ class SettingsProvider extends ChangeNotifier { String get geminiApiKey => _geminiApiKey; String get geminiModel => _geminiModel; int get geminiMaxToolRounds => _geminiMaxToolRounds; + String get geminiThinkingLevel => _geminiThinkingLevel; String get weeklyInsights => _weeklyInsights; DateTime? get weeklyInsightsDate => _weeklyInsightsDate; bool get showAdvancedMetrics => _showAdvancedMetrics; @@ -65,7 +69,14 @@ class SettingsProvider extends ChangeNotifier { _userName = await _storage.getSetting('userName'); _lastSeenVersion = await _storage.getSetting('lastSeenVersion'); _geminiApiKey = await _storage.getSetting('geminiApiKey') ?? ''; - _geminiModel = await _storage.getSetting('geminiModel') ?? 'gemini-3.6-flash'; + // Normalize on read: the picker only offers kGeminiModels, and a value + // left behind by an older build (the list has churned across releases) + // would match none of its items and trip DropdownButtonFormField's + // "exactly one item per value" assertion. + final storedModel = await _storage.getSetting('geminiModel'); + _geminiModel = _isKnownGeminiModel(storedModel) + ? storedModel! + : kDefaultGeminiModel; final maxRounds = await _storage.getSetting('geminiMaxToolRounds'); // Clamp on read as well as on write: a stored value from an older build or // a hand-edited settings row would otherwise bypass the bounds that @@ -73,6 +84,9 @@ class SettingsProvider extends ChangeNotifier { final parsedMaxRounds = maxRounds != null ? int.tryParse(maxRounds) : null; _geminiMaxToolRounds = (parsedMaxRounds ?? kDefaultMaxToolRounds) .clamp(kMinMaxToolRounds, kMaxMaxToolRounds); + final thinkingLevel = await _storage.getSetting('geminiThinkingLevel'); + _geminiThinkingLevel = + clampThinkingLevel(_geminiModel, thinkingLevel ?? kDefaultThinkingLevel); _weeklyInsights = await _storage.getSetting('weeklyInsights') ?? ''; final dateStr = await _storage.getSetting('weeklyInsightsDate'); _weeklyInsightsDate = dateStr != null ? DateTime.tryParse(dateStr) : null; @@ -80,6 +94,10 @@ class SettingsProvider extends ChangeNotifier { _showAdvancedMetrics = advMetrics == 'true'; } + /// Whether [model] is one the model picker actually offers. + static bool _isKnownGeminiModel(String? model) => + model != null && kGeminiModels.any((entry) => entry.$1 == model); + /// A valid bodyweight must be finite (not NaN/Infinity) and strictly positive. static bool _isValidBodyWeight(double? weight) => weight != null && weight.isFinite && weight > 0; @@ -141,15 +159,52 @@ class SettingsProvider extends ChangeNotifier { notifyListeners(); } + /// Persists before committing in memory, for the same reason as + /// [setGeminiThinkingLevel]: the picker's onChanged drops this Future, so a + /// failed write must leave the previously saved model active rather than a + /// value that only exists in memory. Future setGeminiModel(String model) async { - _geminiModel = model; + final previousModel = _geminiModel; await _storage.saveSetting('geminiModel', model); + final clamped = clampThinkingLevel(model, _geminiThinkingLevel); + if (clamped != _geminiThinkingLevel) { + try { + await _storage.saveSetting('geminiThinkingLevel', clamped); + } catch (_) { + // The two writes are not a transaction. Without this the model write + // above survives, so a selection the UI reported as failed would come + // back as the active model on the next launch. + try { + await _storage.saveSetting('geminiModel', previousModel); + } catch (e, st) { + debugPrint('Failed to roll back Gemini model: $e\n$st'); + } + rethrow; + } + } + _geminiModel = model; + _geminiThinkingLevel = clamped; notifyListeners(); } + /// Persists before committing in memory, for the same reason as + /// [setGeminiModel] and [setGeminiThinkingLevel]: the slider's onChangeEnd + /// drops this Future, so a failed write must not leave the provider holding + /// a limit that was never stored. Future setGeminiMaxToolRounds(int rounds) async { - _geminiMaxToolRounds = rounds.clamp(kMinMaxToolRounds, kMaxMaxToolRounds); - await _storage.saveSetting('geminiMaxToolRounds', _geminiMaxToolRounds.toString()); + final clamped = rounds.clamp(kMinMaxToolRounds, kMaxMaxToolRounds); + await _storage.saveSetting('geminiMaxToolRounds', clamped.toString()); + _geminiMaxToolRounds = clamped; + notifyListeners(); + } + + /// Persists before committing in memory: the slider's onChangeEnd swallows + /// a throw from here, so assigning first would leave the provider showing a + /// level that was never written and never notified. + Future setGeminiThinkingLevel(String level) async { + final clamped = clampThinkingLevel(_geminiModel, level); + await _storage.saveSetting('geminiThinkingLevel', clamped); + _geminiThinkingLevel = clamped; notifyListeners(); } diff --git a/workout-logger/lib/services/strategies/growth_curve_fitter.dart b/workout-logger/lib/services/strategies/growth_curve_fitter.dart new file mode 100644 index 0000000..dece3d0 --- /dev/null +++ b/workout-logger/lib/services/strategies/growth_curve_fitter.dart @@ -0,0 +1,298 @@ +// Growth Curve Fitter (Single Responsibility Principle) +// +// Owns the growth-modelling math extracted from MLService: exponentially- +// weighted least squares fit of two candidate curves — linear and +// logarithmic (saturating) — each refined with one robust (Tukey bisquare) +// re-weighting pass so single outlier sessions (deloads, cut-short workouts) +// don't tilt the trend. The better-fitting curve wins; the logarithmic form +// captures the diminishing returns real muscle growth follows, which a +// straight line systematically overshoots. Also owns projecting the fitted +// curve forward to a target value/date. + +import 'dart:math'; + +import '../../models/models.dart'; +import '../interfaces/ml_service_interface.dart'; + +/// Abstract strategy for fitting a growth curve and projecting it forward. +/// +/// Extracted so `MLService` can depend on the abstraction (Dependency +/// Inversion) and a test double can be substituted without pulling in the +/// real WLS/Tukey math. +abstract class IGrowthCurveFitter { + /// Fits linear and logarithmic candidates with exponential recency weights + /// (weight for point i of n: exp(−λ·(n−1−i))) plus one robust re-weighting + /// pass each, then selects the better curve by weighted residual error. + GrowthModel fit(List dataPoints); + + /// Projects the fitted curve forward to the target (x = days). + /// + /// Linear fits extrapolate at the constant rate; logarithmic fits invert + /// the curve, so the flattening trajectory honestly pushes the date out + /// instead of promising linear gains forever. Predictions beyond two years + /// return null — too uncertain to show. + DateTime? predictTargetCompletion({ + required double currentValue, + required double targetValue, + required GrowthModel growthModel, + }); +} + +class GrowthCurveFitter implements IGrowthCurveFitter { + // Decay constant for recency weights. At λ=0.15, a session 10 sessions ago + // carries exp(−1.5) ≈ 22 % of the weight of the most recent session. + static const _lambda = 0.15; + + // Logarithmic candidate is considered only with enough history for + // curvature to be identifiable; over short spans log ≈ linear. + static const _minPointsForLogCurve = 6; + static const _minSpanDaysForLogCurve = 14.0; + + // The log curve must beat linear by this fraction of weighted RSS to win, + // preventing flip-flopping between near-identical fits. + static const _logSelectionMargin = 0.02; + + // Robust pass: points beyond c·σ̂ get fully rejected by Tukey's bisquare. + static const _tukeyC = 4.685; + static const _minPointsForRobustPass = 5; + + // Predictions further out than this are noise, not information. + static const _maxPredictionDays = 365 * 2; + + @override + GrowthModel fit(List dataPoints) { + if (dataPoints.isEmpty) { + return GrowthModel(slope: 0, intercept: 0, r2: 0, lastTrained: DateTime.now()); + } + if (dataPoints.length == 1) { + return GrowthModel( + slope: 0, + intercept: dataPoints.first.y, + r2: 1, + lastTrained: DateTime.now(), + lastX: dataPoints.first.x, + ); + } + + final n = dataPoints.length; + final recency = List.generate(n, (i) => exp(-_lambda * (n - 1 - i))); + final xs = dataPoints.map((p) => p.x).toList(); + final ys = dataPoints.map((p) => p.y).toList(); + final lastX = xs.reduce(max); + final spanDays = lastX - xs.reduce(min); + + final linear = _robustWeightedFit(xs, ys, recency); + + _Fit? logFit; + if (n >= _minPointsForLogCurve && spanDays >= _minSpanDaysForLogCurve) { + final logXs = xs.map((x) => log(1 + max(0.0, x))).toList(); + logFit = _robustWeightedFit(logXs, ys, recency); + } + + final useLog = logFit != null && + logFit.rss < linear.rss * (1 - _logSelectionMargin); + final fit = useLog ? logFit : linear; + final curve = useLog ? GrowthCurve.logarithmic : GrowthCurve.linear; + + // Instantaneous daily rate at the newest point: d/dx [a + b·ln(1+x)]. + final slope = useLog ? fit.slope / (1 + lastX) : fit.slope; + + return GrowthModel( + slope: slope, + intercept: fit.intercept, + r2: fit.r2.clamp(0.0, 1.0), + lastTrained: DateTime.now(), + curve: curve, + coefficient: fit.slope, + lastX: lastX, + stdError: fit.stdError, + ); + } + + /// Weighted least squares with one Tukey-bisquare re-weighting pass. + /// + /// The robust pass estimates residual scale via the weighted MAD, then + /// refits with outliers down-weighted by (1 − (r/cσ̂)²)², so a single + /// deload or cut-short session cannot tilt the trend. Skipped for tiny + /// samples or when residuals are too uniform to identify outliers. + static _Fit _robustWeightedFit( + List xs, + List ys, + List recency, + ) { + var fit = _weightedLeastSquares(xs, ys, recency); + + if (xs.length < _minPointsForRobustPass) return fit; + + final residuals = [ + for (var i = 0; i < xs.length; i++) + (ys[i] - (fit.intercept + fit.slope * xs[i])).abs(), + ]; + final mad = _median(residuals); + if (mad <= 0) return fit; + final scale = 1.4826 * mad; // MAD → σ̂ for normal residuals + + final robust = []; + for (var i = 0; i < xs.length; i++) { + final u = residuals[i] / (_tukeyC * scale); + final tukey = u >= 1 ? 0.0 : pow(1 - u * u, 2).toDouble(); + robust.add(recency[i] * tukey); + } + // Refit only if the pass actually rejected/damped something and enough + // effective weight survives to keep the fit identifiable. + final kept = robust.where((w) => w > 0).length; + if (kept < 3) return fit; + final refit = _weightedLeastSquares(xs, ys, robust); + return refit.degenerate ? fit : refit; + } + + static _Fit _weightedLeastSquares( + List xs, + List ys, + List weights, + ) { + final n = xs.length; + final wSum = weights.fold(0.0, (s, w) => s + w); + + double wSumX = 0, wSumY = 0, wSumXY = 0, wSumX2 = 0; + for (var i = 0; i < n; i++) { + final w = weights[i]; + wSumX += w * xs[i]; + wSumY += w * ys[i]; + wSumXY += w * xs[i] * ys[i]; + wSumX2 += w * xs[i] * xs[i]; + } + + final denom = wSum * wSumX2 - wSumX * wSumX; + if (denom.abs() < 1e-12 || wSum <= 0) { + final mean = wSum > 0 ? wSumY / wSum : 0.0; + return _Fit( + slope: 0, + intercept: mean, + r2: 0, + rss: double.infinity, + stdError: 0, + degenerate: true, + ); + } + + final slope = (wSum * wSumXY - wSumX * wSumY) / denom; + final intercept = (wSumY - slope * wSumX) / wSum; + + final yBar = wSumY / wSum; + double ssTotal = 0, ssResidual = 0, wSqSum = 0; + for (var i = 0; i < n; i++) { + final w = weights[i]; + final predicted = slope * xs[i] + intercept; + ssTotal += w * pow(ys[i] - yBar, 2); + ssResidual += w * pow(ys[i] - predicted, 2); + wSqSum += w * w; + } + + // Weighted mean squared residual, dof-corrected via the Kish effective + // sample size (recency weights make n optimistic). + final nEff = wSqSum > 0 ? (wSum * wSum) / wSqSum : 0.0; + final dof = max(1.0, nEff - 2); + final stdError = sqrt(max(0.0, ssResidual / wSum) * (nEff / dof)); + + return _Fit( + slope: slope, + intercept: intercept, + r2: ssTotal > 0 ? (1 - ssResidual / ssTotal).toDouble() : 0.0, + rss: ssResidual, + stdError: stdError, + degenerate: false, + ); + } + + static double _median(List values) { + final sorted = List.from(values)..sort(); + final mid = sorted.length ~/ 2; + return sorted.length.isOdd + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; + } + + @override + DateTime? predictTargetCompletion({ + required double currentValue, + required double targetValue, + required GrowthModel growthModel, + }) { + if (currentValue >= targetValue) return DateTime.now(); + if (growthModel.slope <= 0) return null; + + final double daysFromNow; + switch (growthModel.curve) { + case GrowthCurve.linear: + daysFromNow = (targetValue - currentValue) / growthModel.slope; + case GrowthCurve.logarithmic: + // Map the live current value and the target through the curve's + // inverse x(y) = exp((y−a)/b) − 1 and take the day difference, so + // drift between the live value and the fitted curve cancels out. + final b = growthModel.coefficient; + if (b <= 0) return null; + final xTarget = exp((targetValue - growthModel.intercept) / b) - 1; + final xCurrent = exp((currentValue - growthModel.intercept) / b) - 1; + daysFromNow = xTarget - xCurrent; + } + + if (daysFromNow <= 0) return DateTime.now(); + if (!daysFromNow.isFinite || daysFromNow > _maxPredictionDays) return null; + return DateTime.now().add(Duration(days: daysFromNow.ceil())); + } + + /// Confidence interval around the predicted completion date. + /// + /// Width comes from the model's residual standard error converted to days + /// at the current growth rate (± how long the typical session-to-session + /// scatter could shift the crossing point), falling back to an R²-scaled + /// margin for legacy models without a stored error. + static ({DateTime optimistic, DateTime expected, DateTime pessimistic})? + predictTargetWithConfidence({ + required double currentValue, + required double targetValue, + required GrowthModel growthModel, + }) { + final expected = GrowthCurveFitter().predictTargetCompletion( + currentValue: currentValue, + targetValue: targetValue, + growthModel: growthModel, + ); + if (expected == null) return null; + + final daysToTarget = expected.difference(DateTime.now()).inDays; + final int uncertainty; + if (growthModel.stdError > 0 && growthModel.slope > 0) { + uncertainty = (growthModel.stdError / growthModel.slope) + .ceil() + .clamp(0, max(1, daysToTarget)); + } else { + uncertainty = ((1 - growthModel.r2) * daysToTarget * 0.5).ceil(); + } + return ( + optimistic: expected.subtract(Duration(days: uncertainty)), + expected: expected, + pessimistic: expected.add(Duration(days: uncertainty)), + ); + } +} + +/// Internal weighted-least-squares result for one candidate curve. +class _Fit { + final double slope; + final double intercept; + final double r2; + final double rss; // weighted residual sum of squares (selection criterion) + final double stdError; + final bool degenerate; + + const _Fit({ + required this.slope, + required this.intercept, + required this.r2, + required this.rss, + required this.stdError, + required this.degenerate, + }); +} diff --git a/workout-logger/lib/services/strategies/progression_rules.dart b/workout-logger/lib/services/strategies/progression_rules.dart new file mode 100644 index 0000000..a8511f7 --- /dev/null +++ b/workout-logger/lib/services/strategies/progression_rules.dart @@ -0,0 +1,281 @@ +// Progression Rule Chain (Open/Closed Principle) +// +// Each tier of MLService's double-progression heuristic (recovery gate, +// deload protocol, decline/plateau detection, weight/rep progression) is its +// own [ProgressionRule]. Rules are tried in order; the first to return a +// non-null [SetRecommendation] wins. New signals (readiness, same-session +// fatigue — see the recommendation-engine-upgrade plan) become new rules +// inserted at the appropriate point in the chain instead of edits to a +// growing if/else. Mirrors the registry shape of +// `strategies/target_calculator.dart`'s TargetCalculatorFactory, adapted +// from a type→strategy map to an ordered chain. + +import 'dart:math'; + +import '../../models/models.dart'; + +/// Everything a [ProgressionRule] needs to decide (or defer) a +/// recommendation for one set. +class ProgressionContext { + final WorkoutSet set; + final int minReps; + final int maxReps; + final bool isPlateau; + final bool isDeclining; + final bool isUnderRecovered; + final int? recoveryPercent; + final bool isPostDeloadRecovery; + + /// True when today's readiness (sleep/RHR/HRV) is in the low band. + /// Defaulted so existing call sites that don't pass it are unaffected. + final bool isLowReadiness; + + /// 0.0 (untouched) to 1.0+ (fully fatigued) — how much of today's + /// earlier training already loaded this set's primary muscle. 0.0 is the + /// default so existing call sites are unaffected. [SessionFatigueRule] + /// hard-holds at ≥1.0; values in between scale [DoubleProgressionRule]'s + /// weight increment instead of blocking it outright. + final double sessionFatigueFactor; + + const ProgressionContext({ + required this.set, + required this.minReps, + required this.maxReps, + required this.isPlateau, + required this.isDeclining, + required this.isUnderRecovered, + this.recoveryPercent, + required this.isPostDeloadRecovery, + this.isLowReadiness = false, + this.sessionFatigueFactor = 0.0, + }); +} + +/// One tier in the progression priority chain. +/// +/// Return `null` to defer to the next rule ("this signal doesn't apply — +/// try the next one"); return a [SetRecommendation] to win and stop the +/// chain. +abstract class ProgressionRule { + SetRecommendation? apply(ProgressionContext context); +} + +/// Priority 1: a still-fatigued primary muscle holds instead of progressing. +class UnderRecoveredRule implements ProgressionRule { + const UnderRecoveredRule(); + + @override + SetRecommendation? apply(ProgressionContext c) { + if (!c.isUnderRecovered) return null; + return SetRecommendation( + weight: c.set.weight, + reps: c.set.reps, + confidence: 'low', + reasoning: 'Muscle only ${c.recoveryPercent}% recovered — maintain ' + 'load, skip progression', + ); + } +} + +/// Priority 2: the session right after a detected deload re-anchors on the +/// pre-deload baseline rather than progressing off the deload itself. +class PostDeloadRecoveryRule implements ProgressionRule { + const PostDeloadRecoveryRule(); + + @override + SetRecommendation? apply(ProgressionContext c) { + if (!c.isPostDeloadRecovery) return null; + return SetRecommendation( + weight: c.set.weight, + reps: c.set.reps, + confidence: 'high', + // No raw weight value embedded here — the recommended weight/unit + // is already surfaced via SetRecommendation.weight and formatted by + // the presentation layer according to the user's unit preference. + reasoning: 'Resuming training after deload — anchored on pre-deload ' + 'baseline (${c.set.reps} reps)', + ); + } +} + +/// Priority 3: low whole-day readiness (sleep/RHR/HRV) holds instead of +/// progressing — a day-level physiological signal, so it outranks the +/// session-local fatigue check below. +class ReadinessRule implements ProgressionRule { + const ReadinessRule(); + + @override + SetRecommendation? apply(ProgressionContext c) { + if (!c.isLowReadiness) return null; + return SetRecommendation( + weight: c.set.weight, + reps: c.set.reps, + confidence: 'low', + reasoning: 'Low readiness today (sleep/recovery signals) — hold load ' + 'and reassess next session', + ); + } +} + +/// Priority 4: heavy same-session fatigue for this muscle hard-holds once +/// [ProgressionContext.sessionFatigueFactor] reaches 1.0. Values between 0 +/// and 1 don't stop here — they defer to [DoubleProgressionRule], which +/// scales its weight increment down instead of blocking it outright. +class SessionFatigueRule implements ProgressionRule { + const SessionFatigueRule(); + + @override + SetRecommendation? apply(ProgressionContext c) { + if (c.sessionFatigueFactor < 1.0) return null; + return SetRecommendation( + weight: c.set.weight, + reps: c.set.reps, + confidence: 'medium', + reasoning: "Already trained hard for this muscle earlier in today's " + 'session — hold and finish strong', + ); + } +} + +/// Priority 5: a trustworthy declining trend triggers a ~10% deload. +class DeclineDeloadRule implements ProgressionRule { + const DeclineDeloadRule(); + + @override + SetRecommendation? apply(ProgressionContext c) { + if (!c.isDeclining) return null; + // Round the deload to the plate increment users can actually load. + final deloaded = max(0.0, ((c.set.weight * 0.9) / 2.5).round() * 2.5); + return SetRecommendation( + weight: deloaded, + reps: c.set.reps, + confidence: 'medium', + reasoning: 'Volume trending down — deload ~10% for a session or two, ' + 'then rebuild', + ); + } +} + +/// Priority 6: a trustworthy flat trend holds load and reps. +class PlateauRule implements ProgressionRule { + const PlateauRule(); + + @override + SetRecommendation? apply(ProgressionContext c) { + if (!c.isPlateau) return null; + return SetRecommendation( + weight: c.set.weight, + reps: c.set.reps, + confidence: 'medium', + reasoning: 'Plateau detected — maintain load and focus on form quality', + ); + } +} + +/// Priority 7 (terminal): plain double progression — add a rep, or bump +/// weight and reset reps once the rep ceiling is hit. Always produces a +/// recommendation, so this must stay last in the chain. +/// +/// When [ProgressionContext.sessionFatigueFactor] is partial (0 < f < 1, +/// not enough to trigger [SessionFatigueRule]'s hard hold), the weight +/// increment scales down by (1 − f) instead of blocking progression +/// outright, snapped to the nearest 2.5kg plate. At f = 0 (the default) this +/// reduces to exactly the original unscaled behavior. +class DoubleProgressionRule implements ProgressionRule { + const DoubleProgressionRule(); + + @override + SetRecommendation apply(ProgressionContext c) { + if (c.set.reps >= c.maxReps) { + final baseIncrement = c.set.weight < 40 ? 2.5 : 5.0; + final increment = c.sessionFatigueFactor > 0 + ? max( + 0.0, + ((baseIncrement * (1 - c.sessionFatigueFactor)) / 2.5).round() * + 2.5, + ) + : baseIncrement; + + if (increment <= 0) { + return SetRecommendation( + weight: c.set.weight, + reps: c.set.reps, + confidence: 'medium', + reasoning: 'Rep target hit, but earlier sets today already ' + 'fatigued this muscle — hold weight for now', + ); + } + + return SetRecommendation( + weight: c.set.weight + increment, + reps: c.minReps, + confidence: increment < baseIncrement ? 'medium' : 'high', + // No raw weight or unit embedded, for the same reason as + // PostDeloadRecoveryRule above: the recommended load rides on + // SetRecommendation.weight and the presentation layer formats it in + // the user's chosen unit. Hardcoding "kg" here read as "add 5.0kg" + // to someone with pounds selected. + reasoning: increment < baseIncrement + ? 'Rep target hit — step the weight up a little (reduced — ' + 'already fatigued this muscle today) and reset to ' + '${c.minReps} reps' + : 'Rep target hit — step the weight up and reset to ' + '${c.minReps} reps', + ); + } + + return SetRecommendation( + weight: c.set.weight, + reps: c.set.reps + 1, + confidence: 'high', + reasoning: 'Add 1 rep (${c.set.reps + 1}/${c.maxReps}) — progressive overload', + ); + } +} + +/// Ordered registry of [ProgressionRule]s, mirroring +/// [TargetCalculatorFactory]'s registration/reset pattern. +class ProgressionRuleFactory { + static List _rules = _defaults(); + + static List _defaults() => const [ + UnderRecoveredRule(), + PostDeloadRecoveryRule(), + ReadinessRule(), + SessionFatigueRule(), + DeclineDeloadRule(), + PlateauRule(), + DoubleProgressionRule(), + ]; + + /// Reset the chain to defaults. + /// + /// This is primarily used in tests to restore isolation after + /// registering a custom rule. + static void reset() { + _rules = _defaults(); + } + + /// The current chain, in priority order (highest priority first). + static List get rules => List.unmodifiable(_rules); + + /// Registers [rule] at the head of the chain, so it is tried before every + /// existing rule (highest priority). + static void registerRuleAtHead(ProgressionRule rule) { + _rules = [rule, ..._rules]; + } + + /// Runs [context] through the chain and returns the first rule's + /// non-null result. [DoubleProgressionRule] is the terminal rule and + /// always matches, so a well-formed chain never falls through. + static SetRecommendation apply(ProgressionContext context) { + for (final rule in _rules) { + final result = rule.apply(context); + if (result != null) return result; + } + throw StateError( + 'No progression rule produced a recommendation — the chain is ' + 'missing a terminal rule.', + ); + } +} diff --git a/workout-logger/lib/services/utils/effort_calibration.dart b/workout-logger/lib/services/utils/effort_calibration.dart new file mode 100644 index 0000000..458068b --- /dev/null +++ b/workout-logger/lib/services/utils/effort_calibration.dart @@ -0,0 +1,30 @@ +// Effort Calibration (rolling, from the once-per-workout chip only) +// +// EffortEstimator anchors every estimate at RPE 8. This is the only real +// calibration signal available for that anchor: a post-workout "how did +// that feel" chip (Easy/Solid/Brutal), answered once per session, not once +// per set. An exponential moving average (alpha ~= 1/10) approximates a +// rolling mean over the last ~10 answered sessions without needing to +// persist per-session history — skipping the chip just means the offset +// stays wherever it last was (0.0 if never answered). + +import 'effort_estimator.dart'; + +class EffortCalibration { + const EffortCalibration(); + + static const double _alpha = 0.1; + static const double maxOffset = 1.0; + + /// Chip value → the RPE it represents. 1 = Easy, 2 = Solid, 3 = Brutal. + static const Map chipRpe = {1: 6.5, 2: 8.0, 3: 9.5}; + + /// Folds one session-effort chip answer into [previousOffset], returning + /// the new rolling offset to apply to [EffortEstimator.anchorRpe]. + double updateOffset(double previousOffset, int chipValue) { + final target = chipRpe[chipValue] ?? EffortEstimator.anchorRpe; + final delta = target - EffortEstimator.anchorRpe; + return (previousOffset * (1 - _alpha) + delta * _alpha) + .clamp(-maxOffset, maxOffset); + } +} diff --git a/workout-logger/lib/services/utils/effort_estimator.dart b/workout-logger/lib/services/utils/effort_estimator.dart new file mode 100644 index 0000000..3149a7a --- /dev/null +++ b/workout-logger/lib/services/utils/effort_estimator.dart @@ -0,0 +1,211 @@ +// Effort Estimator (no per-set user input) +// +// Estimates a 0-10 RPE (rate of perceived exertion) for a set from data +// already logged, so the recommendation engine can distinguish an easy top +// set from a grinder without ever asking the user mid-set. Four additive +// terms on top of an RPE-8 anchor (double progression already assumes +// near-failure working sets): +// +// 1. Trend deviation — actual volume vs. the growth model's prediction. +// 2. Session decline — volume dropping across sets of the same exercise +// today (dropset/fatigue signal). +// 3. Rest/tempo drift — longer-than-usual rest or slower reps vs. this +// exercise's own sets earlier today. +// 4. Heart rate (opt.) — only when pre-resolved HR data is supplied by the +// caller; purely additive, never required. This +// class does no I/O itself — see [HrEffortSignal]. +// +// Every estimate carries a confidence score capped below what a real +// user-reported RPE would get, so low-confidence estimates should only +// annotate reasoning text, never flip a recommendation branch on their own. + +import '../../models/models.dart'; + +/// Pre-resolved heart-rate signal for one set, already read from Health +/// Connect by the caller. [EffortEstimator] never fetches HR itself — the +/// in-workout recommendation path must stay synchronous/offline, so live HR +/// wiring (if added) has to happen upstream of this pure function. +class HrEffortSignal { + /// Peak bpm observed during this set's window. + final double setPeakBpm; + + /// Number of samples that window contained (gates how much to trust it). + final int setSampleCount; + + /// Lowest bpm observed anywhere in today's session so far. + final double sessionFloorBpm; + + /// Highest bpm observed anywhere in today's session so far. + final double sessionPeakBpm; + + const HrEffortSignal({ + required this.setPeakBpm, + required this.setSampleCount, + required this.sessionFloorBpm, + required this.sessionPeakBpm, + }); +} + +class EffortEstimator { + const EffortEstimator(); + + static const double anchorRpe = 8.0; + static const double minRpe = 5.0; + static const double maxRpe = 10.0; + + // Term 1: trend deviation. Reuses the same trustworthy-fit gate as the + // growth-trend recommendation rules (see progression_rules.dart). + static const double _minR2ForTrendSignal = 0.2; + static const double _trendDeviationWeight = 0.8; + + // Term 2: intra-session decline. Deadband absorbs normal set-to-set + // variance; only a real drop-off counts. + static const double _declineDeadband = 0.05; + static const double _declineWeight = 3.0; + static const double _maxDeclineFraction = 0.5; + + // Term 3: rest/tempo drift vs. this exercise's own sets earlier today. + static const double _restGapWeight = 0.6; + static const double _tempoWeight = 0.5; + + // Term 4: heart rate (optional). + static const double _hrWeight = 1.0; + static const double _hrBaselineFraction = 0.8; + static const double _hrSpanFraction = 0.2; + static const int minHrSamplesForSignal = 3; + + // Confidence ladder — base plus bonuses, capped below a real user-reported + // value's confidence of 1.0. + static const double _baseConfidence = 0.35; + static const double _trendGateConfidenceBonus = 0.20; + static const double _sufficientSetsConfidenceBonus = 0.20; + static const double _hrConfidenceBonus = 0.15; + static const double _historyConfidenceBonus = 0.10; + static const double maxEstimatedConfidence = 0.85; + static const int _minSetsForConfidenceBonus = 3; + static const int _minSessionsForConfidenceBonus = 6; + + /// Estimates RPE for [set]. + /// + /// [priorSetsThisExerciseToday] must be this exercise's other sets already + /// logged earlier in today's in-progress session, oldest-first, excluding + /// [set] itself — used for the decline and rest/tempo terms. + /// [growthModel]/[growthModelX] back the trend-deviation term (skipped + /// when either is absent or the fit isn't trustworthy). + /// [calibrationOffset] is the rolling once-per-workout-chip adjustment to + /// the RPE-8 anchor (see [WorkoutSession.sessionEffort]). + /// [sessionHistoryCount] is how many past sessions exist for this exercise + /// (confidence only — more history makes the trend term more trustworthy). + /// [hrSignal] is optional and purely additive; omit when HR data isn't + /// available. + EffortEstimate estimate({ + required WorkoutSet set, + List priorSetsThisExerciseToday = const [], + GrowthModel? growthModel, + double? growthModelX, + double calibrationOffset = 0.0, + int sessionHistoryCount = 0, + HrEffortSignal? hrSignal, + }) { + var rpe = anchorRpe + calibrationOffset; + var confidence = _baseConfidence; + var source = EffortSource.estimatedHrless; + + // Term 1: trend deviation. + if (growthModel != null && + growthModel.r2 > _minR2ForTrendSignal && + growthModelX != null) { + final actualVolume = priorSetsThisExerciseToday.fold( + 0.0, + (sum, s) => sum + s.volume, + ) + + set.volume; + final predicted = growthModel.predict(growthModelX); + final scale = + growthModel.stdError > 0 ? growthModel.stdError : (predicted * 0.05).abs(); + if (scale > 0) { + final z = ((actualVolume - predicted) / scale).clamp(-2.0, 2.0); + rpe += _trendDeviationWeight * -z; + confidence += _trendGateConfidenceBonus; + } + } + + // Term 2: intra-session decline (needs a baseline set today). + if (priorSetsThisExerciseToday.isNotEmpty) { + final firstVolume = priorSetsThisExerciseToday.first.volume; + if (firstVolume > 0) { + final declineFrac = 1 - (set.volume / firstVolume); + rpe += _declineWeight * + (declineFrac - _declineDeadband).clamp(0.0, _maxDeclineFraction); + } + } + + // Term 3: rest/tempo drift vs. the median of this exercise's earlier + // sets today (needs ≥2 prior sets to establish a median baseline). + if (priorSetsThisExerciseToday.length >= 2) { + final gaps = [ + for (var i = 1; i < priorSetsThisExerciseToday.length; i++) + priorSetsThisExerciseToday[i] + .timestamp + .difference(priorSetsThisExerciseToday[i - 1].timestamp) + .inSeconds + .toDouble(), + ]; + final medianGap = _median(gaps); + final currentGap = set.timestamp + .difference(priorSetsThisExerciseToday.last.timestamp) + .inSeconds + .toDouble(); + if (medianGap > 0 && currentGap > 0) { + rpe += _restGapWeight * (currentGap / medianGap - 1).clamp(0.0, 1.0); + } + + final tempos = [ + for (final s in priorSetsThisExerciseToday) + if (s.timeTaken != null && s.reps > 0) s.timeTaken! / s.reps, + ]; + if (tempos.isNotEmpty && set.timeTaken != null && set.reps > 0) { + final medianTempo = _median(tempos); + final currentTempo = set.timeTaken! / set.reps; + if (medianTempo > 0) { + rpe += _tempoWeight * + (currentTempo / medianTempo - 1).clamp(0.0, 1.0); + } + } + } + + // Term 4: heart rate (optional, purely additive). + if (hrSignal != null && + hrSignal.setSampleCount >= minHrSamplesForSignal && + hrSignal.sessionPeakBpm > hrSignal.sessionFloorBpm) { + final hrFrac = (hrSignal.setPeakBpm - hrSignal.sessionFloorBpm) / + (hrSignal.sessionPeakBpm - hrSignal.sessionFloorBpm); + final term = + ((hrFrac - _hrBaselineFraction) / _hrSpanFraction).clamp(-1.0, 1.0); + rpe += _hrWeight * term; + confidence += _hrConfidenceBonus; + source = EffortSource.estimatedWithHr; + } + + if (priorSetsThisExerciseToday.length + 1 >= _minSetsForConfidenceBonus) { + confidence += _sufficientSetsConfidenceBonus; + } + if (sessionHistoryCount >= _minSessionsForConfidenceBonus) { + confidence += _historyConfidenceBonus; + } + + return EffortEstimate( + rpe: rpe.clamp(minRpe, maxRpe), + source: source, + confidence: confidence.clamp(0.0, maxEstimatedConfidence), + ); + } + + static double _median(List values) { + final sorted = List.from(values)..sort(); + final mid = sorted.length ~/ 2; + return sorted.length.isOdd + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; + } +} diff --git a/workout-logger/lib/services/utils/exercise_history.dart b/workout-logger/lib/services/utils/exercise_history.dart index 319a051..a1c19f1 100644 --- a/workout-logger/lib/services/utils/exercise_history.dart +++ b/workout-logger/lib/services/utils/exercise_history.dart @@ -1,4 +1,5 @@ import '../../models/models.dart'; +import '../interfaces/ml_service_interface.dart'; /// Returns the [ExerciseLog] for [exerciseId] from the most-recently-dated /// [WorkoutSession] in [sessions], or `null` if the exercise has never been @@ -27,3 +28,37 @@ ExerciseLog? findMostRecentExerciseLog( } return null; } + +/// Recovery-related inputs for [IMLService.recommendSets]: per-muscle +/// recovery scores across [sessions], and [exerciseId]'s primary muscle. +/// +/// Centralizes what both `WorkoutProvider.getRecommendations` and +/// `AnalyticsManager.getRecommendations` need to assemble so the two call +/// sites can't drift out of sync with each other again (they previously did +/// — see the recommendation-engine-upgrade plan's audit item 6). Returns +/// nulls when [exerciseMap] doesn't resolve [exerciseId], so callers that +/// don't have exercise data on hand degrade to the pre-recovery-aware +/// behavior instead of erroring. +/// Pass [lastTrained] (from [IMLService.lastTrainedPerMuscle]) when the +/// caller already holds a cached one — callers on a per-frame path do, and +/// it skips re-sorting and re-walking every session here. Omitting it +/// computes the same thing from [sessions]. +({Map? recoveryScores, List? primaryMuscleIds}) + recoveryRecommendationInputs({ + required String exerciseId, + required List sessions, + required Map exerciseMap, + required IMLService mlService, + Map? lastTrained, +}) { + final exercise = exerciseMap[exerciseId]; + if (exercise == null) { + return (recoveryScores: null, primaryMuscleIds: null); + } + return ( + recoveryScores: lastTrained != null + ? mlService.recoveryScoresFrom(lastTrained) + : mlService.computeMuscleRecoveryScores(sessions, exerciseMap), + primaryMuscleIds: [exercise.primaryMuscle], + ); +} diff --git a/workout-logger/lib/services/utils/recovery_calculator.dart b/workout-logger/lib/services/utils/recovery_calculator.dart new file mode 100644 index 0000000..9168742 --- /dev/null +++ b/workout-logger/lib/services/utils/recovery_calculator.dart @@ -0,0 +1,133 @@ +// Muscle Recovery Calculator (Single Responsibility Principle) +// +// Pure, stateless per-muscle recovery scoring extracted from MLService. +// Model: recovery(t) = 1 − exp(−t / τ), where t is hours since the last +// session that trained the muscle and τ is a muscle-specific time constant. +// Full recovery (~95%) occurs at ≈ 3τ. Same "pure, no I/O" shape as +// ReadinessCalculator — this is why it lives in utils/, not managers/ +// (managers in this codebase are ChangeNotifier state owners). + +import 'dart:math'; + +import '../../models/models.dart'; +import '../interfaces/ml_service_interface.dart'; + +class RecoveryCalculator { + const RecoveryCalculator(); + + // Recovery time constants τ (hours) per muscle group. + // Full recovery (~95 %) occurs at ≈ 3τ. + static const _tauHours = { + 'chest': 48.0, + 'back': 60.0, + 'lats': 60.0, + 'quads': 60.0, + 'hamstrings': 60.0, + 'glutes': 60.0, + 'legs': 60.0, + 'shoulders': 40.0, + 'traps': 40.0, + 'biceps': 36.0, + 'triceps': 36.0, + 'abs': 24.0, + 'core': 24.0, + 'calves': 24.0, + 'forearms': 24.0, + }; + static const _defaultTauHours = 48.0; + + /// Compute recovery scores for every muscle group trained in [sessions]. + /// + /// Model: recovery(t) = 1 − exp(−t / τ) + /// t = hours since last session that trained this muscle + /// τ = muscle-specific time constant (see [_tauHours]) + /// + /// Full recovery (≥ 95 %) occurs around t = 3τ. + Map computeMuscleRecoveryScores( + List sessions, + Map exerciseMap, { + DateTime? asOf, + }) => + recoveryScoresFrom( + lastTrainedPerMuscle(sessions, exerciseMap), + asOf: asOf, + ); + + /// When each muscle group was last trained across [sessions]. + /// + /// Split out from [computeMuscleRecoveryScores] because this half is the + /// expensive one — it copies and sorts the whole session list and walks + /// every session's exercises — and it depends only on the history, not on + /// the clock. Callers on a hot path (see `WorkoutProvider`) cache this and + /// re-run only the cheap [recoveryScoresFrom] decay per call. + Map lastTrainedPerMuscle( + List sessions, + Map exerciseMap, + ) { + final sorted = List.from(sessions) + ..sort((a, b) => a.date.compareTo(b.date)); + + // Walk sessions forward — each one updates the "last trained" record. + final lastTrained = {}; + for (final session in sorted) { + for (final muscleId in muscleVolumes(session, exerciseMap).keys) { + lastTrained[muscleId] = session.date; + } + } + return lastTrained; + } + + /// Applies the recovery decay to a [lastTrainedPerMuscle] result. + /// + /// O(muscle groups) and clock-dependent — cheap enough to re-run on every + /// call even when the [lastTrained] map behind it is cached. + Map recoveryScoresFrom( + Map lastTrained, { + DateTime? asOf, + }) { + final now = asOf ?? DateTime.now(); + final result = {}; + for (final entry in lastTrained.entries) { + final muscleId = entry.key; + final tau = _tauHours[muscleId] ?? _defaultTauHours; + final hours = now.difference(entry.value).inMinutes / 60.0; + final fraction = (1.0 - exp(-hours / tau)).clamp(0.0, 1.0); + // 95 % recovery ≈ 3τ; remaining = 3τ − elapsed. + final hoursRemaining = tau * 3 - hours; + + result[muscleId] = MuscleRecoveryStatus( + muscleGroupId: muscleId, + recoveryFraction: fraction, + timeSinceLastTrained: Duration(minutes: (hours * 60).round()), + estimatedTimeToFullRecovery: hoursRemaining > 0 + ? Duration(minutes: (hoursRemaining * 60).round()) + : null, + ); + } + return result; + } + + /// Effective volume per muscle group for one session: + /// sum(exerciseVolume × activationPercentage / 100). + /// + /// Public (not `_`-prefixed) so other collaborators — e.g. a same-session + /// fatigue accumulator — can reuse the same activation-weighting instead + /// of duplicating this loop. + Map muscleVolumes( + WorkoutSession session, + Map exerciseMap, + ) { + final volumes = {}; + for (final log in session.exercises) { + final exercise = exerciseMap[log.exerciseId]; + if (exercise == null) continue; + final total = log.totalVolume; + for (final activation in exercise.muscleActivations) { + volumes[activation.muscleGroupId] = + (volumes[activation.muscleGroupId] ?? 0.0) + + total * activation.activationPercentage / 100.0; + } + } + return volumes; + } +} diff --git a/workout-logger/lib/services/utils/session_fatigue.dart b/workout-logger/lib/services/utils/session_fatigue.dart new file mode 100644 index 0000000..71e80e8 --- /dev/null +++ b/workout-logger/lib/services/utils/session_fatigue.dart @@ -0,0 +1,88 @@ +// Session Fatigue Accumulator (intra-session, cross-exercise awareness) +// +// The recommendation engine previously had zero awareness of what the user +// already did earlier in TODAY's session — deload/plateau detection only +// looks at day-to-day history of the SAME exercise. +// +// An earlier version of this accumulator attributed fatigue per muscle group +// using each exercise's hand-authored `Exercise.muscleActivations` table. +// That table proved untrustworthy: Seated Cable Row's own top-activation +// muscle is "back" while Lat Pulldown's and Pull-ups' is "lats" — separate +// ids in this app's taxonomy, not aliased — so a real user's actual +// back/bicep routine (rows, pulldowns, pull-ups back to back — genuinely the +// same movement pattern) could silently miss its own overlap depending on +// which exercise happened to be authored with which label as "primary". +// +// A backtest against 74 real logged sessions (see the recommendation-engine +// plan doc's Phase 6+ notes) then tested whether a same-session order effect +// is statistically detectable AT ALL, independent of the muscle-activation +// table — pooled regression of same-session exercise performance against +// prior same-session hard-set count, session-demeaned: r = 0.005, t = 0.09, +// n = 309. No effect survived a split-half check for any individual +// exercise either. With this user's routine order essentially fixed +// session-to-session (73 of 85 logged exercise pairs never appear in both +// possible orders), there isn't yet enough contrastive data for ANY model — +// simple or complex — to learn a reliable relationship. +// +// So this accumulator now tracks a single, exercise-agnostic scalar — total +// RPE-weighted hard-set-equivalents already logged elsewhere in today's +// session — with NO muscle attribution at all, and the cap that converts it +// to a dampening factor is calibrated to stay at 0.0 for every one of 254 +// real historical set-recommendation contexts (max observed: 11.87; caps set +// well above that). This is an intentionally inert, honest placeholder: the +// mechanism is real and ready, but the current calibration should not change +// any recommendation until genuine order-variation data exists to fit it +// against (see the plan doc's suggested "occasionally swap exercise order" +// data-collection nudge). + +import '../../models/models.dart'; +import 'effort_estimator.dart'; + +class SessionFatigueAccumulator { + const SessionFatigueAccumulator({ + EffortEstimator effortEstimator = const EffortEstimator(), + }) : _effortEstimator = effortEstimator; + + final EffortEstimator _effortEstimator; + + // A set's contribution ramps from 0 at RPE 6 (light/warm-up) to 1.0 at + // RPE 9+ (near failure) — not every logged set counts equally. + static const double _rpeFloor = 6.0; + static const double _rpeSpan = 3.0; + + // Calibrated (2026-08) against 254 real historical set-recommendation + // contexts from a 74-session export: max observed total was 11.87. Both + // caps sit comfortably above that, so this factor evaluates to 0.0 on + // every real case seen so far — see the file-level comment for why. + static const double _softCap = 16.0; + static const double _hardCap = 24.0; + + /// A 0.0–1.0 same-session fatigue dampening factor from [exerciseLogs] + /// (the in-progress session's logs so far, across all exercises), + /// summing each set's estimated-RPE-derived hardness with no per-muscle + /// weighting. + /// + /// [excludeExerciseId], when given, skips that exercise's own sets — the + /// fatigue an exercise contributes to itself is already covered by the + /// deload/decline rules; this accumulator is specifically for carryover + /// from *other* exercises trained earlier today. + double factorFor({ + required List exerciseLogs, + String? excludeExerciseId, + }) { + var total = 0.0; + for (final log in exerciseLogs) { + if (log.exerciseId == excludeExerciseId) continue; + + for (var i = 0; i < log.sets.length; i++) { + final set = log.sets[i]; + final estimate = _effortEstimator.estimate( + set: set, + priorSetsThisExerciseToday: log.sets.sublist(0, i), + ); + total += ((estimate.rpe - _rpeFloor) / _rpeSpan).clamp(0.0, 1.0); + } + } + return ((total - _softCap) / (_hardCap - _softCap)).clamp(0.0, 1.0); + } +} diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 8d3ca62..64c20fe 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -26,6 +26,9 @@ import 'ml_service.dart'; import 'strategies/target_calculator.dart'; import 'managers/program_manager.dart'; import 'managers/history_manager.dart'; +import 'utils/effort_calibration.dart'; +import 'utils/exercise_history.dart'; +import 'utils/session_fatigue.dart'; enum StartWorkoutConflictAction { resume, discardAndStart, cancel } @@ -38,16 +41,50 @@ class WorkoutProvider extends ChangeNotifier { final IMLService _mlService; final HistoryManager? _historyManager; final Uuid _uuid = const Uuid(); + final EffortCalibration _effortCalibration = const EffortCalibration(); + final SessionFatigueAccumulator _sessionFatigueAccumulator = + const SessionFatigueAccumulator(); + + static const String _effortCalibrationOffsetKey = 'effort.calibrationOffset'; // State List _sessions = []; List _routines = []; + double _effortCalibrationOffset = 0.0; List _targets = []; List _muscleGroups = []; List _allExercises = []; final Map _growthModels = {}; // exerciseId -> GrowthModel + // getRecommendations runs from WorkoutFlowScreen's build, so it must not + // rebuild the exercise map and re-walk all of _sessions on every frame. + // Both derived values below depend only on history, so they're cached and + // invalidated by an explicit revision rather than by list identity — + // _sessions is mutated in place in places (insert/sort), so identity would + // go stale silently. + int _historyRevision = 0; + int? _recoveryCacheRevision; + Map? _cachedExerciseMap; + Map? _cachedLastTrained; + + /// Call after any change to [_sessions] or [_allExercises]. + void _invalidateHistoryCache() => _historyRevision++; + + ({Map exerciseMap, Map lastTrained}) + _historyDerived() { + if (_recoveryCacheRevision != _historyRevision) { + final map = {for (final e in _allExercises) e.id: e}; + _cachedExerciseMap = map; + _cachedLastTrained = _mlService.lastTrainedPerMuscle(_sessions, map); + _recoveryCacheRevision = _historyRevision; + } + return ( + exerciseMap: _cachedExerciseMap!, + lastTrained: _cachedLastTrained!, + ); + } + final ProgramManager programManager; // Active workout state @@ -79,6 +116,11 @@ class WorkoutProvider extends ChangeNotifier { List get currentExerciseLogs => _currentExerciseLogs; DateTime? get workoutStartTime => _workoutStartTime; + /// Rolling calibration offset for [EffortEstimator]'s RPE-8 anchor, + /// derived only from the once-per-workout effort chip (see + /// [recordSessionEffort]) — 0.0 until the user has ever answered it. + double get effortCalibrationOffset => _effortCalibrationOffset; + /// Create WorkoutProvider with dependency injection. /// /// Following Dependency Inversion Principle: accepts abstractions @@ -105,10 +147,14 @@ class WorkoutProvider extends ChangeNotifier { Future loadAllData() async { _sessions = await _storage.getAllWorkoutSessions(); + _invalidateHistoryCache(); _routines = await _storage.getAllRoutines(); _targets = await _storage.getAllTargets(); _muscleGroups = await _storage.getAllMuscleGroups(); + final storedOffset = await _storage.getSetting(_effortCalibrationOffsetKey); + _effortCalibrationOffset = double.tryParse(storedOffset ?? '') ?? 0.0; _allExercises = await _storage.getAllExercises(); + _invalidateHistoryCache(); await programManager.loadPrograms(); notifyListeners(); } @@ -354,6 +400,7 @@ class WorkoutProvider extends ChangeNotifier { // Add to local list (use List.from for immutability) _allExercises = List.from(_allExercises)..add(exercise); + _invalidateHistoryCache(); notifyListeners(); } @@ -414,6 +461,7 @@ class WorkoutProvider extends ChangeNotifier { // Remove from local list _allExercises = List.from(_allExercises) ..removeWhere((e) => e.id == exerciseId); + _invalidateHistoryCache(); // Also remove any growth model _growthModels.remove(exerciseId); @@ -622,6 +670,7 @@ class WorkoutProvider extends ChangeNotifier { } await _clearDraft(); _sessions.insert(0, session); + _invalidateHistoryCache(); // Update growth models for performed exercises for (var log in completedExercises) { @@ -662,8 +711,18 @@ class WorkoutProvider extends ChangeNotifier { /// Get set recommendations for an exercise, optionally scoped by [handle]. /// /// Uses up to 3 past sessions for this exercise (and handle variation) as the - /// basis for trend analysis and deload recovery. - List getRecommendations(String exerciseId, {String? handle}) { + /// basis for trend analysis and deload recovery, plus the exercise's primary + /// muscle's recovery status so a still-fatigued muscle holds instead of + /// progressing. + /// [readinessBand] should come from the already-cached + /// `ReadinessManager.snapshot?.band` — this method never fetches it + /// itself, keeping recommendation generation synchronous/offline for the + /// in-workout hot path. + List getRecommendations( + String exerciseId, { + String? handle, + ReadinessBand? readinessBand, + }) { final recent = getRecentSessionsForExercise(exerciseId, handle: handle, limit: 3); if (recent.isEmpty) { @@ -674,10 +733,29 @@ class WorkoutProvider extends ChangeNotifier { // so it must not back a handle-scoped recommendation — that would mix // e.g. "Rope pushdown" trend data into a "Bar pushdown" recommendation. final useHandle = handle != null && handle.isNotEmpty; + // Cached: this runs from WorkoutFlowScreen's build, so the exercise map + // and the walk over every session are reused until history changes. Only + // the decay is recomputed per call, and that's O(muscle groups). + final derived = _historyDerived(); + final recoveryInputs = recoveryRecommendationInputs( + exerciseId: exerciseId, + sessions: _sessions, + exerciseMap: derived.exerciseMap, + mlService: _mlService, + lastTrained: derived.lastTrained, + ); + final sessionFatigueFactor = _sessionFatigueAccumulator.factorFor( + exerciseLogs: _currentExerciseLogs, + excludeExerciseId: exerciseId, + ); return _mlService.recommendSets( lastSession: recent.first, pastSessions: recent, growthModel: useHandle ? null : _growthModels[exerciseId], + recoveryScores: recoveryInputs.recoveryScores, + primaryMuscleIds: recoveryInputs.primaryMuscleIds, + sessionFatigueFactor: sessionFatigueFactor, + readinessBand: readinessBand, ); } @@ -767,6 +845,7 @@ class WorkoutProvider extends ChangeNotifier { // Remove from local list _sessions = List.from(_sessions)..removeWhere((s) => s.id == sessionId); + _invalidateHistoryCache(); // Keep HistoryManager's cache in sync so HistoryScreen rebuilds. _historyManager?.evictSession(sessionId); @@ -810,10 +889,12 @@ class WorkoutProvider extends ChangeNotifier { final index = _sessions.indexWhere((s) => s.id == updatedSession.id); if (index != -1) { _sessions = List.from(_sessions)..[index] = updatedSession; + _invalidateHistoryCache(); } // Sort sessions by date (most recent first) _sessions.sort((a, b) => b.date.compareTo(a.date)); + _invalidateHistoryCache(); // Keep HistoryManager's cache in sync so HistoryScreen rebuilds. _historyManager?.patchSession(updatedSession); @@ -830,6 +911,103 @@ class WorkoutProvider extends ChangeNotifier { notifyListeners(); } + /// Tail of the in-flight effort writes, so overlapping chip taps run one + /// after another. See [recordSessionEffort]. + Future _effortWriteQueue = Future.value(); + + /// Records the once-per-workout effort chip (1 = Easy, 2 = Solid, + /// 3 = Brutal) for [sessionId] and folds it into the rolling + /// [effortCalibrationOffset]. Answering is optional — this is only ever + /// called from a user tap on the post-workout summary screen, never + /// required to finish a workout. + /// + /// Lighter than [updateWorkoutSession]: this only annotates metadata, so + /// it skips the growth-model/target retraining that method does for + /// exercise-data changes. + /// + /// The chip is re-answerable — the summary screen leaves it tappable — so + /// the offset is recomputed from every stored answer in date order rather + /// than folded in incrementally. Folding would count a changed answer + /// twice (tapping Brutal then Easy would apply both). + /// + /// Calls are serialised. Because the chips stay tappable while a write is + /// in flight and [_recordSessionEffort] captures the pre-call session and + /// offset for its rollback, two overlapping taps would otherwise let a + /// failing first call restore that stale snapshot over a second call that + /// had already committed — leaving storage behind provider state. Queuing + /// is enough: each call re-reads `_sessions` when its turn comes. + Future recordSessionEffort(String sessionId, int chipValue) { + final result = _effortWriteQueue.then( + (_) => _recordSessionEffort(sessionId, chipValue), + ); + // The queue must survive a failed write, or every later tap would inherit + // that error. The caller still sees it through [result]. + _effortWriteQueue = result.then((_) {}, onError: (_) {}); + return result; + } + + Future _recordSessionEffort(String sessionId, int chipValue) async { + final index = _sessions.indexWhere((s) => s.id == sessionId); + if (index == -1) return; + + final previousSession = _sessions[index]; + final previousOffset = _effortCalibrationOffset; + final updated = previousSession.copyWith(sessionEffort: chipValue); + + final nextSessions = List.from(_sessions)..[index] = updated; + final nextOffset = _recomputeEffortCalibrationOffset(nextSessions); + + // No transaction across boxes here, so on a partial failure put both the + // session and the offset back the way they were and rethrow — the caller + // (the summary screen's chip) restores its selection on the throw. + await _storage.saveWorkoutSession(updated); + try { + await _storage.saveSetting( + _effortCalibrationOffsetKey, + nextOffset.toString(), + ); + } catch (_) { + await _restoreSessionEffort(previousSession, previousOffset); + rethrow; + } + + _sessions = nextSessions; + _effortCalibrationOffset = nextOffset; + _invalidateHistoryCache(); + _historyManager?.patchSession(updated); + + notifyListeners(); + } + + /// Rebuilds the rolling offset from scratch over every answered session, + /// oldest first, so the result depends only on the stored answers and not + /// on how many times the user tapped to get there. + double _recomputeEffortCalibrationOffset(List sessions) { + final answered = sessions.where((s) => s.sessionEffort != null).toList() + ..sort((a, b) => a.date.compareTo(b.date)); + var offset = 0.0; + for (final session in answered) { + offset = _effortCalibration.updateOffset(offset, session.sessionEffort!); + } + return offset; + } + + /// Best-effort undo of a half-applied [recordSessionEffort]. A failure here + /// leaves the session row ahead of the stored offset, which the next + /// answered chip recomputes away; swallowing it keeps the original error + /// as the one the caller sees. + Future _restoreSessionEffort( + WorkoutSession previousSession, + double previousOffset, + ) async { + try { + await _storage.saveWorkoutSession(previousSession); + } catch (e, st) { + debugPrint('Failed to roll back session effort: $e\n$st'); + } + _effortCalibrationOffset = previousOffset; + } + // ==================== ROUTINES ==================== Future createRoutine(String name, List exerciseIds) async { diff --git a/workout-logger/lib/theme/app_theme.dart b/workout-logger/lib/theme/app_theme.dart index 1b27eb2..3c57bbb 100644 --- a/workout-logger/lib/theme/app_theme.dart +++ b/workout-logger/lib/theme/app_theme.dart @@ -20,11 +20,36 @@ class AppColors { static const glassBorderStrong = Color(0x21FFFFFF); // --border-strong 13% static const divider = Color(0x0FFFFFFF); // 6% white + // GlassCard material. The fill grades top-to-bottom and the edge grades with + // it, so a panel reads as a surface catching light from above rather than as + // an outline. The old fill (3.5% -> 1.6%) resolved to +9/255 and +4/255 over + // the canvas, which left the border twice as bright as the face it wrapped. + static const glassFillTop = Color(0x12FFFFFF); // 7% -> +17.5/255 + static const glassFillBottom = Color(0x05FFFFFF); // 2% -> +4.9/255 + static const glassEdgeTop = Color(0x24FFFFFF); // 14% -> +34.9/255 + static const glassEdgeBottom = Color(0x0AFFFFFF); // 4% -> +9.7/255 + + // Ambient wash floor. A radial pool's reach is finite and a scrolling column + // is not, so this full-height grade is the only layer that can guarantee a + // non-zero floor everywhere. Without it the canvas below the key pool is + // literally flat and glass has nothing to sit on. + static const washFloorTop = Color(0x065B21B6); // 2.4% violet + static const washFloorMid = Color(0x045B21B6); // 1.6% + static const washFloorBottom = Color(0x055B21B6); // 2.0% + // Brand — electric violet primary, cyan data static const primary = Color(0xFF7C3AED); // --accent oklch(0.68 0.18 285) + static const primaryDeep = Color(0xFF5B21B6); // gradient end / ambient wash static const secondary = Color(0xFF00C2D4); // --data oklch(0.78 0.14 200) static const accent = Color(0xFF7C3AED); // alias for primary + /// The one brand gradient, so every violet surface catches the same light. + static const primaryGradient = LinearGradient( + colors: [primary, primaryDeep], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + // Semantic static const success = Color(0xFF00C89B); // --success oklch(0.78 0.16 155) static const warning = Color(0xFFDBA520); // --warn oklch(0.78 0.14 60) diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 8d7e142..ca76f77 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2.0.12+33 +version: 2.1.0+34 environment: sdk: ^3.11.4 diff --git a/workout-logger/test/analytics_manager_test.dart b/workout-logger/test/analytics_manager_test.dart index e954c94..9b1009f 100644 --- a/workout-logger/test/analytics_manager_test.dart +++ b/workout-logger/test/analytics_manager_test.dart @@ -219,6 +219,40 @@ void main() { expect(recs, isNotEmpty); }); + + test('passes pastSessions, recoveryScores, and primaryMuscleIds when ' + 'exercise data is supplied', () { + final exercise = _exercise(id: 'ex1', muscleGroupId: 'chest'); + final sessions = [ + _session(id: 's1', exerciseId: 'ex1', weight: 80, date: DateTime(2024, 1, 1)), + _session(id: 's2', exerciseId: 'ex1', weight: 90, date: DateTime(2024, 1, 8)), + ]; + manager.buildSessionIndex(sessions); + + manager.getRecommendations( + 'ex1', + sessions, + exerciseMap: {'ex1': exercise}, + ); + + expect(mockML.lastPastSessions, isNotNull); + expect(mockML.lastPastSessions!.length, 2); + expect(mockML.lastRecoveryScores, isNotNull); + expect(mockML.lastPrimaryMuscleIds, ['chest']); + }); + + test('omits recoveryScores/primaryMuscleIds when no exercise data is ' + 'supplied (backward-compatible call)', () { + final sessions = [ + _session(id: 's1', exerciseId: 'ex1', weight: 80, date: DateTime(2024, 1, 1)), + ]; + manager.buildSessionIndex(sessions); + + manager.getRecommendations('ex1', sessions); + + expect(mockML.lastRecoveryScores, isNull); + expect(mockML.lastPrimaryMuscleIds, isNull); + }); }); group('AnalyticsManager - getVolumeProgression', () { diff --git a/workout-logger/test/effort_calibration_test.dart b/workout-logger/test/effort_calibration_test.dart new file mode 100644 index 0000000..aeec021 --- /dev/null +++ b/workout-logger/test/effort_calibration_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/services/utils/effort_calibration.dart'; + +void main() { + const calibration = EffortCalibration(); + + test('chipRpe maps every effort chip to an RPE anchor', () { + // The chip values are what WorkoutSummaryScreen sends to + // recordSessionEffort; a gap here would make a chip silently inert. + expect(EffortCalibration.chipRpe.keys, containsAll([1, 2, 3])); + for (final entry in EffortCalibration.chipRpe.entries) { + expect(entry.value, inInclusiveRange(1.0, 10.0), reason: '${entry.key}'); + } + }); + + test('an Easy answer nudges the offset negative', () { + final result = calibration.updateOffset(0.0, 1); + expect(result, lessThan(0.0)); + }); + + test('a Solid answer (matches the anchor) leaves the offset unchanged', () { + final result = calibration.updateOffset(0.0, 2); + expect(result, closeTo(0.0, 0.0001)); + }); + + test('a Brutal answer nudges the offset positive', () { + final result = calibration.updateOffset(0.0, 3); + expect(result, greaterThan(0.0)); + }); + + test('repeated Brutal answers converge toward +1.0 but never exceed it', () { + var offset = 0.0; + for (var i = 0; i < 200; i++) { + offset = calibration.updateOffset(offset, 3); + } + expect(offset, closeTo(EffortCalibration.maxOffset, 0.01)); + expect(offset, lessThanOrEqualTo(EffortCalibration.maxOffset)); + }); + + test('repeated Easy answers converge toward -1.0 but never exceed it', () { + var offset = 0.0; + for (var i = 0; i < 200; i++) { + offset = calibration.updateOffset(offset, 1); + } + expect(offset, closeTo(-EffortCalibration.maxOffset, 0.01)); + expect(offset, greaterThanOrEqualTo(-EffortCalibration.maxOffset)); + }); + + test('an unrecognized chip value is treated as neutral (matches the anchor)', + () { + final result = calibration.updateOffset(0.5, 99); + // delta = anchorRpe - anchorRpe = 0 → offset decays toward 0. + expect(result, closeTo(0.45, 0.0001)); + }); +} diff --git a/workout-logger/test/effort_estimator_test.dart b/workout-logger/test/effort_estimator_test.dart new file mode 100644 index 0000000..f67cd30 --- /dev/null +++ b/workout-logger/test/effort_estimator_test.dart @@ -0,0 +1,278 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/utils/effort_estimator.dart'; + +WorkoutSet _set({ + double weight = 60.0, + int reps = 10, + DateTime? timestamp, + int? timeTaken, +}) => + WorkoutSet( + weight: weight, + reps: reps, + timestamp: timestamp ?? DateTime(2026, 1, 1, 10, 0), + timeTaken: timeTaken, + ); + +void main() { + const estimator = EffortEstimator(); + + group('EffortEstimator - no signals available', () { + test('returns the RPE-8 anchor with base confidence', () { + final result = estimator.estimate(set: _set()); + expect(result.rpe, closeTo(EffortEstimator.anchorRpe, 0.001)); + expect(result.source, EffortSource.estimatedHrless); + expect(result.confidence, closeTo(0.35, 0.001)); + }); + + test('calibrationOffset shifts the anchor', () { + final result = estimator.estimate(set: _set(), calibrationOffset: 0.5); + expect(result.rpe, closeTo(EffortEstimator.anchorRpe + 0.5, 0.001)); + }); + }); + + group('EffortEstimator - term 1: trend deviation', () { + test('actual volume above trend lowers RPE (easier than expected)', () { + final model = GrowthModel( + slope: 5.0, + intercept: 500.0, // predicted volume at x=0 is 500 + r2: 0.9, + lastTrained: DateTime.now(), + stdError: 50.0, + ); + // set volume = 60*10 = 600, well above the 500 prediction. + final result = estimator.estimate( + set: _set(weight: 60, reps: 10), + growthModel: model, + growthModelX: 0, + ); + expect(result.rpe, lessThan(EffortEstimator.anchorRpe)); + expect(result.confidence, greaterThan(0.35)); + }); + + test('actual volume below trend raises RPE (harder than expected)', () { + final model = GrowthModel( + slope: 5.0, + intercept: 900.0, + r2: 0.9, + lastTrained: DateTime.now(), + stdError: 50.0, + ); + final result = estimator.estimate( + set: _set(weight: 60, reps: 10), // volume 600, well below 900 + growthModel: model, + growthModelX: 0, + ); + expect(result.rpe, greaterThan(EffortEstimator.anchorRpe)); + }); + + test('untrustworthy fit (low r2) is ignored', () { + final model = GrowthModel( + slope: 5.0, + intercept: 900.0, + r2: 0.05, // below the 0.2 trust threshold + lastTrained: DateTime.now(), + stdError: 50.0, + ); + final result = estimator.estimate( + set: _set(weight: 60, reps: 10), + growthModel: model, + growthModelX: 0, + ); + expect(result.rpe, closeTo(EffortEstimator.anchorRpe, 0.001)); + }); + + test('missing growthModelX skips the term even with a trustworthy model', + () { + final model = GrowthModel( + slope: 5.0, + intercept: 900.0, + r2: 0.9, + lastTrained: DateTime.now(), + ); + final result = estimator.estimate(set: _set(), growthModel: model); + expect(result.rpe, closeTo(EffortEstimator.anchorRpe, 0.001)); + }); + }); + + group('EffortEstimator - term 2: intra-session decline', () { + test('flat volume across sets contributes nothing', () { + final prior = [_set(weight: 60, reps: 10)]; + final result = estimator.estimate( + set: _set(weight: 60, reps: 10), + priorSetsThisExerciseToday: prior, + ); + expect(result.rpe, closeTo(EffortEstimator.anchorRpe, 0.001)); + }); + + test('a small dip within the deadband contributes nothing', () { + final prior = [_set(weight: 100, reps: 10)]; // volume 1000 + final result = estimator.estimate( + set: _set(weight: 97, reps: 10), // volume 970, 3% drop — within the 5% deadband + priorSetsThisExerciseToday: prior, + ); + expect(result.rpe, closeTo(EffortEstimator.anchorRpe, 0.001)); + }); + + test('a drop just past the deadband raises RPE', () { + final prior = [_set(weight: 100, reps: 10)]; // volume 1000 + final result = estimator.estimate( + set: _set(weight: 90, reps: 10), // volume 900, 10% drop + priorSetsThisExerciseToday: prior, + ); + expect(result.rpe, greaterThan(EffortEstimator.anchorRpe)); + }); + + test('a real volume drop across sets raises RPE', () { + final prior = [_set(weight: 100, reps: 10)]; // volume 1000 + final result = estimator.estimate( + set: _set(weight: 100, reps: 6), // volume 600, 40% drop + priorSetsThisExerciseToday: prior, + ); + expect(result.rpe, greaterThan(EffortEstimator.anchorRpe + 1)); + }); + }); + + group('EffortEstimator - term 3: rest/tempo drift', () { + test('needs at least 2 prior sets to establish a baseline', () { + final prior = [ + _set(timestamp: DateTime(2026, 1, 1, 10, 0), timeTaken: 30), + ]; + final result = estimator.estimate( + set: _set(timestamp: DateTime(2026, 1, 1, 10, 5), timeTaken: 90), + priorSetsThisExerciseToday: prior, + ); + // Only decline term (n/a here, flat volume) applies; rest/tempo is + // skipped with a single prior set. + expect(result.rpe, closeTo(EffortEstimator.anchorRpe, 0.001)); + }); + + test('a much longer rest gap than usual raises RPE', () { + final prior = [ + _set(timestamp: DateTime(2026, 1, 1, 10, 0)), + _set(timestamp: DateTime(2026, 1, 1, 10, 2)), // 120s gap + ]; + final result = estimator.estimate( + // 600s gap vs a 120s median — well over double. + set: _set(timestamp: DateTime(2026, 1, 1, 10, 12)), + priorSetsThisExerciseToday: prior, + ); + expect(result.rpe, greaterThan(EffortEstimator.anchorRpe)); + }); + + test('a much slower tempo than usual raises RPE', () { + final prior = [ + _set(timestamp: DateTime(2026, 1, 1, 10, 0), reps: 10, timeTaken: 20), + _set(timestamp: DateTime(2026, 1, 1, 10, 2), reps: 10, timeTaken: 20), + ]; + final result = estimator.estimate( + set: _set(timestamp: DateTime(2026, 1, 1, 10, 4), reps: 10, timeTaken: 60), + priorSetsThisExerciseToday: prior, + ); + expect(result.rpe, greaterThan(EffortEstimator.anchorRpe)); + }); + }); + + group('EffortEstimator - term 4: heart rate (optional)', () { + test('is skipped entirely when hrSignal is null', () { + final result = estimator.estimate(set: _set()); + expect(result.source, EffortSource.estimatedHrless); + }); + + test('too few samples in the set window is ignored', () { + final result = estimator.estimate( + set: _set(), + hrSignal: const HrEffortSignal( + setPeakBpm: 170, + setSampleCount: 2, // below minHrSamplesForSignal + sessionFloorBpm: 90, + sessionPeakBpm: 175, + ), + ); + expect(result.source, EffortSource.estimatedHrless); + expect(result.rpe, closeTo(EffortEstimator.anchorRpe, 0.001)); + }); + + test('a set peak near the session ceiling raises RPE and confidence', () { + final withoutHr = estimator.estimate(set: _set()); + final withHr = estimator.estimate( + set: _set(), + hrSignal: const HrEffortSignal( + setPeakBpm: 174, + setSampleCount: 5, + sessionFloorBpm: 90, + sessionPeakBpm: 175, + ), + ); + expect(withHr.source, EffortSource.estimatedWithHr); + expect(withHr.rpe, greaterThan(withoutHr.rpe)); + expect(withHr.confidence, greaterThan(withoutHr.confidence)); + }); + + test('a set peak well below the session ceiling lowers RPE', () { + final result = estimator.estimate( + set: _set(), + hrSignal: const HrEffortSignal( + setPeakBpm: 100, + setSampleCount: 5, + sessionFloorBpm: 90, + sessionPeakBpm: 175, + ), + ); + expect(result.rpe, lessThan(EffortEstimator.anchorRpe)); + }); + }); + + group('EffortEstimator - confidence ladder', () { + test('never exceeds maxEstimatedConfidence even with every bonus', () { + final model = GrowthModel( + slope: 5.0, + intercept: 500.0, + r2: 0.9, + lastTrained: DateTime.now(), + stdError: 50.0, + ); + final prior = [ + _set(timestamp: DateTime(2026, 1, 1, 10, 0)), + _set(timestamp: DateTime(2026, 1, 1, 10, 2)), + ]; + final result = estimator.estimate( + set: _set(timestamp: DateTime(2026, 1, 1, 10, 4)), + priorSetsThisExerciseToday: prior, + growthModel: model, + growthModelX: 0, + sessionHistoryCount: 10, + hrSignal: const HrEffortSignal( + setPeakBpm: 174, + setSampleCount: 5, + sessionFloorBpm: 90, + sessionPeakBpm: 175, + ), + ); + expect(result.confidence, lessThanOrEqualTo(EffortEstimator.maxEstimatedConfidence)); + }); + + // The trend term alone cannot reach the outer clamp: z is already + // clamped to ±2, so it contributes at most ±1.6 around the 8.0 anchor. + // Driving calibrationOffset past the bound is what actually exercises + // rpe.clamp — without it these assertions pass with the clamp removed. + test('rpe is clamped to minRpe when the calibration offset drives it under', + () { + final result = estimator.estimate( + set: _set(weight: 1, reps: 1), + calibrationOffset: -50.0, + ); + expect(result.rpe, EffortEstimator.minRpe); + }); + + test('rpe is clamped to maxRpe when the calibration offset drives it over', + () { + final result = estimator.estimate( + set: _set(weight: 1, reps: 1), + calibrationOffset: 50.0, + ); + expect(result.rpe, EffortEstimator.maxRpe); + }); + }); +} diff --git a/workout-logger/test/flutter_test_config.dart b/workout-logger/test/flutter_test_config.dart new file mode 100644 index 0000000..f398996 --- /dev/null +++ b/workout-logger/test/flutter_test_config.dart @@ -0,0 +1,12 @@ +// Wraps every test in this suite. See dart.dev/go/flutter-test-config. + +import 'dart:async'; + +import 'package:repforge/screens/widgets/rf_widgets.dart'; + +Future testExecutable(FutureOr Function() testMain) async { + // AmbientGlow drifts on a loop that never completes, which would keep + // pumpAndSettle waiting for a frame that never stops coming. + AmbientGlow.motionEnabled = false; + await testMain(); +} diff --git a/workout-logger/test/gemini_ai_service_thinking_test.dart b/workout-logger/test/gemini_ai_service_thinking_test.dart new file mode 100644 index 0000000..a41f507 --- /dev/null +++ b/workout-logger/test/gemini_ai_service_thinking_test.dart @@ -0,0 +1,76 @@ +// Unit tests for Gemini thinking-level support and clamping. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/services/ai/gemini_ai_service.dart'; + +void main() { + group('supportedThinkingLevels', () { + test('gemini-3.7-flash does not support minimal', () { + expect(supportedThinkingLevels('gemini-3.7-flash'), ['low', 'medium', 'high']); + }); + + test('other gemini-3.x models support all four levels', () { + expect(supportedThinkingLevels('gemini-3.6-flash'), kThinkingLevels); + expect(supportedThinkingLevels('gemini-3.5-flash'), kThinkingLevels); + expect(supportedThinkingLevels('gemini-3.5-flash-lite'), kThinkingLevels); + expect(supportedThinkingLevels('gemini-3.1-flash-lite'), kThinkingLevels); + }); + + test('gemini-2.x models support no thinking levels', () { + expect(supportedThinkingLevels('gemini-2.5-flash'), isEmpty); + }); + }); + + group('clampThinkingLevel', () { + test('leaves a supported level unchanged', () { + expect(clampThinkingLevel('gemini-3.6-flash', 'high'), 'high'); + }); + + test('snaps minimal to low for gemini-3.7-flash', () { + expect(clampThinkingLevel('gemini-3.7-flash', 'minimal'), 'low'); + }); + + test('leaves the level unchanged for a model with no thinking levels', () { + expect(clampThinkingLevel('gemini-2.5-flash', 'minimal'), 'minimal'); + }); + }); + + group('GeminiAiService thinking level', () { + test('defaults to kDefaultThinkingLevel', () { + final service = GeminiAiService(); + expect(service.thinkingLevel, kDefaultThinkingLevel); + }); + + test('updateThinkingLevel changes the level', () { + final service = GeminiAiService(); + service.updateThinkingLevel('high'); + expect(service.thinkingLevel, 'high'); + }); + + test('updateThinkingLevel clamps against the current model', () { + final service = GeminiAiService(); + service.updateModel('gemini-3.7-flash'); + service.updateThinkingLevel('minimal'); + expect(service.thinkingLevel, 'low'); + }); + + test('updateModel re-clamps an already-set thinking level', () { + final service = GeminiAiService(); + service.updateThinkingLevel('minimal'); + service.updateModel('gemini-3.7-flash'); + expect(service.thinkingLevel, 'low'); + }); + + test('init clamps the supplied thinking level against the supplied model', () { + final service = GeminiAiService(); + service.init('key', model: 'gemini-3.7-flash', thinkingLevel: 'minimal'); + expect(service.thinkingLevel, 'low'); + }); + }); + + group('getFallbackModel', () { + test('gemini-3.7-flash falls back to gemini-3.6-flash', () { + expect(getFallbackModel('gemini-3.7-flash'), 'gemini-3.6-flash'); + }); + }); +} diff --git a/workout-logger/test/gemini_ai_service_usage_test.dart b/workout-logger/test/gemini_ai_service_usage_test.dart index 046bdae..0e6ab8c 100644 --- a/workout-logger/test/gemini_ai_service_usage_test.dart +++ b/workout-logger/test/gemini_ai_service_usage_test.dart @@ -56,4 +56,35 @@ void main() { expect(reloaded.aiRequestCount, 0); }); }); + + group('GeminiAiService model picker', () { + test('offers Gemini 3.7 Flash', () { + expect( + kGeminiModels, + contains(('gemini-3.7-flash', 'Gemini 3.7 Flash')), + ); + }); + + test('model ids are unique and the default is one of them', () { + final ids = kGeminiModels.map((e) => e.$1).toList(); + expect(ids.toSet().length, ids.length); + expect(ids, contains(kDefaultGeminiModel)); + }); + + test('every offered model has a defined fallback shape', () { + // A model either falls back to another offered model or terminates the + // chain; it must never point at an id the picker doesn't know. + final ids = kGeminiModels.map((e) => e.$1).toSet(); + for (final id in ids) { + final next = getFallbackModel(id); + if (next != null) expect(ids, contains(next), reason: id); + } + }); + + test('a selected model can be initialised and reported back', () { + final service = GeminiAiService(storage: MockStorageService()); + service.init('fake_test_api_key', model: 'gemini-3.7-flash'); + expect(service.currentModel, 'gemini-3.7-flash'); + }); + }); } diff --git a/workout-logger/test/ml_service_test.dart b/workout-logger/test/ml_service_test.dart index ff68406..8a2fc3f 100644 --- a/workout-logger/test/ml_service_test.dart +++ b/workout-logger/test/ml_service_test.dart @@ -422,7 +422,7 @@ void main() { lastTrained: DateTime.now(), stdError: 25.0, // → ±5 days at 5 volume/day ); - final result = MLService.predictTargetWithConfidence( + final result = GrowthCurveFitter.predictTargetWithConfidence( currentValue: 100.0, targetValue: 200.0, growthModel: model, diff --git a/workout-logger/test/model_serialization_test.dart b/workout-logger/test/model_serialization_test.dart index 13f922f..2cb2d45 100644 --- a/workout-logger/test/model_serialization_test.dart +++ b/workout-logger/test/model_serialization_test.dart @@ -173,6 +173,22 @@ void main() { final restored = WorkoutSession.fromJson(original.toJson()); expect(restored.hcSyncedAt, syncTime); }); + + test('sessionEffort is null by default and round-trips when set', () { + final withoutEffort = WorkoutSession( + id: 'session-5', + date: date, + exercises: [], + duration: 30, + ); + expect(withoutEffort.sessionEffort, isNull); + expect(WorkoutSession.fromJson(withoutEffort.toJson()).sessionEffort, isNull); + + final withEffort = withoutEffort.copyWith(sessionEffort: 2); + expect(withEffort.sessionEffort, 2); + final restored = WorkoutSession.fromJson(withEffort.toJson()); + expect(restored.sessionEffort, 2); + }); }); // ── Exercise ────────────────────────────────────────────────────────────── diff --git a/workout-logger/test/progression_rules_test.dart b/workout-logger/test/progression_rules_test.dart new file mode 100644 index 0000000..bc20d89 --- /dev/null +++ b/workout-logger/test/progression_rules_test.dart @@ -0,0 +1,275 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/strategies/progression_rules.dart'; + +WorkoutSet _set({double weight = 60.0, int reps = 10}) => + WorkoutSet(weight: weight, reps: reps); + +ProgressionContext _context({ + WorkoutSet? set, + int minReps = 6, + int maxReps = 12, + bool isPlateau = false, + bool isDeclining = false, + bool isUnderRecovered = false, + int? recoveryPercent, + bool isPostDeloadRecovery = false, + bool isLowReadiness = false, + double sessionFatigueFactor = 0.0, +}) { + return ProgressionContext( + set: set ?? _set(), + minReps: minReps, + maxReps: maxReps, + isPlateau: isPlateau, + isDeclining: isDeclining, + isUnderRecovered: isUnderRecovered, + recoveryPercent: recoveryPercent, + isPostDeloadRecovery: isPostDeloadRecovery, + isLowReadiness: isLowReadiness, + sessionFatigueFactor: sessionFatigueFactor, + ); +} + +void main() { + tearDown(() => ProgressionRuleFactory.reset()); + + group('UnderRecoveredRule', () { + final rule = UnderRecoveredRule(); + + test('defers when not under-recovered', () { + expect(rule.apply(_context(isUnderRecovered: false)), isNull); + }); + + test('holds weight/reps with low confidence when under-recovered', () { + final result = rule.apply(_context( + set: _set(weight: 80, reps: 8), + isUnderRecovered: true, + recoveryPercent: 40, + )); + expect(result, isNotNull); + expect(result!.weight, 80); + expect(result.reps, 8); + expect(result.confidence, 'low'); + expect(result.reasoning, contains('40%')); + }); + }); + + group('PostDeloadRecoveryRule', () { + final rule = PostDeloadRecoveryRule(); + + test('defers when not post-deload', () { + expect(rule.apply(_context()), isNull); + }); + + test('holds weight/reps with high confidence when post-deload', () { + final result = rule.apply(_context( + set: _set(weight: 100, reps: 6), + isPostDeloadRecovery: true, + )); + expect(result, isNotNull); + expect(result!.weight, 100); + expect(result.reps, 6); + expect(result.confidence, 'high'); + expect(result.reasoning, contains('deload')); + }); + }); + + group('ReadinessRule', () { + final rule = ReadinessRule(); + + test('defers when readiness is not low', () { + expect(rule.apply(_context(isLowReadiness: false)), isNull); + }); + + test('holds weight/reps with low confidence when readiness is low', () { + final result = rule.apply(_context( + set: _set(weight: 70, reps: 9), + isLowReadiness: true, + )); + expect(result, isNotNull); + expect(result!.weight, 70); + expect(result.reps, 9); + expect(result.confidence, 'low'); + expect(result.reasoning, contains('readiness')); + }); + }); + + group('SessionFatigueRule', () { + final rule = SessionFatigueRule(); + + test('defers when fatigue factor is below 1.0', () { + expect(rule.apply(_context(sessionFatigueFactor: 0.6)), isNull); + }); + + test('hard-holds once fatigue factor reaches 1.0', () { + final result = rule.apply(_context( + set: _set(weight: 60, reps: 10), + sessionFatigueFactor: 1.0, + )); + expect(result, isNotNull); + expect(result!.weight, 60); + expect(result.reps, 10); + expect(result.confidence, 'medium'); + }); + }); + + group('DeclineDeloadRule', () { + final rule = DeclineDeloadRule(); + + test('defers when not declining', () { + expect(rule.apply(_context()), isNull); + }); + + test('deloads ~10% rounded to the nearest 2.5kg plate', () { + final result = rule.apply(_context( + set: _set(weight: 100, reps: 8), + isDeclining: true, + )); + expect(result, isNotNull); + expect(result!.weight, closeTo(90.0, 0.001)); + expect(result.reps, 8); + expect(result.confidence, 'medium'); + }); + }); + + group('PlateauRule', () { + final rule = PlateauRule(); + + test('defers when not plateaued', () { + expect(rule.apply(_context()), isNull); + }); + + test('holds weight/reps with medium confidence on plateau', () { + final result = rule.apply(_context( + set: _set(weight: 60, reps: 10), + isPlateau: true, + )); + expect(result, isNotNull); + expect(result!.weight, 60); + expect(result.reps, 10); + expect(result.confidence, 'medium'); + }); + }); + + group('DoubleProgressionRule', () { + final rule = DoubleProgressionRule(); + + test('never defers (terminal rule)', () { + expect(rule.apply(_context()), isNotNull); + }); + + test('adds one rep below the rep ceiling', () { + final result = rule.apply(_context(set: _set(weight: 60, reps: 10), maxReps: 12)); + expect(result.weight, closeTo(60.0, 0.001)); + expect(result.reps, 11); + expect(result.confidence, 'high'); + }); + + test('bumps weight and resets reps at the rep ceiling', () { + final result = rule.apply( + _context(set: _set(weight: 80, reps: 12), minReps: 6, maxReps: 12), + ); + expect(result.weight, closeTo(85.0, 0.001)); + expect(result.reps, 6); + }); + + test('partial session fatigue scales down the weight increment', () { + // weight >= 40 → base increment 5.0; factor 0.5 → scaled to 2.5. + final result = rule.apply(_context( + set: _set(weight: 80, reps: 12), + minReps: 6, + maxReps: 12, + sessionFatigueFactor: 0.5, + )); + expect(result.weight, closeTo(82.5, 0.001)); + expect(result.reps, 6); + expect(result.confidence, 'medium'); + expect(result.reasoning, contains('reduced')); + }); + + test('heavy partial fatigue that rounds the increment to zero holds weight', + () { + // scaled = 5.0*(1-0.9) = 0.5 → rounds to 0 at the 2.5kg plate. + final result = rule.apply(_context( + set: _set(weight: 80, reps: 12), + minReps: 6, + maxReps: 12, + sessionFatigueFactor: 0.9, + )); + expect(result.weight, closeTo(80.0, 0.001)); + expect(result.reps, 12); // not reset — no progression happened + expect(result.confidence, 'medium'); + }); + + test('zero fatigue factor (the default) is byte-identical to unscaled behavior', + () { + final unscaled = rule.apply( + _context(set: _set(weight: 80, reps: 12), minReps: 6, maxReps: 12), + ); + final explicitZero = rule.apply(_context( + set: _set(weight: 80, reps: 12), + minReps: 6, + maxReps: 12, + sessionFatigueFactor: 0.0, + )); + expect(explicitZero.weight, unscaled.weight); + expect(explicitZero.reps, unscaled.reps); + expect(explicitZero.confidence, unscaled.confidence); + expect(explicitZero.reasoning, unscaled.reasoning); + }); + }); + + group('ProgressionRuleFactory', () { + test('default chain order matches the documented priority', () { + final rules = ProgressionRuleFactory.rules; + expect(rules[0], isA()); + expect(rules[1], isA()); + expect(rules[2], isA()); + expect(rules[3], isA()); + expect(rules[4], isA()); + expect(rules[5], isA()); + expect(rules[6], isA()); + expect(rules.length, 7); + }); + + test('apply falls through to the first matching rule', () { + final result = ProgressionRuleFactory.apply(_context( + set: _set(weight: 80, reps: 8), + isUnderRecovered: true, + recoveryPercent: 50, + // Even though decline is also true, under-recovered has priority. + isDeclining: true, + )); + expect(result.reasoning, contains('recovered')); + }); + + test('registerRuleAtHead overrides the entire chain', () { + ProgressionRuleFactory.registerRuleAtHead(_AlwaysHolds()); + + final result = ProgressionRuleFactory.apply(_context(set: _set(weight: 60, reps: 10))); + + expect(result.reasoning, 'always holds'); + expect(result.weight, 60); + expect(result.reps, 10); + }); + + test('reset restores the default chain', () { + ProgressionRuleFactory.registerRuleAtHead(_AlwaysHolds()); + ProgressionRuleFactory.reset(); + + expect(ProgressionRuleFactory.rules.length, 7); + expect(ProgressionRuleFactory.rules.first, isA()); + }); + }); +} + +class _AlwaysHolds implements ProgressionRule { + @override + SetRecommendation apply(ProgressionContext c) => SetRecommendation( + weight: c.set.weight, + reps: c.set.reps, + confidence: 'low', + reasoning: 'always holds', + ); +} diff --git a/workout-logger/test/screens/ai_settings_section_test.dart b/workout-logger/test/screens/ai_settings_section_test.dart new file mode 100644 index 0000000..cac7f87 --- /dev/null +++ b/workout-logger/test/screens/ai_settings_section_test.dart @@ -0,0 +1,62 @@ +// Widget tests for the Gemini model dropdown + thinking-level slider in +// AiSettingsSection. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:repforge/screens/widgets/profile_sections.dart'; +import 'package:repforge/services/ai/gemini_ai_service.dart'; +import 'package:repforge/services/settings_provider.dart'; +import '../test_utils/mock_storage_service.dart'; + +Widget _wrap(SettingsProvider settings, GeminiAiService gemini) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: settings), + ChangeNotifierProvider.value(value: gemini), + ], + child: const MaterialApp( + home: Scaffold(body: SingleChildScrollView(child: AiSettingsSection())), + ), + ); +} + +void main() { + late SettingsProvider settings; + late GeminiAiService gemini; + + setUp(() async { + settings = SettingsProvider(MockStorageService()); + await settings.init(); + gemini = GeminiAiService(); + }); + + testWidgets('model picker is a dropdown showing the current model', (tester) async { + await tester.pumpWidget(_wrap(settings, gemini)); + await tester.pumpAndSettle(); + + expect(find.byType(DropdownButtonFormField), findsOneWidget); + expect(find.text('Gemini 3.6 Flash'), findsOneWidget); + }); + + testWidgets('selecting gemini-3.7-flash from the dropdown clamps the thinking-level slider off minimal', (tester) async { + await settings.setGeminiThinkingLevel('minimal'); + await tester.pumpWidget(_wrap(settings, gemini)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Gemini 3.7 Flash').last); + await tester.pumpAndSettle(); + + expect(settings.geminiThinkingLevel, 'low'); + }); + + testWidgets('thinking-level slider is hidden for gemini-2.5-flash', (tester) async { + await settings.setGeminiModel('gemini-2.5-flash'); + await tester.pumpWidget(_wrap(settings, gemini)); + await tester.pumpAndSettle(); + + expect(find.text('THINKING LEVEL'), findsNothing); + }); +} diff --git a/workout-logger/test/screens/exercise_input_section_text_scale_test.dart b/workout-logger/test/screens/exercise_input_section_text_scale_test.dart new file mode 100644 index 0000000..d739b34 --- /dev/null +++ b/workout-logger/test/screens/exercise_input_section_text_scale_test.dart @@ -0,0 +1,165 @@ +// Guards the set-entry UI against large system font sizes. A reader running +// Android's bigger-font settings was seeing the weight value clipped inside +// its own field and the assisted-load line run off the edge of its pill. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/widgets/exercise_input_section.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/theme/app_theme.dart'; + +import '../test_utils/mock_storage_service.dart'; + +/// Screen widths worth covering: a small phone, the common ~411dp phone, and a +/// tablet-ish width. +const _widths = [360, 411, 720]; + +/// 1.0 is the default; 1.35 is roughly the reported device; 2.0 is the largest +/// font size Android's accessibility settings offer. +const _textScales = [1.0, 1.35, 2.0]; + +void main() { + late SettingsProvider settings; + + setUp(() async { + settings = SettingsProvider(MockStorageService()); + await settings.init(); + }); + + Widget harness({ + required double width, + required double textScale, + required double weight, + String? exerciseId, + }) { + return MediaQuery( + data: MediaQueryData( + size: Size(width, 900), + textScaler: TextScaler.linear(textScale), + ), + child: MaterialApp( + theme: AppTheme.darkTheme, + home: Scaffold( + body: SizedBox( + width: width, + height: 900, + // Mirrors WorkoutFlowScreen: a scroll view tall enough for the + // section's Spacer to have something to absorb. + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.md), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - AppSpacing.md * 2, + ), + child: IntrinsicHeight( + child: ExerciseInputSection( + contentWidth: + constraints.maxWidth - AppSpacing.md * 2, + currentWeight: weight, + currentReps: 12, + isDropset: false, + drops: const [], + mainWeightController: TextEditingController(), + mainRepsController: TextEditingController(), + dropWeightControllers: const [], + dropRepsControllers: const [], + recommendations: [ + SetRecommendation( + weight: 19, + reps: 14, + confidence: 'high', + reasoning: 'test', + ), + ], + previousSets: const [], + lastSession: null, + settings: settings, + exerciseId: exerciseId, + onWeightChanged: (_) {}, + onRepsChanged: (_) {}, + onDropsetToggled: (_) {}, + onDropAdded: () {}, + onDropRemoved: (_) {}, + onDropWeightChanged: (_, _) {}, + onDropRepsChanged: (_, _) {}, + onApplyRecommendation: () {}, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + group('ExerciseInputSection lays out without overflow', () { + for (final width in _widths) { + for (final scale in _textScales) { + testWidgets('${width.toInt()}dp at ${scale}x text scale', + (tester) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + // A three-digit weight is the widest the field ever has to show. + await tester.pumpWidget(harness( + width: width, + textScale: scale, + weight: 102.5, + exerciseId: 'pull_ups', + )); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + } + } + }); + + group('value text is fitted rather than clipped', () { + testWidgets('a three-digit weight paints inside its field', (tester) async { + tester.view.physicalSize = const Size(411, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget(harness( + width: 411, + textScale: 1.35, + weight: 102.5, + )); + await tester.pump(); + + final field = find.widgetWithText(TextField, '102.5'); + expect(field, findsOneWidget); + + final fieldWidth = tester.getSize(field).width; + final style = tester.widget(field).style!; + final painter = TextPainter( + text: TextSpan(text: '102.5', style: style), + textDirection: TextDirection.ltr, + textScaler: const TextScaler.linear(1.35), + )..layout(); + + expect(painter.width, lessThanOrEqualTo(fieldWidth)); + // Still shrunk only as far as it had to be. + expect(style.fontSize, greaterThanOrEqualTo(18.0)); + }); + + testWidgets('a short value keeps the full display size', (tester) async { + tester.view.physicalSize = const Size(411, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget(harness(width: 411, textScale: 1.0, weight: 20)); + await tester.pump(); + + final field = find.widgetWithText(TextField, '20.0'); + expect(field, findsOneWidget); + expect(tester.widget(field).style!.fontSize, 36.0); + }); + }); +} diff --git a/workout-logger/test/screens/rf_action_sheet_text_scale_test.dart b/workout-logger/test/screens/rf_action_sheet_text_scale_test.dart new file mode 100644 index 0000000..4d6c90b --- /dev/null +++ b/workout-logger/test/screens/rf_action_sheet_text_scale_test.dart @@ -0,0 +1,112 @@ +// Guards showRFActionSheet against large system font sizes and short +// viewports. showModalBottomSheet defaults to isScrollControlled: false, which +// caps the sheet at 9/16 of the viewport — enough that a three-action sheet +// with descriptions clipped its last action, with no way to scroll to it. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/widgets/rf_dialogs.dart'; +import 'package:repforge/theme/app_theme.dart'; + +/// 1.0 is the default; 2.0 is the largest Android's accessibility settings +/// offer. 400x640 is a short viewport, where the 9/16 cap bites hardest. +const _textScales = [1.0, 1.35, 2.0]; +const _viewport = Size(400, 640); + +enum _Choice { save, discard, cancel } + +void main() { + Future openSheet(WidgetTester tester, double textScale) async { + await tester.pumpWidget( + MediaQuery( + data: MediaQueryData( + size: _viewport, + textScaler: TextScaler.linear(textScale), + ), + child: MaterialApp( + theme: AppTheme.darkTheme, + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () => showRFActionSheet<_Choice>( + context, + title: 'Leave this workout?', + message: + 'You have unsaved sets in this session. Choose what ' + 'to do with them before you go.', + actions: const [ + RFAction( + label: 'Save and leave', + value: _Choice.save, + description: + 'Finish the workout here and keep every set you ' + 'have logged so far.', + isPrimary: true, + ), + RFAction( + label: 'Discard workout', + value: _Choice.discard, + description: + 'Throw away this session and everything logged ' + 'in it. This cannot be undone.', + isDanger: true, + ), + RFAction(label: 'Keep going', value: _Choice.cancel), + ], + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + for (final scale in _textScales) { + testWidgets('action sheet lays out without overflow at ${scale}x text', + (tester) async { + tester.view.physicalSize = _viewport; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await openSheet(tester, scale); + + // A RenderFlex overflow is reported as a FlutterError during layout, so + // reaching this point with a clean exception state is the assertion. + expect(tester.takeException(), isNull); + expect(find.text('Leave this workout?'), findsOneWidget); + }); + + testWidgets('every action stays reachable at ${scale}x text', + (tester) async { + tester.view.physicalSize = _viewport; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await openSheet(tester, scale); + + // The last action is the one the 9/16 cap used to cut off. Scroll it + // into view rather than asserting it is already visible: the sheet is + // legitimately taller than the viewport at 2.0x. + final lastAction = find.text('Keep going'); + await tester.scrollUntilVisible( + lastAction, + 80, + scrollable: find.byType(Scrollable).last, + ); + expect(lastAction, findsOneWidget); + + await tester.tap(lastAction); + await tester.pumpAndSettle(); + + // Tapping it dismissed the sheet, so it was genuinely hittable. + expect(find.text('Leave this workout?'), findsNothing); + }); + } +} diff --git a/workout-logger/test/screens/widgets/rf_shell_test.dart b/workout-logger/test/screens/widgets/rf_shell_test.dart new file mode 100644 index 0000000..368d9ff --- /dev/null +++ b/workout-logger/test/screens/widgets/rf_shell_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/widgets/rf_shell.dart'; + +void main() { + // The header cannot measure its children before layout, so a centred title + // is balanced by a counterweight computed from the leading/trailing widths. + // Every widget that sits in the leading run has to be counted, or the title + // drifts by half of whatever was missed. + group('RFScreenHeader centred title', () { + Future pumpHeader( + WidgetTester tester, { + IconData? badgeIcon, + VoidCallback? onBack, + List actions = const [], + }) { + return tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RFScreenHeader( + title: 'Coach', + badgeIcon: badgeIcon, + onBack: onBack, + actions: actions, + centreTitle: true, + ), + ), + ), + ); + } + + void expectTitleCentred(WidgetTester tester) { + final title = tester.getRect(find.text('Coach')); + final screenWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio; + expect(title.center.dx, moreOrLessEquals(screenWidth / 2, epsilon: 0.5)); + } + + testWidgets('lands on true centre with a badge and one action', ( + tester, + ) async { + await pumpHeader( + tester, + badgeIcon: Icons.auto_awesome_rounded, + actions: [ + RFIconButton(icon: Icons.more_vert_rounded, tooltip: 'More', onTap: () {}), + ], + ); + expectTitleCentred(tester); + }); + + testWidgets('lands on true centre with a back button, badge and action', ( + tester, + ) async { + await pumpHeader( + tester, + badgeIcon: Icons.auto_awesome_rounded, + onBack: () {}, + actions: [ + RFIconButton(icon: Icons.more_vert_rounded, tooltip: 'More', onTap: () {}), + ], + ); + expectTitleCentred(tester); + }); + + testWidgets('lands on true centre with no badge', (tester) async { + await pumpHeader( + tester, + onBack: () {}, + actions: [ + RFIconButton(icon: Icons.more_vert_rounded, tooltip: 'More', onTap: () {}), + ], + ); + expectTitleCentred(tester); + }); + }); +} diff --git a/workout-logger/test/screens/workout_flow_screen_full_test.dart b/workout-logger/test/screens/workout_flow_screen_full_test.dart index db4df20..c71a443 100644 --- a/workout-logger/test/screens/workout_flow_screen_full_test.dart +++ b/workout-logger/test/screens/workout_flow_screen_full_test.dart @@ -44,7 +44,7 @@ void main() { robot.expectVisible(WorkoutFlowScreen); // Tap Log Set button if present - final logBtn = find.text('LOG SET'); + final logBtn = find.text('Log set'); if (logBtn.evaluate().isNotEmpty) { await tester.tap(logBtn); await tester.pumpAndSettle(); @@ -73,7 +73,7 @@ void main() { robot.expectVisible(WorkoutFlowScreen); // Log set - final logBtn = find.text('LOG SET'); + final logBtn = find.text('Log set'); if (logBtn.evaluate().isNotEmpty) { await tester.tap(logBtn); await tester.pumpAndSettle(); diff --git a/workout-logger/test/session_fatigue_test.dart b/workout-logger/test/session_fatigue_test.dart new file mode 100644 index 0000000..933d439 --- /dev/null +++ b/workout-logger/test/session_fatigue_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/utils/session_fatigue.dart'; + +WorkoutSet _hardSet({ + double weight = 100, + int reps = 5, + DateTime? timestamp, +}) => + WorkoutSet( + weight: weight, + reps: reps, + timestamp: timestamp ?? DateTime(2026, 1, 1, 10, 0), + ); + +void main() { + const accumulator = SessionFatigueAccumulator(); + + test('empty session logs produce zero factor', () { + expect(accumulator.factorFor(exerciseLogs: []), 0.0); + }); + + test('logs with no sets yet (pre-selected exercises) contribute nothing', () { + final factor = accumulator.factorFor( + exerciseLogs: [const ExerciseLog(exerciseId: 'squat', sets: [])], + ); + expect(factor, 0.0); + }); + + test('excludeExerciseId skips that exercise but keeps others', () { + // Each hard set contributes (8.0 − 6.0) / 3.0 ≈ 0.667, and the factor is + // 0.0 until the raw total passes _softCap (16.0). With one set per + // exercise both branches sit at 0.0 and the comparison holds even if the + // exclusion were ignored — so load each exercise past the cap, where + // dropping one is actually observable. + const setsPerExercise = 30; // ≈20.0 raw, comfortably past _softCap + List hardSets() => [ + for (var i = 0; i < setsPerExercise; i++) + _hardSet(timestamp: DateTime(2026, 1, 1, 10, i)), + ]; + + final logs = [ + ExerciseLog(exerciseId: 'squat', sets: hardSets()), + ExerciseLog(exerciseId: 'leg_press', sets: hardSets()), + ]; + + final withSquatExcluded = accumulator.factorFor( + exerciseLogs: logs, + excludeExerciseId: 'squat', + ); + final legPressOnly = accumulator.factorFor(exerciseLogs: [logs[1]]); + + expect(withSquatExcluded, closeTo(legPressOnly, 0.0001)); + // Strictly inside (0, 1): at 0.0 or 1.0 the assertion above would pass + // whether or not the exclusion happened. + expect(withSquatExcluded, greaterThan(0.0)); + expect(withSquatExcluded, lessThan(1.0)); + // And keeping both exercises must land somewhere different. + expect( + accumulator.factorFor(exerciseLogs: logs), + greaterThan(withSquatExcluded), + ); + }); + + test('a realistic session (a handful of sets across a few exercises) ' + 'stays at zero — this is the calibrated-inert case', () { + // 4 exercises, 3 sets each = 12 sets, well within what a real session + // looks like (backtest max across 254 real contexts was 11.87 raw, + // caps start at 16.0). + final logs = [ + for (var ex = 0; ex < 4; ex++) + ExerciseLog( + exerciseId: 'ex$ex', + sets: [ + for (var s = 0; s < 3; s++) + _hardSet(timestamp: DateTime(2026, 1, 1, 10, ex * 10 + s * 2)), + ], + ), + ]; + final factor = accumulator.factorFor(exerciseLogs: logs); + expect(factor, 0.0); + }); + + test('an extreme, unrealistic session eventually saturates the factor ' + 'toward 1.0 — proves the mechanism works end-to-end even though ' + 'real sessions never reach it', () { + final logs = [ + for (var ex = 0; ex < 10; ex++) + ExerciseLog( + exerciseId: 'ex$ex', + sets: [ + for (var s = 0; s < 5; s++) + _hardSet(timestamp: DateTime(2026, 1, 1, 10, ex * 10 + s * 2)), + ], + ), + ]; + final factor = accumulator.factorFor(exerciseLogs: logs); + expect(factor, greaterThan(0.0)); + }); + + test('factor is always clamped to [0.0, 1.0]', () { + final logs = [ + for (var ex = 0; ex < 30; ex++) + ExerciseLog( + exerciseId: 'ex$ex', + sets: [_hardSet(timestamp: DateTime(2026, 1, 1, 10, ex * 5))], + ), + ]; + final factor = accumulator.factorFor(exerciseLogs: logs); + expect(factor, greaterThanOrEqualTo(0.0)); + expect(factor, lessThanOrEqualTo(1.0)); + }); +} diff --git a/workout-logger/test/settings_provider_test.dart b/workout-logger/test/settings_provider_test.dart index 39cc6e7..f3c9a81 100644 --- a/workout-logger/test/settings_provider_test.dart +++ b/workout-logger/test/settings_provider_test.dart @@ -21,6 +21,7 @@ void main() { expect(provider.userName, isNull); expect(provider.geminiApiKey, isEmpty); expect(provider.geminiModel, equals('gemini-3.6-flash')); + expect(provider.geminiThinkingLevel, equals('minimal')); expect(provider.showAdvancedMetrics, isFalse); }); @@ -31,7 +32,8 @@ void main() { await mockStorage.saveSetting('readinessEnabled', 'true'); await mockStorage.saveSetting('userName', 'Devasy'); await mockStorage.saveSetting('geminiApiKey', 'secret_key'); - await mockStorage.saveSetting('geminiModel', 'gemini-1.5-pro'); + await mockStorage.saveSetting('geminiModel', 'gemini-3.5-flash'); + await mockStorage.saveSetting('geminiThinkingLevel', 'high'); await mockStorage.saveSetting('showAdvancedMetrics', 'true'); await provider.init(); @@ -43,10 +45,31 @@ void main() { expect(provider.readinessEnabled, isTrue); expect(provider.userName, equals('Devasy')); expect(provider.geminiApiKey, equals('secret_key')); - expect(provider.geminiModel, equals('gemini-1.5-pro')); + expect(provider.geminiModel, equals('gemini-3.5-flash')); + expect(provider.geminiThinkingLevel, equals('high')); expect(provider.showAdvancedMetrics, isTrue); }); + test('init falls back to the default model when the stored one is no ' + 'longer offered', () async { + // A model id left behind by an older build. The picker only has items + // for kGeminiModels, so surfacing this one would assert. + await mockStorage.saveSetting('geminiModel', 'gemini-1.5-pro'); + + await provider.init(); + + expect(provider.geminiModel, equals('gemini-3.6-flash')); + }); + + test('init clamps a stored thinking level that the stored model no longer supports', () async { + await mockStorage.saveSetting('geminiModel', 'gemini-3.7-flash'); + await mockStorage.saveSetting('geminiThinkingLevel', 'minimal'); + + await provider.init(); + + expect(provider.geminiThinkingLevel, equals('low')); + }); + test('setUserName updates state and notifies listeners', () async { bool notified = false; provider.addListener(() => notified = true); @@ -104,10 +127,22 @@ void main() { await provider.setGeminiModel('custom-model'); expect(provider.geminiModel, equals('custom-model')); + await provider.setGeminiThinkingLevel('high'); + expect(provider.geminiThinkingLevel, equals('high')); + expect(mockStorage.settings['geminiThinkingLevel'], equals('high')); + await provider.setShowAdvancedMetrics(true); expect(provider.showAdvancedMetrics, isTrue); }); + test('setGeminiModel clamps an already-set thinking level the new model does not support', () async { + await provider.setGeminiThinkingLevel('minimal'); + await provider.setGeminiModel('gemini-3.7-flash'); + + expect(provider.geminiThinkingLevel, equals('low')); + expect(mockStorage.settings['geminiThinkingLevel'], equals('low')); + }); + test('saveWeeklyInsights updates insights string and date', () async { await provider.saveWeeklyInsights('Great progress this week!'); @@ -116,6 +151,52 @@ void main() { expect(mockStorage.settings['weeklyInsights'], equals('Great progress this week!')); }); + test( + 'setGeminiModel rolls the model write back when the thinking-level write fails', + () async { + // The two writes are not a transaction. Without the rollback, storage + // keeps the new model while the provider keeps the old one, so a + // selection the UI reported as failed becomes active on next launch. + await provider.setGeminiThinkingLevel('minimal'); + expect(provider.geminiModel, equals('gemini-3.6-flash')); + + // gemini-3.7-flash doesn't support 'minimal', so the clamp forces the + // second write — which is the one we make fail. + mockStorage.saveSettingErrorResolver = (key, value) => + key == 'geminiThinkingLevel' ? Exception('disk full') : null; + + await expectLater( + provider.setGeminiModel('gemini-3.7-flash'), + throwsA(isA()), + ); + mockStorage.saveSettingErrorResolver = null; + + expect(provider.geminiModel, equals('gemini-3.6-flash')); + expect( + mockStorage.settings['geminiModel'], + equals('gemini-3.6-flash'), + reason: 'the model write must not survive the failed clamp write', + ); + expect(provider.geminiThinkingLevel, equals('minimal')); + }, + ); + + test( + 'setGeminiMaxToolRounds does not commit in memory when the write fails', + () async { + final before = provider.geminiMaxToolRounds; + mockStorage.saveSettingErrorResolver = (key, value) => + key == 'geminiMaxToolRounds' ? Exception('disk full') : null; + + await expectLater( + provider.setGeminiMaxToolRounds(before + 3), + throwsA(isA()), + ); + + expect(provider.geminiMaxToolRounds, equals(before)); + }, + ); + test('availableIncrements returns correct values for unit', () async { expect(provider.availableIncrements, equals([1.25, 2.5, 5.0, 10.0])); diff --git a/workout-logger/test/test_utils/mock_ml_service.dart b/workout-logger/test/test_utils/mock_ml_service.dart index 9372e15..4d6b7bf 100644 --- a/workout-logger/test/test_utils/mock_ml_service.dart +++ b/workout-logger/test/test_utils/mock_ml_service.dart @@ -26,6 +26,11 @@ class MockMLService implements IMLService { // Last parameters received String? lastExtractedExerciseId; List? lastRecommendedLastSession; + List>? lastPastSessions; + Map? lastRecoveryScores; + List? lastPrimaryMuscleIds; + ReadinessBand? lastReadinessBand; + double? lastSessionFatigueFactor; @override GrowthModel trainGrowthModel(List dataPoints) { @@ -84,6 +89,22 @@ class MockMLService implements IMLService { return {}; } + @override + Map lastTrainedPerMuscle( + List sessions, + Map exerciseMap, + ) { + return {}; + } + + @override + Map recoveryScoresFrom( + Map lastTrained, { + DateTime? asOf, + }) { + return {}; + } + @override List recommendSets({ required List lastSession, @@ -93,10 +114,17 @@ class MockMLService implements IMLService { int maxReps = 12, Map? recoveryScores, List? primaryMuscleIds, + ReadinessBand? readinessBand, + double sessionFatigueFactor = 0.0, DateTime? asOf, }) { recommendSetsCallCount++; lastRecommendedLastSession = lastSession; + lastPastSessions = pastSessions; + lastRecoveryScores = recoveryScores; + lastPrimaryMuscleIds = primaryMuscleIds; + lastReadinessBand = readinessBand; + lastSessionFatigueFactor = sessionFatigueFactor; return mockRecommendations ?? [ @@ -150,6 +178,11 @@ class MockMLService implements IMLService { predictTargetCompletionCallCount = 0; lastExtractedExerciseId = null; lastRecommendedLastSession = null; + lastPastSessions = null; + lastRecoveryScores = null; + lastPrimaryMuscleIds = null; + lastReadinessBand = null; + lastSessionFatigueFactor = null; mockGrowthModel = null; mockRecommendations = null; mockPrediction = null; diff --git a/workout-logger/test/test_utils/mock_storage_service.dart b/workout-logger/test/test_utils/mock_storage_service.dart index 2f0a9c3..47531ef 100644 --- a/workout-logger/test/test_utils/mock_storage_service.dart +++ b/workout-logger/test/test_utils/mock_storage_service.dart @@ -29,6 +29,13 @@ class MockStorageService implements IStorageService { Duration saveSettingDelay = Duration.zero; Duration Function(String key, String value)? saveSettingDelayResolver; + /// Return non-null to make [saveSetting] throw for that key/value instead + /// of storing it, for exercising partial-write rollback paths. + Object? Function(String key, String value)? saveSettingErrorResolver; + + /// Return non-null to make [saveWorkoutSession] throw for that session. + Object? Function(WorkoutSession session)? saveWorkoutSessionErrorResolver; + // Public getters for test assertions List get customExercises => _customExercises; List get sessions => _sessions; @@ -85,6 +92,8 @@ class MockStorageService implements IStorageService { @override Future saveWorkoutSession(WorkoutSession session) async { + final error = saveWorkoutSessionErrorResolver?.call(session); + if (error != null) throw error; final index = _sessions.indexWhere((s) => s.id == session.id); if (index >= 0) { _sessions[index] = session; @@ -232,6 +241,8 @@ class MockStorageService implements IStorageService { if (delay > Duration.zero) { await Future.delayed(delay); } + final error = saveSettingErrorResolver?.call(key, value); + if (error != null) throw error; saveSettingCallCount++; _settings[key] = value; } diff --git a/workout-logger/test/userflow_screens_sweep_test.dart b/workout-logger/test/userflow_screens_sweep_test.dart index 44b81a1..7070ecd 100644 --- a/workout-logger/test/userflow_screens_sweep_test.dart +++ b/workout-logger/test/userflow_screens_sweep_test.dart @@ -178,12 +178,12 @@ void main() { robot.expectVisible(WorkoutFlowScreen); // Interact with set logging and rest timer - final logSetBtn = find.text('LOG SET'); + final logSetBtn = find.text('Log set'); if (logSetBtn.evaluate().isNotEmpty) { await tester.tap(logSetBtn); await tester.pumpAndSettle(); - final restTargets = ['+30s', 'SKIP REST']; + final restTargets = ['+30s', 'Skip rest']; await TestSweep.tapAll(tester, restTargets); } diff --git a/workout-logger/test/userflow_workout_logging_test.dart b/workout-logger/test/userflow_workout_logging_test.dart index 4c67c59..72e432f 100644 --- a/workout-logger/test/userflow_workout_logging_test.dart +++ b/workout-logger/test/userflow_workout_logging_test.dart @@ -78,15 +78,15 @@ void main() { // Verify WorkoutFlowScreen renders exercise name expect(find.text('Barbell Bench Press'), findsWidgets); - // 2. Drive production flow: Tap 'LOG SET' to trigger RestTimerView overlay in WorkoutFlowScreen - final logSetBtn = find.text('LOG SET'); + // 2. Drive production flow: Tap 'Log set' to trigger RestTimerView overlay in WorkoutFlowScreen + final logSetBtn = find.text('Log set'); expect(logSetBtn, findsOneWidget); await tester.tap(logSetBtn); await tester.pumpAndSettle(); // Verify RestTimerView overlay appears via WorkoutFlowScreen production state expect(find.text('REST'), findsWidgets); - expect(find.text('SKIP REST'), findsOneWidget); + expect(find.text('Skip rest'), findsOneWidget); // Tap '+30s' button during rest final addTimeBtn = find.text('+30s'); @@ -94,8 +94,8 @@ void main() { await tester.tap(addTimeBtn); await tester.pump(); - // Tap 'SKIP REST' to return to active workout view - final skipBtn = find.text('SKIP REST'); + // Tap 'Skip rest' to return to active workout view + final skipBtn = find.text('Skip rest'); await tester.tap(skipBtn); await tester.pumpAndSettle(); diff --git a/workout-logger/test/workout_provider_test.dart b/workout-logger/test/workout_provider_test.dart index e6088c2..fde0d29 100644 --- a/workout-logger/test/workout_provider_test.dart +++ b/workout-logger/test/workout_provider_test.dart @@ -247,6 +247,290 @@ void main() { expect(recs, isNotEmpty); expect(recs.first.weight, greaterThanOrEqualTo(120)); }); + + test('getRecommendations holds load when the primary muscle is still ' + 'under-recovered from a session a couple hours ago', () async { + // bench_press's primary muscle is chest (tau=48h); a session this + // recent leaves chest well under the 70% recovery threshold. + final recentTimestamp = DateTime.now().subtract(const Duration(hours: 2)); + mockStorage.addMockSession( + session('recent', recentTimestamp, [ + log('bench_press', sets: [WorkoutSet(weight: 80, reps: 8)]), + ]), + ); + + await provider.init(); + + final recs = provider.getRecommendations('bench_press'); + + expect(recs, isNotEmpty); + expect(recs.first.confidence, 'low'); + expect(recs.first.reasoning, contains('recovered')); + expect(recs.first.weight, 80); + expect(recs.first.reps, 8); + }); + + test('getRecommendations holds load when readinessBand is low, even ' + 'when reps are otherwise ready to progress', () async { + mockStorage.addMockSession( + session('old', DateTime(2025, 1, 1), [ + log('bench_press', sets: [WorkoutSet(weight: 60, reps: 12)]), + ]), + ); + await provider.init(); + + final normal = provider.getRecommendations('bench_press'); + // Baseline: with reps at the ceiling and no readiness signal, this + // progresses (weight bump), confirming the low-readiness case below + // is actually suppressing something. + expect(normal.first.weight, greaterThan(60)); + + final lowReadiness = provider.getRecommendations( + 'bench_press', + readinessBand: ReadinessBand.low, + ); + expect(lowReadiness.first.weight, 60); + expect(lowReadiness.first.reps, 12); + expect(lowReadiness.first.confidence, 'low'); + expect(lowReadiness.first.reasoning, contains('readiness')); + }); + + test('getRecommendations ignores a moderate/high readinessBand', () async { + mockStorage.addMockSession( + session('old', DateTime(2025, 1, 1), [ + log('bench_press', sets: [WorkoutSet(weight: 60, reps: 12)]), + ]), + ); + await provider.init(); + + final recs = provider.getRecommendations( + 'bench_press', + readinessBand: ReadinessBand.moderate, + ); + expect(recs.first.weight, greaterThan(60)); + }); + + test('getRecommendations is unaffected by a realistic amount of prior ' + 'same-session training — the fatigue calibration is deliberately ' + 'inert at real-world session sizes (see session_fatigue.dart)', + () async { + await provider.addCustomExercise( + name: 'Ex One', + category: 'compound', + primaryMuscleGroupId: 'chest', + ); + await provider.addCustomExercise( + name: 'Ex Two', + category: 'compound', + primaryMuscleGroupId: 'chest', + ); + final ex1 = provider.allExercises.firstWhere((e) => e.name == 'Ex One'); + final ex2 = provider.allExercises.firstWhere((e) => e.name == 'Ex Two'); + + // History for ex2 so its recommendation is a real weight-bump + // scenario (reps at the ceiling), not the no-history default. + mockStorage.addMockSession( + session('old', DateTime(2025, 1, 1), [ + log(ex2.id, sets: [WorkoutSet(weight: 60, reps: 12)]), + ]), + ); + await provider.init(); + + // Start a live session covering both exercises; log a realistic + // number of sets of ex1 first (12 — more than a real working + // exercise would have), then ask for ex2's recommendation. + provider.startWorkout(exerciseIds: [ex1.id, ex2.id]); + for (var i = 0; i < 12; i++) { + provider.addSet(WorkoutSet(weight: 100, reps: 8)); + } + provider.nextExercise(); + + final withPriorSets = provider.getRecommendations(ex2.id); + + // Compare against a fresh provider with no in-progress session at + // all (only ex2's history) to isolate the same-session effect. + final freshMock = MockStorageService(); + freshMock.addMockSession( + session('old', DateTime(2025, 1, 1), [ + log(ex2.id, sets: [WorkoutSet(weight: 60, reps: 12)]), + ]), + ); + final freshProvider = WorkoutProvider( + freshMock, + programManager: ProgramManager(freshMock), + ); + freshMock.addMockCustomExercise(ex1); + freshMock.addMockCustomExercise(ex2); + await freshProvider.init(); + final withoutPriorSets = freshProvider.getRecommendations(ex2.id); + + expect(withoutPriorSets.first.weight, greaterThan(60)); // baseline progresses + expect(withPriorSets.first.weight, withoutPriorSets.first.weight); + }); + + test('getRecommendations dampens after an extreme, unrealistic amount ' + 'of same-session prior training — proves the fatigue mechanism is ' + 'wired end-to-end even though realistic sessions never reach it', + () async { + await provider.addCustomExercise( + name: 'Warmup Ex', + category: 'isolation', + primaryMuscleGroupId: 'chest', + ); + await provider.addCustomExercise( + name: 'Target Ex', + category: 'compound', + primaryMuscleGroupId: 'chest', + ); + final warmupEx = + provider.allExercises.firstWhere((e) => e.name == 'Warmup Ex'); + final targetEx = + provider.allExercises.firstWhere((e) => e.name == 'Target Ex'); + + mockStorage.addMockSession( + session('old', DateTime(2025, 1, 1), [ + log(targetEx.id, sets: [WorkoutSet(weight: 60, reps: 12)]), + ]), + ); + await provider.init(); + + provider.startWorkout(exerciseIds: [warmupEx.id, targetEx.id]); + // Far beyond any real single-exercise set count — well past the + // point (raw total > softCap = 16.0) where the factor engages. + for (var i = 0; i < 40; i++) { + provider.addSet(WorkoutSet( + weight: 100, + reps: 8, + timestamp: DateTime(2026, 1, 1, 10, i * 3), + )); + } + provider.nextExercise(); + + final recs = provider.getRecommendations(targetEx.id); + // 40 hard sets push the raw accumulation to ~26.7, past _hardCap, so + // the factor clamps to 1.0 and SessionFatigueRule hard-holds. Naming + // the exact weight and reasoning distinguishes that from + // DoubleProgressionRule's partial-increment scaling — `lessThan(65)` + // alone was true of both outcomes. + expect(recs.first.weight, 60.0); + expect(recs.first.reasoning, contains('earlier in today')); + }); + + group('recordSessionEffort / effortCalibrationOffset', () { + test('effortCalibrationOffset defaults to 0.0 when never answered', () { + expect(provider.effortCalibrationOffset, 0.0); + }); + + test( + 'records sessionEffort on the session and updates the rolling offset', + () async { + mockStorage.addMockSession( + session('s1', DateTime(2025, 1, 1), [log('bench_press')]), + ); + await provider.init(); + final sessionId = + provider.sessions.firstWhere((s) => s.id == 's1').id; + + await provider.recordSessionEffort(sessionId, 3); // Brutal + + final updated = provider.sessions.firstWhere((s) => s.id == sessionId); + expect(updated.sessionEffort, 3); + expect(provider.effortCalibrationOffset, greaterThan(0.0)); + }); + + test('persists the offset across a reload', () async { + mockStorage.addMockSession( + session('s1', DateTime(2025, 1, 1), [log('bench_press')]), + ); + await provider.init(); + await provider.recordSessionEffort('s1', 1); // Easy + final offsetAfterRecording = provider.effortCalibrationOffset; + expect(offsetAfterRecording, lessThan(0.0)); + + // A second init() on the same instance would pass even if the + // offset were only ever held in memory. Build a fresh provider over + // the same storage, as the draft-restore test does, so this can + // only pass if the value actually round-tripped through storage. + final reloaded = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + await reloaded.init(); + + expect(reloaded.effortCalibrationOffset, + closeTo(offsetAfterRecording, 0.0001)); + }); + + test('does nothing for an unknown session id', () async { + await provider.recordSessionEffort('does-not-exist', 3); + expect(provider.effortCalibrationOffset, 0.0); + }); + + test('re-answering the chip replaces the previous answer rather than ' + 'folding both in', () async { + mockStorage.addMockSession( + session('s1', DateTime(2025, 1, 1), [log('bench_press')]), + ); + await provider.init(); + + // The summary screen leaves the chip tappable, so changing your + // mind must land on the same offset as answering Easy once. + await provider.recordSessionEffort('s1', 3); // Brutal + await provider.recordSessionEffort('s1', 1); // ...actually, Easy + final afterChange = provider.effortCalibrationOffset; + + final fresh = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + mockStorage.settings.remove('effort.calibrationOffset'); + await fresh.init(); + await fresh.recordSessionEffort('s1', 1); // Easy, first time + + expect(afterChange, closeTo(fresh.effortCalibrationOffset, 0.0001)); + }); + + test('overlapping taps do not let a failed write clobber a later one', + () async { + mockStorage.addMockSession( + session('s1', DateTime(2025, 1, 1), [log('bench_press')]), + ); + await provider.init(); + + // The chips stay tappable while a write is in flight. Delay the + // offset write so a second tap can be issued mid-flight, and fail + // the first one so it takes its rollback path. + mockStorage.saveSettingDelayResolver = (key, value) => + key == 'effort.calibrationOffset' + ? const Duration(milliseconds: 20) + : Duration.zero; + var offsetWrites = 0; + mockStorage.saveSettingErrorResolver = (key, value) { + if (key != 'effort.calibrationOffset') return null; + offsetWrites++; + return offsetWrites == 1 ? Exception('disk full') : null; + }; + + final first = provider.recordSessionEffort('s1', 3); // Brutal + final second = provider.recordSessionEffort('s1', 1); // Easy + + await expectLater(first, throwsA(isA())); + await second; + + mockStorage.saveSettingErrorResolver = null; + mockStorage.saveSettingDelayResolver = null; + + // Serialised, the second call runs after the first has rolled back + // and re-reads state, so provider and storage agree on Easy. + expect(provider.sessions.firstWhere((s) => s.id == 's1').sessionEffort, + 1); + expect( + mockStorage.sessions.firstWhere((s) => s.id == 's1').sessionEffort, + 1, + reason: 'the failed first write must not roll back over the second', + ); + }); + }); }); group('deleteCustomExercise', () {