diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4dda78..393c5ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,14 +115,26 @@ jobs: echo "EOF" } >> $GITHUB_OUTPUT + - name: Decode release keystore + run: | + echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > /tmp/repforge-release.jks + - name: Build APK working-directory: ./workout-logger - run: flutter build apk --release + env: + KEYSTORE_PATH: /tmp/repforge-release.jks + KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: flutter build apk --release --split-per-abi - - name: Rename APK + - name: Rename APKs run: | - mv workout-logger/build/app/outputs/flutter-apk/app-release.apk \ - workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}.apk + V="${{ steps.version.outputs.value }}" + DIR="workout-logger/build/app/outputs/flutter-apk" + mv "$DIR/app-arm64-v8a-release.apk" "$DIR/repforge-v${V}-arm64-v8a.apk" 2>/dev/null || true + mv "$DIR/app-armeabi-v7a-release.apk" "$DIR/repforge-v${V}-armeabi-v7a.apk" 2>/dev/null || true + mv "$DIR/app-x86_64-release.apk" "$DIR/repforge-v${V}-x86_64.apk" 2>/dev/null || true - name: Sanitize ref name for artifact id: sanitize_ref @@ -133,7 +145,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: repforge-v${{ steps.version.outputs.value }}-${{ steps.sanitize_ref.outputs.ref_name }} - path: workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}.apk + path: workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}-*.apk retention-days: 7 - name: Create GitHub Release @@ -156,7 +168,7 @@ jobs: - **Build Date**: ${{ github.event.head_commit.timestamp }} - **Commit**: ${{ github.sha }} files: | - workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}.apk + workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}-*.apk draft: false prerelease: false env: diff --git a/.gitignore b/.gitignore index 529c1be..b6fd96a 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,12 @@ coverage.xml # Streamlit secrets dashboard/.streamlit/secrets.toml + +# graphify knowledge graph output +graphify-out/ + +# Local backup exports +repforge_backup_*.json + +# Claude Code project memory & session files +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md index b09d982..eddf7f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,3 +289,13 @@ Feature proposals live in `docs/design/`. Before implementing a major feature, c - `wearables_integration.md` - `workout_scheduling.md` - `workout_sharing.md` + +## graphify + +This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. + +Rules: +- ALWAYS read graphify-out/GRAPH_REPORT.md before reading any source files, running grep/glob searches, or answering codebase questions. The graph is your primary map of the codebase. +- IF graphify-out/wiki/index.md EXISTS, navigate it instead of reading raw files +- For cross-module "how does X relate to Y" questions, prefer `graphify query ""`, `graphify path "" ""`, or `graphify explain ""` over grep — these traverse the graph's EXTRACTED + INFERRED edges instead of scanning files +- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). diff --git a/docs/FUTURE_IMPROVEMENTS.md b/docs/FUTURE_IMPROVEMENTS.md new file mode 100644 index 0000000..8db491b --- /dev/null +++ b/docs/FUTURE_IMPROVEMENTS.md @@ -0,0 +1,92 @@ +# Future Improvements — Open Source Store Launch + +This file tracks the remaining work for Options B and C of the open-source store launch plan. +Option A (production signing + IzzyOnDroid/Obtainium) is complete. + +--- + +## Option B — F-Droid Readiness + +### 1. Bundle Geist fonts locally (google_fonts) + +Currently `google_fonts` may fetch font files from Google's CDN at first launch. F-Droid requires +all network access to be under user control — a silent font download at startup fails that bar. + +**Fix:** Download the Geist Sans and Geist Mono `.ttf` files, add them to `assets/fonts/`, declare +them in `pubspec.yaml` under `flutter.fonts`, and replace `GoogleFonts.geist(...)` calls with +`TextStyle(fontFamily: 'Geist')`. Then remove the `google_fonts` package. + +### 2. F-Droid metadata file + +Create `fdroid/metadata/com.devasy.repforge.yml` following the F-Droid metadata spec: + +```yaml +Categories: + - Sports & Health +License: Apache-2.0 +SourceCode: https://github.com//repforge +IssueTracker: https://github.com//repforge/issues + +AutoName: RepForge +Summary: Workout logger with AI-powered coaching +Description: |- + RepForge is an open-source workout logging app with set/rep/weight tracking, + progress analytics, AI coaching (optional, requires user-supplied Gemini API key), + and Health Connect integration. + +AntiFeatures: + NonFreeNet: + - description: > + Optional AI Coach and Routine Optimizer features send data to Google's Gemini API. + These features are disabled unless the user provides their own API key in Settings. + +Builds: + - versionName: 2.x.x + versionCode: xx + commit: vX.X.X + subdir: workout-logger + gradle: + - release +``` + +### 3. Fastlane store metadata + +Create `fastlane/metadata/android/en-US/` with: +- `title.txt` — "RepForge" +- `short_description.txt` — one-line summary (≤80 chars) +- `full_description.txt` — full store description +- `changelogs/.txt` — per-release changelog + +IzzyOnDroid also reads fastlane metadata for its store listing. + +--- + +## Option C — Strict F-Droid Compliance + +### 4. Health Connect graceful degradation + +Health Connect is an OS API (not Google Play Services) so F-Droid accepts it. However, for +maximum compatibility on AOSP/custom ROMs without Health Connect: + +- Add an `isHealthConnectAvailable()` check at startup +- Show a "Health Connect not available" state in the Readiness screen instead of crashing +- Make daily readiness score optional in the Home screen when Health Connect is absent + +### 5. Replace google_fonts package entirely + +After completing item B.1, the `google_fonts` package can be removed from `pubspec.yaml` entirely. +This eliminates any risk of runtime Google CDN fetches and removes a transitive dependency. + +--- + +## IzzyOnDroid Submission Checklist + +- [ ] Merge this branch to main and confirm a production-signed release appears on GitHub Releases +- [ ] Submit via: https://gitlab.com/IzzyOnDroid/repo/-/issues (open a new issue, "App submission" template) +- [ ] Provide: repo URL, anti-features (NonFreeNet), brief description +- [ ] Wait for review (typically 1–7 days) + +## Obtainium + +No submission needed. Users add the GitHub repo URL directly in Obtainium and install the latest +release APK automatically. Share the repo URL in your README. diff --git a/docs/superpowers/specs/2026-06-08-conversational-routine-optimizer-design.md b/docs/superpowers/specs/2026-06-08-conversational-routine-optimizer-design.md new file mode 100644 index 0000000..b1daf88 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-conversational-routine-optimizer-design.md @@ -0,0 +1,258 @@ +# Conversational Routine Optimizer — Design + +**Date:** 2026-06-08 +**Status:** Approved (pending spec review) + +## Context + +RepForge has an AI coach (streaming chat with a function-calling tool loop) and, +from a prior session, a **standalone one-shot routine optimizer**: tapping +"Optimize" on a routine card opened a bottom sheet that made a single +`generateOptimization()` JSON call and rendered accept/reject suggestion cards. + +That one-shot sheet has three gaps we now want to close: + +1. **No clarifying questions.** It guesses user intent (goal, frequency, which + exercises to keep) instead of asking. +2. **No data gate.** It will "optimize" a routine that has never been logged. +3. **No conversation / history.** It is isolated from the coach's + streaming + tool-call + persistence machinery and keeps no record. + +This redesign replaces the one-shot sheet with a **dedicated conversational +optimizer screen** that reuses the coach's existing streaming tool-loop, adds an +interactive `ask_user_questions` tool (Claude-Code style: a question + 3–4 +option chips + custom text input), gates on insufficient data, and persists each +optimization session to its own history "inbox" — separate from the coach chat. + +## Goals + +- Optimize a routine through a multi-turn, streaming conversation. +- Let the AI ask the user clarifying questions mid-stream and wait for answers. +- Block optimization when the routine has too little history (< 3 sessions). +- Persist optimization conversations in a **separate inbox**, never mixed with + coach chats. Launching from a routine always starts a **new** conversation. +- Reuse existing infrastructure (`streamCoachReply`, `CoachToolService`, + `ConversationManager`) — no new AI backend method. + +## Non-Goals + +- No new Hive box (the existing `conversations` box is reused, discriminated by + a `kind` field). +- No changes to the coach screen's behavior or its conversation list. +- No streaming-pause/parsing of text "markers" — the SDK already separates + `functionCalls` from text, so a tool call in flight is detectable directly. + +## Architecture Overview + +``` +RoutineCard "Optimize" tap + │ (gate: sessions for routine.id >= 3 ?) + ▼ +RoutineOptimizerScreen ──watches──► RoutineOptimizerViewModel + │ │ + │ renders transcript + │ orchestrates: + │ inline RFQuestionCard │ - new conversation (kind='optimizer') + │ │ - streamCoachReply(systemPrompt, tools, onToolCall) + │ │ - intercepts ask_user_questions → Completer + │ ▼ + │ IAiService.streamCoachReply (EXISTING, unchanged) + │ │ tool-call loop awaits onToolCall(call) + │ ▼ + │ onToolCall router: + │ ask_user_questions ─► VM (UI prompt, await Completer) + │ everything else ─► CoachToolService.handleCall + ▼ +ConversationManager(kind='optimizer') ──► IStorageService (shared box, filtered) +``` + +## Components + +### 1. Entry point & data gate (`routines_screen.dart`) + +The existing "Optimize" button (`_RoutineCard`) changes its action: + +- Compute `sessionCount = wp.sessions.where((s) => s.routineId == routine.id).length`. +- If `sessionCount < 3`: show a SnackBar / inline message: + *"Not enough data yet — log '{routine.name}' at least 3 times so the coach has + something to analyze."* Do not navigate. +- Else: `Navigator.push` to `RoutineOptimizerScreen(routine: routine)`. + +Rationale for client-side gate: deterministic and cheap (no AI call wasted), and +the threshold (3) is a fixed product decision. + +### 2. `ask_user_questions` tool (Claude-Code style) + +A new `FunctionDeclaration` advertised to the model **only in the optimizer +flow** (added to the optimizer's tool list, not the coach's). Schema: + +```jsonc +{ + "preamble": "string? // optional short message shown above the questions", + "questions": [ + { + "question": "string", + "options": ["string", ...], // 3-4 suggested answers + "multiSelect": true | false, // AI chooses per question + "allowCustom": true // always allow free-text + } + ] +} +``` + +The model returns answers indirectly: the tool's **function response** is the +user's answers, e.g. +`{ "answers": [ { "question": "...", "selected": ["Hypertrophy"], "custom": null } ] }`. + +This tool is **not** handled by `CoachToolService` (which has no UI). Instead the +ViewModel's `onToolCall` router intercepts `ask_user_questions` and routes all +other calls to `CoachToolService.handleCall`. + +### 3. The pause/resume mechanism (the "simple Approach C") + +`IAiService.streamCoachReply` already `await`s `onToolCall(call)` inside its +tool-loop. We exploit this directly: + +- When `ask_user_questions` arrives, the VM: + 1. Parses the questions into a `PendingQuestions` value object. + 2. Sets `_pendingQuestions`, `notifyListeners()` → UI renders `RFQuestionCard`s. + 3. Creates a `Completer>` and **returns its `.future`** + from `onToolCall`. The stream loop is now naturally suspended. +- When the user submits, the screen calls `vm.submitAnswers(...)`, which + completes the Completer with the answers map. The loop resumes, feeds answers + back to Gemini, and streaming continues. + +While `isLoading` is true: +- If `_pendingQuestions != null` → render the question card (awaiting input). +- Else → render a small status row ("Analyzing your performance…") + live + streaming text. (A tool call being in flight is simply: loading, no pending + questions, no new text yet.) + +No text-marker parsing is required. + +### 4. `RFQuestionCard` (reusable widget, `rf_widgets.dart`) + +A generic, app-wide widget so future flows (coach, onboarding) can reuse it: + +```dart +RFQuestionCard({ + required QuestionSpec spec, // question, options, multiSelect, allowCustom + required ValueChanged onSubmit, +}) +``` + +- Renders the question text, option chips (single- or multi-select per `spec`), + a "custom answer" text field when `allowCustom`, and a Submit button. +- Single-select: tapping a chip selects it (radio behavior). +- Multi-select: chips toggle; multiple can be active. +- Custom text, when non-empty, is included alongside (or instead of) chips. +- Pure UI — no provider/AI knowledge. Driven entirely by `spec` + `onSubmit`. + +Data classes `QuestionSpec` / `AnswerSpec` live in `models.dart` (or a small +`ai_question.dart`), with `fromJson`/`toJson` for the tool payload. + +### 5. `RoutineOptimizerViewModel` (rewritten from one-shot to conversational) + +Replaces the old one-shot analyze/apply VM. Responsibilities mirror +`AiCoachViewModel`, scoped to optimization: + +- Constructor injects `IAiService`, `CoachToolService`, and a + `ConversationManager` instance **scoped to `kind='optimizer'`**, plus + `WorkoutProvider` (for the seed prompt / gate context) and `SettingsProvider`. +- `startForRoutine(Routine)`: starts a fresh conversation and auto-sends the + seed user message *"Optimize my '{name}' routine based on my past performance."* +- `sendMessage(text)`: same streaming/persist flow as the coach VM, but with: + - an **optimization-focused system prompt** (instructs the model to ask + clarifying questions via `ask_user_questions` when intent is unclear, to use + the read tools to ground analysis, to propose reorder/replace/add changes, + and to apply them via `update_routine` only after the user agrees); + - `tools = _coachTools.buildTools() + [askUserQuestionsDeclaration]`; + - `onToolCall = _routeToolCall` (intercepts `ask_user_questions`). +- `submitAnswers(AnswerSpec...)`: completes the pending Completer. +- Exposed state: `isLoading`, `streamingText`, `messages`, `conversations` + (optimizer inbox), `activeConversationId`, `pendingQuestions`. + +**History persistence note:** the interactive question card is ephemeral UI. +What gets persisted is plain text: the model's `preamble` (as a model message) +and the user's chosen answers (as a user message, e.g. +*"Goal: Hypertrophy · Frequency: 4×/week"*). This keeps conversations replayable +as ordinary text transcripts. + +### 6. Separate optimizer inbox (`Conversation.kind` + scoped manager) + +- Add `String kind` to `Conversation` (default `'coach'`; optimizer uses + `'optimizer'`). Backward compatible: missing JSON field → `'coach'`. +- `ConversationManager` gains an optional `kind` filter (constructor param): + `loadConversations()` filters `getAllConversations()` to that kind; + `appendMessage` stamps the kind on new conversations. Each instance keeps its + own `_active`, so coach and optimizer never collide. +- DI: register a second `ConversationManager(storage, kind: 'optimizer')` (or + construct it inside the optimizer screen's provider scope). The coach's + existing manager defaults to `kind: 'coach'`. +- The optimizer screen shows its own history list (the "inbox") + a "new + optimization" affordance; launching from a routine always begins a new + conversation. + +### 7. Removed code + +- `lib/screens/widgets/routine_optimization_sheet.dart` — deleted. +- `IAiService.generateOptimization` + its `GeminiAiService` implementation — + deleted. +- Old one-shot body of `RoutineOptimizerViewModel` — rewritten. +- `RoutineOptimizationResult` / `RoutineSuggestion` / `SuggestionType` models — + **retained only if** reused by the new prompt/tooling; otherwise deleted. (The + conversational flow applies changes via `update_routine`, so these are likely + removed.) Decision deferred to the implementation plan after confirming no + other references. + +## Data Flow (happy path) + +1. User taps Optimize on "Push Day" (5 logged sessions → passes gate). +2. `RoutineOptimizerScreen` opens; VM starts a new `kind='optimizer'` + conversation and sends the seed prompt. +3. Model streams: *"Let me check a few things first."* then calls + `ask_user_questions` → stream suspends, card renders: + - "Primary goal?" [Strength / Hypertrophy / Endurance] (single, +custom) + - "Sessions per week for this routine?" [2 / 3 / 4 / 5] (single, +custom) +4. User answers → `submitAnswers` → loop resumes; answers persisted as a user + message. +5. Model calls `get_routine_performance` / `get_exercise_performance` (status + row shows "Analyzing…"), then streams its analysis + proposed changes. +6. Model calls `ask_user_questions` again to confirm which changes to apply + (multiSelect over the proposed reorder/replace/add). +7. On confirmation, model calls `update_routine` (existing write tool) → routine + saved. Model confirms in text. Conversation persisted in the optimizer inbox. + +## Error Handling + +- **Insufficient data:** gated before navigation (SnackBar/inline message). +- **AI not configured:** existing check — SnackBar "Add your Gemini API key…". +- **Stream/tool error:** caught in `sendMessage` (as in the coach VM); error + appended as a model message; loop ends cleanly; `_pendingQuestions` cleared so + the UI never gets stuck awaiting answers. +- **User abandons a pending question** (navigates back): the Completer is + completed with an empty/declined answer on dispose so no Future leaks. +- **`update_routine` failure / unresolved exercise names:** the tool already + returns an `error` map; the model surfaces it conversationally. + +## Testing + +- **Unit (`RoutineOptimizerViewModel`)** with a fake `IAiService`: + - Seed prompt is sent on `startForRoutine`. + - A scripted `ask_user_questions` call sets `pendingQuestions`; `submitAnswers` + completes it and the loop resumes (assert tool response shape). + - Abandoning while pending completes the Completer without leaking. + - Error path appends a model error message and clears loading/pending. +- **Unit (`ConversationManager` kind scoping):** an `optimizer` manager only + loads/saves `kind='optimizer'` conversations; legacy (no-kind) rows read as + `coach` and are excluded. +- **Unit (gate):** `< 3` sessions blocks; `>= 3` proceeds. +- **Widget (`RFQuestionCard`):** single-select radio behavior, multi-select + toggling, custom text inclusion, Submit emits correct `AnswerSpec`. +- Run `flutter analyze` and `flutter test` before completion. + +## Open Items (resolve in plan) + +- Final decision on retaining vs deleting `RoutineSuggestion` / + `RoutineOptimizationResult` / `SuggestionType` (grep for references first). +- Exact DI wiring location for the `kind='optimizer'` `ConversationManager` + (composition root in `main.dart` vs screen-scoped provider). diff --git a/docs/superpowers/specs/2026-06-11-sleep-hr-chart-design.md b/docs/superpowers/specs/2026-06-11-sleep-hr-chart-design.md new file mode 100644 index 0000000..353d9dc --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-sleep-hr-chart-design.md @@ -0,0 +1,209 @@ +# Sleep HR Chart — Design Spec + +**Date:** 2026-06-11 +**Status:** Approved +**Feature area:** Readiness → Sleep heart-rate visualization + +--- + +## 1. Problem + +The Readiness feature currently reads resting HR and sleep duration from Health Connect. HRV is unavailable (Samsung Health writer has no HRV permission on this device). Minute-level heart-rate data during sleep is already accessible via `heartRateSeries`, and sleep stage timeline data is already extracted per `SleepPeriod` (`lightMinutes`, `deepMinutes`, `remMinutes`, `awakeMinutes`). Neither is surfaced to the user. + +Users want to understand how their heart behaved overnight — specifically whether deep sleep reached a true low, whether REM stayed elevated, and what a clean P95 "resting proxy" looks like — without needing to open Samsung Health. + +--- + +## 2. Goal + +Two new surfaces: +1. **Compact card** on the home screen (below the readiness ring card) showing a sparkline + three key numbers. +2. **Full detail bottom sheet** accessible by tapping the compact card, showing: + - A 10-minute bar chart (low/high per segment, color-coded by sleep stage, moving-average trend line) + - A "HR range by stage" horizontal distribution chart (min–max + P25–P75 + avg for each of Awake, REM, Light, Deep) + +--- + +## 3. Data Models + +### 3.1 `SleepHrSegment` (new) + +Represents one 10-minute window of the sleep period. + +```dart +class SleepHrSegment { + final DateTime windowStart; // truncated to 10-min boundary + final int minBpm; + final int maxBpm; + final double avgBpm; + final String stage; // 'deep' | 'rem' | 'light' | 'awake' +} +``` + +### 3.2 `SleepStageStats` (new) + +Aggregate stats for one stage, used by the distribution chart. + +```dart +class SleepStageStats { + final String stage; + final int minBpm; + final int p25Bpm; + final double avgBpm; + final int p75Bpm; + final int maxBpm; + final int sampleCount; +} +``` + +### 3.3 `SleepHrSnapshot` (new) + +Container stored in `ReadinessManager` and passed to both widgets. + +```dart +class SleepHrSnapshot { + final DateTime sleepStart; + final DateTime sleepEnd; + final int p95Bpm; // P95 of all overnight HR samples + final List segments; // ordered by windowStart + final List stageStats; // one entry per stage present +} +``` + +No persistence required — recomputed each `refresh()`. If the snapshot is null the compact card hides itself (`SizedBox.shrink()`). + +--- + +## 4. Data Pipeline + +### 4.1 New Health Connect service method + +```dart +// IHealthConnectService +Future> readHeartRateSamples(DateTime start, DateTime end); +// Already exists — no interface change needed. +``` + +`ReadinessManager.refresh()` calls `readHeartRateSamples(sleepStart - 30min, sleepEnd + 30min)` **only when** `HealthReadType.heartRate` is granted and at least one sleep period exists for last night. + +### 4.2 Stage assignment per sample + +Each `HealthSample` is tagged with the sleep stage active at its timestamp by walking the `SleepPeriod.samples` stage timeline (from `SleepSessionRecord.samples`, already loaded). Samples outside any stage window → tagged `'awake'`. + +### 4.3 Segment aggregation + +Samples are bucketed into 10-minute windows aligned to `sleepStart`. For each window: `minBpm`, `maxBpm`, `avgBpm` are computed. The stage for the window is the **mode** of sample stages in that window (most-frequent). Windows with zero samples are omitted. + +### 4.4 P95 and stage stats + +- **P95:** Sort all sample bpms → take the value at index `floor(0.95 * n)`. +- **Stage stats:** Group samples by stage → compute min, P25, avg, P75, max via sort-and-index. + +### 4.5 Where it lives in `ReadinessManager` + +```dart +SleepHrSnapshot? _sleepHrSnapshot; +SleepHrSnapshot? get sleepHrSnapshot => _sleepHrSnapshot; +``` + +Computed and stored at the end of `refresh()`, alongside the readiness score. Triggers `notifyListeners()` once (same call as the score update). + +--- + +## 5. UI Components + +### 5.1 `SleepHrCard` (compact, home screen) + +**File:** `lib/screens/widgets/sleep_hr_card.dart` + +Layout: +``` +┌─────────────────────────────────┐ +│ Sleep heart rate 1:24–8:17 │ ← header row +│ P95 67bpm REM 64bpm Deep 52bpm│ ← three mini-stats +│ [sparkline bar chart] │ ← canvas, 38dp tall +└─────────────────────────────────┘ +``` + +- Tapping the card opens `SleepHrSheet` via `showModalBottomSheet`. +- Hidden (`SizedBox.shrink()`) when `snapshot.sleepHrSnapshot == null`. +- Placed in `HomeScreen` body, directly below `ReadinessCard`. + +### 5.2 `SleepHrSheet` (full detail bottom sheet) + +**File:** `lib/screens/widgets/sleep_hr_sheet.dart` + +Sections top → bottom: +1. **Handle + title + subtitle** ("Sleep heart rate · 1:24 AM – 8:17 AM") +2. **Three key stats** (P95 HR, Deep avg, REM avg) in pill chips +3. **Bar chart** — `CustomPainter`, 140dp tall + - Y-axis: BPM labels (50, 60, 70, 80) with horizontal grid lines + - X-axis: time labels every 60 min + - Each bar: low→high range, fill color = stage color at 73% opacity + - Moving-average line (window=5 segments): `#00D9FF`, dashed +4. **Stage timeline bar** — thin colored strip below chart, same proportions +5. **Legend** (Deep / REM / Light / Awake / Avg line) +6. **"HR range by stage" section** + - Title label + - Four horizontal range rows: Awake → REM → Light → Deep (top → bottom) + - Each row: full-range bar (22% opacity) + IQR bar (72% opacity) + avg dot + avg bpm label + - Shared BPM x-axis with vertical grid lines (45, 50 … 85) + - Sub-legend: min–max / P25–P75 / Avg + +Scrollable (`SingleChildScrollView`) so it fits all screen sizes. + +--- + +## 6. Painting Strategy + +Both the bar chart and the distribution chart use `CustomPainter` (not canvas HTML). Stage colors are sourced from a local constant map in the widget file; no dependency on `AppColors.muscleGroupColors`. + +Stage color map: +```dart +const _stageColors = { + 'deep': Color(0xFF4C8EFF), + 'rem': Color(0xFFA78BFA), + 'light': Color(0xFF34D399), + 'awake': Color(0xFFF59E0B), +}; +``` + +--- + +## 7. Error / Empty States + +| Condition | Behavior | +|-----------|----------| +| `heartRate` not granted | `sleepHrSnapshot` = null → compact card hidden | +| Sleep period missing | `sleepHrSnapshot` = null → compact card hidden | +| < 5 HR samples in a segment | Segment omitted from chart | +| Stage has < 3 samples | `SleepStageStats` for that stage omitted from distribution | +| Sheet opened with null snapshot | Should not happen (card hidden); guard with early return | + +--- + +## 8. HRV Lookback Cleanup + +`_todayHrv()` in `ReadinessManager` currently uses a 30-day diagnostic window. This should be reverted to 48 hours once the Sleep HR feature ships (confirms the device never writes HRV, so the wide window has no ongoing value). + +--- + +## 9. Files Changed / Created + +| Action | File | +|--------|------| +| New | `lib/models/sleep_hr_models.dart` — `SleepHrSegment`, `SleepStageStats`, `SleepHrSnapshot` | +| Modified | `lib/services/managers/readiness_manager.dart` — add `_buildSleepHrSnapshot()`, store result | +| New | `lib/screens/widgets/sleep_hr_card.dart` | +| New | `lib/screens/widgets/sleep_hr_sheet.dart` | +| Modified | `lib/screens/home_screen.dart` (or equivalent) — insert `SleepHrCard` below `ReadinessCard` | +| Modified | `lib/services/managers/readiness_manager.dart` — revert HRV window to 48h | + +--- + +## 10. Out of Scope + +- Trend over multiple nights (tonight vs last 7 nights) — future feature +- Tap-to-see-segment detail in the bar chart — future feature +- P95 participating in the readiness score formula — deferred; it replaces HRV only if baseline data accumulates +- Exporting or sharing the chart diff --git a/fdroid/metadata/com.devasy.repforge.yml b/fdroid/metadata/com.devasy.repforge.yml new file mode 100644 index 0000000..7b0ab30 --- /dev/null +++ b/fdroid/metadata/com.devasy.repforge.yml @@ -0,0 +1,56 @@ +Categories: + - Sports & Health +License: Apache-2.0 +AuthorName: Devasy Patel +SourceCode: https://github.com/Devasy23/RepForge +IssueTracker: https://github.com/Devasy23/RepForge/issues + +AutoName: RepForge +Summary: Open-source workout logger with AI coaching and progress analytics +Description: |- + RepForge is a privacy-first workout logging app that helps you track sets, + reps, and weights across customizable routines. + + Features: + * Log workout sessions with sets, reps, and weights + * Visualise progress with charts (volume, strength curves) + * AI-powered set recommendations using linear regression + * Customisable exercise library with 50+ built-in exercises + * Goal tracking with ML-estimated completion dates + * Reusable workout routines + * Health Connect integration for sleep and heart rate readiness scores + * Optional AI Coach powered by Google Gemini (requires user-supplied API key) + * Full data export/import for portability + + All workout data is stored locally on-device using Hive. No account required. + No data is sent to any server unless you enable the optional AI Coach feature. + +RepoType: git +Repo: https://github.com/Devasy23/RepForge + +AntiFeatures: + NonFreeNet: + - description: > + The optional AI Coach and Routine Optimizer features send data to + Google's Gemini API (a proprietary cloud service). These features are + fully disabled unless the user provides their own Gemini API key in + Settings → AI Settings. The app is fully functional as an offline + workout logger without configuring a key. + +Builds: + - versionName: 2.0.1 + versionCode: 21 + commit: v2.0.1 + subdir: workout-logger + gradle: + - release + prebuild: + - flutter pub get + build: + - flutter build apk --release --split-per-abi + +AutoUpdateMode: Version v%v +UpdateCheckMode: Tags +UpdateCheckData: workout-logger/pubspec.yaml|^version:\s+([\d.]+)\+|.| +CurrentVersion: 2.0.1 +CurrentVersionCode: 21 diff --git a/workout-logger/RELEASE_NOTES.md b/workout-logger/RELEASE_NOTES.md index 88e289e..cd8f2f5 100644 --- a/workout-logger/RELEASE_NOTES.md +++ b/workout-logger/RELEASE_NOTES.md @@ -8,7 +8,7 @@ Your workout logging app has been successfully built and configured with the fol - **Name:** RepForge - **Tagline:** Your personal workout companion to forge strength and track progress - **Package ID:** com.devasy.repforge -- **Version:** 1.0.0 (Build 1) +- **Version:** 2.0.0 (Build 21) ### 👨‍💻 Developer Information - **Name:** Devasy Patel @@ -22,8 +22,7 @@ Your workout logging app has been successfully built and configured with the fol ### 📦 Build Artifacts #### Main Release APK -- **Location:** `RepForge-v1.0.0-release.apk` (root directory) -- **Size:** 47.4 MB +- **Location:** `RepForge-v2.0.0-release.apk` (root directory) - **Type:** Universal APK (all architectures) - **Also available at:** `build/app/outputs/flutter-apk/app-release.apk` @@ -31,7 +30,7 @@ Your workout logging app has been successfully built and configured with the fol 1. **Transfer the APK** to your Android device: - Use USB cable, email, cloud storage, or any file transfer method - - File to transfer: `RepForge-v1.0.0-release.apk` + - File to transfer: `RepForge-v2.0.0-release.apk` 2. **Enable Installation from Unknown Sources** (if needed): - Go to Settings → Security @@ -46,19 +45,18 @@ Your workout logging app has been successfully built and configured with the fol ### 🛠️ What Was Changed -1. **App Name:** Changed from "workout_logger" to "RepForge" -2. **Package Name:** Updated to "com.devasy.repforge" -3. **App Icon:** Created and applied custom minimal dumbbell icon -4. **Android Configuration:** Updated namespace, application ID, and MainActivity -5. **Release Build:** Successfully built production-ready APK +1. **Major UI Refresh:** Cleaner layouts and improved spacing across key screens +2. **Workout Summary Screen:** Added a recap view for completed sessions +3. **Theme Enhancements:** Refined color palette for stronger contrast +4. **Visual Consistency:** Standardized component styling across the app +5. **Release Metadata:** Version bumped to 2.0.0 (Build 21) -### 📋 Configuration Files Updated +### 📋 Areas Updated -- ✅ `pubspec.yaml` - App name and dependencies -- ✅ `android/app/build.gradle.kts` - Package ID and namespace -- ✅ `android/app/src/main/AndroidManifest.xml` - App label -- ✅ `android/app/src/main/kotlin/com/devasy/repforge/MainActivity.kt` - New package structure -- ✅ App icons generated for all densities +- ✅ UI layouts and component styling +- ✅ Workout summary flow and post-workout recap +- ✅ Theme palette and visual hierarchy +- ✅ Release metadata and versioning ### 🎯 Key Features @@ -98,7 +96,7 @@ You're all set! Your app is ready for: --- -**Built on:** January 22, 2026 +**Built on:** May 23, 2026 **Built with:** Flutter 💙 **Made by:** Devasy Patel diff --git a/workout-logger/android/app/build.gradle.kts b/workout-logger/android/app/build.gradle.kts index d12c514..727fd5e 100644 --- a/workout-logger/android/app/build.gradle.kts +++ b/workout-logger/android/app/build.gradle.kts @@ -7,7 +7,8 @@ plugins { android { namespace = "com.devasy.repforge" - compileSdk = flutter.compileSdkVersion + compileSdk = 36 + compileSdkExtension = 19 ndkVersion = flutter.ndkVersion compileOptions { @@ -19,26 +20,52 @@ android { jvmTarget = JavaVersion.VERSION_11.toString() } + signingConfigs { + create("release") { + val keystorePath = System.getenv("KEYSTORE_PATH") + val storePass = System.getenv("KEY_STORE_PASSWORD") + val alias = System.getenv("KEY_ALIAS") + val keyPass = System.getenv("KEY_PASSWORD") + if (keystorePath != null && storePass != null && alias != null && keyPass != null) { + storeFile = file(keystorePath) + storePassword = storePass + keyAlias = alias + keyPassword = keyPass + } + } + } + defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.devasy.repforge" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. // MIGRATION NOTE: minSdk is intentionally set to 26 (Android 8.0 Oreo). // Health Connect requires API 26+. Devices running API <26 are no longer // supported. If downgrading, remove the health_connector dependency and // all HealthConnectService usages, then restore minSdk to flutter.minSdkVersion. minSdk = 26 - targetSdk = flutter.targetSdkVersion + targetSdk = 36 versionCode = flutter.versionCode versionName = flutter.versionName + // App display name; overridden per build type below so debug installs + // alongside the real app instead of replacing it. + manifestPlaceholders["appLabel"] = "RepForge" } buildTypes { + debug { + // Install debug builds as a SEPARATE app (com.devasy.repforge.debug) + // with its own data sandbox, so testing never touches the real app's + // data. Remove this block to go back to a single shared package. + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + manifestPlaceholders["appLabel"] = "RepForge (Debug)" + } release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Uses the production EC P-256 keystore when KEYSTORE_PATH env var is set + // (CI injects it via GitHub Secrets). Falls back to debug key for local + // flutter run --release without env vars configured. + val releaseConfig = signingConfigs.getByName("release") + signingConfig = if (releaseConfig.storeFile != null) releaseConfig + else signingConfigs.getByName("debug") } } } diff --git a/workout-logger/android/app/src/main/AndroidManifest.xml b/workout-logger/android/app/src/main/AndroidManifest.xml index 61b507a..0c0b757 100644 --- a/workout-logger/android/app/src/main/AndroidManifest.xml +++ b/workout-logger/android/app/src/main/AndroidManifest.xml @@ -1,11 +1,17 @@ + + + + + + android:icon="@mipmap/ic_launcher" + android:enableOnBackInvokedCallback="true"> .value(value: _historyManager), + ChangeNotifierProvider.value(value: _prManager), + ChangeNotifierProvider.value(value: _readinessManager), + Provider.value(value: _healthHistoryManager), + // GeminiAiService is the single AI backend instance. It's a ChangeNotifier + // (settings UI watches isConfigured/model), so it's provided as such. + // Consumers that should depend on the abstraction (the coach ViewModel, + // program generator) receive it typed as IAiService at construction — + // the future firebase_ai swap point — without a separate provider. + ChangeNotifierProvider.value(value: _geminiService), + ChangeNotifierProvider.value( + value: _conversationManager, + ), // WorkoutProvider receives dependencies via constructor injection ChangeNotifierProvider( create: (_) => WorkoutProvider( @@ -92,6 +125,13 @@ class WorkoutLoggerApp extends StatelessWidget { programManager: _programManager, ), ), + // CoachToolService backs AI tool calls; reads from WorkoutProvider + PRManager. + Provider( + create: (ctx) => CoachToolService( + ctx.read(), + ctx.read(), + ), + ), ], child: MaterialApp( title: 'Workout Logger', @@ -112,6 +152,7 @@ class AppInitializer extends StatefulWidget { class _AppInitializerState extends State { bool _initialized = false; + bool _needsNamePrompt = false; String? _error; @override @@ -121,32 +162,60 @@ class _AppInitializerState extends State { } Future _initializeApp() async { + // Capture all providers synchronously before any awaits. + final provider = context.read(); + final settings = context.read(); + final historyManager = context.read(); + final prManager = context.read(); + final api = context.read(); + final gemini = context.read(); + final readiness = context.read(); + try { - final provider = context.read(); await provider.init(); - - final settings = context.read(); await settings.init(); - - // Load HistoryManager session list (independent of WorkoutProvider). - final historyManager = context.read(); + gemini.init(settings.geminiApiKey, model: settings.geminiModel); + try { + await gemini.loadUsage(); + } catch (e, st) { + debugPrint('gemini.loadUsage failed: $e\n$st'); + } await historyManager.loadSessions(); + await prManager.load(); + await prManager.backfillFromSessions(historyManager.sessions); + + final version = await settings.getCurrentVersion(); + final needsName = settings.userName == null || settings.userName!.isEmpty; + final versionChanged = !needsName && + settings.lastSeenVersion != null && + settings.lastSeenVersion != version; - // Fire-and-forget analytics in background - final api = context.read(); + // Fire-and-forget readiness refresh — must run after settings.init() + // so the opt-in flag is loaded; never blocks or fails app init. + readiness.refresh(); + + // Fire-and-forget analytics in background. api.sendHeartbeat(); api.trackEvent('app_open'); - provider - .getQuickStats() - .then((stats) { - api.reportUsage(stats); - }) - .catchError((e) { - debugPrint('Failed to report usage: $e'); - }); - - setState(() => _initialized = true); + provider.getQuickStats().then((stats) => api.reportUsage(stats)).catchError( + (Object e) => debugPrint('Failed to report usage: $e'), + ); + + if (!mounted) return; + setState(() { + _initialized = true; + _needsNamePrompt = needsName; + }); + + if (!needsName && versionChanged) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + await showVersionUpdateSheet(context, version); + if (mounted) await settings.markVersionSeen(version); + }); + } } catch (e) { + if (!mounted) return; setState(() => _error = e.toString()); } } @@ -215,6 +284,12 @@ class _AppInitializerState extends State { ); } + if (_needsNamePrompt) { + return WelcomePage( + onComplete: () => setState(() => _needsNamePrompt = false), + ); + } + return const HomeScreen(); } } diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index eea2168..493013a 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -1,8 +1,14 @@ // Data Models for Workout Logger App +import 'dart:math' show log, max; + +import 'package:uuid/uuid.dart'; + // Sentinel value for copyWith methods to distinguish "not provided" from "null" const Object _sentinel = Object(); +const _uuid = Uuid(); + // ==================== Muscle Groups ==================== class MuscleGroup { @@ -167,14 +173,17 @@ class WorkoutSet { } class DropsetEntry { + final String id; final double weight; final int reps; - DropsetEntry({required this.weight, required this.reps}); + DropsetEntry({String? id, required this.weight, required this.reps}) + : id = id ?? _uuid.v4(); - Map toJson() => {'weight': weight, 'reps': reps}; + Map toJson() => {'id': id, 'weight': weight, 'reps': reps}; factory DropsetEntry.fromJson(Map json) => DropsetEntry( + id: json['id'] as String?, weight: (json['weight'] as num).toDouble(), reps: json['reps'], ); @@ -316,6 +325,13 @@ class Routine { exerciseIds: List.from(json['exerciseIds']), createdAt: DateTime.parse(json['createdAt']), ); + + Routine copyWith({String? name, List? exerciseIds}) => Routine( + id: id, + name: name ?? this.name, + exerciseIds: exerciseIds ?? this.exerciseIds, + createdAt: createdAt, + ); } // ==================== Target ==================== @@ -387,24 +403,111 @@ class SetRecommendation { // ==================== Growth Model ==================== +/// Functional form of a fitted growth curve. +/// +/// - [linear]: steady volume gains (typical for newer lifters / new exercises) +/// - [logarithmic]: diminishing returns, y = a + b·ln(1+x) — typical as an +/// exercise matures and progress saturates +enum GrowthCurve { linear, logarithmic } + class GrowthModel { - final double slope; // Growth rate per session - final double intercept; // Starting baseline + /// Instantaneous growth rate (volume per day) at the most recent data point. + /// For linear fits this equals the curve coefficient; for logarithmic fits + /// it is the tangent slope b/(1+lastX), which decays as training history grows. + final double slope; + final double intercept; // Curve intercept a final double r2; // Model fit quality (0-1) final DateTime lastTrained; + final GrowthCurve curve; + + /// Curve coefficient b. Equals [slope] for linear fits. + final double coefficient; + + /// x (days since first session) of the newest point used in training. + final double lastX; + + /// Weighted residual standard error in volume units (0 = unknown/perfect). + final double stdError; GrowthModel({ required this.slope, required this.intercept, required this.r2, required this.lastTrained, - }); + this.curve = GrowthCurve.linear, + double? coefficient, + this.lastX = 0, + this.stdError = 0, + }) : coefficient = coefficient ?? slope; + + double predict(num x) { + switch (curve) { + case GrowthCurve.linear: + return intercept + coefficient * x; + case GrowthCurve.logarithmic: + return intercept + coefficient * log(1 + max(0, x.toDouble())); + } + } + + /// Model's volume estimate at the newest training point ("today's level"). + double get currentEstimate => predict(lastX); - double predict(int sessionNumber) { - return slope * sessionNumber + intercept; + /// Expected volume growth over the next 7 days as a percentage of the + /// current level. The plateau/decline signal used by recommendations. + double get weeklyGrowthPercent { + final current = currentEstimate; + if (current <= 0) return 0; + return slope * 7 / current * 100; } } +// ==================== Personal Record ==================== + +class PersonalRecord { + final String exerciseId; + final double bestWeight; // heaviest weight in any single set + final int bestReps; // most reps in any single set + final double bestVolume; // highest single-set volume (weight × reps) + final DateTime achievedAt; + + PersonalRecord({ + required this.exerciseId, + required this.bestWeight, + required this.bestReps, + required this.bestVolume, + required this.achievedAt, + }); + + Map toJson() => { + 'exerciseId': exerciseId, + 'bestWeight': bestWeight, + 'bestReps': bestReps, + 'bestVolume': bestVolume, + 'achievedAt': achievedAt.toIso8601String(), + }; + + factory PersonalRecord.fromJson(Map json) => PersonalRecord( + exerciseId: json['exerciseId'] as String, + bestWeight: (json['bestWeight'] as num).toDouble(), + bestReps: json['bestReps'] as int, + bestVolume: (json['bestVolume'] as num).toDouble(), + achievedAt: DateTime.parse(json['achievedAt'] as String), + ); + + PersonalRecord copyWith({ + double? bestWeight, + int? bestReps, + double? bestVolume, + DateTime? achievedAt, + }) => PersonalRecord( + exerciseId: exerciseId, + bestWeight: bestWeight ?? this.bestWeight, + bestReps: bestReps ?? this.bestReps, + bestVolume: bestVolume ?? this.bestVolume, + achievedAt: achievedAt ?? this.achievedAt, + ); +} + // ==================== Training Program ==================== /// One exercise slot inside a program day. @@ -758,3 +861,354 @@ class TrainingProgram { createdAt == _sentinel ? this.createdAt : createdAt as DateTime, ); } + +// ==================== AI Coach Chat ==================== + +/// A single message in an AI coach conversation. +class ChatMessage { + final String id; + final String role; // 'user' | 'model' + final String text; + final DateTime timestamp; + + ChatMessage({ + String? id, + required this.role, + required this.text, + DateTime? timestamp, + }) : id = id ?? _uuid.v4(), + timestamp = timestamp ?? DateTime.now(); + + Map toJson() => { + 'id': id, + 'role': role, + 'text': text, + 'timestamp': timestamp.toIso8601String(), + }; + + factory ChatMessage.fromJson(Map json) => ChatMessage( + id: json['id'] as String?, + role: json['role'] as String, + text: json['text'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + ); + + ChatMessage copyWith({ + Object? role = _sentinel, + Object? text = _sentinel, + Object? timestamp = _sentinel, + }) => ChatMessage( + id: id, + role: role == _sentinel ? this.role : role as String, + text: text == _sentinel ? this.text : text as String, + timestamp: timestamp == _sentinel ? this.timestamp : timestamp as DateTime, + ); +} + +/// A persisted AI coach conversation: an ordered list of [ChatMessage]s. +class Conversation { + final String id; + final String title; + final String kind; // 'coach' | 'optimizer' + final DateTime createdAt; + final DateTime updatedAt; + final List messages; + + Conversation({ + String? id, + required this.title, + this.kind = 'coach', + DateTime? createdAt, + DateTime? updatedAt, + List? messages, + }) : id = id ?? _uuid.v4(), + createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? createdAt ?? DateTime.now(), + messages = messages ?? const []; + + Map toJson() => { + 'id': id, + 'title': title, + 'kind': kind, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'messages': messages.map((m) => m.toJson()).toList(), + }; + + factory Conversation.fromJson(Map json) => Conversation( + id: json['id'] as String?, + title: json['title'] as String, + kind: json['kind'] as String? ?? 'coach', + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt'] as String) + : DateTime.parse(json['createdAt'] as String), + messages: (json['messages'] as List) + .map((m) => ChatMessage.fromJson(m as Map)) + .toList(), + ); + + Conversation copyWith({ + Object? title = _sentinel, + Object? kind = _sentinel, + Object? createdAt = _sentinel, + Object? updatedAt = _sentinel, + Object? messages = _sentinel, + }) => Conversation( + id: id, + title: title == _sentinel ? this.title : title as String, + kind: kind == _sentinel ? this.kind : kind as String, + createdAt: createdAt == _sentinel ? this.createdAt : createdAt as DateTime, + updatedAt: updatedAt == _sentinel ? this.updatedAt : updatedAt as DateTime, + messages: messages == _sentinel + ? this.messages + : messages as List, + ); +} + +// ==================== AI Question Models ==================== + +/// One question the AI asks the user, with predefined options. +class QuestionSpec { + final String question; + final List options; + final bool multiSelect; + final bool allowCustom; + + const QuestionSpec({ + required this.question, + required this.options, + this.multiSelect = false, + this.allowCustom = true, + }); + + factory QuestionSpec.fromJson(Map j) => QuestionSpec( + question: j['question'] as String, + options: (j['options'] as List).cast(), + multiSelect: j['multiSelect'] as bool? ?? false, + allowCustom: j['allowCustom'] as bool? ?? true, + ); +} + +/// The user's answer to one [QuestionSpec]. +class AnswerSpec { + final String question; + final List selected; + final String? custom; + + const AnswerSpec({ + required this.question, + required this.selected, + this.custom, + }); + + Map toJson() => { + 'question': question, + 'selected': selected, + if (custom != null && custom!.isNotEmpty) 'custom': custom, + }; +} + +/// The structured payload from an `ask_user_questions` tool call. +class PendingQuestions { + final String? preamble; + final List questions; + + const PendingQuestions({this.preamble, required this.questions}); + + factory PendingQuestions.fromJson(Map j) => PendingQuestions( + preamble: j['preamble'] as String?, + questions: (j['questions'] as List) + .map((q) => QuestionSpec.fromJson(q as Map)) + .toList(), + ); +} + +// ==================== Readiness ==================== + +/// Coarse training-readiness classification derived from [ReadinessSnapshot]. +enum ReadinessBand { high, moderate, low } + +/// A single point-in-time health measurement read from Health Connect. +class HealthSample { + final DateTime time; + final double value; + + const HealthSample({required this.time, required this.value}); +} + +/// One continuous sleep-stage segment within a `SleepPeriod`. +/// +/// Stage is one of: `'deep'`, `'rem'`, `'light'`, `'awake'`. +class SleepStageInterval { + final DateTime start; + final DateTime end; + final String stage; + + const SleepStageInterval({ + required this.start, + required this.end, + required this.stage, + }); +} + +/// A sleep session interval read from Health Connect. +/// +/// When stage data is available (from `SleepSessionRecord.samples`), +/// `lightMinutes`, `deepMinutes`, `remMinutes`, and `awakeMinutes` are +/// populated and `minutes` returns actual sleep time (light + deep + rem), +/// excluding awake/out-of-bed spans. Without stage data `minutes` falls back +/// to the raw session duration. +/// +/// `stageTimeline` carries the ordered list of stage segments when available, +/// used by the Sleep HR chart to colour-code each 10-minute bar. +class SleepPeriod { + final DateTime start; + final DateTime end; + + /// Minutes in light (or unspecified) sleep. Null when no stage data. + final int? lightMinutes; + final int? deepMinutes; + final int? remMinutes; + + /// Awake/out-of-bed minutes within the session window. + final int? awakeMinutes; + + /// Ordered stage segments, populated from `SleepSessionRecord.samples`. + /// Empty when the session record carries no stage breakdown. + final List stageTimeline; + + const SleepPeriod({ + required this.start, + required this.end, + this.lightMinutes, + this.deepMinutes, + this.remMinutes, + this.awakeMinutes, + this.stageTimeline = const [], + }); + + bool get hasStages => + lightMinutes != null || deepMinutes != null || remMinutes != null; + + /// Actual sleep minutes: light + deep + rem when stage data exists, + /// otherwise the raw session span (start → end). + int get minutes => hasStages + ? (lightMinutes ?? 0) + (deepMinutes ?? 0) + (remMinutes ?? 0) + : end.difference(start).inMinutes; +} + +/// Rolling per-component averages used as the personal reference point +/// when scoring today's readiness. Recomputed at most once per day. +class ReadinessBaseline { + final String dateKey; // yyyy-MM-dd the baseline was computed for + final double? avgSleepMinutes; + final int sleepNights; + final double? avgRestingHr; + final int rhrDays; + final double? avgHrvMs; + final int hrvDays; + + const ReadinessBaseline({ + required this.dateKey, + this.avgSleepMinutes, + this.sleepNights = 0, + this.avgRestingHr, + this.rhrDays = 0, + this.avgHrvMs, + this.hrvDays = 0, + }); + + Map toJson() => { + 'dateKey': dateKey, + 'avgSleepMinutes': avgSleepMinutes, + 'sleepNights': sleepNights, + 'avgRestingHr': avgRestingHr, + 'rhrDays': rhrDays, + 'avgHrvMs': avgHrvMs, + 'hrvDays': hrvDays, + }; + + factory ReadinessBaseline.fromJson(Map json) => + ReadinessBaseline( + dateKey: json['dateKey'] as String, + avgSleepMinutes: (json['avgSleepMinutes'] as num?)?.toDouble(), + sleepNights: json['sleepNights'] as int? ?? 0, + avgRestingHr: (json['avgRestingHr'] as num?)?.toDouble(), + rhrDays: json['rhrDays'] as int? ?? 0, + avgHrvMs: (json['avgHrvMs'] as num?)?.toDouble(), + hrvDays: json['hrvDays'] as int? ?? 0, + ); +} + +/// One day's computed readiness with the per-component evidence behind it. +/// +/// Any component (sleep / resting HR / HRV) may be null when the data or a +/// reliable baseline is unavailable; [score] is null when no component could +/// be scored at all, in which case the UI hides readiness entirely. +class ReadinessSnapshot { + final String dateKey; // yyyy-MM-dd this snapshot describes + final int? score; // 0–100 overall, null = nothing scorable + final ReadinessBand? band; + final int? sleepMinutes; + final double? sleepBaselineMinutes; + final int? sleepScore; + final double? restingHr; + final double? rhrBaseline; + final int? rhrScore; + final double? hrvMs; + final double? hrvBaseline; + final int? hrvScore; + final DateTime computedAt; + + ReadinessSnapshot({ + required this.dateKey, + this.score, + this.band, + this.sleepMinutes, + this.sleepBaselineMinutes, + this.sleepScore, + this.restingHr, + this.rhrBaseline, + this.rhrScore, + this.hrvMs, + this.hrvBaseline, + this.hrvScore, + DateTime? computedAt, + }) : computedAt = computedAt ?? DateTime.now(); + + Map toJson() => { + 'dateKey': dateKey, + 'score': score, + 'band': band?.name, + 'sleepMinutes': sleepMinutes, + 'sleepBaselineMinutes': sleepBaselineMinutes, + 'sleepScore': sleepScore, + 'restingHr': restingHr, + 'rhrBaseline': rhrBaseline, + 'rhrScore': rhrScore, + 'hrvMs': hrvMs, + 'hrvBaseline': hrvBaseline, + 'hrvScore': hrvScore, + 'computedAt': computedAt.toIso8601String(), + }; + + factory ReadinessSnapshot.fromJson(Map json) => + ReadinessSnapshot( + dateKey: json['dateKey'] as String, + score: json['score'] as int?, + band: json['band'] != null + ? ReadinessBand.values.byName(json['band'] as String) + : null, + sleepMinutes: json['sleepMinutes'] as int?, + sleepBaselineMinutes: (json['sleepBaselineMinutes'] as num?)?.toDouble(), + sleepScore: json['sleepScore'] as int?, + restingHr: (json['restingHr'] as num?)?.toDouble(), + rhrBaseline: (json['rhrBaseline'] as num?)?.toDouble(), + rhrScore: json['rhrScore'] as int?, + hrvMs: (json['hrvMs'] as num?)?.toDouble(), + hrvBaseline: (json['hrvBaseline'] as num?)?.toDouble(), + hrvScore: json['hrvScore'] as int?, + computedAt: DateTime.parse(json['computedAt'] as String), + ); +} diff --git a/workout-logger/lib/models/sleep_hr_models.dart b/workout-logger/lib/models/sleep_hr_models.dart new file mode 100644 index 0000000..f7a82d7 --- /dev/null +++ b/workout-logger/lib/models/sleep_hr_models.dart @@ -0,0 +1,214 @@ +/// Data models for the Sleep HR chart feature. +/// +/// These are computed at runtime from Health Connect HR + sleep-stage data +/// and are never persisted. If the snapshot is null the compact card hides. +library; + +/// One 10-minute window of overnight HR data, colour-coded by sleep stage. +class SleepHrSegment { + final DateTime windowStart; + + /// BPM floor of all samples in this window. + final int minBpm; + + /// BPM ceiling of all samples in this window. + final int maxBpm; + + /// Mean BPM across all samples in this window. + final double avgBpm; + + /// Dominant sleep stage: 'deep' | 'rem' | 'light' | 'awake'. + final String stage; + + const SleepHrSegment({ + required this.windowStart, + required this.minBpm, + required this.maxBpm, + required this.avgBpm, + required this.stage, + }); +} + +/// Aggregate HR statistics for one sleep stage. +class SleepStageStats { + /// 'deep' | 'rem' | 'light' | 'awake' + final String stage; + final int minBpm; + final int p25Bpm; + final double avgBpm; + final int p75Bpm; + final int maxBpm; + final int sampleCount; + + const SleepStageStats({ + required this.stage, + required this.minBpm, + required this.p25Bpm, + required this.avgBpm, + required this.p75Bpm, + required this.maxBpm, + required this.sampleCount, + }); +} + +/// Complete overnight HR picture — carried by ReadinessManager and consumed +/// by SleepHrCard (compact) and SleepHrSheet (full detail). +class SleepHrSnapshot { + final DateTime sleepStart; + final DateTime sleepEnd; + + /// 5th-percentile — overnight HR floor. + final int p5Bpm; + + /// 95th-percentile of all overnight HR samples — used as an RHR proxy. + final int p95Bpm; + + /// 10-minute segments ordered chronologically. + final List segments; + + /// One entry per stage present (deep / rem / light / awake). + final List stageStats; + + const SleepHrSnapshot({ + required this.sleepStart, + required this.sleepEnd, + required this.p5Bpm, + required this.p95Bpm, + required this.segments, + required this.stageStats, + }); + + SleepStageStats? statsFor(String stage) => + stageStats.where((s) => s.stage == stage).firstOrNull; +} + +// ───────────────────────────────────────────────────────────────────────────── +// History & granularity models — added for the Sleep/HR detail screens. +// Like the snapshots above, these are computed at runtime from Health Connect +// and are not persisted (the heavy per-day HR results may be cached as JSON, +// but that is the manager's concern, not a contract here). +// ───────────────────────────────────────────────────────────────────────────── + +/// Granularity for the Sleep / Heart-rate detail screens. +enum HealthGranularity { day, week, month, year } + +extension HealthGranularityX on HealthGranularity { + /// Short toggle label. + String get label => switch (this) { + HealthGranularity.day => 'Day', + HealthGranularity.week => 'Week', + HealthGranularity.month => 'Month', + HealthGranularity.year => 'Year', + }; +} + +/// One ~30-minute window of all-day HR (min / max / avg). +class HrBucket { + final DateTime windowStart; + final int minBpm; + final int maxBpm; + final double avgBpm; + + const HrBucket({ + required this.windowStart, + required this.minBpm, + required this.maxBpm, + required this.avgBpm, + }); + + Map toJson() => { + 't': windowStart.toIso8601String(), + 'mn': minBpm, + 'mx': maxBpm, + 'av': avgBpm, + }; + + factory HrBucket.fromJson(Map j) => HrBucket( + windowStart: DateTime.parse(j['t'] as String), + minBpm: (j['mn'] as num).toInt(), + maxBpm: (j['mx'] as num).toInt(), + avgBpm: (j['av'] as num).toDouble(), + ); +} + +/// Complete all-day HR picture for one calendar day — backs the Heart-rate +/// card (compact) and the Day tab of HeartRateDetailScreen. +class HrDaySnapshot { + final DateTime day; + + /// Resting HR for the day (RHR record if present, else morning-min fallback). + final int? restingBpm; + final int minBpm; + final int maxBpm; + final double avgBpm; + + /// ~30-minute buckets ordered chronologically. + final List buckets; + + const HrDaySnapshot({ + required this.day, + required this.restingBpm, + required this.minBpm, + required this.maxBpm, + required this.avgBpm, + required this.buckets, + }); + + Map toJson() => { + 'day': day.toIso8601String(), + 'rest': restingBpm, + 'mn': minBpm, + 'mx': maxBpm, + 'av': avgBpm, + 'b': buckets.map((b) => b.toJson()).toList(), + }; + + factory HrDaySnapshot.fromJson(Map j) => HrDaySnapshot( + day: DateTime.parse(j['day'] as String), + restingBpm: (j['rest'] as num?)?.toInt(), + minBpm: (j['mn'] as num).toInt(), + maxBpm: (j['mx'] as num).toInt(), + avgBpm: (j['av'] as num).toDouble(), + buckets: (j['b'] as List) + .map((e) => HrBucket.fromJson(e as Map)) + .toList(), + ); +} + +/// One aggregated sleep bar (a night, or a month in the year view). +class SleepDayBar { + final DateTime date; + final int totalMinutes; + final int deepMin; + final int remMin; + final int lightMin; + final int awakeMin; + + const SleepDayBar({ + required this.date, + required this.totalMinutes, + required this.deepMin, + required this.remMin, + required this.lightMin, + required this.awakeMin, + }); +} + +/// One aggregated HR range bar (a day, or a month in the year view). +class HrRangeBar { + final DateTime date; + final String label; + final int minBpm; + final int maxBpm; + final double avgBpm; + final int? restingBpm; + + const HrRangeBar({ + required this.date, + required this.label, + required this.minBpm, + required this.maxBpm, + required this.avgBpm, + required this.restingBpm, + }); +} diff --git a/workout-logger/lib/models/workout_hr_models.dart b/workout-logger/lib/models/workout_hr_models.dart new file mode 100644 index 0000000..f756b28 --- /dev/null +++ b/workout-logger/lib/models/workout_hr_models.dart @@ -0,0 +1,105 @@ +// Data models for the per-workout heart-rate breakdown shown in the History +// session-details sheet. Computed at runtime from Health Connect HR samples + +// the session's set timestamps; never persisted. +library; + +/// One point on the workout HR curve (~30-second bucket average). +class HrCurvePoint { + final DateTime time; + final double bpm; + const HrCurvePoint({required this.time, required this.bpm}); +} + +/// HR recovery across one rest gap between two sets. +class RestRecovery { + /// 1-based index of the set this rest follows (global across the session). + final int afterSet; + final DateTime restStart; + final int durationSec; + + /// HR at the end of the preceding set (local peak). + final int peakBpm; + + /// Lowest HR reached during the rest. + final int troughBpm; + + /// peakBpm − troughBpm (positive means HR came down). + final int recoveryBpm; + + /// True when the drop met the recovery threshold. + final bool recovered; + + const RestRecovery({ + required this.afterSet, + required this.restStart, + required this.durationSec, + required this.peakBpm, + required this.troughBpm, + required this.recoveryBpm, + required this.recovered, + }); +} + +/// Time span of one exercise within the session — drawn as a labelled flag / +/// section on the HR curve so you can see which part of the workout is which. +class ExerciseHrSpan { + final String exerciseId; + final DateTime start; + final DateTime end; + final int setCount; + + const ExerciseHrSpan({ + required this.exerciseId, + required this.start, + required this.end, + required this.setCount, + }); +} + +/// Complete HR picture for one recorded workout. +class WorkoutHrAnalysis { + final DateTime start; + final DateTime end; + final int avgBpm; + final int peakBpm; + final int minBpm; + + /// Ordered curve points across the session. + final List curve; + + /// Per-rest recovery. Empty when set timestamps aren't trustworthy + /// ([hasRestAnalysis] is false) — the curve still renders. + final List rests; + + /// Exercise sections across the session, ordered in time. Empty when set + /// timestamps aren't trustworthy. + final List exercises; + + /// Whether rest/section analysis was computed (set timestamps spanned the + /// session). + final bool hasRestAnalysis; + + const WorkoutHrAnalysis({ + required this.start, + required this.end, + required this.avgBpm, + required this.peakBpm, + required this.minBpm, + required this.curve, + required this.rests, + required this.exercises, + required this.hasRestAnalysis, + }); + + int get restsRecovered => rests.where((r) => r.recovered).length; + int get restCount => rests.length; + + /// Mean recovery (bpm) across the rests that recovered; 0 when none did. + int get avgRecoveryBpm { + final ok = rests.where((r) => r.recovered).toList(); + if (ok.isEmpty) return 0; + return (ok.fold(0, (s, r) => s + r.recoveryBpm) / ok.length).round(); + } + + int get restsTooShort => rests.where((r) => !r.recovered).length; +} diff --git a/workout-logger/lib/screens/add_custom_exercise_screen.dart b/workout-logger/lib/screens/add_custom_exercise_screen.dart index ba75d83..d0be1d6 100644 --- a/workout-logger/lib/screens/add_custom_exercise_screen.dart +++ b/workout-logger/lib/screens/add_custom_exercise_screen.dart @@ -1,4 +1,4 @@ -// Add Custom Exercise Screen - Form for creating user-defined exercises +// add_custom_exercise_screen.dart — Form for creating a custom exercise import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import '../services/workout_provider.dart'; import '../data/exercise_database.dart'; import '../theme/app_theme.dart'; +import 'widgets/rf_widgets.dart'; class AddCustomExerciseScreen extends StatefulWidget { const AddCustomExerciseScreen({super.key}); @@ -20,8 +21,8 @@ class _AddCustomExerciseScreenState extends State { final _formKey = GlobalKey(); final _nameController = TextEditingController(); - String _selectedCategory = 'compound'; - String? _selectedMuscleGroup; + String _category = 'compound'; + String? _muscleId; bool _isSubmitting = false; @override @@ -30,109 +31,116 @@ class _AddCustomExerciseScreenState extends State { super.dispose(); } - Future _saveExercise() async { + Future _save() async { if (!_formKey.currentState!.validate()) return; - if (_selectedMuscleGroup == null) { + if (_muscleId == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Please select a primary muscle group'), - backgroundColor: AppTheme.error, + backgroundColor: AppColors.error, ), ); return; } setState(() => _isSubmitting = true); - try { - final provider = context.read(); - await provider.addCustomExercise( - name: _nameController.text.trim(), - category: _selectedCategory, - primaryMuscleGroupId: _selectedMuscleGroup!, - ); - + await context.read().addCustomExercise( + name: _nameController.text.trim(), + category: _category, + primaryMuscleGroupId: _muscleId!, + ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Row( - children: [ - const Icon(Icons.check_circle, color: AppTheme.success), - const SizedBox(width: 8), - Text('${_nameController.text.trim()} added successfully!'), - ], + content: Text('${_nameController.text.trim()} added!'), + backgroundColor: AppColors.cardHigh, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), ), - backgroundColor: AppTheme.cardColor, ), ); - Navigator.of(context).pop(true); // Return success + Navigator.of(context).pop(true); } } catch (e) { debugPrint('Failed to save custom exercise: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Failed to save exercise. Please try again.'), - backgroundColor: AppTheme.error, + content: Text('Failed to save. Please try again.'), + backgroundColor: AppColors.error, ), ); } } finally { - if (mounted) { - setState(() => _isSubmitting = false); - } + if (mounted) setState(() => _isSubmitting = false); } } @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: AppColors.background, appBar: AppBar( - title: const Text('Add Custom Exercise'), + backgroundColor: AppColors.surface, + title: const Text( + 'New Exercise', + style: TextStyle(color: AppColors.textPrimary), + ), + iconTheme: const IconThemeData(color: AppColors.textSoft), actions: [ TextButton( - onPressed: _isSubmitting ? null : _saveExercise, + onPressed: _isSubmitting ? null : _save, child: _isSubmitting ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), ) - : const Text('Save'), + : const Text( + 'Save', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w700, + ), + ), ), ], ), body: SingleChildScrollView( + physics: const BouncingScrollPhysics(), padding: const EdgeInsets.all(AppSpacing.md), child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Info Banner + // Info banner Container( padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.1), + color: AppColors.primary.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(AppRadius.md), border: Border.all( - color: AppTheme.primaryColor.withOpacity(0.3), + color: AppColors.primary.withValues(alpha: 0.2), ), ), - child: Row( + child: const Row( children: [ - Icon( - Icons.info_outline, - color: AppTheme.primaryColor, - size: 24, - ), - const SizedBox(width: AppSpacing.sm), + Icon(Icons.info_outline_rounded, + color: AppColors.primary, size: 18), + SizedBox(width: AppSpacing.sm), Expanded( child: Text( - 'Create a custom exercise to track workouts not in the built-in library.', + 'Create a custom exercise to track workouts ' + 'not in the built-in library.', style: TextStyle( - color: AppTheme.textSecondary, - fontSize: 14, + color: AppColors.textSoft, + fontSize: 13, ), ), ), @@ -142,189 +150,117 @@ class _AddCustomExerciseScreenState extends State { const SizedBox(height: AppSpacing.lg), - // Exercise Name - Text( - 'Exercise Name', - style: Theme.of(context).textTheme.titleMedium, - ), + // Name + _label('EXERCISE NAME'), const SizedBox(height: AppSpacing.sm), - TextFormField( - controller: _nameController, - decoration: const InputDecoration( - hintText: 'e.g., Cable Lateral Raise', - prefixIcon: Icon(Icons.fitness_center), + Container( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextFormField( + controller: _nameController, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + ), + textCapitalization: TextCapitalization.words, + inputFormatters: [LengthLimitingTextInputFormatter(50)], + decoration: const InputDecoration( + hintText: 'e.g., Cable Lateral Raise', + hintStyle: TextStyle(color: AppColors.textMuted), + prefixIcon: Icon( + Icons.fitness_center_rounded, + color: AppColors.textMuted, + size: 18, + ), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + ), + validator: (v) { + if (v == null || v.trim().isEmpty) { + return 'Please enter an exercise name'; + } + if (v.trim().length < 3) { + return 'Name must be at least 3 characters'; + } + return null; + }, ), - textCapitalization: TextCapitalization.words, - inputFormatters: [LengthLimitingTextInputFormatter(50)], - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Please enter an exercise name'; - } - if (value.trim().length < 3) { - return 'Name must be at least 3 characters'; - } - return null; - }, ), const SizedBox(height: AppSpacing.lg), - // Category Selection - Text( - 'Exercise Type', - style: Theme.of(context).textTheme.titleMedium, - ), + // Category toggle + _label('EXERCISE TYPE'), const SizedBox(height: AppSpacing.sm), - SegmentedButton( - segments: const [ - ButtonSegment( - value: 'compound', - label: Text('Compound'), - icon: Icon(Icons.fitness_center), + Row( + children: [ + _CategoryTile( + label: 'Compound', + icon: Icons.fitness_center_rounded, + description: 'Multiple muscle groups', + selected: _category == 'compound', + onTap: () => setState(() => _category = 'compound'), ), - ButtonSegment( - value: 'isolation', - label: Text('Isolation'), - icon: Icon(Icons.accessibility_new), + const SizedBox(width: AppSpacing.sm), + _CategoryTile( + label: 'Isolation', + icon: Icons.accessibility_new_rounded, + description: 'Single muscle group', + selected: _category == 'isolation', + onTap: () => setState(() => _category = 'isolation'), ), ], - selected: {_selectedCategory}, - onSelectionChanged: (Set selection) { - setState(() => _selectedCategory = selection.first); - }, - style: ButtonStyle( - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return AppTheme.primaryColor.withOpacity(0.2); - } - return AppTheme.surfaceColor; - }), - ), - ), - - const SizedBox(height: AppSpacing.xs), - Text( - _selectedCategory == 'compound' - ? 'Works multiple muscle groups (e.g., squats, bench press)' - : 'Targets a single muscle group (e.g., bicep curls)', - style: TextStyle(color: AppTheme.textMuted, fontSize: 12), ), const SizedBox(height: AppSpacing.lg), - // Primary Muscle Group - Text( - 'Primary Muscle Group', - style: Theme.of(context).textTheme.titleMedium, - ), + // Muscle group grid + _label('PRIMARY MUSCLE GROUP'), const SizedBox(height: AppSpacing.sm), - - // Muscle Group Grid - Builder( - builder: (context) { - // Materialize keys once to avoid O(n²) lookup - final muscleKeys = MuscleGroups.names.keys.toList(); - - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 2.2, - crossAxisSpacing: AppSpacing.sm, - mainAxisSpacing: AppSpacing.sm, - ), - itemCount: muscleKeys.length, - itemBuilder: (context, index) { - final muscleId = muscleKeys[index]; - final muscleName = MuscleGroups.names[muscleId]!; - final muscleColor = AppTheme.getMuscleColor(muscleId); - final isSelected = _selectedMuscleGroup == muscleId; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => - setState(() => _selectedMuscleGroup = muscleId), - borderRadius: BorderRadius.circular(AppRadius.md), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - decoration: BoxDecoration( - color: isSelected - ? muscleColor.withOpacity(0.3) - : AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all( - color: isSelected - ? muscleColor - : AppTheme.cardColor, - width: 2, - ), - ), - child: Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (isSelected) ...[ - Icon( - Icons.check_circle, - size: 14, - color: muscleColor, - ), - const SizedBox(width: 4), - ], - Flexible( - child: Text( - muscleName, - style: TextStyle( - color: isSelected - ? muscleColor - : AppTheme.textSecondary, - fontSize: 11, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ), - ), - ); - }, - ); - }, + _MuscleGrid( + selected: _muscleId, + onSelect: (id) => setState(() => _muscleId = id), ), - if (_selectedMuscleGroup != null) ...[ + if (_muscleId != null) ...[ const SizedBox(height: AppSpacing.md), Container( - padding: const EdgeInsets.all(AppSpacing.md), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), decoration: BoxDecoration( - color: AppTheme.getMuscleColor( - _selectedMuscleGroup!, - ).withOpacity(0.1), - borderRadius: BorderRadius.circular(AppRadius.md), + color: AppColors.muscle(_muscleId!) + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: AppColors.muscle(_muscleId!) + .withValues(alpha: 0.3), + ), ), child: Row( + mainAxisSize: MainAxisSize.min, children: [ Container( - width: 12, - height: 12, + width: 10, + height: 10, decoration: BoxDecoration( - color: AppTheme.getMuscleColor(_selectedMuscleGroup!), - borderRadius: BorderRadius.circular(6), + color: AppColors.muscle(_muscleId!), + shape: BoxShape.circle, ), ), - const SizedBox(width: AppSpacing.sm), + const SizedBox(width: 8), Text( - 'Primary: ${MuscleGroups.names[_selectedMuscleGroup]}', + 'Primary: ${MuscleGroups.names[_muscleId]}', style: TextStyle( - color: AppTheme.getMuscleColor(_selectedMuscleGroup!), + color: AppColors.muscle(_muscleId!), + fontSize: 13, fontWeight: FontWeight.w600, ), ), @@ -335,26 +271,11 @@ class _AddCustomExerciseScreenState extends State { const SizedBox(height: AppSpacing.xxl), - // Save Button - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _isSubmitting ? null : _saveExercise, - icon: _isSubmitting - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.add), - label: Text(_isSubmitting ? 'Saving...' : 'Add Exercise'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - ), - ), + GlowButton( + label: _isSubmitting ? 'Saving…' : 'Add Exercise', + icon: Icons.add_rounded, + onPressed: _isSubmitting ? null : _save, + fullWidth: true, ), const SizedBox(height: AppSpacing.lg), @@ -364,4 +285,160 @@ class _AddCustomExerciseScreenState extends State { ), ); } + + Widget _label(String text) { + return Text( + text, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1, + ), + ); + } +} + +// ── Category tile ───────────────────────────────────────────────────────────── +class _CategoryTile extends StatelessWidget { + const _CategoryTile({ + required this.label, + required this.icon, + required this.description, + required this.selected, + required this.onTap, + }); + + final String label; + final IconData icon; + final String description; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: selected + ? AppColors.primary.withValues(alpha: 0.12) + : AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: selected + ? AppColors.primary.withValues(alpha: 0.5) + : AppColors.glassBorder, + width: selected ? 1.5 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + icon, + color: selected ? AppColors.primary : AppColors.textMuted, + size: 20, + ), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + color: selected ? AppColors.primary : AppColors.textSoft, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + Text( + description, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 10, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── Muscle group grid ───────────────────────────────────────────────────────── +class _MuscleGrid extends StatelessWidget { + const _MuscleGrid({required this.selected, required this.onSelect}); + final String? selected; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final keys = MuscleGroups.names.keys.toList(); + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 2.3, + crossAxisSpacing: AppSpacing.sm, + mainAxisSpacing: AppSpacing.sm, + ), + itemCount: keys.length, + itemBuilder: (_, i) { + final id = keys[i]; + final name = MuscleGroups.names[id]!; + final color = AppColors.muscle(id); + final isSelected = selected == id; + + return GestureDetector( + onTap: () => onSelect(id), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: isSelected + ? color.withValues(alpha: 0.2) + : AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: isSelected + ? color.withValues(alpha: 0.6) + : AppColors.glassBorder, + width: isSelected ? 1.5 : 1, + ), + ), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isSelected) ...[ + Icon(Icons.check_rounded, size: 12, color: color), + const SizedBox(width: 3), + ], + Flexible( + child: Text( + name, + textAlign: TextAlign.center, + style: TextStyle( + color: isSelected ? color : AppColors.textSoft, + fontSize: 11, + fontWeight: isSelected + ? FontWeight.w700 + : FontWeight.w400, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } } diff --git a/workout-logger/lib/screens/ai_coach_screen.dart b/workout-logger/lib/screens/ai_coach_screen.dart new file mode 100644 index 0000000..69e3a36 --- /dev/null +++ b/workout-logger/lib/screens/ai_coach_screen.dart @@ -0,0 +1,839 @@ +// ai_coach_screen.dart — Conversational AI workout coach (View). +// +// This is a lean View: all orchestration (streaming, tool calls, persistence, +// system-prompt building) lives in AiCoachViewModel. The widget only renders +// state, forwards user intents, and holds UI-local controllers. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; + +import '../models/models.dart'; +import '../viewmodels/ai_coach_view_model.dart'; +import '../services/ai/gemini_ai_service.dart'; +import '../services/ai/coach_tool_service.dart'; +import '../services/managers/conversation_manager.dart'; +import '../services/settings_provider.dart'; +import '../theme/app_theme.dart'; +import 'widgets/rf_widgets.dart'; +import 'profile_screen.dart'; + +/// Public entry point. Owns the screen-scoped [AiCoachViewModel]. +class AiCoachScreen extends StatelessWidget { + const AiCoachScreen({super.key, this.seedPrompt}); + + /// Optional question to auto-send on open (e.g. deep-linked from analytics). + final String? seedPrompt; + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (ctx) => AiCoachViewModel( + ai: ctx.read(), + coachTools: ctx.read(), + conversations: ctx.read(), + settings: ctx.read(), + )..loadConversations(), + child: _AiCoachView(seedPrompt: seedPrompt), + ); + } +} + +class _AiCoachView extends StatefulWidget { + const _AiCoachView({this.seedPrompt}); + final String? seedPrompt; + + @override + State<_AiCoachView> createState() => _AiCoachViewState(); +} + +class _AiCoachViewState extends State<_AiCoachView> { + final _controller = TextEditingController(); + final _scrollCtrl = ScrollController(); + AiCoachViewModel? _vm; + + @override + void initState() { + super.initState(); + final seed = widget.seedPrompt?.trim(); + if (seed != null && seed.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final vm = context.read(); + if (!vm.isConfigured) return; + _controller.clear(); + vm.sendMessage(seed); + }); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Attach a scroll-follow listener once. + final vm = context.read(); + if (!identical(vm, _vm)) { + _vm?.removeListener(_onVmChanged); + _vm = vm..addListener(_onVmChanged); + } + } + + void _onVmChanged() => _scrollToBottom(); + + @override + void dispose() { + _vm?.removeListener(_onVmChanged); + _controller.dispose(); + _scrollCtrl.dispose(); + super.dispose(); + } + + void _send() { + final text = _controller.text.trim(); + if (text.isEmpty) return; + HapticFeedback.lightImpact(); + _controller.clear(); + context.read().sendMessage(text); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollCtrl.hasClients) { + _scrollCtrl.animateTo( + _scrollCtrl.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + }); + } + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + + return Scaffold( + backgroundColor: AppColors.background, + 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), + ], + ), + ), + ], + ), + ); + } + + 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, + ), + ), + ), + 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), + ), + 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(); + }, + ), + ], + ], + ), + ); + } + + Future _openHistory(BuildContext context, AiCoachViewModel vm) async { + HapticFeedback.lightImpact(); + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)), + ), + builder: (_) => _ConversationsSheet(vm: vm), + ); + } + + Widget _buildChatArea(AiCoachViewModel vm) { + final messages = vm.messages; + final hasContent = messages.isNotEmpty || vm.isLoading; + if (!hasContent) return _buildWelcome(); + + return ListView.builder( + controller: _scrollCtrl, + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + AppSpacing.sm, + ), + itemCount: messages.length + (vm.isLoading ? 1 : 0), + itemBuilder: (_, i) { + if (i == messages.length) { + return _StreamingBubble(text: vm.streamingText); + } + return _MessageBubble(message: messages[i]); + }, + ); + } + + Widget _buildWelcome() { + final name = context.read().userName; + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + 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 SizedBox(height: AppSpacing.lg), + Text( + name != null && name.isNotEmpty ? 'Hey $name 👋' : 'Your AI Coach', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Ask me anything — what to train today, how to break a plateau, reading your progress, anything.', + textAlign: TextAlign.center, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 14, + height: 1.5, + ), + ), + const SizedBox(height: AppSpacing.xl), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + alignment: WrapAlignment.center, + children: [ + for (final s in const [ + 'What should I train today?', + 'How\'s my recovery?', + 'Am I progressing on bench?', + 'Suggest a deload week', + ]) + _SuggestionChip( + label: s, + onTap: () { + _controller.text = s; + _send(); + }, + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildNoKeyState(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const RFEmptyState( + icon: Icons.key_rounded, + title: 'API Key Required', + subtitle: 'Add your Gemini API key in\nProfile → AI Features to start chatting', + ), + const SizedBox(height: AppSpacing.lg), + GlowButton( + label: 'Go to Profile', + icon: Icons.person_rounded, + fullWidth: false, + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ProfileScreen()), + ), + ), + ], + ), + ), + ); + } + + 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)), + ), + child: Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: AppColors.glass3, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.glassBorderStrong), + ), + child: TextField( + controller: _controller, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + ), + maxLines: 4, + minLines: 1, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: 'Ask your coach...', + hintStyle: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 14, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 4, + ), + ), + onSubmitted: (_) => _send(), + ), + ), + ), + 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, + ), + ), + ), + ], + ), + ); + } +} + +// ── Header icon button ────────────────────────────────────────────────────── + +class _HeaderIconButton extends StatelessWidget { + const _HeaderIconButton({required this.icon, required this.onTap}); + final IconData icon; + 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), + ), + child: Icon(icon, color: AppColors.textSoft, size: 18), + ), + ); + } +} + +// ── Conversations history sheet ─────────────────────────────────────────────── + +class _ConversationsSheet extends StatelessWidget { + const _ConversationsSheet({required this.vm}); + final AiCoachViewModel vm; + + @override + Widget build(BuildContext context) { + // Rebuild when the conversation list changes (delete, new message). + return AnimatedBuilder( + animation: vm, + builder: (context, _) { + final conversations = vm.conversations; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Conversations', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + GestureDetector( + onTap: () { + vm.newConversation(); + Navigator.pop(context); + }, + child: Row( + children: [ + const Icon(Icons.add_rounded, + color: AppColors.primary, size: 18), + const SizedBox(width: 4), + Text( + 'New chat', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.primary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + if (conversations.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg), + child: Text( + 'No saved conversations yet.', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 13, + ), + ), + ) + else + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.5, + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: conversations.length, + separatorBuilder: (_, __) => + const SizedBox(height: AppSpacing.sm), + itemBuilder: (_, i) { + final c = conversations[i]; + final isActive = c.id == vm.activeConversationId; + return _ConversationTile( + conversation: c, + isActive: isActive, + onTap: () { + vm.selectConversation(c.id); + Navigator.pop(context); + }, + onDelete: () => vm.deleteConversation(c.id), + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _ConversationTile extends StatelessWidget { + const _ConversationTile({ + required this.conversation, + required this.isActive, + required this.onTap, + required this.onDelete, + }); + + final Conversation conversation; + final bool isActive; + final VoidCallback onTap; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + 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, + ), + ), + child: Row( + children: [ + 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', + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + GestureDetector( + onTap: onDelete, + child: const Padding( + padding: EdgeInsets.only(left: AppSpacing.sm), + child: Icon(Icons.delete_outline_rounded, + color: AppColors.textFaint, size: 18), + ), + ), + ], + ), + ), + ); + } +} + +// ── 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 { + const _MessageBubble({required this.message}); + final ChatMessage message; + + @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: 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: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + height: 1.55, + ), + ) + : _CoachMarkdown(text: message.text), + ), + ), + ], + ), + ); + } +} + +class _StreamingBubble extends StatelessWidget { + const _StreamingBubble({required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _AiAvatar(), + const SizedBox(width: AppSpacing.sm), + Flexible( + child: 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() + : _CoachMarkdown(text: text), + ), + ), + ], + ), + ); + } +} + +/// Markdown renderer for coach replies, styled to the app theme. +class _CoachMarkdown extends StatelessWidget { + const _CoachMarkdown({required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + return GptMarkdown( + text, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + height: 1.55, + ), + ); + } +} + +class _AiAvatar extends StatelessWidget { + @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), + ); + } +} diff --git a/workout-logger/lib/screens/ai_program_generator_screen.dart b/workout-logger/lib/screens/ai_program_generator_screen.dart new file mode 100644 index 0000000..9003bca --- /dev/null +++ b/workout-logger/lib/screens/ai_program_generator_screen.dart @@ -0,0 +1,520 @@ +// ai_program_generator_screen.dart — Natural-language training program generation via Gemini + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../models/models.dart'; +import '../services/ai/gemini_ai_service.dart'; +import '../services/workout_provider.dart'; +import '../services/managers/program_manager.dart'; +import '../theme/app_theme.dart'; +import 'widgets/rf_widgets.dart'; + +class AiProgramGeneratorScreen extends StatefulWidget { + const AiProgramGeneratorScreen({super.key}); + + @override + State createState() => + _AiProgramGeneratorScreenState(); +} + +class _AiProgramGeneratorScreenState extends State { + final _promptCtrl = TextEditingController(); + bool _generating = false; + String _statusText = ''; + TrainingProgram? _preview; + String? _error; + + // Prompt suggestions + static const _suggestions = [ + '12-week hypertrophy, 4 days/week, push-pull-legs-upper', + '8-week strength focus, 3 days/week, full body', + '6-week cut program, 5 days/week, high volume', + '16-week powerlifting peaking, 4 days/week', + ]; + + @override + void dispose() { + _promptCtrl.dispose(); + super.dispose(); + } + + Future _generate() async { + final prompt = _promptCtrl.text.trim(); + if (prompt.isEmpty) return; + + final gemini = context.read(); + if (!gemini.isConfigured) { + setState(() { _error = 'Add your Gemini API key in Profile → AI Features first.'; }); + return; + } + + HapticFeedback.mediumImpact(); + + setState(() { + _generating = true; + _preview = null; + _error = null; + _statusText = 'Designing your program…'; + }); + + try { + final wp = context.read(); + + setState(() => _statusText = 'Building workout structure…'); + final program = await gemini.generateProgram( + userPrompt: prompt, + allExercises: wp.allExercises, + ); + + if (mounted) { + setState(() { + _preview = program; + _generating = false; + _statusText = ''; + }); + HapticFeedback.lightImpact(); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString().replaceFirst('Exception: ', ''); + _generating = false; + _statusText = ''; + }); + } + } + } + + Future _saveProgram() async { + if (_preview == null) return; + HapticFeedback.mediumImpact(); + final manager = context.read(); + try { + await manager.saveProgram(_preview!); + if (mounted) Navigator.pop(context, true); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to save program: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: Stack( + children: [ + const AmbientGlow(), + SafeArea( + child: Column( + children: [ + _buildHeader(context), + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildPromptCard(), + const SizedBox(height: AppSpacing.lg), + if (_generating) _buildGeneratingState(), + if (_error != null) _buildError(), + if (_preview != null) _buildPreview(), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context) { + 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, + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF7C3AED), 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, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AI Program Generator', + 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, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildPromptCard() { + return GlassCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.edit_note_rounded, + color: AppColors.primary, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Describe your program', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Container( + decoration: BoxDecoration( + color: AppColors.glass, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorderStrong), + ), + child: TextField( + controller: _promptCtrl, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + height: 1.5, + ), + maxLines: 4, + minLines: 2, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: + 'e.g. "12-week hypertrophy program, 4 days/week, push-pull split, intermediate level"', + hintStyle: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 13, + height: 1.5, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.all(AppSpacing.md), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + 'QUICK PROMPTS', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textFaint, + fontSize: 9, + fontWeight: FontWeight.w600, + letterSpacing: 1.4, + ), + ), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: _suggestions.map((s) { + return GestureDetector( + onTap: () => setState(() => _promptCtrl.text = s), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.glass, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.glassBorder), + ), + child: Text( + s, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 11, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: AppSpacing.lg), + GlowButton( + label: _generating ? 'Generating…' : 'Generate Program', + icon: Icons.auto_awesome_rounded, + onPressed: _generating ? null : _generate, + ), + ], + ), + ); + } + + Widget _buildGeneratingState() { + return GlassCard( + glowColor: AppColors.primary, + child: Column( + children: [ + const SizedBox(height: AppSpacing.sm), + SizedBox( + width: 48, + height: 48, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + _statusText, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + 'Gemini is designing your training block…', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + ), + ), + const SizedBox(height: AppSpacing.sm), + ], + ), + ); + } + + Widget _buildError() { + return GlassCard( + borderColor: AppColors.error.withValues(alpha: 0.4), + child: Row( + children: [ + const Icon(Icons.error_outline_rounded, color: AppColors.error, size: 20), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + _error!, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.error, + fontSize: 13, + ), + ), + ), + ], + ), + ); + } + + Widget _buildPreview() { + final p = _preview!; + final deloads = p.weeks.where((w) => w.isDeload).length; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RFSectionHeader('Generated Program'), + GlassCard( + glowColor: AppColors.success, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.success.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: AppColors.success.withValues(alpha: 0.3), + ), + ), + child: const Icon( + Icons.calendar_month_rounded, + color: AppColors.success, + size: 20, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + p.name, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + if (p.description != null) + Text( + p.description!, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + RFChip(label: '${p.totalWeeks} weeks', color: AppColors.primary), + RFChip(label: '${p.phases.length} phases', color: AppColors.secondary), + RFChip( + label: '${p.weeks.fold(0, (s, w) => s + w.days.length)} training days', + color: AppColors.textSoft, + ), + if (deloads > 0) + RFChip(label: '$deloads deload', color: AppColors.warning), + ], + ), + if (p.phases.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.md), + const Divider(color: AppColors.glassBorder, height: 1), + const SizedBox(height: AppSpacing.md), + Text( + 'PHASES', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textFaint, + fontSize: 9, + fontWeight: FontWeight.w600, + letterSpacing: 1.4, + ), + ), + const SizedBox(height: AppSpacing.sm), + ...p.phases.map( + (phase) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Text( + phase.name, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + Text( + 'Wk ${phase.startWeek}–${phase.endWeek}', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + ), + ], + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + GlowButton( + label: 'Add to My Programs', + icon: Icons.add_rounded, + onPressed: _saveProgram, + ), + const SizedBox(height: AppSpacing.sm), + OutlineGlowButton( + label: 'Regenerate', + icon: Icons.refresh_rounded, + onPressed: _generate, + ), + const SizedBox(height: AppSpacing.xxl), + ], + ); + } +} diff --git a/workout-logger/lib/screens/analytics_screen.dart b/workout-logger/lib/screens/analytics_screen.dart index 8794068..73b9764 100644 --- a/workout-logger/lib/screens/analytics_screen.dart +++ b/workout-logger/lib/screens/analytics_screen.dart @@ -1,16 +1,18 @@ -// Analytics Screen - Visualize progress with charts +// analytics_screen.dart — Analytics: Overview / Exercises / Targets / Records import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:fl_chart/fl_chart.dart'; import 'package:intl/intl.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; +import '../services/managers/pr_manager.dart'; import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; -import '../data/exercise_database.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/analytics_overview.dart'; +import 'widgets/exercise_progress_view.dart'; +import 'widgets/targets_tab.dart'; class AnalyticsScreen extends StatefulWidget { const AnalyticsScreen({super.key}); @@ -19,188 +21,60 @@ class AnalyticsScreen extends StatefulWidget { State createState() => _AnalyticsScreenState(); } -class _AnalyticsScreenState extends State - with SingleTickerProviderStateMixin { - late TabController _tabController; +class _AnalyticsScreenState extends State { + int _tab = 0; - @override - void initState() { - super.initState(); - _tabController = TabController(length: 3, vsync: this); - } - - @override - void dispose() { - _tabController.dispose(); - super.dispose(); - } + static const _tabs = ['Overview', 'Exercises', 'Targets', 'Records']; @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Analytics'), - bottom: TabBar( - controller: _tabController, - tabs: const [ - Tab(text: 'Overview'), - Tab(text: 'Exercises'), - Tab(text: 'Targets'), - ], + return Stack( + children: [ + const AmbientGlow(), + SafeArea( + bottom: false, + child: Column( + children: [ + _buildHeader(), + _buildPillTabBar(), + Expanded(child: _buildTabView()), + ], + ), ), - ), - body: TabBarView( - controller: _tabController, - children: const [ - _OverviewTab(), - _ExercisesTab(), - _TargetsTab(), - ], - ), - ); - } -} - -// ==================== Overview Tab ==================== - -class _OverviewTab extends StatelessWidget { - const _OverviewTab(); - - @override - Widget build(BuildContext context) { - final provider = context.watch(); - - return SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildVolumeChart(context, provider), - const SizedBox(height: AppSpacing.lg), - _buildMuscleVolumeChart(context, provider), - const SizedBox(height: AppSpacing.lg), - _buildWorkoutFrequency(context, provider), - ], - ), + ], ); } - Widget _buildVolumeChart(BuildContext context, WorkoutProvider provider) { - final sessions = provider.sessions.take(14).toList().reversed.toList(); - - if (sessions.isEmpty) { - return _buildEmptyChart(context, 'Volume Progression'); - } - - final spots = sessions.asMap().entries.map((entry) { - return FlSpot(entry.key.toDouble(), entry.value.totalVolume / 1000); - }).toList(); - - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Widget _buildHeader() { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text( - 'Volume Progression (kg)', - style: Theme.of(context).textTheme.titleMedium, - ), - Text( - 'Last ${sessions.length} workouts', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: AppSpacing.lg), - SizedBox( - height: 200, - child: LineChart( - LineChartData( - gridData: FlGridData( - show: true, - drawVerticalLine: false, - horizontalInterval: 1, - getDrawingHorizontalLine: (value) => FlLine( - color: AppTheme.surfaceColor, - strokeWidth: 1, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'INSIGHTS', + style: TextStyle(fontFamily: 'Geist', + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textFaint, + letterSpacing: 1.2, ), ), - titlesData: FlTitlesData( - show: true, - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 30, - interval: 1, - getTitlesWidget: (value, meta) { - final index = value.toInt(); - if (index >= 0 && index < sessions.length) { - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - DateFormat('d/M').format(sessions[index].date), - style: const TextStyle( - color: AppTheme.textMuted, - fontSize: 10, - ), - ), - ); - } - return const Text(''); - }, - ), - ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, - getTitlesWidget: (value, meta) => Text( - '${value.toStringAsFixed(0)}k', - style: const TextStyle( - color: AppTheme.textMuted, - fontSize: 10, - ), - ), - ), + const SizedBox(height: 2), + Text( + 'Analytics', + style: TextStyle(fontFamily: 'Geist', + fontSize: 28, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.6, ), ), - borderData: FlBorderData(show: false), - lineBarsData: [ - LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.3, - color: AppTheme.primaryColor, - barWidth: 3, - isStrokeCapRound: true, - dotData: FlDotData( - show: true, - getDotPainter: (spot, percent, barData, index) => - FlDotCirclePainter( - radius: 4, - color: AppTheme.primaryColor, - strokeWidth: 2, - strokeColor: AppTheme.cardColor, - ), - ), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - colors: [ - AppTheme.primaryColor.withOpacity(0.3), - AppTheme.primaryColor.withOpacity(0.0), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), - ), - ], - ), + ], ), ), ], @@ -208,264 +82,327 @@ class _OverviewTab extends StatelessWidget { ); } - Widget _buildMuscleVolumeChart(BuildContext context, WorkoutProvider provider) { - final volumeByMuscle = provider.getWeeklyVolumeByMuscle(); - - if (volumeByMuscle.isEmpty) { - return _buildEmptyChart(context, 'Weekly Muscle Volume'); - } - - // Sort by volume and take top 8 - final sorted = volumeByMuscle.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value)); - final top = sorted.take(8).toList(); - final maxVolume = top.first.value; - - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Weekly Muscle Volume', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.md), - ...top.map((entry) { - final muscleName = MuscleGroups.names[entry.key] ?? entry.key; - final color = AppTheme.getMuscleColor(entry.key); - final percentage = entry.value / maxVolume; - - return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - muscleName, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 13, - ), - ), - Text( - '${(entry.value / 1000).toStringAsFixed(1)}k kg', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - ], + Widget _buildPillTabBar() { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: List.generate(_tabs.length, (i) { + final active = i == _tab; + return Expanded( + child: GestureDetector( + onTap: () => setState(() => _tab = i), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: active ? AppColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(11), + boxShadow: active + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.35), + blurRadius: 12, + ), + ] + : null, ), - const SizedBox(height: 4), - LinearProgressIndicator( - value: percentage, - backgroundColor: AppTheme.surfaceColor, - valueColor: AlwaysStoppedAnimation(color), - borderRadius: BorderRadius.circular(4), - minHeight: 8, + child: Text( + _tabs[i], + textAlign: TextAlign.center, + style: TextStyle(fontFamily: 'Geist', + fontSize: 13, + fontWeight: FontWeight.w600, + color: active ? Colors.white : AppColors.textMuted, + ), ), - ], + ), ), ); }), - ], + ), ), ); } - Widget _buildWorkoutFrequency(BuildContext context, WorkoutProvider provider) { - // Calculate workouts per week for last 4 weeks - final now = DateTime.now(); - final weeks = {}; - - for (int i = 0; i < 4; i++) { - weeks[i] = 0; - } - - for (var session in provider.sessions) { - final weeksAgo = now.difference(session.date).inDays ~/ 7; - if (weeksAgo < 4) { - weeks[weeksAgo] = (weeks[weeksAgo] ?? 0) + 1; - } + Widget _buildTabView() { + switch (_tab) { + case 0: + return const AnalyticsOverviewTab(); + case 1: + return const ExerciseProgressView(); + case 2: + return const TargetsTab(); + case 3: + return const _RecordsTab(); + default: + return const SizedBox.shrink(); } - - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Workout Frequency', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.md), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: weeks.entries.map((entry) { - final label = entry.key == 0 - ? 'This Week' - : '${entry.key} week${entry.key > 1 ? 's' : ''} ago'; - return Column( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity( - entry.value > 0 ? 0.2 + (entry.value * 0.15) : 0.1 - ), - borderRadius: BorderRadius.circular(12), - ), - child: Center( - child: Text( - '${entry.value}', - style: TextStyle( - color: entry.value > 0 - ? AppTheme.primaryColor - : AppTheme.textMuted, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - ), - ), - const SizedBox(height: 4), - Text( - entry.key == 0 ? 'This' : '-${entry.key}w', - style: const TextStyle( - color: AppTheme.textMuted, - fontSize: 10, - ), - ), - ], - ); - }).toList(), - ), - ], - ), - ); - } - - Widget _buildEmptyChart(BuildContext context, String title) { - return Container( - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: Column( - children: [ - Text( - title, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.lg), - Icon( - Icons.show_chart, - size: 48, - color: AppTheme.textMuted, - ), - const SizedBox(height: AppSpacing.md), - const Text( - 'No data yet', - style: TextStyle(color: AppTheme.textSecondary), - ), - const Text( - 'Complete workouts to see your progress', - style: TextStyle(color: AppTheme.textMuted, fontSize: 12), - ), - ], - ), - ); } } -// ==================== Exercises Tab ==================== +// ── Records Tab ──────────────────────────────────────────────────────────────── + +enum _RecordsFilter { all, thisMonth, byExercise } +enum _RecordsSort { recent, heaviest } -class _ExercisesTab extends StatefulWidget { - const _ExercisesTab(); +class _RecordsTab extends StatefulWidget { + const _RecordsTab(); @override - State<_ExercisesTab> createState() => _ExercisesTabState(); + State<_RecordsTab> createState() => _RecordsTabState(); } -class _ExercisesTabState extends State<_ExercisesTab> { - String? _selectedExerciseId; +class _RecordsTabState extends State<_RecordsTab> { + _RecordsFilter _filter = _RecordsFilter.all; + _RecordsSort _sort = _RecordsSort.recent; @override Widget build(BuildContext context) { - final provider = context.watch(); - - // Get exercises that have been performed - final performedExercises = {}; - for (var session in provider.sessions) { - for (var log in session.exercises) { - performedExercises.add(log.exerciseId); - } - } + final prManager = context.watch(); + final provider = context.read(); + final allRecords = prManager.allRecords; - if (performedExercises.isEmpty) { + if (allRecords.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.fitness_center, - size: 64, - color: AppTheme.textMuted, - ), - const SizedBox(height: 16), + const Icon(Icons.emoji_events_rounded, + size: 48, color: AppColors.textFaint), + const SizedBox(height: 12), Text( - 'No Exercise Data', - style: Theme.of(context).textTheme.titleLarge, + 'No records yet', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 15, + fontWeight: FontWeight.w600, + ), ), - const SizedBox(height: 8), - const Text( - 'Complete workouts to track exercises', - style: TextStyle(color: AppTheme.textSecondary), + const SizedBox(height: 4), + Text( + 'Finish a workout to set your first PRs', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, fontSize: 12), ), ], ), ); } - return Column( - children: [ - // Exercise selector - Container( - padding: const EdgeInsets.all(AppSpacing.md), - child: DropdownButtonFormField( - initialValue: _selectedExerciseId, - decoration: const InputDecoration( - labelText: 'Select Exercise', - prefixIcon: Icon(Icons.fitness_center), + // Filter + final now = DateTime.now(); + final startOfMonth = DateTime(now.year, now.month, 1); + List filtered = switch (_filter) { + _RecordsFilter.all => [...allRecords], + _RecordsFilter.thisMonth => allRecords + .where((r) => !r.achievedAt.isBefore(startOfMonth)) + .toList(), + _RecordsFilter.byExercise => [...allRecords], + }; + + // Sort + switch (_sort) { + case _RecordsSort.recent: + filtered.sort((a, b) => b.achievedAt.compareTo(a.achievedAt)); + case _RecordsSort.heaviest: + filtered.sort((a, b) => b.bestWeight.compareTo(a.bestWeight)); + } + + // Stats for summary + final thisMonthCount = allRecords + .where((r) => !r.achievedAt.isBefore(startOfMonth)) + .length; + final newest = allRecords.isEmpty + ? null + : ([...allRecords] + ..sort((a, b) => b.achievedAt.compareTo(a.achievedAt))) + .first; + + // Group by exercise if needed + Map>? grouped; + if (_filter == _RecordsFilter.byExercise) { + grouped = {}; + for (final r in filtered) { + grouped.putIfAbsent(r.exerciseId, () => []).add(r); + } + } + + return CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Summary header + Row( + children: [ + Text( + '${allRecords.length} PRs', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + if (thisMonthCount > 0) ...[ + const SizedBox(width: AppSpacing.sm), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.15), + borderRadius: + BorderRadius.circular(AppRadius.full), + border: Border.all( + color: + AppColors.warning.withValues(alpha: 0.4)), + ), + child: Text( + '$thisMonthCount this month', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.warning, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + + // Newest PR hero + if (newest != null) ...[ + const SizedBox(height: 12), + _NewestPRHero( + record: newest, + exerciseName: + provider.getExerciseName(newest.exerciseId), + ), + ], + + // Filter + sort bar + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _FilterChip( + label: 'All', + selected: + _filter == _RecordsFilter.all, + onTap: () => setState( + () => _filter = _RecordsFilter.all), + ), + const SizedBox(width: 6), + _FilterChip( + label: 'This month', + selected: _filter == + _RecordsFilter.thisMonth, + onTap: () => setState(() => + _filter = _RecordsFilter.thisMonth), + ), + const SizedBox(width: 6), + _FilterChip( + label: 'By exercise', + selected: _filter == + _RecordsFilter.byExercise, + onTap: () => setState(() => + _filter = _RecordsFilter.byExercise), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => setState(() => _sort = _sort == + _RecordsSort.recent + ? _RecordsSort.heaviest + : _RecordsSort.recent), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: + BorderRadius.circular(AppRadius.sm), + border: + Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + Icon( + _sort == _RecordsSort.recent + ? Icons.access_time_rounded + : Icons.fitness_center_rounded, + size: 13, + color: AppColors.textMuted, + ), + const SizedBox(width: 4), + Text( + _sort == _RecordsSort.recent + ? 'Recent' + : 'Heaviest', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ], ), - items: performedExercises.map((id) { - final name = provider.getExerciseName(id); - return DropdownMenuItem(value: id, child: Text(name)); - }).toList(), - onChanged: (value) => setState(() => _selectedExerciseId = value), ), ), - - // Exercise stats - if (_selectedExerciseId != null) - Expanded( - child: _ExerciseProgressView( - exerciseId: _selectedExerciseId!, - provider: provider, + + if (grouped != null) + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) { + final entry = grouped!.entries.elementAt(i); + return _ExercisePRGroup( + exerciseName: provider.getExerciseName(entry.key), + records: entry.value, + ); + }, + childCount: grouped.length, + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) => _PRCard( + record: filtered[i], + exerciseName: + provider.getExerciseName(filtered[i].exerciseId), + ), + childCount: filtered.length, + ), ), ), ], @@ -473,175 +410,63 @@ class _ExercisesTabState extends State<_ExercisesTab> { } } -class _ExerciseProgressView extends StatelessWidget { - final String exerciseId; - final WorkoutProvider provider; - - const _ExerciseProgressView({ - required this.exerciseId, - required this.provider, - }); +class _NewestPRHero extends StatelessWidget { + const _NewestPRHero({required this.record, required this.exerciseName}); + final PersonalRecord record; + final String exerciseName; @override Widget build(BuildContext context) { - final progression = provider.getVolumeProgression(exerciseId); - final growthModel = provider.getGrowthModel(exerciseId); - final exercise = provider.getExercise(exerciseId); - final settings = context.watch(); - final bestOneRM = provider.getBestOneRM(exerciseId); - - return SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 1RM card - if (bestOneRM != null) - _buildOneRMCard(context, bestOneRM, settings), - - if (bestOneRM != null) const SizedBox(height: AppSpacing.md), - - // Growth rate card - if (growthModel != null) - _buildGrowthCard(context, growthModel), - - const SizedBox(height: AppSpacing.md), - - // Volume chart - _buildVolumeChart(context, progression), - - const SizedBox(height: AppSpacing.md), - - // Session history - _buildSessionHistory(context, progression), - ], - ), - ); - } + final dateStr = DateFormat('MMM d, yyyy').format(record.achievedAt); + final w = settings.toDisplay(record.bestWeight); - Widget _buildOneRMCard( - BuildContext context, - double bestOneRMkg, - SettingsProvider settings, - ) { - return Container( + return GlassCard( + glowColor: AppColors.warning, padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppTheme.primaryColor.withOpacity(0.2), - AppTheme.primaryColor.withOpacity(0.1), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.md), - ), child: Row( children: [ Container( - padding: const EdgeInsets.all(10), + width: 44, + height: 44, decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: const Icon( - Icons.emoji_events_rounded, - color: AppTheme.primaryColor, - size: 28, + gradient: const LinearGradient( + colors: [AppColors.warning, Color(0xFFFF9500)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.md), ), + child: const Icon(Icons.emoji_events_rounded, + color: Colors.white, size: 22), ), - const SizedBox(width: 12), + const SizedBox(width: AppSpacing.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Estimated 1RM', - style: TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), Text( - settings.formatWeight(bestOneRMkg), - style: const TextStyle( - color: AppTheme.primaryColor, - fontSize: 28, - fontWeight: FontWeight.bold, + 'Latest PR', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.warning, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, ), ), - ], - ), - ), - const Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - 'Epley formula', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 10, - ), - ), - Text( - 'Best across all sets', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 10, - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildGrowthCard(BuildContext context, GrowthModel model) { - final isGrowing = model.slope > 0; - final slopeFormatted = model.slope.abs().toStringAsFixed(1); - - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: isGrowing - ? [AppTheme.success.withOpacity(0.2), AppTheme.success.withOpacity(0.1)] - : [AppTheme.warning.withOpacity(0.2), AppTheme.warning.withOpacity(0.1)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Row( - children: [ - Icon( - isGrowing ? Icons.trending_up : Icons.trending_down, - color: isGrowing ? AppTheme.success : AppTheme.warning, - size: 40, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ Text( - isGrowing ? 'Growing!' : 'Plateau', - style: TextStyle( - color: isGrowing ? AppTheme.success : AppTheme.warning, - fontWeight: FontWeight.bold, - fontSize: 18, + exerciseName, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w700, ), ), Text( - isGrowing - ? '+$slopeFormatted kg volume per session' - : 'Volume trend is flat or declining', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, + dateStr, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 11, ), ), ], @@ -651,17 +476,19 @@ class _ExerciseProgressView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - 'R² = ${(model.r2 * 100).toStringAsFixed(0)}%', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, + '${w % 1 == 0 ? w.toStringAsFixed(0) : w.toStringAsFixed(1)} ${settings.unitLabel}', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.warning, + fontSize: 18, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], ), ), Text( - 'Model Fit', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 10, + '${record.bestReps} reps', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textMuted, + fontSize: 11, ), ), ], @@ -670,443 +497,203 @@ class _ExerciseProgressView extends StatelessWidget { ), ); } - - Widget _buildVolumeChart( - BuildContext context, - List<({DateTime date, double volume})> data, - ) { - if (data.isEmpty) { - return Container( - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: const Center( - child: Text('No data', style: TextStyle(color: AppTheme.textMuted)), - ), - ); - } - - final spots = data.asMap().entries.map((entry) { - return FlSpot(entry.key.toDouble(), entry.value.volume / 100); - }).toList(); - - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Volume Progression', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.lg), - SizedBox( - height: 180, - child: LineChart( - LineChartData( - gridData: FlGridData( - show: true, - drawVerticalLine: false, - getDrawingHorizontalLine: (value) => FlLine( - color: AppTheme.surfaceColor, - strokeWidth: 1, - ), - ), - titlesData: FlTitlesData( - show: true, - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - bottomTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, - getTitlesWidget: (value, meta) => Text( - (value * 100).toStringAsFixed(0), - style: const TextStyle( - color: AppTheme.textMuted, - fontSize: 10, - ), - ), - ), - ), - ), - borderData: FlBorderData(show: false), - lineBarsData: [ - LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.3, - color: AppTheme.secondaryColor, - barWidth: 3, - dotData: const FlDotData(show: true), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - colors: [ - AppTheme.secondaryColor.withOpacity(0.3), - AppTheme.secondaryColor.withOpacity(0.0), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), - ), - ], - ), - ), - ), - ], - ), - ); - } - - Widget _buildSessionHistory( - BuildContext context, - List<({DateTime date, double volume})> data, - ) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Session History', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.md), - Builder( - builder: (context) { - final settings = context.watch(); - return Column( - children: data.take(10).map((entry) { - final displayVolume = settings.toDisplay(entry.volume); - return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - DateFormat('MMM d, yyyy').format(entry.date), - style: const TextStyle( - color: AppTheme.textSecondary, - ), - ), - Text( - '${displayVolume.toStringAsFixed(0)} ${settings.unitLabel}', - style: const TextStyle( - color: AppTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ); - }).toList(), - ); - }, - ), - ], - ), - ); - } } -// ==================== Targets Tab ==================== - -class _TargetsTab extends StatelessWidget { - const _TargetsTab(); +class _ExercisePRGroup extends StatelessWidget { + const _ExercisePRGroup( + {required this.exerciseName, required this.records}); + final String exerciseName; + final List records; @override Widget build(BuildContext context) { - final provider = context.watch(); - final targets = provider.targets; - final activeTargets = targets.where((t) => !t.isCompleted).toList(); - final completedTargets = targets.where((t) => t.isCompleted).toList(); - - return Scaffold( - body: targets.isEmpty - ? _buildEmptyState(context) - : ListView( - padding: const EdgeInsets.all(AppSpacing.md), - children: [ - if (activeTargets.isNotEmpty) ...[ - Text( - 'Active Targets', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.sm), - ...activeTargets.map((t) => _TargetCard(target: t, provider: provider)), - ], - if (completedTargets.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.lg), - Text( - 'Completed', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.sm), - ...completedTargets.map((t) => _TargetCard(target: t, provider: provider)), - ], - ], + final best = records.reduce( + (a, b) => a.bestWeight >= b.bestWeight ? a : b); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 6, top: 4), + child: Text( + exerciseName, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _showCreateTargetDialog(context), - icon: const Icon(Icons.add), - label: const Text('New Target'), - ), - ); - } - - Widget _buildEmptyState(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.flag, - size: 64, - color: AppTheme.textMuted, - ), - const SizedBox(height: 16), - Text( - 'No Targets Set', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - const Text( - 'Set a target to track your progress', - style: TextStyle(color: AppTheme.textSecondary), ), - ], - ), - ); - } - - void _showCreateTargetDialog(BuildContext context) { - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardColor, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => const _CreateTargetSheet(), + ), + _PRCard(record: best, exerciseName: exerciseName), + const SizedBox(height: 4), + ], ); } } -class _TargetCard extends StatelessWidget { - final Target target; - final WorkoutProvider provider; - - const _TargetCard({ - required this.target, - required this.provider, +class _FilterChip extends StatelessWidget { + const _FilterChip({ + required this.label, + required this.selected, + required this.onTap, }); + final String label; + final bool selected; + final VoidCallback onTap; @override Widget build(BuildContext context) { - final exerciseName = provider.getExerciseName(target.exerciseId); - final progress = target.progressPercentage; - final isCompleted = target.isCompleted; - - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - isCompleted ? Icons.check_circle : Icons.flag, - color: isCompleted ? AppTheme.success : AppTheme.warning, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - exerciseName, - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - ), - IconButton( - icon: const Icon(Icons.close, size: 18), - onPressed: () => provider.deleteTarget(target.id), - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - Text( - '${target.targetType}: ${target.currentValue.toStringAsFixed(0)} / ${target.targetValue.toStringAsFixed(0)}', - style: const TextStyle(color: AppTheme.textSecondary), - ), - const SizedBox(height: AppSpacing.sm), - LinearProgressIndicator( - value: progress / 100, - backgroundColor: AppTheme.surfaceColor, - valueColor: AlwaysStoppedAnimation( - isCompleted ? AppTheme.success : AppTheme.primaryColor, - ), - borderRadius: BorderRadius.circular(4), - minHeight: 8, - ), - const SizedBox(height: AppSpacing.sm), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${progress.toStringAsFixed(0)}%', - style: TextStyle( - color: isCompleted ? AppTheme.success : AppTheme.primaryColor, - fontWeight: FontWeight.w600, - ), - ), - if (target.estimatedCompletionDate != null && !isCompleted) - Text( - 'Est: ${DateFormat('MMM d').format(target.estimatedCompletionDate!)}', - style: const TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - ), - ], - ), - ], + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: selected + ? AppColors.primary.withValues(alpha: 0.18) + : AppColors.glass2, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: selected + ? AppColors.primary.withValues(alpha: 0.5) + : AppColors.glassBorder, + ), + ), + child: Text( + label, + style: TextStyle(fontFamily: 'GeistMono', + color: selected ? AppColors.primary : AppColors.textMuted, + fontSize: 11, + fontWeight: + selected ? FontWeight.w700 : FontWeight.w500, + ), ), ), ); } } -class _CreateTargetSheet extends StatefulWidget { - const _CreateTargetSheet(); - - @override - State<_CreateTargetSheet> createState() => _CreateTargetSheetState(); -} - -class _CreateTargetSheetState extends State<_CreateTargetSheet> { - String? _selectedExerciseId; - String _targetType = 'reps'; - final _valueController = TextEditingController(); +class _PRCard extends StatelessWidget { + const _PRCard({required this.record, required this.exerciseName}); - @override - void dispose() { - _valueController.dispose(); - super.dispose(); - } + final PersonalRecord record; + final String exerciseName; @override Widget build(BuildContext context) { - final exercises = ExerciseDatabase.getAll(); + final settings = context.watch(); + final dateStr = DateFormat('MMM d, yyyy').format(record.achievedAt); + final displayWeight = settings.toDisplay(record.bestWeight); + final displayVol = settings.toDisplay(record.bestVolume); + final unit = settings.unitLabel; - return Padding( - padding: EdgeInsets.only( - left: AppSpacing.lg, - right: AppSpacing.lg, - top: AppSpacing.lg, - bottom: MediaQuery.of(context).viewInsets.bottom + AppSpacing.lg, - ), + return GlassCard( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(AppSpacing.md), child: Column( - mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Create Target', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: AppSpacing.lg), - - DropdownButtonFormField( - initialValue: _selectedExerciseId, - decoration: const InputDecoration( - labelText: 'Exercise', - ), - items: exercises.map((e) => DropdownMenuItem( - value: e.id, - child: Text(e.name), - )).toList(), - onChanged: (val) => setState(() => _selectedExerciseId = val), - ), - - const SizedBox(height: AppSpacing.md), - - DropdownButtonFormField( - initialValue: _targetType, - decoration: const InputDecoration( - labelText: 'Target Type', - ), - items: const [ - DropdownMenuItem(value: 'reps', child: Text('Max Reps')), - DropdownMenuItem(value: 'weight', child: Text('Max Weight (kg)')), - DropdownMenuItem(value: 'volume', child: Text('Total Volume (kg)')), + Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.emoji_events_rounded, + color: AppColors.warning, size: 18), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + exerciseName, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + Text( + dateStr, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, fontSize: 11), + ), + ], + ), + ), ], - onChanged: (val) => setState(() => _targetType = val!), ), - - const SizedBox(height: AppSpacing.md), - - TextField( - controller: _valueController, - decoration: const InputDecoration( - labelText: 'Target Value', - ), - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + _PRStat( + label: 'Best Weight', + value: + '${displayWeight.toStringAsFixed(displayWeight % 1 == 0 ? 0 : 1)} $unit', + color: AppColors.warning, + ), + const SizedBox(width: AppSpacing.sm), + _PRStat( + label: 'Best Reps', + value: '${record.bestReps}', + color: AppColors.secondary, + ), + const SizedBox(width: AppSpacing.sm), + _PRStat( + label: 'Best Vol.', + value: '${displayVol.toStringAsFixed(0)} $unit', + color: AppColors.success, + ), ], ), - - const SizedBox(height: AppSpacing.lg), - - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _createTarget, - child: const Text('Create Target'), - ), - ), ], ), ); } +} - void _createTarget() async { - if (_selectedExerciseId == null || _valueController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please fill all fields')), - ); - return; - } +class _PRStat extends StatelessWidget { + const _PRStat( + {required this.label, + required this.value, + required this.color}); - final value = double.tryParse(_valueController.text); - if (value == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Invalid target value')), - ); - return; - } + final String label; + final String value; + final Color color; - await context.read().createTarget( - exerciseId: _selectedExerciseId!, - type: _targetType, - targetValue: value, + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: + const EdgeInsets.symmetric(vertical: 8, horizontal: 10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: color.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, fontSize: 10)), + const SizedBox(height: 2), + Text(value, + style: TextStyle(fontFamily: 'GeistMono', + color: color, + fontSize: 13, + fontWeight: FontWeight.w700)), + ], + ), + ), ); - - if (mounted) Navigator.pop(context); } } diff --git a/workout-logger/lib/screens/edit_workout_session_screen.dart b/workout-logger/lib/screens/edit_workout_session_screen.dart index feea401..94da3db 100644 --- a/workout-logger/lib/screens/edit_workout_session_screen.dart +++ b/workout-logger/lib/screens/edit_workout_session_screen.dart @@ -1,4 +1,4 @@ -// Edit Workout Session Screen - Modify recorded workout sessions +// edit_workout_session_screen.dart — Edit a recorded workout session import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -8,11 +8,12 @@ import 'package:intl/intl.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; import '../theme/app_theme.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/editable_exercise_card.dart'; class EditWorkoutSessionScreen extends StatefulWidget { - final WorkoutSession session; - const EditWorkoutSessionScreen({super.key, required this.session}); + final WorkoutSession session; @override State createState() => @@ -22,9 +23,9 @@ class EditWorkoutSessionScreen extends StatefulWidget { class _EditWorkoutSessionScreenState extends State { late DateTime _selectedDate; late TimeOfDay _selectedTime; - late TextEditingController _notesController; - late TextEditingController _durationController; - late List<_EditableExerciseLog> _editableExercises; + late TextEditingController _notesCtrl; + late TextEditingController _durationCtrl; + late List _exercises; bool _isSubmitting = false; bool _hasChanges = false; @@ -33,24 +34,22 @@ class _EditWorkoutSessionScreenState extends State { super.initState(); _selectedDate = widget.session.date; _selectedTime = TimeOfDay.fromDateTime(widget.session.date); - _notesController = TextEditingController(text: widget.session.notes ?? ''); - _durationController = TextEditingController( + _notesCtrl = TextEditingController(text: widget.session.notes ?? ''); + _durationCtrl = TextEditingController( text: widget.session.duration.toString(), ); - - // Convert to editable structure, preserving all set metadata - _editableExercises = widget.session.exercises.map((log) { - return _EditableExerciseLog( + _exercises = widget.session.exercises.map((log) { + return EditableExerciseLog( exerciseId: log.exerciseId, sets: log.sets .map( - (set) => _EditableSet( - weight: set.weight, - reps: set.reps, - isDropset: set.isDropset, - drops: set.drops, - timeTaken: set.timeTaken, - timestamp: set.timestamp, + (s) => EditableSet( + weight: s.weight, + reps: s.reps, + isDropset: s.isDropset, + drops: s.drops?.toList(), + timeTaken: s.timeTaken, + timestamp: s.timestamp, ), ) .toList(), @@ -61,15 +60,13 @@ class _EditWorkoutSessionScreenState extends State { @override void dispose() { - _notesController.dispose(); - _durationController.dispose(); + _notesCtrl.dispose(); + _durationCtrl.dispose(); super.dispose(); } void _markChanged() { - if (!_hasChanges) { - setState(() => _hasChanges = true); - } + if (!_hasChanges) setState(() => _hasChanges = true); } Future _selectDate() async { @@ -78,28 +75,24 @@ class _EditWorkoutSessionScreenState extends State { initialDate: _selectedDate, firstDate: DateTime(2020), lastDate: DateTime.now().add(const Duration(days: 1)), - builder: (context, child) { - return Theme( - data: Theme.of(context).copyWith( - colorScheme: const ColorScheme.dark( - primary: AppTheme.primaryColor, - surface: AppTheme.cardColor, - ), + builder: (ctx, child) => Theme( + data: Theme.of(ctx).copyWith( + colorScheme: const ColorScheme.dark( + primary: AppColors.primary, + surface: AppColors.cardHigh, ), - child: child!, - ); - }, + ), + child: child!, + ), ); if (picked != null) { - setState(() { - _selectedDate = DateTime( - picked.year, - picked.month, - picked.day, - _selectedTime.hour, - _selectedTime.minute, - ); - }); + setState(() => _selectedDate = DateTime( + picked.year, + picked.month, + picked.day, + _selectedTime.hour, + _selectedTime.minute, + )); _markChanged(); } } @@ -108,17 +101,15 @@ class _EditWorkoutSessionScreenState extends State { final picked = await showTimePicker( context: context, initialTime: _selectedTime, - builder: (context, child) { - return Theme( - data: Theme.of(context).copyWith( - colorScheme: const ColorScheme.dark( - primary: AppTheme.primaryColor, - surface: AppTheme.cardColor, - ), + builder: (ctx, child) => Theme( + data: Theme.of(ctx).copyWith( + colorScheme: const ColorScheme.dark( + primary: AppColors.primary, + surface: AppColors.cardHigh, ), - child: child!, - ); - }, + ), + child: child!, + ), ); if (picked != null) { setState(() { @@ -137,70 +128,43 @@ class _EditWorkoutSessionScreenState extends State { void _addSet(int exerciseIndex) { setState(() { - // Copy last set values or use defaults - final lastSet = _editableExercises[exerciseIndex].sets.isNotEmpty - ? _editableExercises[exerciseIndex].sets.last + final last = _exercises[exerciseIndex].sets.isNotEmpty + ? _exercises[exerciseIndex].sets.last : null; - _editableExercises[exerciseIndex].sets.add( - _EditableSet( - weight: lastSet?.weight ?? 0, - reps: lastSet?.reps ?? 0, - isDropset: false, - drops: null, - timeTaken: null, - timestamp: DateTime.now(), - ), - ); + _exercises[exerciseIndex].sets.add(EditableSet( + weight: last?.weight ?? 0, + reps: last?.reps ?? 0, + timestamp: DateTime.now(), + )); }); _markChanged(); } - void _deleteSet(int exerciseIndex, int setIndex) { - setState(() { - _editableExercises[exerciseIndex].sets.removeAt(setIndex); - }); + void _deleteSet(int exIdx, int setIdx) { + setState(() => _exercises[exIdx].sets.removeAt(setIdx)); _markChanged(); } - void _deleteExercise(int exerciseIndex) { - setState(() { - _editableExercises.removeAt(exerciseIndex); - }); + void _deleteExercise(int exIdx) { + setState(() => _exercises.removeAt(exIdx)); _markChanged(); } - Future _saveChanges() async { - // Validate duration - final duration = int.tryParse(_durationController.text); + Future _save() async { + final duration = int.tryParse(_durationCtrl.text); if (duration == null || duration < 0) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Please enter a valid duration'), - backgroundColor: AppTheme.error, - ), - ); + _snack('Please enter a valid duration', isError: true); return; } - - // Validate that there's at least one exercise with sets - final exercisesWithSets = _editableExercises - .where((e) => e.sets.isNotEmpty) - .toList(); - if (exercisesWithSets.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Workout must have at least one exercise with sets'), - backgroundColor: AppTheme.error, - ), - ); + final withSets = _exercises.where((e) => e.sets.isNotEmpty).toList(); + if (withSets.isEmpty) { + _snack('Workout must have at least one exercise with sets', isError: true); return; } setState(() => _isSubmitting = true); - try { - // Convert editable exercises back to ExerciseLog, preserving metadata - final updatedExercises = exercisesWithSets.map((e) { + final updatedExercises = withSets.map((e) { return ExerciseLog( exerciseId: e.exerciseId, sets: e.sets @@ -219,69 +183,55 @@ class _EditWorkoutSessionScreenState extends State { ); }).toList(); - // Create updated session - final updatedSession = widget.session.copyWith( + final updated = widget.session.copyWith( date: _selectedDate, duration: duration, - notes: _notesController.text.isEmpty ? null : _notesController.text, + notes: _notesCtrl.text.isEmpty ? null : _notesCtrl.text, exercises: updatedExercises, ); - // Save via provider - final provider = context.read(); - await provider.updateWorkoutSession(updatedSession); + await context.read().updateWorkoutSession(updated); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: const [ - Icon(Icons.check_circle, color: AppTheme.success), - SizedBox(width: 8), - Text('Workout updated successfully'), - ], - ), - backgroundColor: AppTheme.cardColor, - ), - ); - Navigator.of(context).pop(true); // Return success + _snack('Workout updated'); + Navigator.of(context).pop(true); } } catch (e) { - debugPrint('Failed to save workout session: $e'); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to save workout. Please try again.'), - backgroundColor: AppTheme.error, - ), - ); - } + debugPrint('Save failed: $e'); + if (mounted) _snack('Failed to save. Please try again.', isError: true); } finally { - if (mounted) { - setState(() => _isSubmitting = false); - } + if (mounted) setState(() => _isSubmitting = false); } } Future _onWillPop() async { if (!_hasChanges) return true; - final result = await showDialog( context: context, - builder: (context) => AlertDialog( - backgroundColor: AppTheme.cardColor, - title: const Text('Discard Changes?'), + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + title: const Text( + 'Discard Changes?', + style: TextStyle(color: AppColors.textPrimary), + ), content: const Text( - 'You have unsaved changes. Are you sure you want to discard them?', + 'You have unsaved changes. Discard them?', + style: TextStyle(color: AppColors.textSoft), ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text( + 'Cancel', + style: TextStyle(color: AppColors.textSoft), + ), ), TextButton( - onPressed: () => Navigator.of(context).pop(true), - style: TextButton.styleFrom(foregroundColor: AppTheme.error), + onPressed: () => Navigator.of(ctx).pop(true), + style: TextButton.styleFrom(foregroundColor: AppColors.error), child: const Text('Discard'), ), ], @@ -290,418 +240,259 @@ class _EditWorkoutSessionScreenState extends State { return result ?? false; } + void _snack(String msg, {bool isError = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg, style: const TextStyle(color: AppColors.textPrimary)), + backgroundColor: isError ? AppColors.error : AppColors.cardHigh, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ); + } + @override Widget build(BuildContext context) { final provider = context.read(); - final dateFormat = DateFormat('EEEE, MMMM d, yyyy'); - final timeFormat = DateFormat('h:mm a'); return PopScope( canPop: !_hasChanges, - onPopInvokedWithResult: (didPop, result) async { + onPopInvokedWithResult: (didPop, _) async { if (didPop) return; - final shouldPop = await _onWillPop(); - if (shouldPop && context.mounted) { + if (await _onWillPop() && context.mounted) { Navigator.of(context).pop(); } }, child: Scaffold( + backgroundColor: AppColors.background, appBar: AppBar( - title: const Text('Edit Workout'), + backgroundColor: AppColors.surface, + title: const Text( + 'Edit Workout', + style: TextStyle(color: AppColors.textPrimary), + ), + iconTheme: const IconThemeData(color: AppColors.textSoft), actions: [ TextButton( - onPressed: _isSubmitting ? null : _saveChanges, + onPressed: _isSubmitting ? null : _save, child: _isSubmitting ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), ) - : const Text('Save'), + : const Text( + 'Save', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w700, + ), + ), ), ], ), body: ListView( + physics: const BouncingScrollPhysics(), padding: const EdgeInsets.all(AppSpacing.md), children: [ - // Date & Time Section - Card( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - Icons.calendar_today, - size: 20, - color: AppTheme.primaryColor, - ), - const SizedBox(width: 8), - Text( - 'Date & Time', - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - const SizedBox(height: AppSpacing.md), - Row( - children: [ - Expanded( - child: InkWell( - onTap: _selectDate, - borderRadius: BorderRadius.circular(AppRadius.md), - child: Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.surfaceColor, - borderRadius: BorderRadius.circular( - AppRadius.md, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Date', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - ), - const SizedBox(height: 4), - Text( - dateFormat.format(_selectedDate), - style: const TextStyle( - color: AppTheme.textPrimary, - ), - ), - ], - ), - ), - ), - ), - const SizedBox(width: AppSpacing.sm), - InkWell( - onTap: _selectTime, - borderRadius: BorderRadius.circular(AppRadius.md), - child: Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Time', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - ), - const SizedBox(height: 4), - Text( - timeFormat.format(_selectedDate), - style: const TextStyle( - color: AppTheme.textPrimary, - ), - ), - ], - ), - ), + // Date & Time + _SectionCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel(icon: Icons.calendar_today_rounded, + color: AppColors.primary, label: 'Date & Time'), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: _TapField( + label: 'Date', + value: DateFormat('EEE, MMM d, yyyy') + .format(_selectedDate), + onTap: _selectDate, ), - ], - ), - ], - ), + ), + const SizedBox(width: AppSpacing.sm), + _TapField( + label: 'Time', + value: DateFormat('h:mm a').format(_selectedDate), + onTap: _selectTime, + ), + ], + ), + ], ), ), const SizedBox(height: AppSpacing.md), - // Duration & Notes Section - Card( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - Icons.timer_outlined, - size: 20, - color: AppTheme.secondaryColor, - ), - const SizedBox(width: 8), - Text( - 'Duration (minutes)', - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - TextField( - controller: _durationController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: const InputDecoration( - hintText: 'Duration in minutes', - ), - onChanged: (_) => _markChanged(), - ), - const SizedBox(height: AppSpacing.md), - Row( - children: [ - const Icon( - Icons.notes, - size: 20, - color: AppTheme.warning, - ), - const SizedBox(width: 8), - Text( - 'Notes', - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - TextField( - controller: _notesController, - maxLines: 3, - decoration: const InputDecoration( - hintText: 'Optional workout notes...', - ), - onChanged: (_) => _markChanged(), - ), - ], - ), + // Duration & Notes + _SectionCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel(icon: Icons.timer_outlined, + color: AppColors.secondary, label: 'Duration (minutes)'), + const SizedBox(height: AppSpacing.sm), + _StyledField( + controller: _durationCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + hint: 'Minutes', + onChanged: (_) => _markChanged(), + ), + const SizedBox(height: AppSpacing.md), + _sectionLabel(icon: Icons.notes_rounded, + color: AppColors.warning, label: 'Notes'), + const SizedBox(height: AppSpacing.sm), + _StyledField( + controller: _notesCtrl, + hint: 'Optional workout notes…', + maxLines: 3, + onChanged: (_) => _markChanged(), + ), + ], ), ), const SizedBox(height: AppSpacing.lg), - // Exercises Header Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + const RFSectionHeader('Exercises'), + const Spacer(), Text( - 'Exercises', - style: Theme.of(context).textTheme.titleLarge, - ), - Text( - '${_editableExercises.length} exercises', - style: const TextStyle(color: AppTheme.textMuted), + '${_exercises.length} exercises', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + ), ), ], ), const SizedBox(height: AppSpacing.md), - // Exercise Cards - ..._editableExercises.asMap().entries.map((entry) { - final exerciseIndex = entry.key; - final editableLog = entry.value; - final exercise = provider.getExercise(editableLog.exerciseId); - - return _EditableExerciseCard( - key: ValueKey('exercise_$exerciseIndex'), - exerciseName: exercise?.name ?? 'Unknown Exercise', - editableLog: editableLog, - onSetChanged: (setIndex, weight, reps, isDropset, drops) { + ..._exercises.asMap().entries.map((entry) { + final i = entry.key; + final log = entry.value; + final ex = provider.getExercise(log.exerciseId); + return EditableExerciseCard( + key: ValueKey('exercise_$i'), + exerciseName: ex?.name ?? 'Unknown Exercise', + editableLog: log, + onSetChanged: ({required int setIndex, required double weight, required int reps, required bool isDropset, List? drops}) { setState(() { - editableLog.sets[setIndex].weight = weight; - editableLog.sets[setIndex].reps = reps; - if (editableLog.sets[setIndex].isDropset != isDropset) { - editableLog.sets[setIndex].isDropset = isDropset; - if (isDropset && - editableLog.sets[setIndex].drops == null) { - editableLog.sets[setIndex].drops = []; - } - } - if (drops != null) { - editableLog.sets[setIndex].drops = drops; - } + log.sets[setIndex].weight = weight; + log.sets[setIndex].reps = reps; + log.sets[setIndex].isDropset = isDropset; + if (drops != null) log.sets[setIndex].drops = drops; }); _markChanged(); }, - onAddSet: () => _addSet(exerciseIndex), - onDeleteSet: (setIndex) => _deleteSet(exerciseIndex, setIndex), - onDeleteExercise: () => _deleteExercise(exerciseIndex), + onAddSet: () => _addSet(i), + onDeleteSet: (si) => _deleteSet(i, si), + onDeleteExercise: () => _deleteExercise(i), ); }), - if (_editableExercises.isEmpty) - Container( - padding: const EdgeInsets.all(AppSpacing.xl), - child: Center( - child: Column( - children: [ - const Icon( - Icons.fitness_center, - size: 48, - color: AppTheme.textMuted, - ), - const SizedBox(height: AppSpacing.md), - const Text( - 'No exercises in this workout', - style: TextStyle(color: AppTheme.textMuted), - ), - ], - ), - ), + if (_exercises.isEmpty) + RFEmptyState( + icon: Icons.fitness_center_rounded, + title: 'No exercises', + subtitle: 'All exercises have been removed', ), - const SizedBox(height: 80), // Space for bottom + const SizedBox(height: 80), ], ), ), ); } -} - -// Helper class for editable exercise data -class _EditableExerciseLog { - final String exerciseId; - final List<_EditableSet> sets; - final String? notes; - _EditableExerciseLog({ - required this.exerciseId, - required this.sets, - this.notes, - }); + Widget _sectionLabel({required IconData icon, required Color color, required String label}) { + return Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } } -class _EditableSet { - double weight; - int reps; - bool isDropset; - List? drops; - int? timeTaken; - DateTime timestamp; +// ── Section card ────────────────────────────────────────────────────────────── +class _SectionCard extends StatelessWidget { + const _SectionCard({required this.child}); + final Widget child; - _EditableSet({ - required this.weight, - required this.reps, - required this.timestamp, - this.isDropset = false, - this.drops, - this.timeTaken, - }); + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: child, + ); + } } -// Editable Exercise Card Widget -class _EditableExerciseCard extends StatelessWidget { - final String exerciseName; - final _EditableExerciseLog editableLog; - final Function( - int setIndex, - double weight, - int reps, - bool isDropset, - List? drops, - ) - onSetChanged; - final VoidCallback onAddSet; - final Function(int setIndex) onDeleteSet; - final VoidCallback onDeleteExercise; - - const _EditableExerciseCard({ - super.key, - required this.exerciseName, - required this.editableLog, - required this.onSetChanged, - required this.onAddSet, - required this.onDeleteSet, - required this.onDeleteExercise, +// ── Tappable date/time display field ────────────────────────────────────────── +class _TapField extends StatelessWidget { + const _TapField({ + required this.label, + required this.value, + required this.onTap, }); + final String label; + final String value; + final VoidCallback onTap; @override Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.md), - child: Padding( + return GestureDetector( + onTap: onTap, + child: Container( padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header - Row( - children: [ - Expanded( - child: Text( - exerciseName, - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - fontSize: 16, - ), - ), - ), - IconButton( - onPressed: () => _confirmDeleteExercise(context), - icon: const Icon(Icons.delete_outline, size: 20), - color: AppTheme.error, - tooltip: 'Remove exercise', - ), - ], + Text( + label, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), ), - - const Divider(), - - // Sets - ...editableLog.sets.asMap().entries.map((entry) { - final setIndex = entry.key; - final set = entry.value; - - return _EditableSetRow( - setNumber: setIndex + 1, - weight: set.weight, - reps: set.reps, - isDropset: set.isDropset, - drops: set.drops, - onWeightChanged: (weight) => onSetChanged( - setIndex, - weight, - set.reps, - set.isDropset, - set.drops, - ), - onRepsChanged: (reps) => onSetChanged( - setIndex, - set.weight, - reps, - set.isDropset, - set.drops, - ), - onDropsChanged: (drops) => onSetChanged( - setIndex, - set.weight, - set.reps, - set.isDropset, - drops, - ), - onIsDropsetChanged: (val) => onSetChanged( - setIndex, - set.weight, - set.reps, - val, - set.drops, - ), - onDelete: () => onDeleteSet(setIndex), - ); - }), - - // Add Set Button - Center( - child: TextButton.icon( - onPressed: onAddSet, - icon: const Icon(Icons.add, size: 18), - label: const Text('Add Set'), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, ), ), ], @@ -709,484 +500,50 @@ class _EditableExerciseCard extends StatelessWidget { ), ); } - - void _confirmDeleteExercise(BuildContext context) { - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: AppTheme.cardColor, - title: const Text('Remove Exercise?'), - content: Text('Remove "$exerciseName" from this workout?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - onDeleteExercise(); - }, - style: TextButton.styleFrom(foregroundColor: AppTheme.error), - child: const Text('Remove'), - ), - ], - ), - ); - } } -// Editable Set Row Widget -class _EditableSetRow extends StatefulWidget { - final int setNumber; - final double weight; - final int reps; - final bool isDropset; - final List? drops; - final Function(double) onWeightChanged; - final Function(int) onRepsChanged; - final Function(List) onDropsChanged; - final Function(bool) onIsDropsetChanged; - final VoidCallback onDelete; - - const _EditableSetRow({ - required this.setNumber, - required this.weight, - required this.reps, - this.isDropset = false, - this.drops, - required this.onWeightChanged, - required this.onRepsChanged, - required this.onDropsChanged, - required this.onIsDropsetChanged, - required this.onDelete, +// ── Styled text field ───────────────────────────────────────────────────────── +class _StyledField extends StatelessWidget { + const _StyledField({ + required this.controller, + required this.hint, + this.keyboardType, + this.inputFormatters, + this.maxLines = 1, + this.onChanged, }); - @override - State<_EditableSetRow> createState() => _EditableSetRowState(); -} - -class _EditableSetRowState extends State<_EditableSetRow> { - late TextEditingController _weightController; - late TextEditingController _repsController; - final FocusNode _weightFocus = FocusNode(); - final FocusNode _repsFocus = FocusNode(); - - @override - void initState() { - super.initState(); - _weightController = TextEditingController(text: widget.weight.toString()); - _repsController = TextEditingController(text: widget.reps.toString()); - } - - @override - void didUpdateWidget(covariant _EditableSetRow oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.weight != oldWidget.weight && !_weightFocus.hasFocus) { - if (double.tryParse(_weightController.text) != widget.weight) { - _weightController.text = widget.weight.toString(); - } - } - if (widget.reps != oldWidget.reps && !_repsFocus.hasFocus) { - if (int.tryParse(_repsController.text) != widget.reps) { - _repsController.text = widget.reps.toString(); - } - } - } - - @override - void dispose() { - _weightController.dispose(); - _repsController.dispose(); - _weightFocus.dispose(); - _repsFocus.dispose(); - super.dispose(); - } + final TextEditingController controller; + final String hint; + final TextInputType? keyboardType; + final List? inputFormatters; + final int maxLines; + final ValueChanged? onChanged; @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), - child: Column( - children: [ - Row( - children: [ - // Set number - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(14), - ), - child: Center( - child: Text( - '${widget.setNumber}', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: AppTheme.primaryColor, - ), - ), - ), - ), - const SizedBox(width: AppSpacing.sm), - - // Weight input - SizedBox( - width: 80, - child: TextField( - controller: _weightController, - focusNode: _weightFocus, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 14), - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, - ), - suffixText: 'kg', - suffixStyle: const TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - filled: true, - fillColor: AppTheme.surfaceColor, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, - ), - ), - onChanged: (value) { - final weight = double.tryParse(value) ?? 0; - widget.onWeightChanged(weight); - }, - ), - ), - const SizedBox(width: AppSpacing.sm), - - // × symbol - const Text('×', style: TextStyle(color: AppTheme.textMuted)), - const SizedBox(width: AppSpacing.sm), - - // Reps input - SizedBox( - width: 70, - child: TextField( - controller: _repsController, - focusNode: _repsFocus, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 14), - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, - ), - suffixText: 'reps', - suffixStyle: const TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - filled: true, - fillColor: AppTheme.surfaceColor, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, - ), - ), - onChanged: (value) { - final reps = int.tryParse(value) ?? 0; - widget.onRepsChanged(reps); - }, - ), - ), - - const Spacer(), - - // Toggle Dropset button - IconButton( - onPressed: () => widget.onIsDropsetChanged(!widget.isDropset), - icon: Icon( - widget.isDropset ? Icons.layers : Icons.layers_outlined, - size: 18, - ), - color: widget.isDropset - ? AppTheme.primaryColor - : AppTheme.textMuted, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - tooltip: widget.isDropset - ? 'Remove drops' - : 'Convert to dropset', - ), - - // Delete button - IconButton( - onPressed: widget.onDelete, - icon: const Icon(Icons.close, size: 18), - color: AppTheme.textMuted, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - tooltip: 'Delete set', - ), - ], - ), - - // Dropset Rows - if (widget.isDropset) ...[ - if (widget.drops != null) ...[ - const SizedBox(height: 4), - ...widget.drops!.asMap().entries.map((entry) { - final index = entry.key; - final drop = entry.value; - return _EditableDropRow( - key: ValueKey('drop_${widget.setNumber}_$index'), - dropNumber: index + 1, - weight: drop.weight, - reps: drop.reps, - onWeightChanged: (val) { - final newDrops = List.from(widget.drops!); - newDrops[index] = DropsetEntry( - weight: val, - reps: drop.reps, - ); - widget.onDropsChanged(newDrops); - }, - onRepsChanged: (val) { - final newDrops = List.from(widget.drops!); - newDrops[index] = DropsetEntry( - weight: drop.weight, - reps: val, - ); - widget.onDropsChanged(newDrops); - }, - onDelete: () { - final newDrops = List.from(widget.drops!) - ..removeAt(index); - widget.onDropsChanged(newDrops); - }, - ); - }), - ], - - // Add drop button - Padding( - padding: const EdgeInsets.only(left: 32, top: 4, bottom: 4), - child: InkWell( - onTap: () { - final newDrops = List.from(widget.drops ?? []); - // Default to 80% of last weight or current weight - double initialWeight = widget.weight * 0.8; - if (newDrops.isNotEmpty) { - initialWeight = newDrops.last.weight * 0.8; - } - // Round to nearest 0.5 - initialWeight = (initialWeight * 2).round() / 2; - - newDrops.add( - DropsetEntry(weight: initialWeight, reps: widget.reps), - ); - widget.onDropsChanged(newDrops); - }, - child: Row( - children: [ - Icon( - Icons.add_circle_outline, - size: 14, - color: AppTheme.primaryColor.withOpacity(0.7), - ), - const SizedBox(width: 4), - Text( - 'Add Drop', - style: TextStyle( - fontSize: 12, - color: AppTheme.primaryColor.withOpacity(0.7), - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ], - ], + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), ), - ); - } -} - -class _EditableDropRow extends StatefulWidget { - final int dropNumber; - final double weight; - final int reps; - final Function(double) onWeightChanged; - final Function(int) onRepsChanged; - final VoidCallback onDelete; - - const _EditableDropRow({ - super.key, - required this.dropNumber, - required this.weight, - required this.reps, - required this.onWeightChanged, - required this.onRepsChanged, - required this.onDelete, - }); - - @override - State<_EditableDropRow> createState() => _EditableDropRowState(); -} - -class _EditableDropRowState extends State<_EditableDropRow> { - late TextEditingController _weightController; - late TextEditingController _repsController; - final FocusNode _weightFocus = FocusNode(); - final FocusNode _repsFocus = FocusNode(); - - @override - void initState() { - super.initState(); - _weightController = TextEditingController(text: widget.weight.toString()); - _repsController = TextEditingController(text: widget.reps.toString()); - } - - @override - void didUpdateWidget(covariant _EditableDropRow oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.weight != oldWidget.weight && !_weightFocus.hasFocus) { - if (double.tryParse(_weightController.text) != widget.weight) { - _weightController.text = widget.weight.toString(); - } - } - if (widget.reps != oldWidget.reps && !_repsFocus.hasFocus) { - if (int.tryParse(_repsController.text) != widget.reps) { - _repsController.text = widget.reps.toString(); - } - } - } - - @override - void dispose() { - _weightController.dispose(); - _repsController.dispose(); - _weightFocus.dispose(); - _repsFocus.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(left: 32, top: 4, bottom: 4), - child: Row( - children: [ - Icon( - Icons.subdirectory_arrow_right, - size: 16, - color: AppTheme.textMuted.withOpacity(0.5), - ), - const SizedBox(width: 8), - - Text( - 'Drop ${widget.dropNumber}', - style: const TextStyle(color: AppTheme.textMuted, fontSize: 12), + child: TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: maxLines, + onChanged: onChanged, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: AppColors.textMuted, fontSize: 14), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, ), - const SizedBox(width: AppSpacing.sm), - - // Weight input - SizedBox( - width: 70, - height: 32, - child: TextField( - controller: _weightController, - focusNode: _weightFocus, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 13), - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 0, - ), - suffixText: 'kg', - suffixStyle: const TextStyle( - fontSize: 10, - color: AppTheme.textMuted, - ), - filled: true, - fillColor: AppTheme.surfaceColor.withOpacity(0.7), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(6), - borderSide: BorderSide.none, - ), - ), - onChanged: (value) { - final weight = double.tryParse(value) ?? 0; - widget.onWeightChanged(weight); - }, - ), - ), - - const SizedBox(width: 8), - const Text( - '×', - style: TextStyle(color: AppTheme.textMuted, fontSize: 12), - ), - const SizedBox(width: 8), - - // Reps input - SizedBox( - width: 60, - height: 32, - child: TextField( - controller: _repsController, - focusNode: _repsFocus, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 13), - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 0, - ), - suffixText: 'reps', - suffixStyle: const TextStyle( - fontSize: 10, - color: AppTheme.textMuted, - ), - filled: true, - fillColor: AppTheme.surfaceColor.withOpacity(0.7), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(6), - borderSide: BorderSide.none, - ), - ), - onChanged: (value) { - final reps = int.tryParse(value) ?? 0; - widget.onRepsChanged(reps); - }, - ), - ), - - const Spacer(), - - IconButton( - onPressed: widget.onDelete, - icon: const Icon(Icons.close, size: 16), - color: AppTheme.textMuted, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: 'Remove drop', - ), - ], + ), ), ); } diff --git a/workout-logger/lib/screens/exercise_library_screen.dart b/workout-logger/lib/screens/exercise_library_screen.dart index ee80ea2..40ae9dd 100644 --- a/workout-logger/lib/screens/exercise_library_screen.dart +++ b/workout-logger/lib/screens/exercise_library_screen.dart @@ -1,4 +1,4 @@ -// Exercise Library Screen - Browse and search exercises +// exercise_library_screen.dart — Browse and search the exercise library import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -8,6 +8,9 @@ import '../services/workout_provider.dart'; import '../theme/app_theme.dart'; import '../data/exercise_database.dart'; import 'add_custom_exercise_screen.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/rf_cards.dart'; +import 'widgets/exercise_details_sheet.dart'; class ExerciseLibraryScreen extends StatefulWidget { const ExerciseLibraryScreen({super.key}); @@ -17,729 +20,457 @@ class ExerciseLibraryScreen extends StatefulWidget { } class _ExerciseLibraryScreenState extends State { - String _searchQuery = ''; - String? _selectedMuscleGroup; + String _query = ''; + String? _muscleFilter; @override Widget build(BuildContext context) { - // Use Provider's exercise list (includes custom exercises) - final allExercises = context.watch().allExercises; + final provider = context.watch(); + final all = provider.allExercises; + final customCount = all.where((e) => e.isCustom).length; + + final filtered = all.where((e) { + final matchQ = _query.isEmpty || + e.name.toLowerCase().contains(_query.toLowerCase()); + final matchM = _muscleFilter == null || + e.muscleActivations.any((m) => m.muscleGroupId == _muscleFilter); + return matchQ && matchM; + }).toList() + ..sort((a, b) { + if (a.isCustom && !b.isCustom) return -1; + if (!a.isCustom && b.isCustom) return 1; + return a.name.compareTo(b.name); + }); - // Filter exercises - var filteredExercises = allExercises.where((e) { - final matchesSearch = - _searchQuery.isEmpty || - e.name.toLowerCase().contains(_searchQuery.toLowerCase()); - final matchesMuscle = - _selectedMuscleGroup == null || - e.muscleActivations.any( - (m) => m.muscleGroupId == _selectedMuscleGroup, - ); - return matchesSearch && matchesMuscle; - }).toList(); - - // Sort: custom exercises first within each group for visibility - filteredExercises.sort((a, b) { - // First by custom status (custom first) - if (a.isCustom && !b.isCustom) return -1; - if (!a.isCustom && b.isCustom) return 1; - // Then alphabetically - return a.name.compareTo(b.name); - }); - - // Group by primary muscle final grouped = >{}; - for (var exercise in filteredExercises) { - final primary = exercise.primaryMuscle; - grouped.putIfAbsent(primary, () => []).add(exercise); + for (final ex in filtered) { + grouped.putIfAbsent(ex.primaryMuscle, () => []).add(ex); } - // Count custom exercises for display - final customCount = allExercises.where((e) => e.isCustom).length; - return Scaffold( - appBar: AppBar( - title: const Text('Exercise Library'), - actions: [ - if (customCount > 0) - Padding( - padding: const EdgeInsets.only(right: AppSpacing.md), - child: Center( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '$customCount custom', - style: const TextStyle( - color: AppTheme.primaryColor, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ), - ), + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + children: [ + _Header(customCount: customCount), + _SearchBar( + query: _query, + onChanged: (v) => setState(() => _query = v), ), - ], - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () async { - final result = await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const AddCustomExerciseScreen(), + _MuscleFilterChips( + selected: _muscleFilter, + onSelected: (id) => setState(() => _muscleFilter = id), ), - ); - // No need to manually refresh - Provider will notify listeners - if (result == true && mounted) { - // Optional: Show a subtle confirmation - } - }, - icon: const Icon(Icons.add), - label: const Text('Add Exercise'), - ), - body: Column( - children: [ - // Search bar - Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: TextField( - decoration: InputDecoration( - hintText: 'Search exercises...', - prefixIcon: const Icon(Icons.search), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () => setState(() => _searchQuery = ''), - ) - : null, - ), - onChanged: (val) => setState(() => _searchQuery = val), - ), - ), - - // Muscle group filter chips - SizedBox( - height: 48, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), - children: [ - FilterChip( - label: const Text('All'), - selected: _selectedMuscleGroup == null, - onSelected: (_) => - setState(() => _selectedMuscleGroup = null), - ), - const SizedBox(width: 8), - ...MuscleGroups.names.entries.map( - (entry) => Padding( - padding: const EdgeInsets.only(right: 8), - child: FilterChip( - label: Text(entry.value), - selected: _selectedMuscleGroup == entry.key, - selectedColor: AppTheme.getMuscleColor( - entry.key, - ).withOpacity(0.3), - onSelected: (selected) => setState(() { - _selectedMuscleGroup = selected ? entry.key : null; - }), + Expanded( + child: grouped.isEmpty + ? RFEmptyState( + icon: Icons.search_off_rounded, + title: 'No exercises found', + subtitle: 'Try a different search or filter', + ) + : ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + 100, + ), + itemCount: grouped.length, + itemBuilder: (_, i) { + final muscleId = grouped.keys.elementAt(i); + final exercises = grouped[muscleId]!; + return _MuscleGroup( + muscleId: muscleId, + exercises: exercises, + onTap: (ex) => _openDetails(context, ex, provider), + ); + }, ), - ), - ), - ], ), + ], + ), + ), + floatingActionButton: Padding( + padding: const EdgeInsets.only(bottom: AppBreakpoints.navBarClearance), + child: FloatingActionButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AddCustomExerciseScreen()), ), + backgroundColor: AppColors.primary, + elevation: 0, + child: const Icon(Icons.add_rounded, color: Colors.white), + ), + ), + ); + } - const SizedBox(height: AppSpacing.sm), - - // Exercise list - Expanded( - child: grouped.isEmpty - ? _buildEmptyState() - : ListView.builder( - padding: const EdgeInsets.only( - left: AppSpacing.md, - right: AppSpacing.md, - top: AppSpacing.md, - bottom: 80, // Space for FAB - ), - itemCount: grouped.length, - itemBuilder: (context, index) { - final muscleId = grouped.keys.elementAt(index); - final exercises = grouped[muscleId]!; - final muscleName = - MuscleGroups.names[muscleId] ?? muscleId; - final muscleColor = AppTheme.getMuscleColor(muscleId); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.sm, - ), - child: Row( - children: [ - Container( - width: 4, - height: 20, - decoration: BoxDecoration( - color: muscleColor, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 8), - Text( - muscleName, - style: TextStyle( - color: muscleColor, - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - const SizedBox(width: 8), - Text( - '(${exercises.length})', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - ), - ], - ), - ), - ...exercises.map( - (exercise) => _ExerciseCard(exercise: exercise), - ), - const SizedBox(height: AppSpacing.md), - ], - ); - }, - ), - ), - ], + void _openDetails( + BuildContext context, + Exercise exercise, + WorkoutProvider provider, + ) { + showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => ExerciseDetailsSheet( + exercise: exercise, + provider: provider, ), ); } +} + +// ── Header ───────────────────────────────────────────────────────────────────── +class _Header extends StatelessWidget { + const _Header({required this.customCount}); + final int customCount; - Widget _buildEmptyState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.lg, + AppSpacing.md, + AppSpacing.md, + ), + child: Row( children: [ - Icon(Icons.search_off, size: 64, color: AppTheme.textMuted), - const SizedBox(height: 16), const Text( - 'No exercises found', - style: TextStyle(color: AppTheme.textSecondary), + 'Exercises', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 28, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + ), ), + const Spacer(), + if (customCount > 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: AppColors.warning.withValues(alpha: 0.3), + ), + ), + child: Text( + '$customCount custom', + style: const TextStyle( + color: AppColors.warning, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), ], ), ); } } -class _ExerciseCard extends StatelessWidget { - final Exercise exercise; - - const _ExerciseCard({required this.exercise}); +// ── Search bar ───────────────────────────────────────────────────────────────── +class _SearchBar extends StatelessWidget { + const _SearchBar({required this.query, required this.onChanged}); + final String query; + final ValueChanged onChanged; @override Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - child: InkWell( - onTap: () => _showExerciseDetails(context), - borderRadius: BorderRadius.circular(AppRadius.lg), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Row( - children: [ - // Icon with custom badge - Stack( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: exercise.isCustom - ? AppTheme.warning.withOpacity(0.2) - : AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - exercise.category == 'compound' - ? Icons.fitness_center - : Icons.accessibility_new, - color: exercise.isCustom - ? AppTheme.warning - : AppTheme.primaryColor, - ), - ), - if (exercise.isCustom) - Positioned( - right: -2, - top: -2, - child: Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: AppTheme.warning, - borderRadius: BorderRadius.circular(6), - ), - child: const Icon( - Icons.star, - size: 10, - color: Colors.black, - ), - ), - ), - ], - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - exercise.name, - style: const TextStyle( - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - ), - ), - ], - ), - const SizedBox(height: 4), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: exercise.category == 'compound' - ? AppTheme.primaryColor.withOpacity(0.2) - : AppTheme.secondaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - exercise.category.toUpperCase(), - style: TextStyle( - color: exercise.category == 'compound' - ? AppTheme.primaryColor - : AppTheme.secondaryColor, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - if (exercise.isCustom) ...[ - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'CUSTOM', - style: TextStyle( - color: AppTheme.warning, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - const SizedBox(width: 8), - Text( - '${exercise.muscleActivations.length} muscle${exercise.muscleActivations.length != 1 ? 's' : ''}', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - ), - ], + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.sm, + ), + child: Container( + height: 44, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextField( + onChanged: onChanged, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + decoration: InputDecoration( + hintText: 'Search exercises…', + hintStyle: + const TextStyle(color: AppColors.textMuted, fontSize: 14), + prefixIcon: const Icon( + Icons.search_rounded, + color: AppColors.textMuted, + size: 18, + ), + suffixIcon: query.isNotEmpty + ? GestureDetector( + onTap: () => onChanged(''), + child: const Icon( + Icons.close_rounded, + color: AppColors.textMuted, + size: 16, ), - ], - ), - ), - const Icon(Icons.chevron_right, color: AppTheme.textMuted), - ], + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 12), ), ), ), ); } - - void _showExerciseDetails(BuildContext context) { - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => _ExerciseDetailsSheet(exercise: exercise), - ); - } } -class _ExerciseDetailsSheet extends StatelessWidget { - final Exercise exercise; - - const _ExerciseDetailsSheet({required this.exercise}); +// ── Muscle filter chips ──────────────────────────────────────────────────────── +class _MuscleFilterChips extends StatelessWidget { + const _MuscleFilterChips({required this.selected, required this.onSelected}); + final String? selected; + final ValueChanged onSelected; @override Widget build(BuildContext context) { - final provider = context.read(); - final lastSession = provider.getLastSessionForExercise(exercise.id); - final growthModel = provider.getGrowthModel(exercise.id); - - return Container( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + return SizedBox( + height: 40, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), children: [ - // Handle - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppTheme.textMuted, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: AppSpacing.lg), - - // Header - Row( - children: [ - Stack( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: exercise.isCustom - ? AppTheme.warning.withOpacity(0.2) - : AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - exercise.category == 'compound' - ? Icons.fitness_center - : Icons.accessibility_new, - color: exercise.isCustom - ? AppTheme.warning - : AppTheme.primaryColor, - ), - ), - if (exercise.isCustom) - Positioned( - right: -2, - top: -2, - child: Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: AppTheme.warning, - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.star, - size: 10, - color: Colors.black, - ), - ), - ), - ], - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - exercise.name, - style: Theme.of(context).textTheme.titleLarge, - ), - Row( - children: [ - Text( - exercise.category == 'compound' - ? 'Compound Exercise' - : 'Isolation Exercise', - style: const TextStyle(color: AppTheme.textSecondary), - ), - if (exercise.isCustom) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'CUSTOM', - style: TextStyle( - color: AppTheme.warning, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ], - ), - ], - ), - ), - // Delete button for custom exercises - if (exercise.isCustom) - IconButton( - onPressed: () => _confirmDelete(context, provider), - icon: const Icon(Icons.delete_outline), - color: AppTheme.error, - tooltip: 'Delete custom exercise', - ), - ], - ), - - const SizedBox(height: AppSpacing.lg), - - // Muscle activations - Text( - 'Muscle Activation', - style: Theme.of(context).textTheme.titleMedium, + _Chip( + label: 'All', + isSelected: selected == null, + color: AppColors.primary, + onTap: () => onSelected(null), ), - const SizedBox(height: AppSpacing.sm), - ...exercise.muscleActivations.map((activation) { - final muscleName = - MuscleGroups.names[activation.muscleGroupId] ?? - activation.muscleGroupId; - final color = AppTheme.getMuscleColor(activation.muscleGroupId); - + const SizedBox(width: 6), + ...MuscleGroups.names.entries.map((e) { + final color = AppColors.muscle(e.key); return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: Row( - children: [ - Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(6), - ), - ), - const SizedBox(width: 8), - Expanded(child: Text(muscleName)), - Text( - '${activation.activationPercentage}%', - style: TextStyle(color: color, fontWeight: FontWeight.bold), - ), - ], + padding: const EdgeInsets.only(right: 6), + child: _Chip( + label: e.value, + isSelected: selected == e.key, + color: color, + onTap: () => onSelected(selected == e.key ? null : e.key), ), ); }), - - if (lastSession != null) ...[ - const SizedBox(height: AppSpacing.lg), - Text( - 'Last Session', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: 8, - runSpacing: 8, - children: lastSession.sets.asMap().entries.map((entry) { - final set = entry.value; - return Chip( - label: Text('${set.weight}kg × ${set.reps}'), - backgroundColor: AppTheme.surfaceColor, - ); - }).toList(), - ), - ], - - if (growthModel != null && growthModel.r2 > 0.2) ...[ - const SizedBox(height: AppSpacing.md), - Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.success.withOpacity(0.1), - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Row( - children: [ - const Icon(Icons.trending_up, color: AppTheme.success), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Growing at +${growthModel.slope.toStringAsFixed(1)} kg volume/session', - style: const TextStyle(color: AppTheme.success), - ), - ), - ], - ), - ), - ], - - const SizedBox(height: AppSpacing.lg), ], ), ); } +} - Future _confirmDelete( - BuildContext context, - WorkoutProvider provider, - ) async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: AppTheme.cardColor, - title: const Text('Delete Custom Exercise?'), - content: Text( - 'Are you sure you want to delete "${exercise.name}"? This action cannot be undone.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), +class _Chip extends StatelessWidget { + const _Chip({ + required this.label, + required this.isSelected, + required this.color, + required this.onTap, + }); + final String label; + final bool isSelected; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isSelected ? color.withValues(alpha: 0.15) : AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: isSelected + ? color.withValues(alpha: 0.5) + : AppColors.glassBorder, ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - style: TextButton.styleFrom(foregroundColor: AppTheme.error), - child: const Text('Delete'), + ), + child: Text( + label, + style: TextStyle( + color: isSelected ? color : AppColors.textMuted, + fontSize: 12, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w400, ), - ], + ), ), ); + } +} - if (confirmed == true && context.mounted) { - final success = await provider.deleteCustomExercise(exercise.id); - if (success && context.mounted) { - // Capture messenger before pop to avoid deactivated context - final messenger = ScaffoldMessenger.of(context); - Navigator.of(context).pop(); // Close the bottom sheet - messenger.showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.check_circle, color: AppTheme.success), - const SizedBox(width: 8), - Text('"${exercise.name}" deleted'), - ], - ), - backgroundColor: AppTheme.cardColor, +// ── Muscle group section ─────────────────────────────────────────────────────── +class _MuscleGroup extends StatelessWidget { + const _MuscleGroup({ + required this.muscleId, + required this.exercises, + required this.onTap, + }); + final String muscleId; + final List exercises; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) { + final color = AppColors.muscle(muscleId); + final name = MuscleGroups.names[muscleId] ?? muscleId; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Row( + children: [ + Container( + width: 3, + height: 16, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + name, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + const SizedBox(width: 6), + Text( + '${exercises.length}', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], ), - ); - } - } + ), + ...exercises.map( + (ex) => ExerciseCard( + exercise: ex, + onTap: () => onTap(ex), + ), + ), + const SizedBox(height: AppSpacing.sm), + ], + ); } } -// ==================== Exercise Selector Screen ==================== - +// ── Exercise Selector Screen (used by workout flow for quick start) ──────────── class ExerciseSelectorScreen extends StatefulWidget { - final bool selectionMode; - final Function(List)? onExercisesSelected; - const ExerciseSelectorScreen({ super.key, this.selectionMode = false, this.onExercisesSelected, }); + final bool selectionMode; + final void Function(List)? onExercisesSelected; + @override - State createState() => _ExerciseSelectorScreenState(); + State createState() => + _ExerciseSelectorScreenState(); } class _ExerciseSelectorScreenState extends State { final Set _selectedIds = {}; - String _searchQuery = ''; + String _query = ''; @override Widget build(BuildContext context) { - // Use Provider's exercise list (includes custom exercises) - final allExercises = context.watch().allExercises; - - var filteredExercises = allExercises.where((e) { - return _searchQuery.isEmpty || - e.name.toLowerCase().contains(_searchQuery.toLowerCase()); - }).toList(); - - // Sort alphabetically within groups - filteredExercises.sort((a, b) => a.name.compareTo(b.name)); + final all = context.watch().allExercises; + final filtered = all + .where( + (e) => _query.isEmpty || + e.name.toLowerCase().contains(_query.toLowerCase()), + ) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); - // Group by primary muscle final grouped = >{}; - for (var exercise in filteredExercises) { - final primary = exercise.primaryMuscle; - grouped.putIfAbsent(primary, () => []).add(exercise); + for (final ex in filtered) { + grouped.putIfAbsent(ex.primaryMuscle, () => []).add(ex); } return Column( children: [ Padding( padding: const EdgeInsets.all(AppSpacing.md), - child: TextField( - decoration: const InputDecoration( - hintText: 'Search exercises...', - prefixIcon: Icon(Icons.search), + child: Container( + height: 44, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextField( + onChanged: (v) => setState(() => _query = v), + style: + const TextStyle(color: AppColors.textPrimary, fontSize: 14), + decoration: const InputDecoration( + hintText: 'Search exercises…', + hintStyle: + TextStyle(color: AppColors.textMuted, fontSize: 14), + prefixIcon: Icon( + Icons.search_rounded, + color: AppColors.textMuted, + size: 18, + ), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 12), + ), ), - onChanged: (val) => setState(() => _searchQuery = val), ), ), - if (widget.selectionMode && _selectedIds.isNotEmpty) - Container( + Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), child: Row( children: [ Text( '${_selectedIds.length} selected', style: const TextStyle( - color: AppTheme.primaryColor, + color: AppColors.primary, fontWeight: FontWeight.w600, + fontSize: 13, ), ), const Spacer(), - TextButton( - onPressed: () => setState(() => _selectedIds.clear()), - child: const Text('Clear'), + GestureDetector( + onTap: () => setState(() => _selectedIds.clear()), + child: const Text( + 'Clear', + style: TextStyle(color: AppColors.error, fontSize: 12), + ), ), ], ), ), - Expanded( child: ListView.builder( - padding: const EdgeInsets.all(AppSpacing.md), + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), itemCount: grouped.length, - itemBuilder: (context, index) { - final muscleId = grouped.keys.elementAt(index); + itemBuilder: (_, i) { + final muscleId = grouped.keys.elementAt(i); final exercises = grouped[muscleId]!; - final muscleName = MuscleGroups.names[muscleId] ?? muscleId; - + final name = MuscleGroups.names[muscleId] ?? muscleId; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -747,53 +478,20 @@ class _ExerciseSelectorScreenState extends State { padding: const EdgeInsets.symmetric( vertical: AppSpacing.sm, ), - child: Text( - muscleName, - style: TextStyle( - color: AppTheme.getMuscleColor(muscleId), - fontWeight: FontWeight.w600, - ), - ), + child: RFSectionHeader(name), ), - ...exercises.map((exercise) { - final isSelected = _selectedIds.contains(exercise.id); - return ListTile( - leading: widget.selectionMode - ? Checkbox( - value: isSelected, - onChanged: (val) { - setState(() { - if (val == true) { - _selectedIds.add(exercise.id); - } else { - _selectedIds.remove(exercise.id); - } - }); - }, - ) - : null, - title: Text(exercise.name), - subtitle: Text(exercise.category), - trailing: widget.selectionMode && isSelected - ? Text( - '${_selectedIds.toList().indexOf(exercise.id) + 1}', - style: const TextStyle( - color: AppTheme.primaryColor, - fontWeight: FontWeight.bold, - ), - ) - : null, - onTap: widget.selectionMode - ? () { - setState(() { - if (isSelected) { - _selectedIds.remove(exercise.id); - } else { - _selectedIds.add(exercise.id); - } - }); - } - : null, + ...exercises.map((ex) { + final sel = _selectedIds.contains(ex.id); + return ExerciseCard( + exercise: ex, + selected: sel, + onTap: () => setState(() { + if (sel) { + _selectedIds.remove(ex.id); + } else { + _selectedIds.add(ex.id); + } + }), ); }), ], @@ -801,20 +499,17 @@ class _ExerciseSelectorScreenState extends State { }, ), ), - if (widget.selectionMode) - Container( + Padding( padding: const EdgeInsets.all(AppSpacing.md), - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _selectedIds.isEmpty - ? null - : () { - widget.onExercisesSelected?.call(_selectedIds.toList()); - }, - child: Text('Start with ${_selectedIds.length} exercises'), - ), + child: GlowButton( + label: 'Start with ${_selectedIds.length} exercises', + icon: Icons.play_arrow_rounded, + onPressed: _selectedIds.isEmpty + ? null + : () => + widget.onExercisesSelected?.call(_selectedIds.toList()), + fullWidth: true, ), ), ], diff --git a/workout-logger/lib/screens/heart_rate_detail_screen.dart b/workout-logger/lib/screens/heart_rate_detail_screen.dart new file mode 100644 index 0000000..695689d --- /dev/null +++ b/workout-logger/lib/screens/heart_rate_detail_screen.dart @@ -0,0 +1,298 @@ +// heart_rate_detail_screen.dart — full-screen all-day heart-rate history. +// +// Day : ~30-min min–max HR bars + resting line for the selected day. +// Week / Month / Year : daily/monthly min–max range bars with resting markers. + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import '../models/sleep_hr_models.dart'; +import '../services/managers/health_history_manager.dart'; +import '../services/workout_provider.dart'; +import '../theme/app_theme.dart'; +import 'widgets/health_bar_chart.dart'; +import 'widgets/health_detail_shell.dart'; +import 'widgets/rf_widgets.dart'; + +class HeartRateDetailScreen extends StatefulWidget { + const HeartRateDetailScreen({super.key, this.initialDate}); + + final DateTime? initialDate; + + @override + State createState() => _HeartRateDetailScreenState(); +} + +class _HeartRateDetailScreenState extends State { + late HealthHistoryManager _mgr; + HealthGranularity _g = HealthGranularity.day; + late DateTime _anchor; + late Set _workoutDays; + Future? _future; + + @override + void initState() { + super.initState(); + final now = widget.initialDate ?? DateTime.now(); + _anchor = DateTime(now.year, now.month, now.day); + final sessions = context.read().sessions; + _workoutDays = sessions.map((s) => HealthHistoryManager.dateKey(s.date)).toSet(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _mgr = context.read(); + _future ??= _load(); + } + + Future _load() => _g == HealthGranularity.day + ? _mgr.hrDay(_anchor) + : _mgr.hrBars(_anchor, _g); + + bool get _canGoNext { + final today = DateTime.now(); + return HealthHistoryManager.stepBy(_anchor, _g, 1) + .isBefore(DateTime(today.year, today.month, today.day + 1)); + } + + void _step(int dir) => setState(() { + _anchor = HealthHistoryManager.stepBy(_anchor, _g, dir); + _future = _load(); + }); + + void _setG(HealthGranularity g) => setState(() { + _g = g; + _future = _load(); + }); + + String get _dateLabel { + switch (_g) { + case HealthGranularity.day: + return DateFormat('EEE · MMM d').format(_anchor); + case HealthGranularity.week: + final start = _anchor.subtract(const Duration(days: 6)); + return '${DateFormat('MMM d').format(start)} – ${DateFormat('MMM d').format(_anchor)}'; + case HealthGranularity.month: + return DateFormat('MMMM yyyy').format(_anchor); + case HealthGranularity.year: + return DateFormat('yyyy').format(_anchor); + } + } + + @override + Widget build(BuildContext context) { + return HealthDetailShell( + title: 'Heart rate', + icon: Icons.favorite_rounded, + iconColor: AppColors.accent, + dateLabel: _dateLabel, + granularity: _g, + onGranularityChanged: _setG, + onPrev: () => _step(-1), + onNext: () => _step(1), + canGoNext: _canGoNext, + child: FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const SizedBox(height: 220, child: Center(child: RFLoadingDots())); + } + if (_g == HealthGranularity.day) { + final data = snap.data as HrDaySnapshot?; + if (data == null) return const _Empty('No heart-rate data for this day.'); + return _DayBody(snapshot: data); + } + final bars = (snap.data as List?) ?? const []; + return _AggBody(bars: bars, workoutDays: _workoutDays, granularity: _g); + }, + ), + ); + } +} + +class _DayBody extends StatelessWidget { + const _DayBody({required this.snapshot}); + final HrDaySnapshot snapshot; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + _Pill(label: 'Resting', value: snapshot.restingBpm?.toString() ?? '—', color: AppColors.secondary), + const SizedBox(width: 6), + _Pill(label: 'Min', value: '${snapshot.minBpm}', color: AppColors.textMuted), + const SizedBox(width: 6), + _Pill(label: 'Max', value: '${snapshot.maxBpm}', color: AppColors.accent), + const SizedBox(width: 6), + _Pill(label: 'Avg', value: '${snapshot.avgBpm.round()}', color: AppColors.primary), + ], + ), + const SizedBox(height: 12), + GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'All-day heart rate · 30-min bars', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + ), + Text('bpm', style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11)), + ], + ), + const SizedBox(height: 8), + HrDayChart(snapshot: snapshot), + const SizedBox(height: 10), + Wrap( + spacing: 12, + children: [ + _legend('Min–max', AppColors.secondary), + _legendDash('Resting', AppColors.secondary), + ], + ), + ], + ), + ), + ], + ); + } + + Widget _legend(String label, Color c) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 4), + Text(label, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), + ], + ); + + Widget _legendDash(String label, Color c) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 14, height: 2, color: c), + const SizedBox(width: 4), + Text(label, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), + ], + ); +} + +class _AggBody extends StatelessWidget { + const _AggBody({required this.bars, required this.workoutDays, required this.granularity}); + + final List bars; + final Set workoutDays; + final HealthGranularity granularity; + + @override + Widget build(BuildContext context) { + final withData = bars.where((b) => b.maxBpm > 0).toList(); + final resting = withData.where((b) => b.restingBpm != null).map((b) => b.restingBpm!).toList(); + final avgRest = resting.isEmpty ? null : (resting.reduce((a, b) => a + b) / resting.length).round(); + final mn = withData.isEmpty ? null : withData.map((b) => b.minBpm).reduce((a, b) => a < b ? a : b); + final mx = withData.isEmpty ? null : withData.map((b) => b.maxBpm).reduce((a, b) => a > b ? a : b); + final unit = granularity == HealthGranularity.year ? 'monthly' : 'daily'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + _Pill(label: 'Avg resting', value: avgRest?.toString() ?? '—', color: AppColors.secondary), + const SizedBox(width: 6), + _Pill(label: 'Min', value: mn?.toString() ?? '—', color: AppColors.textMuted), + const SizedBox(width: 6), + _Pill(label: 'Max', value: mx?.toString() ?? '—', color: AppColors.accent), + ], + ), + const SizedBox(height: 12), + GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$unit range · resting ●', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + ), + const SizedBox(height: 12), + HrRangeChart(bars: bars, workoutDays: workoutDays), + const SizedBox(height: 10), + Wrap( + spacing: 12, + children: [ + _legend('Min–max', AppColors.primary), + _legend('Resting', AppColors.secondary), + _legend('Workout day', AppColors.accent), + ], + ), + ], + ), + ), + ], + ); + } + + Widget _legend(String label, Color c) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 4), + Text(label, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), + ], + ); +} + +class _Pill extends StatelessWidget { + const _Pill({required this.label, required this.value, required this.color}); + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: AppColors.glass2, + border: Border.all(color: AppColors.glassBorder), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 9, letterSpacing: 0.5), + ), + const SizedBox(height: 2), + Text( + value, + style: TextStyle(fontFamily: 'GeistMono', color: color, fontSize: 16, fontWeight: FontWeight.w700), + ), + ], + ), + ), + ); + } +} + +class _Empty extends StatelessWidget { + const _Empty(this.message); + final String message; + @override + Widget build(BuildContext context) => GlassCard( + padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 16), + child: Center( + child: Text(message, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 13)), + ), + ); +} diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index 0e55912..7b6d5df 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -1,4 +1,4 @@ -// History Screen - View past workout sessions +// history_screen.dart — Workout history with calendar + session list import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -10,607 +10,572 @@ import '../services/managers/history_manager.dart'; import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; import 'edit_workout_session_screen.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/session_details_sheet.dart'; +import 'widgets/calendar_grid.dart'; -// Teal color shared by the HC badge and sync status indicators. -const Color _hcColor = Color(0xFF00BFA5); +const Color _hcColor = Color(0xFF4ECDC4); -class HistoryScreen extends StatelessWidget { +class HistoryScreen extends StatefulWidget { const HistoryScreen({super.key}); @override - Widget build(BuildContext context) { - // Watch HistoryManager so the list rebuilds when hcSyncedAt changes. - final historyManager = context.watch(); - final provider = context.read(); - final settings = context.watch(); - final sessions = historyManager.sessions; + State createState() => _HistoryScreenState(); +} - final hasUnsynced = settings.healthConnectEnabled && - sessions.any((s) => s.hcSyncedAt == null); +class _HistoryScreenState extends State { + final _searchController = TextEditingController(); + String _query = ''; + bool _showSearch = false; - return Scaffold( - appBar: AppBar( - title: const Text('Workout History'), - actions: [ - if (hasUnsynced) - IconButton( - icon: const Icon(Icons.monitor_heart_outlined, color: _hcColor), - tooltip: 'Sync all to Health Connect', - onPressed: () { - historyManager.syncAllUnsynced(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Syncing all unsynced workouts…'), - backgroundColor: AppTheme.cardColor, - duration: Duration(seconds: 2), - ), - ); - }, - ), - ], - ), - body: sessions.isEmpty - ? _buildEmptyState(context) - : _buildSessionList(context, sessions, provider, historyManager), - ); + // Calendar state + DateTime _calendarMonth = DateTime(DateTime.now().year, DateTime.now().month); + int? _selectedDay; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); } - Widget _buildEmptyState(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.history, size: 64, color: AppTheme.textMuted), - const SizedBox(height: 16), - Text( - 'No Workout History', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - 'Complete a workout to see it here', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ); + List _filtered(List sessions, WorkoutProvider provider) { + if (_query.isEmpty) return sessions; + final q = _query.toLowerCase(); + return sessions.where((s) { + final dateStr = DateFormat('EEEE MMM d yyyy').format(s.date).toLowerCase(); + if (dateStr.contains(q)) return true; + return s.exercises.any((e) { + final name = provider.getExerciseName(e.exerciseId).toLowerCase(); + return name.contains(q); + }); + }).toList(); } - Widget _buildSessionList( - BuildContext context, - List sessions, - WorkoutProvider provider, - HistoryManager historyManager, - ) { - // Group sessions by month - final groupedSessions = >{}; - for (var session in sessions) { - final monthKey = DateFormat('MMMM yyyy').format(session.date); - groupedSessions.putIfAbsent(monthKey, () => []).add(session); + Map> _group(List sessions) { + final map = >{}; + for (final s in sessions) { + final key = DateFormat('MMMM yyyy').format(s.date); + map.putIfAbsent(key, () => []).add(s); } - - return ListView.builder( - padding: const EdgeInsets.all(AppSpacing.md), - itemCount: groupedSessions.length, - itemBuilder: (context, index) { - final month = groupedSessions.keys.elementAt(index); - final monthSessions = groupedSessions[month]!; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), - child: Text( - month, - style: const TextStyle( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - ), - ...monthSessions.map( - (session) => _SessionCard( - session: session, - provider: provider, - historyManager: historyManager, - ), - ), - ], - ); - }, - ); + return map; } -} -class _SessionCard extends StatelessWidget { - final WorkoutSession session; - final WorkoutProvider provider; - final HistoryManager historyManager; - - const _SessionCard({ - required this.session, - required this.provider, - required this.historyManager, - }); + Map _buildCalendarData(List sessions) { + final map = {}; + final monthSessions = sessions.where((s) => + s.date.year == _calendarMonth.year && s.date.month == _calendarMonth.month); + final dayVolumes = {}; + for (final s in monthSessions) { + dayVolumes[s.date.day] = (dayVolumes[s.date.day] ?? 0) + s.totalVolume; + } + for (final entry in dayVolumes.entries) { + final vol = entry.value; + final intensity = vol > 15000 ? 3 : vol > 5000 ? 2 : 1; + map[entry.key] = CalendarDayData(intensity: intensity); + } + return map; + } @override Widget build(BuildContext context) { - final dateFormat = DateFormat('EEEE, MMM d'); - final timeFormat = DateFormat('h:mm a'); + final historyManager = context.watch(); + final provider = context.read(); final settings = context.watch(); - final isSynced = session.hcSyncedAt != null; - final showSyncOption = !isSynced && settings.healthConnectEnabled; - - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.md), - child: InkWell( - onTap: () => _showSessionDetails(context), - borderRadius: BorderRadius.circular(AppRadius.lg), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── Header row ──────────────────────────────────────── - Row( - children: [ - Expanded( - child: Text( - dateFormat.format(session.date), - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, + final all = historyManager.sessions; + final filtered = _filtered(all, provider); + final grouped = _group(filtered); + final months = grouped.keys.toList(); + + final totalVolume = all.fold(0, (s, e) => s + e.totalVolume); + final hasUnsynced = settings.healthConnectEnabled && all.any((s) => s.hcSyncedAt == null); + + return Stack( + children: [ + const AmbientGlow(), + SafeArea( + bottom: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + // Header + SliverToBoxAdapter( + child: _buildHeader(context, hasUnsynced: hasUnsynced, historyManager: historyManager), + ), + + // Search bar (animated) + if (_showSearch) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: _SearchBar( + controller: _searchController, + onChanged: (v) => setState(() => _query = v), ), ), ), - // HC synced badge - if (isSynced) - Tooltip( - message: 'Synced to Health Connect', - child: Padding( - padding: const EdgeInsets.only(right: 6), - child: Icon( - Icons.monitor_heart, - color: _hcColor, - size: 16, - ), + + // Lifetime summary + SliverToBoxAdapter( + child: _buildSummaryCard(all: all, totalVolume: totalVolume, settings: settings), + ), + + // Calendar card + if (_query.isEmpty) + SliverToBoxAdapter( + child: _buildCalendarCard(all), + ), + + // Session list + if (filtered.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.history_rounded, size: 48, color: AppColors.textFaint), + const SizedBox(height: 12), + Text( + _query.isNotEmpty ? 'No results' : 'No Workout History', + style: TextStyle(fontFamily: 'Geist', fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textMuted), + ), + const SizedBox(height: 4), + Text( + _query.isNotEmpty ? 'Try a different search term' : 'Complete a workout to see it here', + style: TextStyle(fontFamily: 'Geist', fontSize: 13, color: AppColors.textFaint), + ), + ], ), ), - Text( - timeFormat.format(session.date), - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) { + final month = months[i]; + final sessions = grouped[month]!; + return _MonthGroup( + month: month, + sessions: sessions, + provider: provider, + historyManager: historyManager, + settings: settings, + ); + }, + childCount: months.length, + ), ), ), - // ⋮ popup menu - _SessionMenu( - session: session, - provider: provider, - historyManager: historyManager, - showSyncOption: showSyncOption, - onDetailRequested: () => _showSessionDetails(context), - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - Row( - children: [ - _buildStat( - Icons.fitness_center, - '${session.exercises.length} exercises', + ], + ), + ), + ], + ); + } + + Widget _buildHeader(BuildContext context, {required bool hasUnsynced, required HistoryManager historyManager}) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'LOG', + style: TextStyle(fontFamily: 'Geist', + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textFaint, + letterSpacing: 1.2, ), - const SizedBox(width: AppSpacing.md), - _buildStat(Icons.timer_outlined, '${session.duration} min'), - const SizedBox(width: AppSpacing.md), - _buildStat( - Icons.trending_up, - '${(session.totalVolume / 1000).toStringAsFixed(1)}k kg', + ), + const SizedBox(height: 2), + Text( + 'History', + style: TextStyle(fontFamily: 'Geist', + fontSize: 28, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.6, ), - ], - ), - const SizedBox(height: AppSpacing.sm), - const Divider(), - const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: 8, - runSpacing: 4, - children: session.exercises.take(4).map((log) { - final exerciseName = provider.getExerciseName(log.exerciseId); - return Chip( - label: Text( - exerciseName, - style: const TextStyle(fontSize: 11), - ), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - ); - }).toList(), + ), + ], + ), + ), + if (hasUnsynced) + GestureDetector( + onTap: () { + historyManager.syncAllUnsynced(); + ScaffoldMessenger.of(context).showSnackBar( + _snackBar('Syncing all unsynced workouts…'), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: _hcColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: _hcColor.withValues(alpha: 0.3)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.favorite_rounded, size: 13, color: _hcColor), + SizedBox(width: 5), + Text('Sync', style: TextStyle(color: _hcColor, fontSize: 12, fontWeight: FontWeight.w600)), + ], + ), ), - if (session.exercises.length > 4) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - '+${session.exercises.length - 4} more', - style: const TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - ), + ), + GestureDetector( + onTap: () => setState(() { + _showSearch = !_showSearch; + if (!_showSearch) { + _query = ''; + _searchController.clear(); + } + }), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: _showSearch ? AppColors.primary.withValues(alpha: 0.15) : AppColors.glass2, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: _showSearch ? AppColors.primary.withValues(alpha: 0.4) : AppColors.glassBorder, ), - ], + ), + child: Icon( + _showSearch ? Icons.close_rounded : Icons.search_rounded, + size: 16, + color: _showSearch ? AppColors.primary : AppColors.textMuted, + ), + ), ), - ), + ], ), ); } - Widget _buildStat(IconData icon, String text) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: AppTheme.textSecondary), - const SizedBox(width: 4), - Text( - text, - style: const TextStyle(color: AppTheme.textSecondary, fontSize: 12), + Widget _buildSummaryCard({required List all, required double totalVolume, required SettingsProvider settings}) { + final displayVol = settings.toDisplay(totalVolume); + final volStr = displayVol >= 1000000 + ? '${(displayVol / 1000000).toStringAsFixed(1)}M' + : displayVol >= 1000 + ? '${(displayVol / 1000).toStringAsFixed(0)}k' + : displayVol.toStringAsFixed(0); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: GlassCard( + padding: const EdgeInsets.symmetric(vertical: 16), + child: IntrinsicHeight( + child: Row( + children: [ + _SummaryCell(label: 'WORKOUTS', value: '${all.length}', unit: 'total'), + const _VertDivider(), + _SummaryCell(label: 'VOLUME', value: volStr, unit: settings.unitLabel), + const _VertDivider(), + _SummaryCell(label: 'THIS MONTH', value: '${all.where((s) => s.date.month == DateTime.now().month && s.date.year == DateTime.now().year).length}', unit: 'sessions'), + ], + ), ), - ], + ), ); } - void _showSessionDetails(BuildContext context) { - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardColor, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) => _SessionDetailsSheet( - session: session, - provider: provider, - historyManager: historyManager, - scrollController: scrollController, + Widget _buildCalendarCard(List sessions) { + final monthLabel = DateFormat('MMMM yyyy').format(_calendarMonth); + final monthSessions = sessions.where((s) => + s.date.year == _calendarMonth.year && s.date.month == _calendarMonth.month).length; + final calData = _buildCalendarData(sessions); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + GestureDetector( + onTap: () => setState(() { + _calendarMonth = DateTime(_calendarMonth.year, _calendarMonth.month - 1); + _selectedDay = null; + }), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.chevron_left_rounded, size: 18, color: AppColors.textMuted), + ), + ), + Expanded( + child: Column( + children: [ + Text( + monthLabel, + style: TextStyle(fontFamily: 'Geist', + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + Text( + '$monthSessions session${monthSessions == 1 ? '' : 's'}', + style: TextStyle(fontFamily: 'Geist', fontSize: 11, color: AppColors.textMuted), + textAlign: TextAlign.center, + ), + ], + ), + ), + GestureDetector( + onTap: () => setState(() { + final next = DateTime(_calendarMonth.year, _calendarMonth.month + 1); + if (next.isBefore(DateTime.now()) || next.month == DateTime.now().month) { + _calendarMonth = next; + _selectedDay = null; + } + }), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.chevron_right_rounded, size: 18, color: AppColors.textMuted), + ), + ), + ], + ), + const SizedBox(height: 16), + CalendarMonthGrid( + year: _calendarMonth.year, + month: _calendarMonth.month, + workoutDays: calData, + selectedDay: _selectedDay, + onDayTap: (day) => setState(() => _selectedDay = _selectedDay == day ? null : day), + ), + ], ), ), ); } } -// ── 3-button popup menu ──────────────────────────────────────────────────────── - -enum _SessionMenuAction { edit, syncHc, delete } - -class _SessionMenu extends StatelessWidget { - final WorkoutSession session; - final WorkoutProvider provider; - final HistoryManager historyManager; - final bool showSyncOption; - final VoidCallback onDetailRequested; +// ── Summary cell ─────────────────────────────────────────────────────────────── - const _SessionMenu({ - required this.session, - required this.provider, - required this.historyManager, - required this.showSyncOption, - required this.onDetailRequested, - }); +class _SummaryCell extends StatelessWidget { + const _SummaryCell({required this.label, required this.value, required this.unit}); + final String label; + final String value; + final String unit; @override Widget build(BuildContext context) { - return PopupMenuButton<_SessionMenuAction>( - icon: const Icon(Icons.more_vert, color: AppTheme.textSecondary, size: 20), - color: AppTheme.cardColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - onSelected: (action) => _handleAction(context, action), - itemBuilder: (_) => [ - const PopupMenuItem( - value: _SessionMenuAction.edit, - child: ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.edit_outlined, color: AppTheme.primaryColor), - title: Text('Edit', style: TextStyle(color: AppTheme.textPrimary)), - ), - ), - if (showSyncOption) - const PopupMenuItem( - value: _SessionMenuAction.syncHc, - child: ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.monitor_heart_outlined, color: _hcColor), - title: Text( - 'Sync to Health Connect', - style: TextStyle(color: AppTheme.textPrimary), - ), + return Expanded( + child: Column( + children: [ + Text( + label, + style: TextStyle(fontFamily: 'Geist', + fontSize: 9, + fontWeight: FontWeight.w600, + color: AppColors.textFaint, + letterSpacing: 0.8, ), ), - const PopupMenuItem( - value: _SessionMenuAction.delete, - child: ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.delete_outline, color: AppTheme.error), - title: Text( - 'Delete', - style: TextStyle(color: AppTheme.error), + const SizedBox(height: 4), + Text( + value, + style: TextStyle(fontFamily: 'GeistMono', + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, ), ), - ), - ], + Text( + unit, + style: TextStyle(fontFamily: 'Geist', fontSize: 10, color: AppColors.textMuted), + ), + ], + ), ); } +} - void _handleAction(BuildContext context, _SessionMenuAction action) { - switch (action) { - case _SessionMenuAction.edit: - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => EditWorkoutSessionScreen(session: session), - ), - ); - case _SessionMenuAction.syncHc: - historyManager.syncSession(session); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ), - SizedBox(width: 12), - Text('Syncing to Health Connect…'), - ], - ), - backgroundColor: AppTheme.cardColor, - duration: Duration(seconds: 2), - ), - ); - case _SessionMenuAction.delete: - _confirmDelete(context); - } +class _VertDivider extends StatelessWidget { + const _VertDivider(); + + @override + Widget build(BuildContext context) { + return Container(width: 1, color: AppColors.glassBorder); } +} - Future _confirmDelete(BuildContext context) async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: AppTheme.cardColor, - title: const Text('Delete Workout?'), - content: Text( - 'Are you sure you want to delete this workout from ' - '${DateFormat('MMMM d, yyyy').format(session.date)}? ' - 'This action cannot be undone.', +// ── Search bar ───────────────────────────────────────────────────────────────── + +class _SearchBar extends StatelessWidget { + const _SearchBar({required this.controller, required this.onChanged}); + final TextEditingController controller; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + height: 44, + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextField( + controller: controller, + onChanged: onChanged, + autofocus: true, + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14), + decoration: InputDecoration( + hintText: 'Search by date or exercise…', + hintStyle: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 14), + prefixIcon: const Icon(Icons.search_rounded, color: AppColors.textMuted, size: 18), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 12), ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - style: TextButton.styleFrom(foregroundColor: AppTheme.error), - child: const Text('Delete'), - ), - ], ), ); - - if (confirmed == true && context.mounted) { - final messenger = ScaffoldMessenger.of(context); - try { - await provider.deleteWorkoutSession(session.id); - if (context.mounted) { - messenger.showSnackBar( - const SnackBar( - content: Row( - children: [ - Icon(Icons.check_circle, color: AppTheme.success), - SizedBox(width: 8), - Text('Workout deleted'), - ], - ), - backgroundColor: AppTheme.cardColor, - ), - ); - } - } catch (e) { - debugPrint('Failed to delete workout session: $e'); - if (context.mounted) { - messenger.showSnackBar( - const SnackBar( - content: Text('Failed to delete workout. Please try again.'), - backgroundColor: AppTheme.error, - ), - ); - } - } - } } } -// ── Detail bottom sheet ──────────────────────────────────────────────────────── - -class _SessionDetailsSheet extends StatelessWidget { - final WorkoutSession session; - final WorkoutProvider provider; - final HistoryManager historyManager; - final ScrollController scrollController; +// ── Month group ──────────────────────────────────────────────────────────────── - const _SessionDetailsSheet({ - required this.session, +class _MonthGroup extends StatelessWidget { + const _MonthGroup({ + required this.month, + required this.sessions, required this.provider, required this.historyManager, - required this.scrollController, + required this.settings, }); + final String month; + final List sessions; + final WorkoutProvider provider; + final HistoryManager historyManager; + final SettingsProvider settings; @override Widget build(BuildContext context) { - final dateFormat = DateFormat('EEEE, MMMM d, yyyy'); - final timeFormat = DateFormat('h:mm a'); - - return ListView( - controller: scrollController, - padding: const EdgeInsets.all(AppSpacing.lg), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Handle - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppTheme.textMuted, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: AppSpacing.md), - - // Action Buttons Row - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - // Edit Button - TextButton.icon( - onPressed: () => _editSession(context), - icon: const Icon(Icons.edit_outlined, size: 18), - label: const Text('Edit'), - style: TextButton.styleFrom( - foregroundColor: AppTheme.primaryColor, - ), - ), - const SizedBox(width: 8), - // Delete Button - TextButton.icon( - onPressed: () => _confirmDelete(context), - icon: const Icon(Icons.delete_outline, size: 18), - label: const Text('Delete'), - style: TextButton.styleFrom(foregroundColor: AppTheme.error), - ), - ], - ), - - const SizedBox(height: AppSpacing.sm), - - // Header - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - dateFormat.format(session.date), - style: Theme.of(context).textTheme.titleLarge, - ), - Text( - '${timeFormat.format(session.date)} • ${session.duration} minutes', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ), - if (session.hcSyncedAt != null) - Tooltip( - message: - 'Synced to Health Connect\n${DateFormat('MMM d, h:mm a').format(session.hcSyncedAt!)}', - child: const Icon(Icons.monitor_heart, color: _hcColor, size: 20), - ), - ], - ), - - const SizedBox(height: AppSpacing.lg), - - // Stats row - Row( - children: [ - Expanded( - child: _StatBox( - value: '${session.exercises.length}', - label: 'Exercises', - color: AppTheme.primaryColor, - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: _StatBox( - value: - '${session.exercises.fold(0, (sum, e) => sum + e.sets.length)}', - label: 'Total Sets', - color: AppTheme.secondaryColor, + Padding( + padding: const EdgeInsets.only(top: 20, bottom: 10), + child: Row( + children: [ + Text( + month, + style: TextStyle(fontFamily: 'Geist', + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSoft, + ), ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: _StatBox( - value: '${(session.totalVolume / 1000).toStringAsFixed(1)}k', - label: 'Volume (kg)', - color: AppTheme.success, + const SizedBox(width: 10), + Expanded(child: Container(height: 1, color: AppColors.glassBorder)), + const SizedBox(width: 10), + Text( + '${sessions.length}', + style: TextStyle(fontFamily: 'GeistMono', fontSize: 11, color: AppColors.textMuted), ), - ), - ], + ], + ), ), - - const SizedBox(height: AppSpacing.lg), - const Divider(), - const SizedBox(height: AppSpacing.md), - - // Exercises - ...session.exercises.map( - (log) => _ExerciseDetailCard(log: log, provider: provider), + ...sessions.map( + (s) => _HistoryCard( + session: s, + provider: provider, + historyManager: historyManager, + showSync: settings.healthConnectEnabled && s.hcSyncedAt == null, + ), ), - - if (session.notes != null && session.notes!.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.lg), - Text('Notes', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: AppSpacing.sm), - Text(session.notes!, style: Theme.of(context).textTheme.bodyMedium), - ], ], ); } +} + +// ── Per-session card ──────────────────────────────────────────────────────────── + +class _HistoryCard extends StatelessWidget { + const _HistoryCard({ + required this.session, + required this.provider, + required this.historyManager, + required this.showSync, + }); + + final WorkoutSession session; + final WorkoutProvider provider; + final HistoryManager historyManager; + final bool showSync; - void _editSession(BuildContext context) { - final navigator = Navigator.of(context); - navigator.pop(); - navigator.push( - MaterialPageRoute( - builder: (context) => EditWorkoutSessionScreen(session: session), + void _openDetails(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (ctx, sc) => SessionDetailsSheet( + session: session, + provider: provider, + scrollController: sc, + onEdit: () { + Navigator.of(ctx).pop(); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => EditWorkoutSessionScreen(session: session)), + ); + }, + onDelete: () async { + final confirmed = await _confirmDelete(context); + if (confirmed && ctx.mounted) Navigator.of(ctx).pop(); + }, + ), ), ); } - Future _confirmDelete(BuildContext context) async { + Future _confirmDelete(BuildContext context) async { final confirmed = await showDialog( context: context, - builder: (context) => AlertDialog( - backgroundColor: AppTheme.cardColor, - title: const Text('Delete Workout?'), + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.lg)), + title: const Text('Delete Workout?', style: TextStyle(color: AppColors.textPrimary)), content: Text( - 'Are you sure you want to delete this workout from ${DateFormat('MMMM d, yyyy').format(session.date)}? This action cannot be undone.', + 'Delete workout from ${DateFormat('MMMM d, yyyy').format(session.date)}? This cannot be undone.', + style: const TextStyle(color: AppColors.textSoft), ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel', style: TextStyle(color: AppColors.textSoft)), ), TextButton( - onPressed: () => Navigator.of(context).pop(true), - style: TextButton.styleFrom(foregroundColor: AppTheme.error), + onPressed: () => Navigator.of(ctx).pop(true), + style: TextButton.styleFrom(foregroundColor: AppColors.error), child: const Text('Delete'), ), ], @@ -618,198 +583,165 @@ class _SessionDetailsSheet extends StatelessWidget { ); if (confirmed == true && context.mounted) { - final navigator = Navigator.of(context); final messenger = ScaffoldMessenger.of(context); try { await provider.deleteWorkoutSession(session.id); if (context.mounted) { - navigator.pop(); - messenger.showSnackBar( - const SnackBar( - content: Row( - children: [ - Icon(Icons.check_circle, color: AppTheme.success), - SizedBox(width: 8), - Text('Workout deleted'), - ], - ), - backgroundColor: AppTheme.cardColor, - ), - ); + messenger.showSnackBar(_snackBar('Workout deleted')); } + return true; } catch (e) { - debugPrint('Failed to delete workout session: $e'); if (context.mounted) { - messenger.showSnackBar( - const SnackBar( - content: Text('Failed to delete workout. Please try again.'), - backgroundColor: AppTheme.error, - ), - ); + messenger.showSnackBar(_snackBar('Failed to delete workout', isError: true)); } } } + return false; } -} - -class _StatBox extends StatelessWidget { - final String value; - final String label; - final Color color; - - const _StatBox({ - required this.value, - required this.label, - required this.color, - }); - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - children: [ - Text( - value, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: color, - ), - ), - const SizedBox(height: 4), - Text( - label, - style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), - ), - ], - ), - ); + void _handleMenu(BuildContext context, String value) { + if (value == 'edit') { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => EditWorkoutSessionScreen(session: session)), + ); + } else if (value == 'sync') { + historyManager.syncSession(session); + ScaffoldMessenger.of(context).showSnackBar(_snackBar('Syncing to Health Connect…')); + } else if (value == 'delete') { + _confirmDelete(context); + } } -} - -class _ExerciseDetailCard extends StatelessWidget { - final ExerciseLog log; - final WorkoutProvider provider; - - const _ExerciseDetailCard({required this.log, required this.provider}); @override Widget build(BuildContext context) { - final exercise = provider.getExercise(log.exerciseId); - - return Container( - margin: const EdgeInsets.only(bottom: AppSpacing.md), - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + final dayAbbr = DateFormat('EEE').format(session.date); + final dayNum = session.date.day; + final exCount = session.exercises.length; + final setCount = session.exercises.fold(0, (s, e) => s + e.sets.length); + final settings = context.read(); + final vol = settings.toDisplay(session.totalVolume); + final volStr = vol >= 1000 ? '${(vol / 1000).toStringAsFixed(1)}k' : vol.toStringAsFixed(0); + final duration = session.duration; + final routineName = session.routineId != null + ? provider.routines.cast().firstWhere( + (r) => r?.id == session.routineId, orElse: () => null)?.name ?? 'Workout' + : 'Quick Workout'; + + return GestureDetector( + onTap: () => _openDetails(context), + child: Padding( + padding: const EdgeInsets.only(bottom: 10), + child: GlassCard( + padding: EdgeInsets.zero, + child: Row( children: [ - Text( - exercise?.name ?? 'Unknown Exercise', - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - Text( - '${log.sets.length} sets', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, + // Date column + Container( + width: 56, + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: const BorderRadius.horizontal(left: Radius.circular(18)), ), - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - ...log.sets.asMap().entries.map((entry) { - final index = entry.key; - final set = entry.value; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), + child: Column( + children: [ + Text( + dayAbbr.toUpperCase(), + style: TextStyle(fontFamily: 'Geist', fontSize: 9, fontWeight: FontWeight.w600, color: AppColors.textMuted, letterSpacing: 0.6), ), - child: Center( - child: Text( - '${index + 1}', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: AppTheme.primaryColor, - ), - ), + const SizedBox(height: 2), + Text( + '$dayNum', + style: TextStyle(fontFamily: 'GeistMono', fontSize: 20, fontWeight: FontWeight.w700, color: AppColors.textPrimary), ), - ), - const SizedBox(width: 12), - Text( - '${set.weight} kg × ${set.reps} reps', - style: const TextStyle(color: AppTheme.textPrimary), - ), - const Spacer(), - Text( - '${set.volume.toStringAsFixed(0)} kg', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - if (set.isDropset) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, + ], + ), + ), + // Vertical divider + Container(width: 1, height: 56, color: AppColors.glassBorder), + const SizedBox(width: 12), + // Content + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + routineName, + style: TextStyle(fontFamily: 'Geist', fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), + const SizedBox(height: 3), + Text( + '$exCount exercises · $setCount sets${duration > 0 ? ' · ${duration}m' : ''}', + style: TextStyle(fontFamily: 'Geist', fontSize: 11, color: AppColors.textMuted), ), - child: const Text( - 'DROP', - style: TextStyle( - color: AppTheme.warning, - fontSize: 10, - fontWeight: FontWeight.bold, - ), + ], + ), + ), + ), + // Volume + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + volStr, + style: TextStyle(fontFamily: 'GeistMono', + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.secondary, ), ), + Text(settings.unitLabel, style: TextStyle(fontFamily: 'Geist', fontSize: 10, color: AppColors.textMuted)), ], - ], - ), - ); - }), - const SizedBox(height: AppSpacing.sm), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text( - 'Total: ${log.totalVolume.toStringAsFixed(0)} kg', - style: const TextStyle( - color: AppTheme.success, - fontWeight: FontWeight.w600, ), ), + // Menu + PopupMenuButton( + color: AppColors.cardHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.md)), + icon: const Icon(Icons.more_vert_rounded, color: AppColors.textMuted, size: 18), + onSelected: (v) => _handleMenu(context, v), + itemBuilder: (_) => [ + _menuItem(value: 'edit', icon: Icons.edit_outlined, label: 'Edit', color: AppColors.primary), + if (showSync) + _menuItem(value: 'sync', icon: Icons.favorite_outlined, label: 'Sync to Health Connect', color: _hcColor), + _menuItem(value: 'delete', icon: Icons.delete_outline, label: 'Delete', color: AppColors.error), + ], + ), ], ), + ), + ), + ); + } + + PopupMenuItem _menuItem({required String value, required IconData icon, required String label, required Color color}) { + return PopupMenuItem( + value: value, + child: Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 10), + Text(label, style: TextStyle(color: color, fontSize: 14)), ], ), ); } } + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +SnackBar _snackBar(String msg, {bool isError = false}) { + return SnackBar( + content: Text(msg, style: const TextStyle(color: AppColors.textPrimary)), + backgroundColor: isError ? AppColors.error : AppColors.cardHigh, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.md)), + duration: const Duration(seconds: 2), + ); +} diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index ba453a7..4be9887 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -1,18 +1,32 @@ -// Home Screen - Dashboard with quick actions and stats +// home_screen.dart — Navigation shell + Dashboard tab (soft-futurist redesign) import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; +import '../models/models.dart'; import '../services/workout_provider.dart'; +import '../services/settings_provider.dart'; +import '../services/ai/gemini_ai_service.dart'; +import '../services/gemini_context_builder.dart'; import '../theme/app_theme.dart'; import 'workout_flow_screen.dart'; import 'history_screen.dart'; import 'routines_screen.dart'; import 'analytics_screen.dart'; -import 'exercise_library_screen.dart'; import 'profile_screen.dart'; import 'widgets/workout_conflict_dialog.dart'; +import 'ai_coach_screen.dart'; +import 'widgets/readiness_card.dart'; +import 'widgets/sleep_hr_card.dart'; +import 'widgets/heart_rate_card.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/sparkline_painter.dart'; +import 'widgets/activity_heatmap.dart'; +import 'widgets/body_heatmap.dart'; + +// ── HomeScreen ──────────────────────────────────────────────────────────────── class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -24,715 +38,1336 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State { int _currentIndex = 0; + static const _navItems = [ + RFNavItem(icon: Icons.home_rounded, label: 'Home'), + RFNavItem(icon: Icons.layers_rounded, label: 'Routines'), + RFNavItem(icon: Icons.history_rounded, label: 'History'), + RFNavItem(icon: Icons.bar_chart_rounded, label: 'Stats'), + ]; + + void switchTab(int index) => setState(() => _currentIndex = index); + @override Widget build(BuildContext context) { return Scaffold( + extendBody: true, + backgroundColor: AppColors.background, body: IndexedStack( index: _currentIndex, children: const [ - DashboardTab(), - HistoryScreen(), + _DashboardTab(), RoutinesScreen(), + HistoryScreen(), AnalyticsScreen(), - ProfileScreen(), ], ), - bottomNavigationBar: Container( - decoration: BoxDecoration( - color: AppTheme.surfaceColor, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 10, - offset: const Offset(0, -2), - ), - ], - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildNavItem(0, Icons.home_rounded, 'Home'), - _buildNavItem(1, Icons.history_rounded, 'History'), - _buildNavItem(2, Icons.list_alt_rounded, 'Routines'), - _buildNavItem(3, Icons.analytics_rounded, 'Analytics'), - _buildNavItem(4, Icons.person_rounded, 'Profile'), - ], - ), - ), - ), + bottomNavigationBar: RFNavBar( + currentIndex: _currentIndex, + onTap: switchTab, + items: _navItems, ), ); } - Widget _buildNavItem(int index, IconData icon, String label) { - final isSelected = _currentIndex == index; - return GestureDetector( - onTap: () => setState(() => _currentIndex = index), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: isSelected - ? AppTheme.primaryColor.withOpacity(0.2) - : Colors.transparent, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - color: isSelected - ? AppTheme.primaryColor - : AppTheme.textSecondary, - size: 24, - ), - const SizedBox(height: 4), - Text( - label, - style: TextStyle( - color: isSelected - ? AppTheme.primaryColor - : AppTheme.textSecondary, - fontSize: 12, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - ), - ), - ], - ), + Future _resolveConflict( + BuildContext context, + WorkoutProvider provider, + ) async { + final action = await showWorkoutConflictDialog( + context, + workoutStartTime: provider.workoutStartTime ?? DateTime.now(), + ); + return action ?? StartWorkoutConflictAction.cancel; + } + + void _resumeWorkout(BuildContext context) { + Navigator.push(context, _slide(const WorkoutFlowScreen(isQuickStart: true))); + } + + Future _startQuickWorkout(BuildContext context) async { + final provider = context.read(); + StartWorkoutConflictAction conflictAction = StartWorkoutConflictAction.cancel; + + final started = await provider.startWorkoutSafely( + exerciseIds: const [], + onConflict: () async { + conflictAction = await _resolveConflict(context, provider); + return conflictAction; + }, + ); + + if (!context.mounted) return; + if (started || conflictAction == StartWorkoutConflictAction.resume) { + HapticFeedback.mediumImpact(); + Navigator.push(context, _slide(const WorkoutFlowScreen(isQuickStart: true))); + } + } + + Future startRoutineWorkout(BuildContext context, Routine routine) async { + final provider = context.read(); + StartWorkoutConflictAction conflictAction = StartWorkoutConflictAction.cancel; + + final started = await provider.startWorkoutSafely( + routine: routine, + onConflict: () async { + conflictAction = await _resolveConflict(context, provider); + return conflictAction; + }, + ); + + if (!context.mounted) return; + if (started || conflictAction == StartWorkoutConflictAction.resume) { + HapticFeedback.mediumImpact(); + Navigator.push(context, _slide(WorkoutFlowScreen(routine: routine))); + } + } + + void _showRoutineSelector(BuildContext context) { + final provider = context.read(); + showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (sheetCtx) => _RoutineSelectorSheet( + routines: provider.routines, + onSelect: (r) { + Navigator.pop(sheetCtx); + startRoutineWorkout(context, r); + }, + onQuickStart: () { + Navigator.pop(sheetCtx); + _startQuickWorkout(context); + }, ), ); } } -class DashboardTab extends StatelessWidget { - const DashboardTab({super.key}); +// ── Dashboard Tab ───────────────────────────────────────────────────────────── + +class _DashboardTab extends StatelessWidget { + const _DashboardTab(); + + String _greeting() { + final h = DateTime.now().hour; + if (h < 12) return 'Good morning,'; + if (h < 17) return 'Good afternoon,'; + return 'Good evening,'; + } + + // Generate deterministic 14-week heatmap (98 cells, col-major) + List _buildHeatmapData(List sessions) { + final nowRaw = DateTime.now(); + final now = DateTime(nowRaw.year, nowRaw.month, nowRaw.day); + final data = List.filled(98, 0); + for (final s in sessions) { + final sessionDate = DateTime(s.date.year, s.date.month, s.date.day); + final diff = now.difference(sessionDate).inDays; + if (diff < 0 || diff >= 98) continue; + final col = (97 - diff) ~/ 7; + final row = (97 - diff) % 7; + final idx = col * 7 + row; + if (idx >= 0 && idx < 98) { + data[idx] = (data[idx] + 1).clamp(0, 4); + } + } + return data; + } @override Widget build(BuildContext context) { - return SafeArea( - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeader(context), - const SizedBox(height: AppSpacing.lg), - _buildQuickStartCard(context), - const SizedBox(height: AppSpacing.lg), - _buildStatsSection(context), - const SizedBox(height: AppSpacing.lg), - _buildRecentWorkouts(context), - const SizedBox(height: AppSpacing.lg), - _buildQuickActions(context), - ], - ), - ), + final provider = context.watch(); + final homeState = context.findAncestorStateOfType<_HomeScreenState>(); + + return Stack( + children: [ + const AmbientGlow(), + SafeArea( + bottom: false, + child: LayoutBuilder( + builder: (context, constraints) { + final hp = AppBreakpoints.hPadding(constraints.maxWidth); + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: AppBreakpoints.contentMaxWidth, + ), + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB(hp, 14, hp, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context, homeState), + const SizedBox(height: 24), + _buildStreakHero(context: context, provider: provider, homeState: homeState), + const SizedBox(height: 16), + const ReadinessCard(), + const SleepHrCard(), + const HeartRateCard(), + _buildStatsGrid(context, provider), + const SizedBox(height: 16), + _buildHeatmapCard(context, provider), + const SizedBox(height: 16), + _buildMuscleVolumeCard(context, provider), + const SizedBox(height: 16), + const _WeeklyInsightsCard(), + const SizedBox(height: 16), + _buildRecentWorkouts(context: context, provider: provider, homeState: homeState), + const SizedBox(height: 100), + ], + ), + ), + ), + ], + ), + ), + ); + }, ), - ], - ), + ), + ], ); } - Widget _buildHeader(BuildContext context) { + Widget _buildHeader(BuildContext context, _HomeScreenState? homeState) { final now = DateTime.now(); - final greeting = now.hour < 12 - ? 'Good morning' - : (now.hour < 17 ? 'Good afternoon' : 'Good evening'); - + final dateStr = DateFormat('EEEE · MMM d').format(now); return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - greeting, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary), + dateStr.toUpperCase(), + style: TextStyle(fontFamily: 'Geist', + fontSize: 12, + color: AppColors.textMuted, + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + ), ), const SizedBox(height: 4), - Text( - 'Ready to crush it? 💪', - style: Theme.of(context).textTheme.headlineMedium, + RichText( + text: TextSpan( + style: TextStyle(fontFamily: 'Geist', + fontSize: 28, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + letterSpacing: -1.12, + height: 1.05, + ), + children: [ + TextSpan(text: '${_greeting()}\n'), + TextSpan( + text: '${context.watch().userName ?? 'You'}.', + style: const TextStyle(color: AppColors.textMuted), + ), + ], + ), ), ], ), - IconButton( - onPressed: () { - // Navigate to Profile tab (index 4) - final homeState = context - .findAncestorStateOfType<_HomeScreenState>(); - if (homeState != null) { - homeState.setState(() => homeState._currentIndex = 4); - } - }, - icon: const Icon(Icons.person_rounded), - color: AppTheme.textPrimary, + Row( + children: [ + GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const AiCoachScreen()), + ), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: const LinearGradient( + colors: [AppColors.primary, Color(0xFF5B21B6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primaryGlow(0.35), + blurRadius: 12, + spreadRadius: -3, + ), + ], + ), + child: const Icon( + Icons.auto_awesome_rounded, + size: 18, + color: Colors.white, + ), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ProfileScreen()), + ), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: AppColors.glass2, + border: Border.all(color: AppColors.glassBorder), + ), + child: const Icon( + Icons.person_outline_rounded, + size: 18, + color: AppColors.textSoft, + ), + ), + ), + ], ), ], ); } - Widget _buildQuickStartCard(BuildContext context) { - final provider = context.watch(); + Widget _buildStreakHero({ + required BuildContext context, + required WorkoutProvider provider, + _HomeScreenState? homeState, + }) { + final sessions = provider.sessions; + // Calculate current streak + int streak = 0; + final today = DateTime.now(); + for (int i = 0; i < 60; i++) { + final d = today.subtract(Duration(days: i)); + final hasWorkout = sessions.any((s) => + s.date.year == d.year && + s.date.month == d.month && + s.date.day == d.day); + if (hasWorkout) { + streak++; + } else if (i > 0) { + break; + } + } - return Container( - width: double.infinity, - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppTheme.primaryColor, Color(0xFF8B7FE8)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.lg), - boxShadow: [ - BoxShadow( - color: AppTheme.primaryColor.withOpacity(0.4), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + // Week dots (Mon–Sun) + final weekDays = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; + final weekStart = today.subtract(Duration(days: today.weekday - 1)); + final hasWorkoutDays = List.generate(7, (i) { + final d = weekStart.add(Duration(days: i)); + return sessions.any((s) => + s.date.year == d.year && + s.date.month == d.month && + s.date.day == d.day); + }); + final todayWeekday = today.weekday - 1; // 0=Mon + + final isActive = provider.hasActiveWorkout; + + return GlassCard( + padding: const EdgeInsets.all(20), + child: Stack( + clipBehavior: Clip.none, children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(12), + // Ambient blob top-right + Positioned( + top: -40, + right: -40, + child: IgnorePointer( + child: Container( + width: 180, + height: 180, decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.play_arrow_rounded, - color: Colors.white, - size: 28, + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.primary.withValues(alpha: 0.15), + Colors.transparent, + ], + ), ), ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Start Workout', - style: TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.local_fire_department_rounded, + size: 14, + color: AppColors.primary, + ), + const SizedBox(width: 6), + Text( + 'STREAK', + style: TextStyle(fontFamily: 'Geist', + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.primary, + letterSpacing: 0.5, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '$streak', + style: TextStyle(fontFamily: 'GeistMono', + fontSize: 56, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + letterSpacing: -2.24, + height: 1, + ), + ), + const SizedBox(width: 6), + Text( + 'days', + style: TextStyle(fontFamily: 'Geist', + fontSize: 16, + color: AppColors.textMuted, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + streak == 0 + ? 'Start your streak today' + : 'Keep it going — you\'re on a roll', + style: TextStyle(fontFamily: 'Geist', + fontSize: 13, + color: AppColors.textMuted, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: isActive + ? () => homeState?._resumeWorkout(context) + : () => homeState?._showRoutineSelector(context), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: isActive ? AppColors.warning : AppColors.primary, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withValues(alpha: 0.18), + ), + boxShadow: [ + BoxShadow( + color: (isActive ? AppColors.warning : AppColors.primary) + .withValues(alpha: 0.35), + blurRadius: 16, + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isActive + ? Icons.play_arrow_rounded + : Icons.flash_on_rounded, + size: 12, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + isActive ? 'Resume' : 'Start', + style: TextStyle(fontFamily: 'Geist', + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], ), ), - Text( - provider.routines.isEmpty - ? 'Quick start or create a routine' - : '${provider.routines.length} routines available', - style: TextStyle( - color: Colors.white.withOpacity(0.8), - fontSize: 14, + ), + ], + ), + const SizedBox(height: 18), + // Week dots + Row( + children: List.generate(7, (i) { + final done = hasWorkoutDays[i]; + final isToday = i == todayWeekday; + final isFuture = i > todayWeekday; + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Column( + children: [ + Text( + weekDays[i], + style: TextStyle(fontFamily: 'Geist', + fontSize: 10, + color: AppColors.textFaint, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 6), + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 6, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: done + ? AppColors.primary + : isToday + ? AppColors.primary.withValues(alpha: 0.3) + : isFuture + ? const Color(0x00000000) + : const Color(0x0FFFFFFF), + border: isToday + ? Border.all( + color: AppColors.primary, + width: 1, + ) + : null, + boxShadow: done + ? [ + BoxShadow( + color: AppColors.primary + .withValues(alpha: 0.4), + blurRadius: 6, + ), + ] + : null, + ), + ), + ], ), ), - ], - ), + ); + }), ), ], ), - const SizedBox(height: AppSpacing.lg), + ], + ), + ); + } + + Widget _buildStatsGrid(BuildContext context, WorkoutProvider provider) { + final sessions = provider.sessions; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final weekStart = today.subtract(Duration(days: today.weekday - 1)); + final weekEnd = weekStart.add(const Duration(days: 7)); + final weekSessions = sessions + .where((s) => !s.date.isBefore(weekStart) && s.date.isBefore(weekEnd)) + .toList(); + + final settings = context.read(); + final weekVol = weekSessions.fold(0, (s, e) => s + e.totalVolume); + final displayVol = settings.toDisplay(weekVol); + final weekSets = + weekSessions.fold(0, (s, e) => s + e.exercises.fold(0, (a, ex) => a + ex.sets.length)); + final avgDuration = sessions.isEmpty + ? 0 + : sessions.take(7).fold(0, (s, e) => s + e.duration) ~/ + sessions.take(7).length; + + // Sparkline data (last 7 weeks), anchored to midnight boundaries + List weeklyWorkouts = List.generate(7, (i) { + final wStart = today.subtract(Duration(days: (6 - i) * 7 + today.weekday - 1)); + final wEnd = wStart.add(const Duration(days: 7)); + return sessions.where((s) => !s.date.isBefore(wStart) && s.date.isBefore(wEnd)).length.toDouble(); + }); + List weeklyVolumes = List.generate(7, (i) { + final wStart = today.subtract(Duration(days: (6 - i) * 7 + today.weekday - 1)); + final wEnd = wStart.add(const Duration(days: 7)); + final rawVol = sessions + .where((s) => !s.date.isBefore(wStart) && s.date.isBefore(wEnd)) + .fold(0, (s, e) => s + e.totalVolume); + return settings.toDisplay(rawVol); + }); + List weeklySets = List.generate(7, (i) { + final wStart = today.subtract(Duration(days: (6 - i) * 7 + today.weekday - 1)); + final wEnd = wStart.add(const Duration(days: 7)); + return sessions + .where((s) => !s.date.isBefore(wStart) && s.date.isBefore(wEnd)) + .fold(0, (s, e) => s + e.exercises.fold(0, (a, ex) => a + ex.sets.length)); + }); + List weeklyAvgDurations = List.generate(7, (i) { + final wStart = today.subtract(Duration(days: (6 - i) * 7 + today.weekday - 1)); + final wEnd = wStart.add(const Duration(days: 7)); + final ws = sessions.where((s) => !s.date.isBefore(wStart) && s.date.isBefore(wEnd)).toList(); + if (ws.isEmpty) return 0; + return ws.fold(0, (s, e) => s + e.duration) / ws.length; + }); + + final stats = [ + _StatItem( + label: 'This week', + value: '${weekSessions.length}', + unit: '/ 5 goal', + color: AppColors.primary, + spark: weeklyWorkouts, + ), + _StatItem( + label: 'Volume', + value: displayVol >= 1000 + ? '${(displayVol / 1000).toStringAsFixed(1)}k' + : displayVol.toStringAsFixed(0), + unit: settings.unitLabel, + color: AppColors.secondary, + spark: weeklyVolumes, + ), + _StatItem( + label: 'Sets', + value: '$weekSets', + unit: 'this week', + color: AppColors.success, + spark: weeklySets, + ), + _StatItem( + label: 'Avg time', + value: '$avgDuration', + unit: 'min', + color: AppColors.warning, + spark: weeklyAvgDurations, + ), + ]; + + return LayoutBuilder( + builder: (context, constraints) { + final cols = AppBreakpoints.gridColumns(constraints.maxWidth); + final ratio = cols == 4 ? 1.8 : 1.4; + return GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: ratio, + ), + itemCount: stats.length, + itemBuilder: (_, i) => _StatCard(item: stats[i]), + ); + }, + ); + } + + Widget _buildHeatmapCard(BuildContext context, WorkoutProvider provider) { + final data = _buildHeatmapData(provider.sessions); + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded( - child: ElevatedButton( - onPressed: () => _startQuickWorkout(context), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: AppTheme.primaryColor, - ), - child: const Text('Quick Start'), + Text( + 'Activity', + style: TextStyle(fontFamily: 'Geist', + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, ), ), - if (provider.routines.isNotEmpty) ...[ - const SizedBox(width: 12), - Expanded( - child: OutlinedButton( - onPressed: () => _showRoutineSelector(context), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white), - ), - child: const Text('From Routine'), - ), + Text( + 'Last 14 weeks', + style: TextStyle(fontFamily: 'Geist', + fontSize: 11, + color: AppColors.textMuted, ), - ], + ), ], ), + const SizedBox(height: 14), + ActivityHeatmap(data: data), ], ), ); } - Widget _buildStatsSection(BuildContext context) { - return FutureBuilder>( - future: context.read().getQuickStats(), - builder: (context, snapshot) { - final stats = - snapshot.data ?? - { - 'totalWorkouts': 0, - 'weeklyWorkouts': 0, - 'weeklyVolume': 0.0, - 'exercisesThisWeek': 0, - }; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('This Week', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: AppSpacing.md), - Row( - children: [ - Expanded( - child: _StatCard( - icon: Icons.fitness_center, - value: '${stats['weeklyWorkouts']}', - label: 'Workouts', - color: AppTheme.primaryColor, - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: _StatCard( - icon: Icons.trending_up, - value: _formatVolume( - stats['weeklyVolume']?.toDouble() ?? 0, - ), - label: 'Volume (kg)', - color: AppTheme.success, - ), - ), - ], - ), - const SizedBox(height: AppSpacing.md), - Row( - children: [ - Expanded( - child: _StatCard( - icon: Icons.list_alt, - value: '${stats['exercisesThisWeek']}', - label: 'Exercises', - color: AppTheme.secondaryColor, - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: _StatCard( - icon: Icons.all_inclusive, - value: '${stats['totalWorkouts']}', - label: 'Total Sessions', - color: AppTheme.warning, - ), - ), - ], - ), - ], - ); - }, - ); - } + Widget _buildMuscleVolumeCard(BuildContext context, WorkoutProvider provider) { + final settings = Provider.of(context, listen: false); + final now = DateTime.now(); + final weekStart = now.subtract(Duration(days: now.weekday - 1)); + final weekSessions = provider.sessions + .where((s) => s.date.isAfter(weekStart.subtract(const Duration(days: 1)))) + .toList(); - String _formatVolume(double volume) { - if (volume >= 1000) { - return '${(volume / 1000).toStringAsFixed(1)}k'; + final muscleVols = {}; + for (final s in weekSessions) { + for (final el in s.exercises) { + final ex = provider.getExercise(el.exerciseId); + if (ex == null) continue; + final vol = settings.toDisplay(el.totalVolume); + for (final ma in ex.muscleActivations) { + muscleVols[ma.muscleGroupId] = + (muscleVols[ma.muscleGroupId] ?? 0) + vol * ma.activationPercentage / 100; + } + } } - return volume.toStringAsFixed(0); - } - Widget _buildRecentWorkouts(BuildContext context) { - final provider = context.watch(); - final recentSessions = provider.sessions.take(3).toList(); + final maxVol = muscleVols.values.isEmpty ? 1.0 : muscleVols.values.reduce((a, b) => a > b ? a : b); + final muscleList = muscleVols.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final topMuscles = muscleList.take(5).toList(); - if (recentSessions.isEmpty) { - return Container( - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: Column( - children: [ - Icon(Icons.fitness_center, size: 48, color: AppTheme.textMuted), - const SizedBox(height: AppSpacing.md), - Text( - 'No workouts yet', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text( - 'Start your first workout to see it here', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ); - } + final normalizedVols = { + for (final e in muscleVols.entries) e.key: e.value / maxVol + }; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Recent Workouts', - style: Theme.of(context).textTheme.titleLarge, - ), - TextButton( - onPressed: () { - final homeState = context - .findAncestorStateOfType<_HomeScreenState>(); - if (homeState != null) { - homeState.setState(() => homeState._currentIndex = 1); - } - }, - child: const Text('See All'), - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - ...recentSessions.map( - (session) => _RecentWorkoutCard(session: session), - ), - ], + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Weekly muscle volume', + style: TextStyle(fontFamily: 'Geist', + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + Text( + settings.unitLabel, + style: TextStyle(fontFamily: 'Geist', + fontSize: 11, + color: AppColors.textMuted, + ), + ), + ], + ), + const SizedBox(height: 14), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BodyHeatmapWidget(muscleVolumes: normalizedVols), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: topMuscles.isEmpty + ? [ + Text( + 'No data yet', + style: TextStyle(fontFamily: 'Geist', + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ] + : topMuscles.map((e) { + final color = AppColors.muscle(e.key); + final pct = (e.value / maxVol).clamp(0.0, 1.0); + final volStr = e.value >= 1000 + ? '${(e.value / 1000).toStringAsFixed(1)}k' + : e.value.toStringAsFixed(0); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _capitalize(e.key.replaceAll('_', ' ')), + style: TextStyle(fontFamily: 'Geist', + fontSize: 11, + color: AppColors.textSoft, + ), + ), + Text( + volStr, + style: TextStyle(fontFamily: 'GeistMono', + fontSize: 11, + color: AppColors.textMuted, + ), + ), + ], + ), + const SizedBox(height: 3), + Container( + height: 4, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: AppColors.glass2, + ), + child: FractionallySizedBox( + widthFactor: pct, + alignment: Alignment.centerLeft, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: color, + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.4), + blurRadius: 4, + ), + ], + ), + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ), + ], + ), ); } - Widget _buildQuickActions(BuildContext context) { + Widget _buildRecentWorkouts({ + required BuildContext context, + required WorkoutProvider provider, + _HomeScreenState? homeState, + }) { + final settings = context.read(); + final recentSessions = provider.sessions.take(3).toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Quick Actions', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: AppSpacing.md), - Row( - children: [ - Expanded( - child: _QuickActionCard( - icon: Icons.add_circle_outline, - label: 'New Routine', - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const RoutinesScreen()), + Padding( + padding: const EdgeInsets.only(bottom: 10, left: 4, right: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Recent workouts', + style: TextStyle(fontFamily: 'Geist', + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, ), ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: _QuickActionCard( - icon: Icons.library_books_outlined, - label: 'Exercises', - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const ExerciseLibraryScreen(), + GestureDetector( + onTap: () => homeState?.switchTab(2), + child: Text( + 'See all', + style: TextStyle(fontFamily: 'Geist', + fontSize: 12, + color: AppColors.primary, + fontWeight: FontWeight.w500, ), ), ), - ), - ], + ], + ), ), + if (recentSessions.isEmpty) + GlassCard( + padding: const EdgeInsets.all(16), + child: Center( + child: Text( + 'No workouts yet — start one!', + style: TextStyle(fontFamily: 'Geist', + fontSize: 13, + color: AppColors.textMuted, + ), + ), + ), + ) + else + ...recentSessions.map((s) { + final dateStr = _formatSessionDate(s.date); + final displayVol = settings.toDisplay(s.totalVolume); + final volStr = displayVol >= 1000 + ? '${(displayVol / 1000).toStringAsFixed(1)}k' + : displayVol.toStringAsFixed(0); + final exCount = s.exercises.length; + final setCount = s.exercises.fold(0, (a, e) => a + e.sets.length); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: GlassCard( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Container( + width: 4, + height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: AppColors.primary, + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.4), + blurRadius: 6, + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + s.routineId != null + ? (provider.routines.cast().firstWhere((r) => r?.id == s.routineId, orElse: () => null)?.name ?? 'Workout') + : 'Quick Workout', + style: TextStyle(fontFamily: 'Geist', + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + '$dateStr · $exCount exercises · $setCount sets', + style: TextStyle(fontFamily: 'Geist', + fontSize: 11, + color: AppColors.textMuted, + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + volStr, + style: TextStyle(fontFamily: 'GeistMono', + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.secondary, + ), + ), + Text( + '${settings.unitLabel} vol', + style: TextStyle(fontFamily: 'Geist', + fontSize: 10, + color: AppColors.textFaint, + ), + ), + ], + ), + ], + ), + ), + ); + }), ], ); } - Future _resolveWorkoutConflict( - BuildContext context, - WorkoutProvider provider, - ) async { - final action = await showWorkoutConflictDialog( - context, - workoutStartTime: provider.workoutStartTime ?? DateTime.now(), - ); - return action ?? StartWorkoutConflictAction.cancel; + String _formatSessionDate(DateTime d) { + final now = DateTime.now(); + final diff = now.difference(d).inDays; + if (diff == 0) return 'Today'; + if (diff == 1) return 'Yesterday'; + if (diff < 7) return '${diff}d ago'; + return DateFormat('MMM d').format(d); } - Future _startQuickWorkout(BuildContext context) async { - final provider = context.read(); - StartWorkoutConflictAction conflictAction = - StartWorkoutConflictAction.cancel; + String _capitalize(String s) => + s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); +} - final started = await provider.startWorkoutSafely( - exerciseIds: const [], - onConflict: () async { - conflictAction = await _resolveWorkoutConflict(context, provider); - return conflictAction; - }, - ); +// ── Stat helpers ────────────────────────────────────────────────────────────── - if (!context.mounted) return; - if (started || conflictAction == StartWorkoutConflictAction.resume) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const WorkoutFlowScreen(isQuickStart: true), - ), - ); - } - } +class _StatItem { + const _StatItem({ + required this.label, + required this.value, + required this.unit, + required this.color, + required this.spark, + }); + final String label; + final String value; + final String unit; + final Color color; + final List spark; +} - void _showRoutineSelector(BuildContext context) { - final provider = context.read(); +class _StatCard extends StatelessWidget { + const _StatCard({required this.item}); + final _StatItem item; - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (sheetContext) => Container( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Select Routine', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: AppSpacing.md), - ...provider.routines.map( - (routine) => ListTile( - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.fitness_center, - color: AppTheme.primaryColor, - ), + @override + Widget build(BuildContext context) { + return GlassCard( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.label, + style: TextStyle(fontFamily: 'Geist', + fontSize: 11, + color: AppColors.textMuted, + fontWeight: FontWeight.w500, + letterSpacing: 0.2, ), - title: Text(routine.name), - subtitle: Text('${routine.exerciseIds.length} exercises'), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - Navigator.pop(sheetContext); - - StartWorkoutConflictAction conflictAction = - StartWorkoutConflictAction.cancel; - final started = await provider.startWorkoutSafely( - routine: routine, - onConflict: () async { - conflictAction = await _resolveWorkoutConflict( - context, - provider, - ); - return conflictAction; - }, - ); - - if (!context.mounted) return; - if (started || - conflictAction == StartWorkoutConflictAction.resume) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => WorkoutFlowScreen(routine: routine), - ), - ); - } - }, ), - ), - const SizedBox(height: AppSpacing.md), - ], - ), + Sparkline( + data: item.spark, + color: item.color, + width: 42, + height: 16, + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.value, + style: TextStyle(fontFamily: 'GeistMono', + fontSize: 26, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + letterSpacing: -0.52, + ), + ), + Text( + item.unit, + style: TextStyle(fontFamily: 'Geist', + fontSize: 11, + color: AppColors.textMuted, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ], ), ); } } -class _StatCard extends StatelessWidget { - final IconData icon; - final String value; - final String label; - final Color color; +// ── Routine Selector Sheet ──────────────────────────────────────────────────── - const _StatCard({ - required this.icon, - required this.value, - required this.label, - required this.color, +class _RoutineSelectorSheet extends StatelessWidget { + const _RoutineSelectorSheet({ + required this.routines, + required this.onSelect, + required this.onQuickStart, }); + final List routines; + final void Function(Routine) onSelect; + final VoidCallback onQuickStart; + @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, color: color, size: 20), + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration( + color: AppColors.textMuted, + borderRadius: BorderRadius.circular(AppRadius.full), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - value, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: RFSectionHeader('Select Routine'), + ), + Flexible( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + children: [ + ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: const Icon( + Icons.flash_on_rounded, + color: AppColors.primary, + size: 20, ), ), - Text( - label, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, + title: Text( + 'Quick Start', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontWeight: FontWeight.w600, ), ), - ], - ), + subtitle: Text( + 'Empty workout, no routine', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted), + ), + trailing: const Icon(Icons.play_arrow_rounded, color: AppColors.primary), + onTap: onQuickStart, + ), + ...routines.map((r) => ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: const Icon( + Icons.fitness_center_rounded, + color: AppColors.primary, + size: 20, + ), + ), + title: Text( + r.name, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + '${r.exerciseIds.length} exercises', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted), + ), + trailing: const Icon( + Icons.play_arrow_rounded, + color: AppColors.primary, + ), + onTap: () => onSelect(r), + )), + ], ), - ], - ), + ), + ], ); } } -class _RecentWorkoutCard extends StatelessWidget { - final dynamic session; +// ── Route helper ────────────────────────────────────────────────────────────── + +// Thin alias to the shared slideRoute helper in rf_widgets.dart. +PageRouteBuilder _slide(Widget page) => slideRoute(page); + +// ── Weekly Insights Card ────────────────────────────────────────────────────── + +class _WeeklyInsightsCard extends StatefulWidget { + const _WeeklyInsightsCard(); + + @override + State<_WeeklyInsightsCard> createState() => _WeeklyInsightsCardState(); +} + +class _WeeklyInsightsCardState extends State<_WeeklyInsightsCard> { + bool _loading = false; + + Future _refresh() async { + final gemini = context.read(); + if (!gemini.isConfigured) return; + + setState(() => _loading = true); + + final wp = context.read(); + final settings = context.read(); + + final exerciseMap = {for (final e in wp.allExercises) e.id: e}; + + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final startOfWeek = today.subtract(Duration(days: today.weekday - 1)); + final startOfLastWeek = startOfWeek.subtract(const Duration(days: 7)); + + final thisWeek = wp.sessions + .where((s) => !s.date.isBefore(startOfWeek) && s.date.isBefore(startOfWeek.add(const Duration(days: 7)))) + .toList(); + final lastWeek = wp.sessions + .where( + (s) => !s.date.isBefore(startOfLastWeek) && s.date.isBefore(startOfWeek), + ) + .toList(); + + final context_ = GeminiContextBuilder.buildWeeklyInsightsContext( + thisWeek: thisWeek, + lastWeek: lastWeek, + exerciseMap: exerciseMap, + unitLabel: settings.unitLabel, + ); - const _RecentWorkoutCard({required this.session}); + try { + final insights = await gemini.generateWeeklyInsights(context_); + if (mounted) await settings.saveWeeklyInsights(insights); + } catch (_) { + // silently ignore network/API errors + } finally { + if (mounted) setState(() => _loading = false); + } + } @override Widget build(BuildContext context) { - final provider = context.read(); - final dateFormat = DateFormat('MMM d, yyyy'); - final timeFormat = DateFormat('h:mm a'); - - return Container( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Row( + final gemini = context.watch(); + final settings = context.watch(); + + if (!gemini.isConfigured) return const SizedBox.shrink(); + + final insights = settings.weeklyInsights; + final updatedAt = settings.weeklyInsightsDate; + final hasInsights = insights.isNotEmpty; + + return GlassCard( + glowColor: AppColors.primary, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.fitness_center, - color: AppTheme.primaryColor, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - dateFormat.format(session.date), - style: const TextStyle( - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, + Row( + children: [ + Container( + padding: const EdgeInsets.all(7), + 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: 10, + spreadRadius: -3, + ), + ], ), - const SizedBox(height: 2), - Text( - '${session.exercises.length} exercises • ${session.duration} min', - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, + child: const Icon( + Icons.auto_awesome_rounded, + color: Colors.white, + size: 14, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'This Week\'s Insights', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, ), ), - ], - ), + ), + GestureDetector( + onTap: _loading ? null : _refresh, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.30), + ), + ), + child: _loading + ? const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ) + : Text( + hasInsights ? 'Refresh' : 'Generate', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.primary, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ + if (hasInsights || _loading) ...[ + const SizedBox(height: AppSpacing.md), + const Divider(color: AppColors.glassBorder, height: 1), + const SizedBox(height: AppSpacing.md), + if (_loading && !hasInsights) + const Padding( + padding: EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: RFLoadingDots(), + ) + else Text( - timeFormat.format(session.date), - style: const TextStyle(fontSize: 12, color: AppTheme.textMuted), + insights, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 13, + height: 1.6, + ), ), - const SizedBox(height: 2), + if (updatedAt != null && !_loading) ...[ + const SizedBox(height: AppSpacing.sm), Text( - '${(session.totalVolume / 1000).toStringAsFixed(1)}k kg', - style: const TextStyle( - fontSize: 12, - color: AppTheme.success, - fontWeight: FontWeight.w600, + 'Updated ${DateFormat('MMM d, h:mm a').format(updatedAt)}', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textFaint, + fontSize: 10, ), ), ], - ), - ], - ), - ); - } -} - -class _QuickActionCard extends StatelessWidget { - final IconData icon; - final String label; - final VoidCallback onTap; - - const _QuickActionCard({ - required this.icon, - required this.label, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all(color: AppTheme.surfaceColor), - ), - child: Column( - children: [ - Icon(icon, color: AppTheme.primaryColor, size: 28), - const SizedBox(height: 8), + ] else ...[ + const SizedBox(height: AppSpacing.sm), Text( - label, - style: const TextStyle( - color: AppTheme.textPrimary, - fontWeight: FontWeight.w500, + 'Tap Generate to get a personalised coaching summary for this week.', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 13, + height: 1.5, ), ), ], - ), + ], ), ); } diff --git a/workout-logger/lib/screens/onboarding_screen.dart b/workout-logger/lib/screens/onboarding_screen.dart new file mode 100644 index 0000000..2837231 --- /dev/null +++ b/workout-logger/lib/screens/onboarding_screen.dart @@ -0,0 +1,344 @@ +// onboarding_screen.dart — First-launch name prompt + version update modal. +// +// Shown by AppInitializer when: +// • userName == null → full welcome page asking for name +// • userName != null && version changed → version-update bottom sheet + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../services/settings_provider.dart'; +import '../theme/app_theme.dart'; +import 'widgets/rf_widgets.dart'; + +// ── Welcome page (first install) ────────────────────────────────────────────── + +class WelcomePage extends StatefulWidget { + const WelcomePage({super.key, required this.onComplete}); + + final VoidCallback onComplete; + + @override + State createState() => _WelcomePageState(); +} + +class _WelcomePageState extends State { + final _controller = TextEditingController(); + bool _saving = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_saving) return; + final name = _controller.text.trim(); + if (name.isEmpty) return; + + setState(() => _saving = true); + try { + final settings = context.read(); + await settings.setUserName(name); + final version = await settings.getCurrentVersion(); + await settings.markVersionSeen(version); + if (mounted) widget.onComplete(); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: Stack( + children: [ + const AmbientGlow(), + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(flex: 2), + // Logo mark + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.lg), + gradient: LinearGradient( + colors: [ + AppColors.primary, + AppColors.secondary.withValues(alpha: 0.8), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.5), + blurRadius: 32, + spreadRadius: 4, + ), + ], + ), + child: const Icon( + Icons.fitness_center_rounded, + color: Colors.white, + size: 36, + ), + ), + const SizedBox(height: AppSpacing.xl), + Text( + 'Welcome to\nRepForge', + style: TextStyle(fontFamily: 'Geist', + fontSize: 36, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + height: 1.1, + letterSpacing: -1, + ), + ), + const SizedBox(height: 12), + Text( + 'Track every rep. Beat every record.\nForge your best self.', + style: TextStyle(fontFamily: 'Geist', + fontSize: 15, + color: AppColors.textMuted, + height: 1.5, + ), + ), + const Spacer(flex: 2), + Text( + 'WHAT SHOULD WE CALL YOU?', + style: TextStyle(fontFamily: 'Geist', + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textFaint, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: AppSpacing.sm), + TextField( + controller: _controller, + autofocus: true, + textCapitalization: TextCapitalization.words, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + decoration: InputDecoration( + hintText: 'Your name', + hintStyle: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint), + filled: true, + fillColor: AppColors.glass2, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: AppColors.glassBorder), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: AppColors.glassBorder), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: AppColors.primary, width: 1.5), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: GlowButton( + label: _saving ? 'Setting up…' : "Let's Go!", + icon: Icons.arrow_forward_rounded, + onPressed: _saving ? null : _submit, + ), + ), + const Spacer(flex: 1), + ], + ), + ), + ), + ], + ), + ); + } +} + +// ── Version-update bottom sheet ─────────────────────────────────────────────── + +Future showVersionUpdateSheet( + BuildContext context, + String version, +) async { + await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => _VersionUpdateSheet(version: version), + ); +} + +class _VersionUpdateSheet extends StatelessWidget { + const _VersionUpdateSheet({required this.version}); + + final String version; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + border: Border(top: BorderSide(color: AppColors.glassBorder)), + ), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 40), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.glassBorder, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.new_releases_rounded, + color: AppColors.primary, + size: 22, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Updated to v$version', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + Text( + 'RepForge is better than ever', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 20), + const _WhatsNewItem( + icon: Icons.emoji_events_rounded, + color: AppColors.warning, + title: 'Personal Records', + description: 'Automatically tracks your best weight, reps, and volume for every exercise.', + ), + const SizedBox(height: 12), + const _WhatsNewItem( + icon: Icons.bar_chart_rounded, + color: AppColors.secondary, + title: 'Records Tab', + description: 'View all your PRs at a glance in Analytics → Records.', + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: GlowButton( + label: "Let's Crush It", + icon: Icons.check_rounded, + onPressed: () => Navigator.pop(context), + ), + ), + ], + ), + ); + } +} + +class _WhatsNewItem extends StatelessWidget { + const _WhatsNewItem({ + required this.icon, + required this.color, + required this.title, + required this.description, + }); + + final IconData icon; + final Color color; + final String title; + final String description; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 32, + height: 32, + margin: const EdgeInsets.only(top: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: color, size: 16), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + description, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + height: 1.4, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index 5f8fca8..60ccf00 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -1,5 +1,6 @@ -// Profile Screen - User preferences, data management, and about +// profile_screen.dart — User preferences, data management, and about +import 'dart:async' show unawaited; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -9,16 +10,15 @@ import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:intl/intl.dart'; - import 'package:package_info_plus/package_info_plus.dart'; import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; import '../services/api_service.dart'; import '../services/interfaces/health_connect_service_interface.dart'; +import '../services/managers/readiness_manager.dart'; import '../theme/app_theme.dart'; - -const String _createdBy = 'Devasy Patel'; +import 'widgets/profile_sections.dart'; class ProfileScreen extends StatefulWidget { const ProfileScreen({super.key}); @@ -33,6 +33,7 @@ class _ProfileScreenState extends State bool _isImporting = false; bool _isBackingUp = false; bool _isRequestingHcPermission = false; + bool _isRequestingReadinessPermission = false; String _appVersion = ''; @override @@ -42,8 +43,9 @@ class _ProfileScreenState extends State PackageInfo.fromPlatform().then((info) { if (mounted) setState(() => _appVersion = info.version); }); - // Reconcile stored HC flag against runtime state on screen load. - WidgetsBinding.instance.addPostFrameCallback((_) => _reconcileHealthConnectState()); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _reconcileHealthConnectState(), + ); } @override @@ -54,38 +56,39 @@ class _ProfileScreenState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { - // Re-check HC state when the user returns from background - // (e.g. after visiting Health Connect settings). if (state == AppLifecycleState.resumed) { _reconcileHealthConnectState(); } } - /// Reconciles the persisted [SettingsProvider.healthConnectEnabled] flag - /// with the actual runtime HC availability and permission state. - /// If HC is unavailable or permissions are revoked, the flag is cleared - /// so the toggle and status row reflect reality. Future _reconcileHealthConnectState() async { if (!mounted) return; final settings = context.read(); - // Only run the runtime checks when the flag is currently enabled — - // avoids unnecessary plugin calls when HC is already off. - if (!settings.healthConnectEnabled) return; + if (!settings.healthConnectEnabled && !settings.readinessEnabled) return; try { final hc = context.read(); final available = await hc.isAvailable(); if (!available) { if (mounted) await settings.setHealthConnectEnabled(false); + if (mounted) await settings.setReadinessEnabled(false); return; } - final hasPerms = await hc.hasPermissions(); - if (!hasPerms) { - if (mounted) await settings.setHealthConnectEnabled(false); + if (settings.healthConnectEnabled) { + final hasPerms = await hc.hasPermissions(); + if (!hasPerms && mounted) { + await settings.setHealthConnectEnabled(false); + } + } + if (settings.readinessEnabled) { + final granted = await hc.grantedReadTypes(); + if (granted.isEmpty && mounted) { + await settings.setReadinessEnabled(false); + } } } catch (e) { - // If we can't determine state, fail-safe: disable the flag. debugPrint('HC reconciliation error: $e'); if (mounted) await settings.setHealthConnectEnabled(false); + if (mounted) await settings.setReadinessEnabled(false); } } @@ -95,21 +98,20 @@ class _ProfileScreenState extends State final hc = context.read(); final available = await hc.isAvailable(); if (!available) { - if (mounted) _showSnack('Health Connect is not available on this device.', AppTheme.error); + if (mounted) { + _showSnack( + 'Health Connect is not available on this device.', + AppColors.error, + ); + } return; } - // Check if permissions were already granted (e.g. via HC settings). bool granted = await hc.hasPermissions(); - if (!granted) { - // Try to show the in-app permission dialog. try { granted = await hc.requestPermissions(); } catch (_) { - // requestPermissions can fail if the plugin loses its activity reference - // during the async gap (known issue with health_connector on some devices). - // Re-check hasPermissions in case the user already granted via HC settings. granted = await hc.hasPermissions(); } } @@ -118,43 +120,101 @@ class _ProfileScreenState extends State if (granted) { final settings = context.read(); await settings.setHealthConnectEnabled(true); - _showSnack('Health Connect connected!', AppTheme.success); + _showSnack('Health Connect connected!', AppColors.success); } else { _showSnack( 'Open Health Connect → App permissions → RepForge and enable Exercise.', - AppTheme.warning, + AppColors.warning, ); } } catch (e) { - if (mounted) _showSnack('Could not connect to Health Connect.', AppTheme.error); + if (mounted) { + _showSnack('Could not connect to Health Connect.', AppColors.error); + } } finally { if (mounted) setState(() => _isRequestingHcPermission = false); } } - // ==================== Data Actions ==================== + Future _requestReadinessPermission() async { + debugPrint('[Readiness] toggle tapped — starting permission flow'); + setState(() => _isRequestingReadinessPermission = true); + try { + final hc = context.read(); + final available = await hc.isAvailable(); + debugPrint('[Readiness] isAvailable = $available'); + if (!available) { + if (mounted) { + _showSnack( + 'Health Connect is not available on this device.', + AppColors.error, + ); + } + return; + } + + // Any single granted read type is enough — readiness components + // degrade independently when data is missing. + var granted = await hc.grantedReadTypes(); + debugPrint('[Readiness] granted before request = $granted'); + if (granted.isEmpty) { + debugPrint('[Readiness] requesting read permissions…'); + try { + await hc.requestReadPermissions(); + } catch (e) { + debugPrint('[Readiness] requestReadPermissions threw: $e'); + } + granted = await hc.grantedReadTypes(); + debugPrint('[Readiness] granted after request = $granted'); + } + + if (!mounted) return; + if (granted.isNotEmpty) { + debugPrint('[Readiness] permissions granted — enabling readiness'); + final settings = context.read(); + await settings.setReadinessEnabled(true); + if (!mounted) return; + // Compute the first snapshot right away so the home card appears. + unawaited(context.read().refresh(force: true)); + _showSnack('Readiness insights enabled!', AppColors.success); + } else { + debugPrint('[Readiness] still no granted types — showing manual instructions'); + _showSnack( + 'Open Health Connect → App permissions → RepForge and allow Sleep and Heart rate.', + AppColors.warning, + ); + } + } catch (e) { + debugPrint('[Readiness] unexpected error: $e'); + if (mounted) { + _showSnack('Could not connect to Health Connect.', AppColors.error); + } + } finally { + if (mounted) setState(() => _isRequestingReadinessPermission = false); + } + } Future _exportToFile() async { setState(() => _isExporting = true); try { final provider = context.read(); final jsonString = await provider.exportAllData(); - final tempDir = await getTemporaryDirectory(); final dateStr = DateFormat('yyyy-MM-dd_HHmmss').format(DateTime.now()); final file = File('${tempDir.path}/repforge_backup_$dateStr.json'); await file.writeAsString(jsonString); - - final result = await Share.shareXFiles([XFile(file.path)], - subject: 'RepForge Backup'); - + // ignore: deprecated_member_use + final result = await Share.shareXFiles( + [XFile(file.path)], + subject: 'RepForge Backup', + ); if (!mounted) return; if (result.status == ShareResultStatus.success || result.status == ShareResultStatus.dismissed) { - _showSnack('Backup exported successfully!', AppTheme.success); + _showSnack('Backup exported successfully!', AppColors.success); } } catch (e) { - if (mounted) _showSnack('Export failed. Please try again.', AppTheme.error); + if (mounted) _showSnack('Export failed. Please try again.', AppColors.error); } finally { if (mounted) setState(() => _isExporting = false); } @@ -164,28 +224,37 @@ class _ProfileScreenState extends State final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.cardColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: const Text('Import Backup', - style: TextStyle(color: AppTheme.textPrimary)), - content: const Text( + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + title: Text( + 'Import Backup', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + content: Text( 'This will merge the backup with your existing data. ' 'Select a .json RepForge backup file to continue.', - style: TextStyle(color: AppTheme.textSecondary), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), - child: - const Text('Cancel', style: TextStyle(color: AppTheme.textSecondary)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Text( + 'Cancel', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted), ), + ), + TextButton( onPressed: () => Navigator.pop(ctx, true), - child: const Text('Choose File'), + style: TextButton.styleFrom(foregroundColor: AppColors.primary), + child: Text( + 'Choose File', + style: TextStyle(fontFamily: 'Geist', fontWeight: FontWeight.w600), + ), ), ], ), @@ -194,32 +263,33 @@ class _ProfileScreenState extends State setState(() => _isImporting = true); try { - final result = await FilePicker.platform - .pickFiles(type: FileType.custom, allowedExtensions: ['json']); + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + ); if (result == null || result.files.single.path == null) { if (mounted) setState(() => _isImporting = false); return; } - final file = File(result.files.single.path!); final jsonString = await file.readAsString(); final data = jsonDecode(jsonString) as Map; if (!data.containsKey('sessions') && !data.containsKey('routines')) { - if (mounted) _showSnack('Invalid backup file.', AppTheme.error); + if (mounted) _showSnack('Invalid backup file.', AppColors.error); return; } - + if (!mounted) return; final provider = context.read(); await provider.importData(jsonString); - if (!mounted) return; final sessionCount = (data['sessions'] as List?)?.length ?? 0; final routineCount = (data['routines'] as List?)?.length ?? 0; _showSnack( - 'Import complete! $sessionCount sessions, $routineCount routines.', - AppTheme.success); + 'Import complete! $sessionCount sessions, $routineCount routines.', + AppColors.success, + ); } catch (e) { - if (mounted) _showSnack('Import failed. Invalid backup file.', AppTheme.error); + if (mounted) _showSnack('Import failed. Invalid backup file.', AppColors.error); } finally { if (mounted) setState(() => _isImporting = false); } @@ -227,22 +297,20 @@ class _ProfileScreenState extends State Future _performCloudBackup() async { setState(() => _isBackingUp = true); + final provider = context.read(); + final api = context.read(); try { - final provider = context.read(); final jsonString = await provider.exportAllData(); final data = jsonDecode(jsonString) as Map; - - final api = context.read(); await api.trackEvent('backup_triggered').catchError((_) => null); final success = await api.backupData(data); - if (!mounted) return; _showSnack( success ? 'Cloud backup successful!' : 'Backup failed. Please try again.', - success ? AppTheme.success : AppTheme.error, + success ? AppColors.success : AppColors.error, ); } catch (_) { - if (mounted) _showSnack('Something went wrong.', AppTheme.error); + if (mounted) _showSnack('Something went wrong.', AppColors.error); } finally { if (mounted) setState(() => _isBackingUp = false); } @@ -250,34 +318,78 @@ class _ProfileScreenState extends State void _showSnack(String message, Color color) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), backgroundColor: color), + SnackBar( + content: Text( + message, + style: TextStyle(fontFamily: 'Geist', color: AppColors.textPrimary), + ), + backgroundColor: color, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), ); } - // ==================== Build ==================== - @override Widget build(BuildContext context) { final settings = context.watch(); return Scaffold( - backgroundColor: AppTheme.backgroundColor, + backgroundColor: AppColors.background, body: CustomScrollView( + physics: const BouncingScrollPhysics(), slivers: [ - _buildAppBar(), + _buildHero(), SliverPadding( - padding: const EdgeInsets.all(AppSpacing.md), + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), sliver: SliverList( delegate: SliverChildListDelegate([ - _buildPreferencesSection(settings), - const SizedBox(height: AppSpacing.lg), - _buildHealthConnectSection(settings), - const SizedBox(height: AppSpacing.lg), - _buildDataSection(), - const SizedBox(height: AppSpacing.lg), - _buildCloudSyncSection(), - const SizedBox(height: AppSpacing.lg), - _buildAboutSection(), + PreferencesSection( + settings: settings, + onHaptic: () => HapticFeedback.selectionClick(), + ), + const SizedBox(height: AppSpacing.md), + HealthConnectSection( + settings: settings, + isLoading: _isRequestingHcPermission, + onToggle: (value) async { + if (value) { + await _requestHealthConnectPermission(); + } else { + await settings.setHealthConnectEnabled(false); + } + }, + isReadinessLoading: _isRequestingReadinessPermission, + onReadinessToggle: (value) async { + if (value) { + await _requestReadinessPermission(); + } else { + await settings.setReadinessEnabled(false); + } + }, + ), + const SizedBox(height: AppSpacing.md), + DataManagementSection( + isExporting: _isExporting, + isImporting: _isImporting, + isBackingUp: _isBackingUp, + onExport: _isExporting ? null : _exportToFile, + onImport: _isImporting ? null : _importFromFile, + onCloudBackup: _isBackingUp ? null : _performCloudBackup, + ), + const SizedBox(height: AppSpacing.md), + const AiSettingsSection(), + const SizedBox(height: AppSpacing.md), + const CloudSyncSection(), + const SizedBox(height: AppSpacing.md), + AboutSection(appVersion: _appVersion), const SizedBox(height: AppSpacing.xxl), ]), ), @@ -287,612 +399,124 @@ class _ProfileScreenState extends State ); } - Widget _buildAppBar() { - return SliverAppBar( - expandedHeight: 160, - pinned: true, - backgroundColor: AppTheme.surfaceColor, - flexibleSpace: FlexibleSpaceBar( - background: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [AppTheme.primaryColor, Color(0xFF8B7FE8)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, + Widget _buildHero() { + return SliverToBoxAdapter( + child: Stack( + clipBehavior: Clip.none, + children: [ + // Ambient violet wash centred at top + Positioned( + top: -80, + left: 0, + right: 0, + child: Center( + child: Container( + width: 400, + height: 400, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.primary.withValues(alpha: 0.28), + Colors.transparent, + ], + stops: const [0, 0.65], + ), + ), + ), ), ), - child: SafeArea( + SafeArea( + bottom: false, child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.md, + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.xl, + AppSpacing.lg, + AppSpacing.lg, ), child: Column( - mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: const Icon( - Icons.fitness_center, - color: Colors.white, - size: 32, - ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Brand avatar + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.lg), + gradient: const LinearGradient( + colors: [AppColors.primary, Color(0xFF5B21B6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primaryGlow(0.50), + blurRadius: 24, + spreadRadius: -4, + ), + ], + ), + child: const Icon( + Icons.fitness_center_rounded, + color: Colors.white, + size: 28, + ), + ), + const Spacer(), + // Version pill + if (_appVersion.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: AppColors.glass3, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: AppColors.glassBorderStrong, + ), + ), + child: Text( + 'v$_appVersion', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ], ), - const SizedBox(height: AppSpacing.sm), - const Text( + const SizedBox(height: AppSpacing.md), + Text( 'RepForge', - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: -0.6, ), ), + const SizedBox(height: 2), Text( - 'v$_appVersion', - style: TextStyle( - color: Colors.white.withOpacity(0.75), - fontSize: 13, + 'Settings & preferences', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 14, + fontWeight: FontWeight.w400, ), ), ], ), ), ), - ), - ), - ); - } - - // ==================== Preferences ==================== - - Widget _buildPreferencesSection(SettingsProvider settings) { - return _ProfileSection( - icon: Icons.tune_rounded, - iconColor: AppTheme.primaryColor, - title: 'Preferences', - subtitle: 'Customize weight display and input steps', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SettingsLabel('Weight Unit'), - const SizedBox(height: AppSpacing.sm), - Row( - children: [ - Expanded( - child: _UnitToggleButton( - label: 'kg', - selected: settings.weightUnit == WeightUnit.kg, - onTap: () => settings.setWeightUnit(WeightUnit.kg), - ), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: _UnitToggleButton( - label: 'lbs', - selected: settings.weightUnit == WeightUnit.lbs, - onTap: () => settings.setWeightUnit(WeightUnit.lbs), - ), - ), - ], - ), - const SizedBox(height: AppSpacing.md), - _SettingsLabel('Weight Increment'), - const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: 8, - runSpacing: 8, - children: settings.availableIncrements.map((inc) { - final selected = settings.weightIncrement == inc; - final label = inc == inc.truncateToDouble() - ? '${inc.toStringAsFixed(0)} ${settings.unitLabel}' - : '${inc.toStringAsFixed(2).replaceAll(RegExp(r'0+$'), '')} ${settings.unitLabel}'; - return ChoiceChip( - label: Text(label), - selected: selected, - onSelected: (_) { - HapticFeedback.selectionClick(); - settings.setWeightIncrement(inc); - }, - selectedColor: AppTheme.primaryColor.withOpacity(0.25), - backgroundColor: AppTheme.surfaceColor, - labelStyle: TextStyle( - color: - selected ? AppTheme.primaryColor : AppTheme.textSecondary, - fontWeight: - selected ? FontWeight.bold : FontWeight.normal, - fontSize: 13, - ), - side: BorderSide( - color: selected ? AppTheme.primaryColor : Colors.transparent, - ), - padding: - const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - ); - }).toList(), - ), ], ), ); } - - // ==================== Health Connect ==================== - - Widget _buildHealthConnectSection(SettingsProvider settings) { - final enabled = settings.healthConnectEnabled; - return _ProfileSection( - icon: Icons.monitor_heart_outlined, - iconColor: const Color(0xFF00BFA5), - title: 'Health Connect', - subtitle: 'Sync workouts to Android Health Connect', - child: Column( - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Sync workouts after finishing', - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 14, - ), - ), - Text( - 'Writes session + per-set reps to Health Connect', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - ], - ), - ), - Switch( - value: enabled, - onChanged: _isRequestingHcPermission - ? null - : (value) async { - if (value) { - await _requestHealthConnectPermission(); - } else { - await settings.setHealthConnectEnabled(false); - } - }, - activeThumbColor: const Color(0xFF00BFA5), - activeTrackColor: const Color(0xFF00BFA5).withValues(alpha: 0.4), - ), - ], - ), - if (enabled) ...[ - const SizedBox(height: AppSpacing.sm), - const Divider(color: AppTheme.surfaceColor, height: 1), - const SizedBox(height: AppSpacing.sm), - Row( - children: [ - const Icon(Icons.check_circle_outline, - color: Color(0xFF00BFA5), size: 16), - const SizedBox(width: 8), - const Text( - 'Connected — syncing after each workout', - style: TextStyle( - color: Color(0xFF00BFA5), - fontSize: 12, - ), - ), - ], - ), - ], - ], - ), - ); - } - - // ==================== Data Management ==================== - - Widget _buildDataSection() { - return _ProfileSection( - icon: Icons.storage_rounded, - iconColor: AppTheme.secondaryColor, - title: 'Data Management', - subtitle: 'Export, import, or backup your workout data', - child: Column( - children: [ - _ActionTile( - icon: Icons.upload_file_rounded, - iconColor: AppTheme.secondaryColor, - title: 'Export Backup', - subtitle: 'Save a local .json backup file', - loading: _isExporting, - onTap: _isExporting ? null : _exportToFile, - ), - const _Divider(), - _ActionTile( - icon: Icons.download_rounded, - iconColor: AppTheme.secondaryColor, - title: 'Import Backup', - subtitle: 'Merge data from a .json backup', - loading: _isImporting, - onTap: _isImporting ? null : _importFromFile, - ), - const _Divider(), - _ActionTile( - icon: Icons.cloud_upload_outlined, - iconColor: AppTheme.primaryColor, - title: 'Cloud Backup', - subtitle: 'Sync to RepForge cloud (requires account)', - loading: _isBackingUp, - onTap: _isBackingUp ? null : _performCloudBackup, - ), - ], - ), - ); - } - - // ==================== Cloud Sync (placeholder) ==================== - - Widget _buildCloudSyncSection() { - return _ProfileSection( - icon: Icons.sync_rounded, - iconColor: AppTheme.warning, - title: 'Cloud Sync', - subtitle: 'Sync your data across devices', - trailing: _ComingSoonBadge(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _SettingsLabel('MongoDB Connection String'), - const SizedBox(height: AppSpacing.sm), - TextField( - enabled: false, - decoration: InputDecoration( - hintText: 'mongodb+srv://user:pass@cluster.mongodb.net/db', - hintStyle: const TextStyle( - color: AppTheme.textMuted, fontSize: 13), - prefixIcon: const Icon(Icons.link_rounded, - color: AppTheme.textMuted, size: 20), - filled: true, - fillColor: AppTheme.surfaceColor.withOpacity(0.5), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppRadius.sm), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 12), - ), - ), - const SizedBox(height: AppSpacing.sm), - Text( - 'Cloud sync with custom MongoDB will be available in a future update.', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 11, - fontStyle: FontStyle.italic, - ), - ), - ], - ), - ); - } - - // ==================== About ==================== - - Widget _buildAboutSection() { - return _ProfileSection( - icon: Icons.info_outline_rounded, - iconColor: AppTheme.textSecondary, - title: 'About', - subtitle: 'RepForge Workout Logger', - child: Column( - children: [ - _InfoTile( - label: 'Version', - value: _appVersion, - icon: Icons.tag_rounded, - ), - const _Divider(), - _InfoTile( - label: 'Created by', - value: _createdBy, - icon: Icons.person_rounded, - ), - const _Divider(), - _InfoTile( - label: 'Platform', - value: 'Android', - icon: Icons.phone_android_rounded, - ), - const _Divider(), - _InfoTile( - label: 'Package', - value: 'com.devasy.repforge', - icon: Icons.inventory_2_outlined, - ), - ], - ), - ); - } -} - -// ==================== Reusable Widgets ==================== - -class _ProfileSection extends StatelessWidget { - final IconData icon; - final Color iconColor; - final String title; - final String subtitle; - final Widget child; - final Widget? trailing; - - const _ProfileSection({ - required this.icon, - required this.iconColor, - required this.title, - required this.subtitle, - required this.child, - this.trailing, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: iconColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, color: iconColor, size: 20), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - color: AppTheme.textPrimary, - fontWeight: FontWeight.bold, - fontSize: 15, - ), - ), - Text( - subtitle, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - ], - ), - ), - if (trailing != null) trailing!, - ], - ), - const SizedBox(height: AppSpacing.md), - const Divider(color: AppTheme.surfaceColor, height: 1), - const SizedBox(height: AppSpacing.md), - child, - ], - ), - ); - } -} - -class _SettingsLabel extends StatelessWidget { - final String text; - const _SettingsLabel(this.text); - - @override - Widget build(BuildContext context) { - return Text( - text, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ); - } -} - -class _UnitToggleButton extends StatelessWidget { - final String label; - final bool selected; - final VoidCallback onTap; - - const _UnitToggleButton({ - required this.label, - required this.selected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - HapticFeedback.selectionClick(); - onTap(); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: selected - ? AppTheme.primaryColor.withOpacity(0.2) - : AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(AppRadius.sm), - border: Border.all( - color: selected ? AppTheme.primaryColor : Colors.transparent, - width: 1.5, - ), - ), - child: Center( - child: Text( - label, - style: TextStyle( - color: - selected ? AppTheme.primaryColor : AppTheme.textSecondary, - fontWeight: - selected ? FontWeight.bold : FontWeight.normal, - fontSize: 15, - ), - ), - ), - ), - ); - } -} - -class _ActionTile extends StatelessWidget { - final IconData icon; - final Color iconColor; - final String title; - final String subtitle; - final bool loading; - final VoidCallback? onTap; - - const _ActionTile({ - required this.icon, - required this.iconColor, - required this.title, - required this.subtitle, - required this.loading, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return ListTile( - contentPadding: EdgeInsets.zero, - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: iconColor.withOpacity(0.12), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, color: iconColor, size: 20), - ), - title: Text( - title, - style: const TextStyle(color: AppTheme.textPrimary, fontSize: 14), - ), - subtitle: Text( - subtitle, - style: const TextStyle(color: AppTheme.textSecondary, fontSize: 12), - ), - trailing: loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation(AppTheme.primaryColor), - ), - ) - : const Icon( - Icons.chevron_right, - color: AppTheme.textMuted, - ), - onTap: onTap, - ); - } -} - -class _InfoTile extends StatelessWidget { - final String label; - final String value; - final IconData icon; - - const _InfoTile({ - required this.label, - required this.value, - required this.icon, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Icon(icon, color: AppTheme.textMuted, size: 18), - const SizedBox(width: 12), - Text( - label, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 13, - ), - ), - const Spacer(), - Text( - value, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); - } -} - -class _Divider extends StatelessWidget { - const _Divider(); - - @override - Widget build(BuildContext context) { - return const Divider( - color: AppTheme.surfaceColor, - height: 1, - indent: 40, - ); - } -} - -class _ComingSoonBadge extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.15), - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all( - color: AppTheme.warning.withOpacity(0.4), - ), - ), - child: const Text( - 'Coming Soon', - style: TextStyle( - color: AppTheme.warning, - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 0.3, - ), - ), - ); - } } diff --git a/workout-logger/lib/screens/programs/program_designer_screen.dart b/workout-logger/lib/screens/programs/program_designer_screen.dart index 1a6ad5f..b3856dd 100644 --- a/workout-logger/lib/screens/programs/program_designer_screen.dart +++ b/workout-logger/lib/screens/programs/program_designer_screen.dart @@ -657,9 +657,13 @@ class _ProgramDesignerScreenState extends State { onChanged: (v) => setDlg(() => exerciseSearch = v), ), const SizedBox(height: AppSpacing.sm), - SizedBox( - height: 140, + ConstrainedBox( + constraints: const BoxConstraints( + minHeight: 80, + maxHeight: 160, + ), child: ListView.builder( + shrinkWrap: true, itemCount: filtered.length, itemBuilder: (_, i) => ListTile( dense: true, diff --git a/workout-logger/lib/screens/programs/program_detail_screen.dart b/workout-logger/lib/screens/programs/program_detail_screen.dart index aeb71d6..36758c0 100644 --- a/workout-logger/lib/screens/programs/program_detail_screen.dart +++ b/workout-logger/lib/screens/programs/program_detail_screen.dart @@ -1,7 +1,4 @@ -// Program Detail Screen -// -// Shows a full training program: phase timeline, week list with deload badges, -// and per-day exercise details (sets, rep range, rest, tempo, weight%, notes). +// program_detail_screen.dart — Full training program view import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -12,11 +9,11 @@ import '../../services/workout_provider.dart'; import '../../theme/app_theme.dart'; import '../workout_flow_screen.dart'; import '../widgets/workout_conflict_dialog.dart'; +import '../widgets/program_week_tile.dart'; class ProgramDetailScreen extends StatefulWidget { - final TrainingProgram program; - const ProgramDetailScreen({super.key, required this.program}); + final TrainingProgram program; @override State createState() => _ProgramDetailScreenState(); @@ -24,7 +21,6 @@ class ProgramDetailScreen extends StatefulWidget { class _ProgramDetailScreenState extends State { late TrainingProgram _program; - int? _expandedWeekIndex; @override void initState() { @@ -34,24 +30,42 @@ class _ProgramDetailScreenState extends State { @override Widget build(BuildContext context) { + final provider = context.read(); + return Scaffold( - backgroundColor: AppTheme.backgroundColor, + backgroundColor: AppColors.background, appBar: AppBar( - title: Text(_program.name), + backgroundColor: AppColors.surface, + iconTheme: const IconThemeData(color: AppColors.textSoft), + title: Text( + _program.name, + style: const TextStyle(color: AppColors.textPrimary), + ), actions: [ PopupMenuButton( + color: AppColors.cardHigh, onSelected: _handleMenuAction, itemBuilder: (_) => [ - const PopupMenuItem(value: 'export', child: Text('Export JSON')), + const PopupMenuItem( + value: 'export', + child: Text( + 'Export JSON', + style: TextStyle(color: AppColors.textPrimary), + ), + ), const PopupMenuItem( value: 'delete', - child: Text('Delete', style: TextStyle(color: AppTheme.error)), + child: Text( + 'Delete', + style: TextStyle(color: AppColors.error), + ), ), ], ), ], ), body: CustomScrollView( + physics: const BouncingScrollPhysics(), slivers: [ SliverToBoxAdapter(child: _buildHeader()), if (_program.phases.isNotEmpty) @@ -64,10 +78,12 @@ class _ProgramDetailScreenState extends State { AppSpacing.md, AppSpacing.sm, ), - child: Text( + child: const Text( 'WEEKS', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: AppTheme.textMuted, + style: TextStyle( + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w700, letterSpacing: 1.2, ), ), @@ -75,7 +91,14 @@ class _ProgramDetailScreenState extends State { ), SliverList( delegate: SliverChildBuilderDelegate( - (context, index) => _buildWeekTile(index), + (context, index) => ProgramWeekTile( + key: ValueKey('week_$index'), + week: _program.weeks[index], + weekIndex: index, + program: _program, + provider: provider, + onStartDay: _startProgramDayWorkout, + ), childCount: _program.weeks.length, ), ), @@ -87,8 +110,7 @@ class _ProgramDetailScreenState extends State { Future _startProgramDayWorkout(ProgramDay day, ProgramWeek week) async { final provider = context.read(); - StartWorkoutConflictAction conflictAction = - StartWorkoutConflictAction.cancel; + StartWorkoutConflictAction conflictAction = StartWorkoutConflictAction.cancel; final started = await provider.startWorkoutSafely( exerciseIds: day.exercises.map((slot) => slot.exerciseId).toList(), @@ -106,26 +128,23 @@ class _ProgramDetailScreenState extends State { if (!mounted) return; if (started || conflictAction == StartWorkoutConflictAction.resume) { - final resumeProgramDay = provider.hasActiveWorkout - ? provider.activeProgramDay - : day; - final resumeProgramWeek = provider.hasActiveWorkout - ? provider.activeProgramWeek - : week; - + final resumeDay = + provider.hasActiveWorkout ? provider.activeProgramDay : day; + final resumeWeek = + provider.hasActiveWorkout ? provider.activeProgramWeek : week; Navigator.push( context, MaterialPageRoute( builder: (_) => WorkoutFlowScreen( - programDay: resumeProgramDay, - programWeek: resumeProgramWeek, + programDay: resumeDay, + programWeek: resumeWeek, ), ), ); } } - // ── Header ────────────────────────────────────────────────────────────── + // ── Header ─────────────────────────────────────────────────────────────────── Widget _buildHeader() { final deloadCount = _program.weeks.where((w) => w.isDeload).length; @@ -134,12 +153,9 @@ class _ProgramDetailScreenState extends State { margin: const EdgeInsets.all(AppSpacing.md), padding: const EdgeInsets.all(AppSpacing.lg), decoration: BoxDecoration( - color: AppTheme.cardColor, + color: AppColors.card, borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all( - color: AppTheme.primaryColor.withOpacity(0.3), - width: 1, - ), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.25)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -147,29 +163,27 @@ class _ProgramDetailScreenState extends State { if (_program.description != null) ...[ Text( _program.description!, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + style: const TextStyle(color: AppColors.textSoft, fontSize: 13), ), const SizedBox(height: AppSpacing.md), ], - Row( + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, children: [ _statChip( - icon: Icons.calendar_today, + icon: Icons.calendar_today_rounded, label: '${_program.totalWeeks} weeks', - color: AppTheme.primaryColor, + color: AppColors.primary, ), - const SizedBox(width: AppSpacing.sm), _statChip( - icon: Icons.bolt, + icon: Icons.bolt_rounded, label: '${_program.phases.length} phases', - color: AppTheme.secondaryColor, + color: AppColors.secondary, ), - const SizedBox(width: AppSpacing.sm), if (deloadCount > 0) _statChip( - icon: Icons.battery_charging_full, + icon: Icons.battery_charging_full_rounded, label: '$deloadCount deload${deloadCount > 1 ? 's' : ''}', color: Colors.amber, ), @@ -179,22 +193,18 @@ class _ProgramDetailScreenState extends State { const SizedBox(height: AppSpacing.sm), Text( 'by ${_program.author}', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: AppTheme.textMuted), + style: const TextStyle(color: AppColors.textMuted, fontSize: 12), ), ], if (_program.isImported) ...[ const SizedBox(height: AppSpacing.xs), - Row( + const Row( children: [ - const Icon(Icons.download, size: 12, color: AppTheme.textMuted), - const SizedBox(width: 4), + Icon(Icons.download_done_rounded, size: 12, color: AppColors.textMuted), + SizedBox(width: 4), Text( 'Imported', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: AppTheme.textMuted), + style: TextStyle(color: AppColors.textMuted, fontSize: 12), ), ], ), @@ -212,7 +222,7 @@ class _ProgramDetailScreenState extends State { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( - color: color.withOpacity(0.15), + color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(AppRadius.full), ), child: Row( @@ -233,15 +243,7 @@ class _ProgramDetailScreenState extends State { ); } - // ── Phase Timeline ──────────────────────────────────────────────────── - - static const List _phaseColors = [ - AppTheme.primaryColor, - AppTheme.secondaryColor, - Colors.orange, - Colors.pink, - Colors.green, - ]; + // ── Phase Timeline ─────────────────────────────────────────────────────────── Widget _buildPhaseTimeline() { return Container( @@ -253,21 +255,23 @@ class _ProgramDetailScreenState extends State { ), padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( - color: AppTheme.cardColor, + color: AppColors.card, borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( + const Text( 'PHASES', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: AppTheme.textMuted, + style: TextStyle( + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w700, letterSpacing: 1.2, ), ), const SizedBox(height: AppSpacing.sm), - // Visual timeline bar SizedBox( height: 8, child: Row( @@ -275,7 +279,8 @@ class _ProgramDetailScreenState extends State { final phase = entry.value; final fraction = (phase.endWeek - phase.startWeek + 1) / _program.totalWeeks; - final color = _phaseColors[entry.key % _phaseColors.length]; + final color = + kProgramPhaseColors[entry.key % kProgramPhaseColors.length]; return Expanded( flex: ((fraction * 100).round()).clamp(1, 100), child: Container( @@ -295,7 +300,8 @@ class _ProgramDetailScreenState extends State { runSpacing: 4, children: _program.phases.asMap().entries.map((entry) { final phase = entry.value; - final color = _phaseColors[entry.key % _phaseColors.length]; + final color = + kProgramPhaseColors[entry.key % kProgramPhaseColors.length]; return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -310,9 +316,9 @@ class _ProgramDetailScreenState extends State { const SizedBox(width: 4), Text( '${phase.name} (W${phase.startWeek}–${phase.endWeek})', - style: TextStyle( + style: const TextStyle( fontSize: 12, - color: AppTheme.textSecondary, + color: AppColors.textSoft, ), ), ], @@ -324,440 +330,7 @@ class _ProgramDetailScreenState extends State { ); } - // ── Week Tile ──────────────────────────────────────────────────────── - - Widget _buildWeekTile(int index) { - final week = _program.weeks[index]; - final isExpanded = _expandedWeekIndex == index; - final phase = _program.phaseForWeek(week.weekNumber); - final phaseIdx = phase == null - ? 0 - : _program.phases.indexWhere((p) => p.id == phase.id); - final phaseColor = phaseIdx >= 0 - ? _phaseColors[phaseIdx % _phaseColors.length] - : AppTheme.primaryColor; - - return Container( - margin: const EdgeInsets.fromLTRB( - AppSpacing.md, - 0, - AppSpacing.md, - AppSpacing.sm, - ), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: week.isDeload - ? Border.all(color: Colors.amber.withOpacity(0.5), width: 1) - : null, - ), - child: Column( - children: [ - InkWell( - onTap: () => setState(() { - _expandedWeekIndex = isExpanded ? null : index; - }), - borderRadius: BorderRadius.circular(AppRadius.lg), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: phaseColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - alignment: Alignment.center, - child: Text( - 'W${week.weekNumber}', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - color: phaseColor, - ), - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (week.isDeload) ...[ - const Icon( - Icons.battery_charging_full, - size: 14, - color: Colors.amber, - ), - const SizedBox(width: 4), - const Text( - 'DELOAD ', - style: TextStyle( - fontSize: 11, - color: Colors.amber, - fontWeight: FontWeight.bold, - letterSpacing: 0.8, - ), - ), - ], - if (phase != null) - Text( - phase.name, - style: TextStyle( - fontSize: 12, - color: phaseColor, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - Text( - '${week.days.length} day${week.days.length != 1 ? 's' : ''}', - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - if (week.isDeload) - Padding( - padding: const EdgeInsets.only(right: AppSpacing.sm), - child: Text( - '${((week.deloadIntensityFactor) * 100).round()}%', - style: const TextStyle( - fontSize: 12, - color: Colors.amber, - fontWeight: FontWeight.bold, - ), - ), - ), - Icon( - isExpanded ? Icons.expand_less : Icons.expand_more, - color: AppTheme.textMuted, - size: 20, - ), - ], - ), - ), - ), - if (isExpanded) ...[ - const Divider( - height: 1, - color: AppTheme.surfaceColor, - indent: AppSpacing.md, - endIndent: AppSpacing.md, - ), - ...week.days.map((day) => _buildDaySection(day, week)), - if (week.notes != null) - Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - 0, - AppSpacing.md, - AppSpacing.md, - ), - child: Row( - children: [ - const Icon( - Icons.info_outline, - size: 14, - color: AppTheme.textMuted, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - week.notes!, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, - fontStyle: FontStyle.italic, - ), - ), - ), - ], - ), - ), - ], - ], - ), - ); - } - - // ── Day Section ───────────────────────────────────────────────────── - - Widget _buildDaySection(ProgramDay day, ProgramWeek week) { - final provider = context.read(); - - // Group exercises into contiguous runs by supersetGroupId - // to preserve original order (a map would collapse non-contiguous groups). - final runs = >[]; - String? currentRunKey; - List currentRun = []; - for (final slot in day.exercises) { - final key = slot.supersetGroupId; - if (key == null) { - // Flush any open superset run - if (currentRun.isNotEmpty) { - runs.add(currentRun); - currentRun = []; - currentRunKey = null; - } - // Standalone exercise - runs.add([slot]); - } else if (key == currentRunKey) { - currentRun.add(slot); - } else { - // Flush previous run and start new - if (currentRun.isNotEmpty) { - runs.add(currentRun); - } - currentRunKey = key; - currentRun = [slot]; - } - } - if (currentRun.isNotEmpty) { - runs.add(currentRun); - } - - return Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.md, - AppSpacing.md, - 0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: Text( - day.name.toUpperCase(), - style: const TextStyle( - fontSize: 11, - color: AppTheme.primaryColor, - fontWeight: FontWeight.bold, - letterSpacing: 0.8, - ), - ), - ), - if (day.dayOfWeek != null) ...[ - const SizedBox(width: AppSpacing.sm), - Text( - _dayName(day.dayOfWeek!), - style: const TextStyle( - fontSize: 11, - color: AppTheme.textMuted, - ), - ), - ], - ], - ), - const SizedBox(height: AppSpacing.sm), - // Render standalone exercises and superset groups - ...runs.map((slots) { - final isSuperset = - slots.length > 1 || slots.first.supersetGroupId != null; - if (isSuperset) { - return _buildSupersetGroup( - slots: slots, - provider: provider, - week: week, - ); - } - return _buildExerciseRow( - slot: slots.first, - provider: provider, - week: week, - ); - }), - const SizedBox(height: AppSpacing.sm), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () => _startProgramDayWorkout(day, week), - icon: const Icon(Icons.play_arrow, size: 18), - label: Text('Start ${day.name}'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryColor, - padding: const EdgeInsets.symmetric(vertical: 10), - ), - ), - ), - const SizedBox(height: AppSpacing.sm), - ], - ), - ); - } - - Widget _buildSupersetGroup({ - required List slots, - required WorkoutProvider provider, - required ProgramWeek week, - }) { - return Container( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - decoration: BoxDecoration( - border: Border( - left: BorderSide( - color: AppTheme.secondaryColor.withOpacity(0.6), - width: 3, - ), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: AppSpacing.sm, bottom: 2), - child: Text( - 'SUPERSET', - style: TextStyle( - fontSize: 10, - color: AppTheme.secondaryColor, - fontWeight: FontWeight.bold, - letterSpacing: 0.6, - ), - ), - ), - ...slots.map( - (slot) => _buildExerciseRow( - slot: slot, - provider: provider, - week: week, - indent: true, - ), - ), - ], - ), - ); - } - - Widget _buildExerciseRow({ - required ProgramExerciseSlot slot, - required WorkoutProvider provider, - required ProgramWeek week, - bool indent = false, - }) { - final exercise = provider.getExercise(slot.exerciseId); - final name = exercise?.name ?? slot.exerciseId; - - // Apply deload adjustments for display - final displaySets = week.isDeload - ? (slot.sets - week.deloadSetReduction).clamp(1, 99) - : slot.sets; - final displayIntensity = week.isDeload ? week.deloadIntensityFactor : 1.0; - - final repRange = slot.minReps == slot.maxReps - ? '${slot.minReps}' - : '${slot.minReps}–${slot.maxReps}'; - - return Padding( - padding: EdgeInsets.only( - left: indent ? AppSpacing.md : 0, - bottom: AppSpacing.sm, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Sets × Reps badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: Text( - '$displaySets × $repRange', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - fontFeatures: [FontFeature.tabularFigures()], - ), - ), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - ), - const SizedBox(height: 2), - Wrap( - spacing: AppSpacing.sm, - runSpacing: 2, - children: [ - _infoChip( - Icons.timer_outlined, - '${slot.restSeconds}s rest', - ), - if (slot.tempo != null) _infoChip(Icons.speed, slot.tempo!), - if (slot.weightPercentage != null) - _infoChip( - Icons.fitness_center, - week.isDeload - ? '${(slot.weightPercentage! * displayIntensity).toStringAsFixed(0)}%' - : '${slot.weightPercentage!.toStringAsFixed(0)}%', - ), - ], - ), - if (slot.notes != null) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - slot.notes!, - style: const TextStyle( - fontSize: 11, - color: AppTheme.textMuted, - fontStyle: FontStyle.italic, - ), - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _infoChip(IconData icon, String label) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 11, color: AppTheme.textMuted), - const SizedBox(width: 2), - Text( - label, - style: const TextStyle(fontSize: 11, color: AppTheme.textMuted), - ), - ], - ); - } - - // ── Actions ────────────────────────────────────────────────────────── + // ── Actions ────────────────────────────────────────────────────────────────── void _handleMenuAction(String action) { switch (action) { @@ -776,7 +349,14 @@ class _ProgramDetailScreenState extends State { showDialog( context: context, builder: (_) => AlertDialog( - title: const Text('Export Program'), + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + title: const Text( + 'Export Program', + style: TextStyle(color: AppColors.textPrimary), + ), content: SizedBox( width: double.maxFinite, child: SingleChildScrollView( @@ -785,7 +365,7 @@ class _ProgramDetailScreenState extends State { style: const TextStyle( fontFamily: 'monospace', fontSize: 10, - color: AppTheme.textSecondary, + color: AppColors.textSoft, ), ), ), @@ -796,14 +376,30 @@ class _ProgramDetailScreenState extends State { Clipboard.setData(ClipboardData(text: json)); Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('JSON copied to clipboard')), + SnackBar( + content: const Text( + 'JSON copied to clipboard', + style: TextStyle(color: AppColors.textPrimary), + ), + backgroundColor: AppColors.cardHigh, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), ); }, - child: const Text('Copy to Clipboard'), + child: const Text( + 'Copy', + style: TextStyle(color: AppColors.primary), + ), ), TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Close'), + child: const Text( + 'Close', + style: TextStyle(color: AppColors.textSoft), + ), ), ], ), @@ -814,44 +410,40 @@ class _ProgramDetailScreenState extends State { showDialog( context: context, builder: (_) => AlertDialog( - title: const Text('Delete Program?'), - content: Text('Delete "${_program.name}"? This cannot be undone.'), + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + title: const Text( + 'Delete Program?', + style: TextStyle(color: AppColors.textPrimary), + ), + content: Text( + 'Delete "${_program.name}"? This cannot be undone.', + style: const TextStyle(color: AppColors.textSoft), + ), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), + child: const Text( + 'Cancel', + style: TextStyle(color: AppColors.textSoft), + ), ), TextButton( onPressed: () async { final provider = context.read(); await provider.programManager.deleteProgram(_program.id); if (mounted) { - Navigator.pop(context); // close dialog - Navigator.pop(context); // go back to list + Navigator.pop(context); + Navigator.pop(context); } }, - child: const Text( - 'Delete', - style: TextStyle(color: AppTheme.error), - ), + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Delete'), ), ], ), ); } - - // ── Utils ──────────────────────────────────────────────────────────── - - static const _dayNames = [ - '', - 'Mon', - 'Tue', - 'Wed', - 'Thu', - 'Fri', - 'Sat', - 'Sun', - ]; - - String _dayName(int dow) => dow >= 1 && dow <= 7 ? _dayNames[dow] : ''; -} +} \ No newline at end of file diff --git a/workout-logger/lib/screens/programs/programs_screen.dart b/workout-logger/lib/screens/programs/programs_screen.dart index 328bab1..584afd5 100644 --- a/workout-logger/lib/screens/programs/programs_screen.dart +++ b/workout-logger/lib/screens/programs/programs_screen.dart @@ -1,9 +1,4 @@ -// Programs Screen -// -// Shows the list of training programs and provides entry points for: -// - Viewing program details -// - Creating a new program -// - Importing a program from JSON (full-screen ImportProgramScreen) +// programs_screen.dart — Training programs list import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -11,9 +6,11 @@ import 'package:provider/provider.dart'; import '../../models/models.dart'; import '../../services/workout_provider.dart'; import '../../theme/app_theme.dart'; +import '../widgets/rf_widgets.dart'; import 'program_detail_screen.dart'; import 'program_designer_screen.dart'; import 'import_program_screen.dart'; +import '../ai_program_generator_screen.dart'; class ProgramsScreen extends StatelessWidget { const ProgramsScreen({super.key}); @@ -27,70 +24,86 @@ class ProgramsScreen extends StatelessWidget { context.read().programManager.programs; return Scaffold( - backgroundColor: AppTheme.backgroundColor, - body: programs.isEmpty - ? _buildEmptyState(context) - : _buildList(context, programs), - floatingActionButton: Column( + backgroundColor: AppColors.background, + body: SafeArea( + bottom: false, + child: programs.isEmpty + ? _buildEmptyState(context) + : _buildList(context, programs), + ), + floatingActionButton: Padding( + padding: const EdgeInsets.only(bottom: AppBreakpoints.navBarClearance), + child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ FloatingActionButton.small( heroTag: 'import_json', onPressed: () => _openImport(context), - backgroundColor: AppTheme.surfaceColor, - child: const Icon(Icons.download, color: AppTheme.secondaryColor), + backgroundColor: AppColors.card, + elevation: 0, + child: const Icon( + Icons.download_rounded, + color: AppColors.secondary, + ), + ), + const SizedBox(height: AppSpacing.sm), + FloatingActionButton.small( + heroTag: 'ai_generate', + onPressed: () => _openAiGenerator(context), + backgroundColor: AppColors.card, + elevation: 0, + child: const Icon( + Icons.auto_awesome_rounded, + color: AppColors.primary, + ), ), const SizedBox(height: AppSpacing.sm), FloatingActionButton.extended( heroTag: 'new_program', onPressed: () => _openDesigner(context, null), - icon: const Icon(Icons.add), - label: const Text('New Program'), + backgroundColor: AppColors.primary, + elevation: 0, + icon: const Icon(Icons.add_rounded, color: Colors.white), + label: const Text( + 'New Program', + style: TextStyle(color: Colors.white), + ), ), ], + ), ), ); }, ); } - // ── Empty State ────────────────────────────────────────────────────── - Widget _buildEmptyState(BuildContext context) { - return Center( + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.calendar_month, size: 64, color: AppTheme.textMuted), - const SizedBox(height: 16), - Text( - 'No Training Programs', - style: Theme.of(context).textTheme.titleLarge, + const RFEmptyState( + icon: Icons.calendar_month_rounded, + title: 'No Training Programs', + subtitle: 'Create a structured multi-week program\nor import one from JSON', ), - const SizedBox(height: 8), - Text( - 'Create a structured multi-week program\nor import one from JSON', - textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 24), + const SizedBox(height: AppSpacing.lg), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - ElevatedButton.icon( + GlowButton( + label: 'Create', + icon: Icons.add_rounded, + fullWidth: false, onPressed: () => _openDesigner(context, null), - icon: const Icon(Icons.add), - label: const Text('Create'), ), const SizedBox(width: AppSpacing.md), - OutlinedButton.icon( + OutlineGlowButton( + label: 'Import JSON', + icon: Icons.download_rounded, onPressed: () => _openImport(context), - icon: const Icon(Icons.download), - label: const Text('Import JSON'), ), ], ), @@ -99,24 +112,20 @@ class ProgramsScreen extends StatelessWidget { ); } - // ── Program List ───────────────────────────────────────────────────── - Widget _buildList(BuildContext context, List programs) { return ListView.builder( - padding: const EdgeInsets.fromLTRB( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.fromLTRB( AppSpacing.md, AppSpacing.md, AppSpacing.md, - 100, // FAB clearance + MediaQuery.of(context).padding.bottom + 100, ), itemCount: programs.length, - itemBuilder: (context, index) => - _ProgramCard(program: programs[index]), + itemBuilder: (context, index) => _ProgramCard(program: programs[index]), ); } - // ── Actions ────────────────────────────────────────────────────────── - void _openDesigner(BuildContext context, TrainingProgram? existing) { Navigator.push( context, @@ -126,6 +135,28 @@ class ProgramsScreen extends StatelessWidget { ); } + Future _openAiGenerator(BuildContext context) async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const AiProgramGeneratorScreen()), + ); + if (result == true && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'AI program added!', + style: TextStyle(color: AppColors.textPrimary), + ), + backgroundColor: AppColors.cardHigh, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ); + } + } + Future _openImport(BuildContext context) async { final result = await Navigator.push( context, @@ -133,141 +164,138 @@ class ProgramsScreen extends StatelessWidget { ); if (result == true && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Program imported successfully!')), + SnackBar( + content: const Text( + 'Program imported successfully!', + style: TextStyle(color: AppColors.textPrimary), + ), + backgroundColor: AppColors.cardHigh, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), ); } } } -// ── Program Card ────────────────────────────────────────────────────────── - +// ── Program Card ────────────────────────────────────────────────────────────── class _ProgramCard extends StatelessWidget { + const _ProgramCard({required this.program}); final TrainingProgram program; - const _ProgramCard({required this.program}); + static const _phaseColors = [ + AppColors.primary, + AppColors.secondary, + Colors.orange, + Colors.pink, + Colors.green, + ]; @override Widget build(BuildContext context) { final deloadCount = program.weeks.where((w) => w.isDeload).length; - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.md), - child: InkWell( - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ProgramDetailScreen(program: program), - ), + return GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProgramDetailScreen(program: program), ), - borderRadius: BorderRadius.circular(AppRadius.lg), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: const Icon( - Icons.calendar_month, - color: AppTheme.primaryColor, - size: 20, - ), + ), + child: Container( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: const Icon( + Icons.calendar_month_rounded, + color: AppColors.primary, + size: 20, ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + program.name, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 15, + color: AppColors.textPrimary, + ), + ), + if (program.author != null) Text( - program.name, + program.author!, style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, - color: AppTheme.textPrimary, + fontSize: 12, + color: AppColors.textMuted, ), ), - if (program.author != null) - Text( - program.author!, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textMuted, - ), - ), - ], - ), + ], ), - if (program.isImported) - const Padding( - padding: EdgeInsets.only(right: 4), - child: Icon( - Icons.download_done, - size: 16, - color: AppTheme.textMuted, - ), + ), + if (program.isImported) + const Padding( + padding: EdgeInsets.only(right: 4), + child: Icon( + Icons.download_done_rounded, + size: 16, + color: AppColors.textMuted, ), - const Icon( - Icons.chevron_right, - color: AppTheme.textMuted, - ), - ], - ), - const SizedBox(height: AppSpacing.md), - if (program.description != null) ...[ - Text( - program.description!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, ), - ), - const SizedBox(height: AppSpacing.sm), + const Icon(Icons.chevron_right_rounded, color: AppColors.textMuted), ], - // Stats row - Row( - children: [ - _badge( - '${program.totalWeeks}w', - AppTheme.primaryColor, - ), + ), + if (program.description != null) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + program.description!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: AppColors.textSoft), + ), + ], + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + _badge('${program.totalWeeks}w', AppColors.primary), + const SizedBox(width: AppSpacing.xs), + _badge('${program.phases.length} phases', AppColors.secondary), + if (deloadCount > 0) ...[ const SizedBox(width: AppSpacing.xs), - _badge( - '${program.phases.length} phases', - AppTheme.secondaryColor, - ), - if (deloadCount > 0) ...[ - const SizedBox(width: AppSpacing.xs), - _badge('$deloadCount deload', Colors.amber), - ], + _badge('$deloadCount deload', Colors.amber), ], - ), - if (program.phases.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.sm), - _buildMiniTimeline(), ], + ), + if (program.phases.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.sm), + _buildMiniTimeline(), ], - ), + ], ), ), ); } - static const _phaseColors = [ - AppTheme.primaryColor, - AppTheme.secondaryColor, - Colors.orange, - Colors.pink, - Colors.green, - ]; - Widget _buildMiniTimeline() { return SizedBox( height: 4, @@ -275,8 +303,9 @@ class _ProgramCard extends StatelessWidget { children: program.phases.asMap().entries.map((entry) { final phase = entry.value; final color = _phaseColors[entry.key % _phaseColors.length]; + final safeDenominator = program.totalWeeks <= 0 ? 1 : program.totalWeeks; final fraction = - (phase.endWeek - phase.startWeek + 1) / program.totalWeeks; + (phase.endWeek - phase.startWeek + 1) / safeDenominator; return Expanded( flex: ((fraction * 100).round()).clamp(1, 100), child: Container( @@ -296,7 +325,7 @@ class _ProgramCard extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: color.withOpacity(0.15), + color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(AppRadius.full), ), child: Text( diff --git a/workout-logger/lib/screens/routine_optimizer_screen.dart b/workout-logger/lib/screens/routine_optimizer_screen.dart new file mode 100644 index 0000000..166e649 --- /dev/null +++ b/workout-logger/lib/screens/routine_optimizer_screen.dart @@ -0,0 +1,648 @@ +// routine_optimizer_screen.dart — Full-screen conversational routine optimizer UI. +// +// This is a lean View: all orchestration (streaming, tool calls, question +// intercept, persistence) lives in RoutineOptimizerViewModel. The widget only +// renders state, forwards user intents, and holds UI-local controllers. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; + +import '../models/models.dart'; +import '../viewmodels/routine_optimizer_view_model.dart'; +import '../services/ai/gemini_ai_service.dart'; +import '../services/ai/coach_tool_service.dart'; +import '../services/managers/conversation_manager.dart'; +import '../services/interfaces/storage_service_interface.dart'; +import '../services/settings_provider.dart'; +import '../theme/app_theme.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/rf_question_card.dart'; + +/// Public entry point. Owns the screen-scoped [RoutineOptimizerViewModel]. +class RoutineOptimizerScreen extends StatelessWidget { + const RoutineOptimizerScreen({super.key, required this.routine}); + + final Routine routine; + + /// Renders only the view body with an externally provided VM. + /// Use this in widget tests to avoid wiring up real AI services. + @visibleForTesting + static Widget testBody(Routine routine) => _OptimizerView(routine: routine); + + @override + Widget build(BuildContext context) { + final storage = context.read(); + return ChangeNotifierProvider( + create: (ctx) { + final conversations = + ConversationManager(storage, kind: 'optimizer'); + return RoutineOptimizerViewModel( + ai: ctx.read(), + coachTools: ctx.read(), + conversations: conversations, + settings: ctx.read(), + ) + ..loadConversations() + ..startForRoutine(routine); + }, + child: _OptimizerView(routine: routine), + ); + } +} + +// ── View ────────────────────────────────────────────────────────────────────── + +class _OptimizerView extends StatefulWidget { + const _OptimizerView({required this.routine}); + final Routine routine; + + @override + State<_OptimizerView> createState() => _OptimizerViewState(); +} + +class _OptimizerViewState extends State<_OptimizerView> { + final _scrollCtrl = ScrollController(); + RoutineOptimizerViewModel? _vm; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final vm = context.read(); + if (!identical(vm, _vm)) { + _vm?.removeListener(_onVmChanged); + _vm = vm..addListener(_onVmChanged); + } + } + + void _onVmChanged() => _scrollToBottom(); + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollCtrl.hasClients) { + _scrollCtrl.animateTo( + _scrollCtrl.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + }); + } + + @override + void dispose() { + _vm?.removeListener(_onVmChanged); + _scrollCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + + return Scaffold( + backgroundColor: AppColors.background, + body: Stack( + children: [ + const AmbientGlow(), + SafeArea( + child: Column( + children: [ + _buildHeader(context, vm), + Expanded(child: _buildChatArea(vm)), + ], + ), + ), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context, RoutineOptimizerViewModel vm) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + 0, + ), + child: Row( + children: [ + // Back button + 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, + ), + ), + ), + const SizedBox(width: AppSpacing.md), + // Icon + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.secondary, Color(0xFF0097A7)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.sm), + boxShadow: [ + BoxShadow( + color: AppColors.secondaryGlow(0.4), + blurRadius: 12, + spreadRadius: -4, + ), + ], + ), + child: const Icon( + Icons.auto_fix_high_rounded, + color: Colors.white, + size: 16, + ), + ), + const SizedBox(width: AppSpacing.sm), + // Title + subtitle + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Optimize Routine', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + Text( + widget.routine.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + // History button + _HeaderIconButton( + icon: Icons.history_rounded, + onTap: () => _openHistory(context, vm), + ), + ], + ), + ); + } + + Future _openHistory( + BuildContext context, + RoutineOptimizerViewModel vm, + ) async { + HapticFeedback.lightImpact(); + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)), + ), + builder: (_) => _ConversationsSheet(vm: vm), + ); + } + + Widget _buildChatArea(RoutineOptimizerViewModel vm) { + final messages = vm.messages; + final hasContent = messages.isNotEmpty || vm.isLoading; + if (!hasContent) return _buildEmpty(); + + return ListView.builder( + controller: _scrollCtrl, + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + AppSpacing.sm, + ), + itemCount: messages.length + (vm.isLoading ? 1 : 0), + itemBuilder: (_, i) { + if (i == messages.length) { + // Loading slot — show question card if pending, else streaming bubble + if (vm.pendingQuestions != null) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: RFQuestionCard( + questions: vm.pendingQuestions!.questions, + onSubmit: vm.submitAnswers, + ), + ); + } + return _StreamingBubble(text: vm.streamingText); + } + return _MessageBubble(message: messages[i]); + }, + ); + } + + Widget _buildEmpty() { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.secondary, Color(0xFF0097A7)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.xl), + boxShadow: [ + BoxShadow( + color: AppColors.secondaryGlow(0.45), + blurRadius: 28, + spreadRadius: -4, + ), + ], + ), + child: const Icon( + Icons.auto_fix_high_rounded, + color: Colors.white, + size: 32, + ), + ), + const SizedBox(height: AppSpacing.lg), + Text( + 'Analyzing your routine…', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Reviewing your history and building a personalized plan.', + textAlign: TextAlign.center, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 14, + height: 1.5, + ), + ), + ], + ), + ), + ); + } +} + +// ── Header icon button ────────────────────────────────────────────────────── + +class _HeaderIconButton extends StatelessWidget { + const _HeaderIconButton({required this.icon, required this.onTap}); + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.glass3, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: Icon(icon, color: AppColors.textSoft, size: 18), + ), + ); + } +} + +// ── Message bubble ──────────────────────────────────────────────────────────── + +class _MessageBubble extends StatelessWidget { + const _MessageBubble({required this.message}); + final ChatMessage message; + + @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) ...[ + _OptimizerAvatar(), + const SizedBox(width: AppSpacing.sm), + ], + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + 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: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + height: 1.55, + ), + ) + : _OptimizerMarkdown(text: message.text), + ), + ), + ], + ), + ); + } +} + +// ── Streaming bubble ────────────────────────────────────────────────────────── + +class _StreamingBubble extends StatelessWidget { + const _StreamingBubble({required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _OptimizerAvatar(), + const SizedBox(width: AppSpacing.sm), + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + 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(color: AppColors.secondary) + : _OptimizerMarkdown(text: text), + ), + ), + ], + ), + ); + } +} + +/// Markdown renderer styled to the app theme. +class _OptimizerMarkdown extends StatelessWidget { + const _OptimizerMarkdown({required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + return GptMarkdown( + text, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + height: 1.55, + ), + ); + } +} + +class _OptimizerAvatar extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + width: 28, + height: 28, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.secondary, Color(0xFF0097A7)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.sm), + boxShadow: [ + BoxShadow( + color: AppColors.secondaryGlow(0.35), + blurRadius: 8, + spreadRadius: -2, + ), + ], + ), + child: const Icon(Icons.auto_fix_high_rounded, color: Colors.white, size: 14), + ); + } +} + +// ── Conversations history sheet ─────────────────────────────────────────────── + +class _ConversationsSheet extends StatelessWidget { + const _ConversationsSheet({required this.vm}); + final RoutineOptimizerViewModel vm; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: vm, + builder: (context, _) { + final conversations = vm.conversations; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Optimization History', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.md), + if (conversations.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg), + child: Text( + 'No saved optimization sessions yet.', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 13, + ), + ), + ) + else + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.5, + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: conversations.length, + separatorBuilder: (_, __) => + const SizedBox(height: AppSpacing.sm), + itemBuilder: (_, i) { + final c = conversations[i]; + final isActive = c.id == vm.activeConversationId; + return _ConversationTile( + conversation: c, + isActive: isActive, + onTap: () { + vm.selectConversation(c.id); + Navigator.pop(context); + }, + onDelete: () => vm.deleteConversation(c.id), + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _ConversationTile extends StatelessWidget { + const _ConversationTile({ + required this.conversation, + required this.isActive, + required this.onTap, + required this.onDelete, + }); + + final Conversation conversation; + final bool isActive; + final VoidCallback onTap; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: isActive + ? AppColors.secondary.withValues(alpha: 0.12) + : AppColors.glass3, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: isActive + ? AppColors.secondary.withValues(alpha: 0.4) + : AppColors.glassBorder, + ), + ), + child: Row( + children: [ + const Icon(Icons.auto_fix_high_rounded, + color: AppColors.textMuted, size: 16), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + conversation.title.isEmpty + ? 'Optimization session' + : conversation.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + Text( + '${conversation.messages.length} messages', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 11, + ), + ), + ], + ), + ), + GestureDetector( + onTap: onDelete, + child: const Padding( + padding: EdgeInsets.only(left: AppSpacing.sm), + child: Icon(Icons.delete_outline_rounded, + color: AppColors.textFaint, size: 18), + ), + ), + ], + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/routines_screen.dart b/workout-logger/lib/screens/routines_screen.dart index 81380f4..2218294 100644 --- a/workout-logger/lib/screens/routines_screen.dart +++ b/workout-logger/lib/screens/routines_screen.dart @@ -1,763 +1,578 @@ -// Routines Screen - Manage workout routines and training programs +// routines_screen.dart — Routines + Programs (soft-futurist redesign) import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; import '../theme/app_theme.dart'; -import '../data/exercise_database.dart'; -import 'workout_flow_screen.dart'; import 'programs/programs_screen.dart'; -import 'widgets/workout_conflict_dialog.dart'; - -Future _startRoutineWorkoutFlow( - BuildContext context, - Routine routine, -) async { - final provider = context.read(); - StartWorkoutConflictAction conflictAction = StartWorkoutConflictAction.cancel; - - final started = await provider.startWorkoutSafely( - routine: routine, - onConflict: () async { - final action = await showWorkoutConflictDialog( - context, - workoutStartTime: provider.workoutStartTime ?? DateTime.now(), - ); - conflictAction = action ?? StartWorkoutConflictAction.cancel; - return conflictAction; - }, - ); - - if (!context.mounted) return; - if (started || conflictAction == StartWorkoutConflictAction.resume) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => WorkoutFlowScreen(routine: routine)), - ); - } -} +import 'routine_optimizer_screen.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/routine_creator.dart'; class RoutinesScreen extends StatelessWidget { const RoutinesScreen({super.key}); @override Widget build(BuildContext context) { - return DefaultTabController( - length: 2, - child: Scaffold( - appBar: AppBar( - title: const Text('Routines'), - bottom: const TabBar( - tabs: [ - Tab(icon: Icon(Icons.list_alt), text: 'Routines'), - Tab(icon: Icon(Icons.calendar_month), text: 'Programs'), - ], + final provider = context.watch(); + final routines = provider.routines; + + return Stack( + children: [ + const AmbientGlow(), + SafeArea( + bottom: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _buildHeader(context, routines)), + if (routines.isNotEmpty) ...[ + SliverToBoxAdapter(child: _buildQuickStartCard(context: context, routine: routines.first, provider: provider)), + SliverToBoxAdapter(child: _buildAllRoutinesHeader(routines)), + SliverList( + delegate: SliverChildBuilderDelegate( + (ctx, i) => _RoutineCard( + routine: routines[i], + provider: provider, + ), + childCount: routines.length, + ), + ), + ] else ...[ + SliverToBoxAdapter(child: _buildEmptyState(context)), + ], + SliverToBoxAdapter(child: _buildProgramsSection(context)), + SliverToBoxAdapter(child: _buildNewRoutineButton(context)), + const SliverPadding(padding: EdgeInsets.only(bottom: 100)), + ], + ), ), - ), - body: const TabBarView(children: [_RoutinesTab(), ProgramsScreen()]), + ], + ); + } + + Widget _buildHeader(BuildContext context, List routines) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'PROGRAMS', + style: TextStyle(fontFamily: 'Geist', + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textFaint, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 2), + Text( + 'Routines', + style: TextStyle(fontFamily: 'Geist', + fontSize: 28, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.6, + ), + ), + ], + ), + ), + GestureDetector( + onTap: () => _openCreate(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add_rounded, size: 15, color: AppColors.primary), + const SizedBox(width: 4), + Text( + 'New', + style: TextStyle(fontFamily: 'Geist', + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ], + ), + ), + ), + ], ), ); } -} - -class _RoutinesTab extends StatelessWidget { - const _RoutinesTab(); - @override - Widget build(BuildContext context) { - final provider = context.watch(); - final routines = provider.routines; - - return Scaffold( - backgroundColor: AppTheme.backgroundColor, - body: routines.isEmpty - ? _buildEmptyState(context) - : _buildRoutineList(context, routines, provider), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _showCreateRoutineDialog(context), - icon: const Icon(Icons.add), - label: const Text('New Routine'), + Widget _buildQuickStartCard({required BuildContext context, required Routine routine, required WorkoutProvider provider}) { + final exCount = routine.exerciseIds.length; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: GlassCard( + padding: const EdgeInsets.all(18), + child: Stack( + children: [ + // Ambient blob + Positioned( + top: -20, + right: -20, + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.primary.withValues(alpha: 0.18), + AppColors.primary.withValues(alpha: 0), + ], + ), + ), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.bolt_rounded, size: 14, color: AppColors.primary), + ), + const SizedBox(width: 8), + Text( + 'UP NEXT · TODAY', + style: TextStyle(fontFamily: 'Geist', + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.primary, + letterSpacing: 1.0, + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + routine.name, + style: TextStyle(fontFamily: 'Geist', + fontSize: 22, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 4), + Text( + '$exCount exercises', + style: TextStyle(fontFamily: 'Geist', + fontSize: 12, + color: AppColors.textMuted, + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () { + HapticFeedback.mediumImpact(); + startRoutineWorkoutFlow(context, routine); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.35), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.play_arrow_rounded, size: 16, color: Colors.white), + const SizedBox(width: 6), + Text( + 'Start workout', + style: TextStyle(fontFamily: 'Geist', + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 10), + GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => CreateRoutineScreen(routine: routine)), + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.glassBorder), + ), + child: const Icon(Icons.edit_rounded, size: 16, color: AppColors.textMuted), + ), + ), + ], + ), + ], + ), + ], + ), ), ); } - Widget _buildEmptyState(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + Widget _buildAllRoutinesHeader(List routines) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Row( children: [ - Icon(Icons.list_alt, size: 64, color: AppTheme.textMuted), - const SizedBox(height: 16), - Text( - 'No Routines Yet', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), Text( - 'Create a routine to organize your workouts', - style: Theme.of(context).textTheme.bodyMedium, + 'All Routines', + style: TextStyle(fontFamily: 'Geist', + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSoft, + ), ), - const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: () => _showCreateRoutineDialog(context), - icon: const Icon(Icons.add), - label: const Text('Create Routine'), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${routines.length}', + style: TextStyle(fontFamily: 'GeistMono', + fontSize: 11, + color: AppColors.textMuted, + ), + ), ), ], ), ); } - Widget _buildRoutineList( - BuildContext context, - List routines, - WorkoutProvider provider, - ) { - return ListView.builder( - padding: const EdgeInsets.all(AppSpacing.md), - itemCount: routines.length, - itemBuilder: (context, index) { - final routine = routines[index]; - return _RoutineCard(routine: routine, provider: provider); - }, - ); - } - - void _showCreateRoutineDialog(BuildContext context) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const CreateRoutineScreen()), + Widget _buildEmptyState(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: GlassCard( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(16), + ), + child: const Icon(Icons.fitness_center_rounded, size: 32, color: AppColors.primary), + ), + const SizedBox(height: 16), + Text( + 'No Routines Yet', + style: TextStyle(fontFamily: 'Geist', + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + Text( + 'Create a routine to organize your workouts', + style: TextStyle(fontFamily: 'Geist', fontSize: 13, color: AppColors.textMuted), + textAlign: TextAlign.center, + ), + ], + ), + ), ); } -} - -class _RoutineCard extends StatelessWidget { - final Routine routine; - final WorkoutProvider provider; - const _RoutineCard({required this.routine, required this.provider}); - - @override - Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.md), - child: InkWell( - onTap: () => _showRoutineDetails(context), - onLongPress: () => _showRoutineOptions(context), - borderRadius: BorderRadius.circular(AppRadius.lg), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Widget _buildProgramsSection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 12), + child: Row( children: [ - Row( + Text( + 'Programs', + style: TextStyle(fontFamily: 'Geist', + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + letterSpacing: -0.3, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ProgramsScreen()), + ), + child: GlassCard( + padding: const EdgeInsets.all(16), + child: Row( children: [ Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), + color: AppColors.secondary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(12), ), - child: const Icon( - Icons.fitness_center, - color: AppTheme.primaryColor, - ), + child: const Icon(Icons.auto_awesome_rounded, size: 20, color: AppColors.secondary), ), - const SizedBox(width: 12), + const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - routine.name, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: AppTheme.textPrimary, + 'Browse Programs', + style: TextStyle(fontFamily: 'Geist', + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, ), ), Text( - '${routine.exerciseIds.length} exercises', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), + 'Structured multi-week training plans', + style: TextStyle(fontFamily: 'Geist', fontSize: 12, color: AppColors.textMuted), ), ], ), ), - IconButton( - icon: const Icon(Icons.play_circle_fill), - color: AppTheme.primaryColor, - iconSize: 40, - onPressed: () => _startRoutineWorkoutFlow(context, routine), - ), + const Icon(Icons.chevron_right_rounded, color: AppColors.textFaint), ], ), - const SizedBox(height: AppSpacing.md), - Wrap( - spacing: 8, - runSpacing: 4, - children: routine.exerciseIds.take(5).map((id) { - final name = provider.getExerciseName(id); - return Chip( - label: Text(name, style: const TextStyle(fontSize: 11)), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - ); - }).toList(), - ), - if (routine.exerciseIds.length > 5) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - '+${routine.exerciseIds.length - 5} more', - style: const TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), - ), - ), - ], + ), ), ), - ), + ], ); } - void _showRoutineDetails(BuildContext context) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => RoutineDetailScreen(routine: routine)), - ); - } - - void _showRoutineOptions(BuildContext context) { - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => Container( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.edit), - title: const Text('Edit Routine'), - onTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => CreateRoutineScreen(routine: routine), - ), - ); - }, + Widget _buildNewRoutineButton(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: GestureDetector( + onTap: () => _openCreate(context), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: AppColors.glassBorderStrong, + style: BorderStyle.solid, ), - ListTile( - leading: const Icon(Icons.delete, color: AppTheme.error), - title: const Text( - 'Delete Routine', - style: TextStyle(color: AppTheme.error), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.add_rounded, size: 16, color: AppColors.textMuted), + const SizedBox(width: 6), + Text( + 'New Routine', + style: TextStyle(fontFamily: 'Geist', + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textMuted, + ), ), - onTap: () { - Navigator.pop(context); - _confirmDelete(context); - }, - ), - ], + ], + ), ), ), ); } - void _confirmDelete(BuildContext context) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Delete Routine?'), - content: Text('Are you sure you want to delete "${routine.name}"?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - provider.deleteRoutine(routine.id); - Navigator.pop(context); - }, - child: const Text( - 'Delete', - style: TextStyle(color: AppTheme.error), - ), - ), - ], - ), + void _openCreate(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const CreateRoutineScreen()), ); } } -class CreateRoutineScreen extends StatefulWidget { - final Routine? routine; - - const CreateRoutineScreen({super.key, this.routine}); - - @override - State createState() => _CreateRoutineScreenState(); -} - -class _CreateRoutineScreenState extends State { - final _nameController = TextEditingController(); - final List _selectedExerciseIds = []; +// ── Routine Card ────────────────────────────────────────────────────────────── - @override - void initState() { - super.initState(); - if (widget.routine != null) { - _nameController.text = widget.routine!.name; - _selectedExerciseIds.addAll(widget.routine!.exerciseIds); - } - } +class _RoutineCard extends StatelessWidget { + const _RoutineCard({required this.routine, required this.provider}); - @override - void dispose() { - _nameController.dispose(); - super.dispose(); - } + final Routine routine; + final WorkoutProvider provider; @override Widget build(BuildContext context) { - // Use provider's exercises list (includes custom exercises) - final provider = context.watch(); - final exercises = provider.allExercises; + final exCount = routine.exerciseIds.length; - return Scaffold( - appBar: AppBar( - title: Text(widget.routine == null ? 'New Routine' : 'Edit Routine'), - actions: [ - TextButton(onPressed: _saveRoutine, child: const Text('Save')), - ], - ), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: TextField( - controller: _nameController, - decoration: const InputDecoration( - labelText: 'Routine Name', - hintText: 'e.g., Push Day, Leg Day', + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), + child: GlassCard( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + // Accent left bar + Container( + width: 4, + height: 48, + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(2), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.4), + blurRadius: 8, + ), + ], ), ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), - child: Row( - children: [ - Text( - 'Exercises (${_selectedExerciseIds.length})', - style: Theme.of(context).textTheme.titleMedium, - ), - const Spacer(), - if (_selectedExerciseIds.isNotEmpty) - TextButton( - onPressed: () => - setState(() => _selectedExerciseIds.clear()), - child: const Text('Clear All'), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + routine.name, + style: TextStyle(fontFamily: 'Geist', + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), ), - ], + const SizedBox(height: 3), + Text( + '$exCount exercise${exCount == 1 ? '' : 's'}', + style: TextStyle(fontFamily: 'Geist', + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ], + ), ), - ), - Expanded( - child: ReorderableListView.builder( - padding: const EdgeInsets.all(AppSpacing.md), - itemCount: _selectedExerciseIds.length + 1, - onReorder: (oldIndex, newIndex) { - if (oldIndex >= _selectedExerciseIds.length || - newIndex >= _selectedExerciseIds.length + 1) { - return; - } - - setState(() { - if (newIndex > oldIndex) newIndex--; - final item = _selectedExerciseIds.removeAt(oldIndex); - _selectedExerciseIds.insert(newIndex, item); - }); - }, - itemBuilder: (context, index) { - if (index == _selectedExerciseIds.length) { - return Padding( - key: const ValueKey('add_button'), - padding: const EdgeInsets.only(top: AppSpacing.md), - child: OutlinedButton.icon( - onPressed: () => _showExercisePicker(exercises), - icon: const Icon(Icons.add), - label: const Text('Add Exercises'), + // Optimize button + GestureDetector( + onTap: () { + HapticFeedback.lightImpact(); + final sessionCount = provider.sessions + .where((s) => s.routineId == routine.id) + .length; + if (sessionCount < 3) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Log "${routine.name}" at least 3 times so the ' + 'optimizer has enough data to work with.', + ), + behavior: SnackBarBehavior.floating, ), ); + return; } - - final exerciseId = _selectedExerciseIds[index]; - final exercise = provider.getExercise(exerciseId); - - return Card( - key: ValueKey(exerciseId), - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - child: ListTile( - leading: ReorderableDragStartListener( - index: index, - child: const Icon( - Icons.drag_handle, - color: AppTheme.textMuted, - ), - ), - title: Text(exercise?.name ?? 'Unknown'), - subtitle: Text( - exercise?.category ?? '', - style: const TextStyle(fontSize: 12), - ), - trailing: IconButton( - icon: const Icon( - Icons.remove_circle_outline, - color: AppTheme.error, - ), - onPressed: () { - setState(() => _selectedExerciseIds.removeAt(index)); - }, - ), + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => RoutineOptimizerScreen(routine: routine), ), ); }, - ), - ), - ], - ), - ); - } - - void _showExercisePicker(List allExercises) { - // Local state for picker search - scoped to this modal only - String pickerSearchQuery = ''; - // Use List instead of Set to preserve selection order - final List tempSelectedIds = []; - - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardColor, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => StatefulBuilder( - builder: (context, setModalState) { - // Filter exercises by search and exclude already selected - var filteredExercises = allExercises.where((ex) { - if (_selectedExerciseIds.contains(ex.id)) return false; - if (pickerSearchQuery.isEmpty) return true; - return ex.name.toLowerCase().contains( - pickerSearchQuery.toLowerCase(), - ); - }).toList(); - - // Group by muscle - final grouped = >{}; - for (var ex in filteredExercises) { - final primary = ex.primaryMuscle; - grouped.putIfAbsent(primary, () => []).add(ex); - } - - return DraggableScrollableSheet( - initialChildSize: 0.8, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) { - return Column( - children: [ - // Fixed header with search and done button - Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppTheme.textMuted, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: AppSpacing.lg), - Row( - children: [ - Expanded( - child: Text( - 'Add Exercises', - style: Theme.of(context).textTheme.titleLarge, - ), - ), - if (tempSelectedIds.isNotEmpty) - TextButton.icon( - onPressed: () { - setState(() { - _selectedExerciseIds.addAll( - tempSelectedIds, - ); - }); - Navigator.pop(context); - }, - icon: const Icon(Icons.check), - label: Text('Add ${tempSelectedIds.length}'), - ), - ], - ), - const SizedBox(height: AppSpacing.md), - // Search field - TextField( - decoration: InputDecoration( - hintText: 'Search exercises...', - prefixIcon: const Icon(Icons.search), - suffixIcon: pickerSearchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - setModalState( - () => pickerSearchQuery = '', - ); - }, - ) - : null, - isDense: true, - ), - onChanged: (val) { - setModalState(() => pickerSearchQuery = val); - }, - ), - if (tempSelectedIds.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.sm), - Text( - '${tempSelectedIds.length} exercise${tempSelectedIds.length > 1 ? 's' : ''} selected', - style: const TextStyle( - color: AppTheme.primaryColor, - fontWeight: FontWeight.w600, - ), - ), - ], - ], - ), - ), - // Scrollable exercise list - Expanded( - child: ListView( - controller: scrollController, - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - ), - children: [ - ...grouped.entries.map((entry) { - final muscleName = - MuscleGroups.names[entry.key] ?? entry.key; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.sm, - ), - child: Text( - muscleName, - style: const TextStyle( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - ), - ...entry.value.map((exercise) { - final isSelected = tempSelectedIds.contains( - exercise.id, - ); - return ListTile( - leading: Checkbox( - value: isSelected, - onChanged: (val) { - setModalState(() { - if (val == true) { - // Prevent duplicates - if (!tempSelectedIds.contains( - exercise.id, - )) { - tempSelectedIds.add(exercise.id); - } - } else { - tempSelectedIds.remove(exercise.id); - } - }); - }, - ), - title: Text( - exercise.name, - style: TextStyle( - color: isSelected - ? AppTheme.primaryColor - : null, - ), - ), - subtitle: exercise.isCustom - ? const Text( - 'Custom', - style: TextStyle( - color: AppTheme.primaryColor, - fontSize: 12, - ), - ) - : null, - trailing: isSelected - ? const Icon( - Icons.check_circle, - color: AppTheme.primaryColor, - ) - : const Icon( - Icons.add_circle_outline, - color: AppTheme.textMuted, - ), - onTap: () { - setModalState(() { - if (isSelected) { - tempSelectedIds.remove(exercise.id); - } else { - // Prevent duplicates - if (!tempSelectedIds.contains( - exercise.id, - )) { - tempSelectedIds.add(exercise.id); - } - } - }); - }, - ); - }), - ], - ); - }), - const SizedBox(height: AppSpacing.xl), - ], - ), + child: Container( + width: 34, + height: 34, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: AppColors.secondary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppColors.secondary.withValues(alpha: 0.35), ), - ], - ); - }, - ); - }, - ), - ); - } - - void _saveRoutine() async { - if (_nameController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please enter a routine name')), - ); - return; - } - - if (_selectedExerciseIds.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please add at least one exercise')), - ); - return; - } - - final provider = context.read(); - - if (widget.routine != null) { - // Update existing - final updated = Routine( - id: widget.routine!.id, - name: _nameController.text, - exerciseIds: _selectedExerciseIds, - createdAt: widget.routine!.createdAt, - ); - await provider.updateRoutine(updated); - } else { - // Create new - await provider.createRoutine(_nameController.text, _selectedExerciseIds); - } - - if (mounted) Navigator.pop(context); - } -} - -class RoutineDetailScreen extends StatelessWidget { - final Routine routine; - - const RoutineDetailScreen({super.key, required this.routine}); - - @override - Widget build(BuildContext context) { - final provider = context.read(); - - return Scaffold( - appBar: AppBar( - title: Text(routine.name), - actions: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () { - Navigator.pushReplacement( + ), + child: const Icon(Icons.auto_fix_high_rounded, size: 15, color: AppColors.secondary), + ), + ), + // Edit button + GestureDetector( + onTap: () => Navigator.push( context, MaterialPageRoute( builder: (_) => CreateRoutineScreen(routine: routine), ), - ); - }, - ), - ], - ), - body: ListView.builder( - padding: const EdgeInsets.all(AppSpacing.md), - itemCount: routine.exerciseIds.length, - itemBuilder: (context, index) { - final exerciseId = routine.exerciseIds[index]; - final exercise = provider.getExercise(exerciseId); - - return Card( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - child: ListTile( - leading: CircleAvatar( - backgroundColor: AppTheme.primaryColor.withOpacity(0.2), - child: Text( - '${index + 1}', - style: const TextStyle( - color: AppTheme.primaryColor, - fontWeight: FontWeight.bold, - ), + ), + child: Container( + width: 34, + height: 34, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.glassBorder), ), + child: const Icon(Icons.edit_rounded, size: 15, color: AppColors.textMuted), ), - title: Text(exercise?.name ?? 'Unknown'), - subtitle: exercise != null - ? Text( - '${exercise.category} • ${MuscleGroups.names[exercise.primaryMuscle] ?? ""}', - style: const TextStyle(fontSize: 12), - ) - : null, ), - ); - }, - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { - _startRoutineWorkoutFlow(context, routine); - }, - icon: const Icon(Icons.play_arrow), - label: const Text('Start Workout'), + // Play button + GestureDetector( + onTap: () { + HapticFeedback.mediumImpact(); + startRoutineWorkoutFlow(context, routine); + }, + child: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.35), + blurRadius: 10, + ), + ], + ), + child: const Icon(Icons.play_arrow_rounded, size: 20, color: Colors.white), + ), + ), + ], + ), ), ); } diff --git a/workout-logger/lib/screens/sleep_detail_screen.dart b/workout-logger/lib/screens/sleep_detail_screen.dart new file mode 100644 index 0000000..13a38c1 --- /dev/null +++ b/workout-logger/lib/screens/sleep_detail_screen.dart @@ -0,0 +1,258 @@ +// sleep_detail_screen.dart — full-screen sleep history. +// +// Day : overnight HR breakdown (SleepHrDayView) for the selected night. +// Week / Month / Year : stacked sleep-duration bars (SleepBarsChart) with an +// 8h goal line and workout-day highlights. + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import '../models/sleep_hr_models.dart'; +import '../services/managers/health_history_manager.dart'; +import '../services/workout_provider.dart'; +import '../theme/app_theme.dart'; +import 'widgets/health_bar_chart.dart'; +import 'widgets/health_detail_shell.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/sleep_hr_charts.dart'; + +class SleepDetailScreen extends StatefulWidget { + const SleepDetailScreen({super.key, this.initialDate}); + + final DateTime? initialDate; + + @override + State createState() => _SleepDetailScreenState(); +} + +class _SleepDetailScreenState extends State { + late HealthHistoryManager _mgr; + HealthGranularity _g = HealthGranularity.day; + late DateTime _anchor; + late Set _workoutDays; + Future? _future; + + @override + void initState() { + super.initState(); + final now = widget.initialDate ?? DateTime.now(); + _anchor = DateTime(now.year, now.month, now.day); + final sessions = context.read().sessions; + _workoutDays = sessions.map((s) => HealthHistoryManager.dateKey(s.date)).toSet(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _mgr = context.read(); + _future ??= _load(); + } + + Future _load() => _g == HealthGranularity.day + ? _mgr.sleepNight(_anchor) + : _mgr.sleepBars(_anchor, _g); + + bool get _canGoNext { + final today = DateTime.now(); + return HealthHistoryManager.stepBy(_anchor, _g, 1) + .isBefore(DateTime(today.year, today.month, today.day + 1)); + } + + void _step(int dir) { + setState(() { + _anchor = HealthHistoryManager.stepBy(_anchor, _g, dir); + _future = _load(); + }); + } + + void _setG(HealthGranularity g) { + setState(() { + _g = g; + _future = _load(); + }); + } + + String get _dateLabel { + switch (_g) { + case HealthGranularity.day: + final prev = _anchor.subtract(const Duration(days: 1)); + return '${DateFormat('MMM d').format(prev)} → ${DateFormat('d').format(_anchor)}'; + case HealthGranularity.week: + final start = _anchor.subtract(const Duration(days: 6)); + return '${DateFormat('MMM d').format(start)} – ${DateFormat('MMM d').format(_anchor)}'; + case HealthGranularity.month: + return DateFormat('MMMM yyyy').format(_anchor); + case HealthGranularity.year: + return DateFormat('yyyy').format(_anchor); + } + } + + @override + Widget build(BuildContext context) { + return HealthDetailShell( + title: 'Sleep', + icon: Icons.nightlight_round, + iconColor: kSleepStageColors['rem']!, + dateLabel: _dateLabel, + granularity: _g, + onGranularityChanged: _setG, + onPrev: () => _step(-1), + onNext: () => _step(1), + canGoNext: _canGoNext, + child: FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const _Loading(); + } + if (_g == HealthGranularity.day) { + final data = snap.data as SleepHrSnapshot?; + if (data == null) return const _Empty('No sleep data for this night.'); + return _DayBody(snapshot: data); + } + final bars = (snap.data as List?) ?? const []; + return _AggBody(bars: bars, workoutDays: _workoutDays, granularity: _g); + }, + ), + ); + } +} + +class _DayBody extends StatelessWidget { + const _DayBody({required this.snapshot}); + final SleepHrSnapshot snapshot; + + static DateTime _ist(DateTime dt) => dt.toUtc().add(const Duration(hours: 5, minutes: 30)); + static String _fmt(DateTime dt) { + final h = dt.hour == 0 ? 12 : dt.hour > 12 ? dt.hour - 12 : dt.hour; + final m = dt.minute.toString().padLeft(2, '0'); + return '$h:$m ${dt.hour < 12 ? 'AM' : 'PM'}'; + } + + @override + Widget build(BuildContext context) { + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Asleep · ${_fmt(_ist(snapshot.sleepStart))} – ${_fmt(_ist(snapshot.sleepEnd))} IST', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12), + ), + const SizedBox(height: 16), + SleepHrDayView(snapshot: snapshot), + ], + ), + ); + } +} + +class _AggBody extends StatelessWidget { + const _AggBody({ + required this.bars, + required this.workoutDays, + required this.granularity, + }); + + final List bars; + final Set workoutDays; + final HealthGranularity granularity; + + @override + Widget build(BuildContext context) { + final withData = bars.where((b) => b.totalMinutes > 0).toList(); + final avg = withData.isEmpty + ? 0 + : withData.fold(0, (s, b) => s + b.totalMinutes) ~/ withData.length; + final avgLabel = '${avg ~/ 60}h${(avg % 60).toString().padLeft(2, '0')}'; + final unit = granularity == HealthGranularity.year ? 'per month' : 'per night'; + + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Sleep duration · $unit', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + ), + Text( + withData.isEmpty ? '—' : 'avg $avgLabel', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 12), + SleepBarsChart(bars: bars, workoutDays: workoutDays), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 4, + children: [ + _legend('Deep', kSleepStageColors['deep']!), + _legend('REM', kSleepStageColors['rem']!), + _legend('Light', kSleepStageColors['light']!), + _legendDash('8h goal', kSleepStageColors['awake']!), + _legend('Workout day', AppColors.accent), + ], + ), + ], + ), + ); + } + + Widget _legend(String label, Color c) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(2)), + ), + const SizedBox(width: 4), + Text(label, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), + ], + ); + + Widget _legendDash(String label, Color c) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 14, height: 2, color: c), + const SizedBox(width: 4), + Text(label, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), + ], + ); +} + +class _Loading extends StatelessWidget { + const _Loading(); + @override + Widget build(BuildContext context) => const SizedBox( + height: 220, + child: Center(child: RFLoadingDots()), + ); +} + +class _Empty extends StatelessWidget { + const _Empty(this.message); + final String message; + @override + Widget build(BuildContext context) => GlassCard( + padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 16), + child: Center( + child: Text( + message, + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 13), + ), + ), + ); +} diff --git a/workout-logger/lib/screens/widgets/activity_heatmap.dart b/workout-logger/lib/screens/widgets/activity_heatmap.dart new file mode 100644 index 0000000..ea500d4 --- /dev/null +++ b/workout-logger/lib/screens/widgets/activity_heatmap.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_theme.dart'; + +/// 14-week × 7-day activity heatmap grid. +/// [data] is a list of 98 integers (0–4) ordered column-by-column +/// (col 0 = oldest week, row 0 = Mon). +class ActivityHeatmap extends StatelessWidget { + const ActivityHeatmap({super.key, required this.data}); + + final List data; // length 98 (14 cols × 7 rows) + + static const _cols = 14; + static const _rows = 7; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _cols, + crossAxisSpacing: 3, + mainAxisSpacing: 3, + childAspectRatio: 1, + ), + itemCount: _cols * _rows, + itemBuilder: (context, index) { + // Transpose: GridView fills row-by-row, we want col-by-col + final col = index % _cols; + final row = index ~/ _cols; + final dataIndex = col * _rows + row; + final intensity = dataIndex < data.length ? data[dataIndex] : 0; + return _HeatCell(intensity: intensity); + }, + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Less', + style: TextStyle( + fontSize: 10, color: AppColors.textFaint), + ), + Row( + children: List.generate(5, (i) { + final opacity = i == 0 ? 0.05 : 0.2 + i * 0.15; + return Container( + width: 10, + height: 10, + margin: const EdgeInsets.symmetric(horizontal: 1.5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: i == 0 + ? const Color(0x0DFFFFFF) + : AppColors.primary.withValues(alpha: opacity), + ), + ); + }), + ), + const Text( + 'More', + style: TextStyle( + fontSize: 10, color: AppColors.textFaint), + ), + ], + ), + ], + ), + ); + } +} + +class _HeatCell extends StatelessWidget { + const _HeatCell({required this.intensity}); + final int intensity; + + @override + Widget build(BuildContext context) { + final opacity = intensity == 0 ? 0.0 : 0.2 + intensity * 0.18; + final hasGlow = intensity >= 3; + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: intensity == 0 + ? const Color(0x0AFFFFFF) + : AppColors.primary.withValues(alpha: opacity.clamp(0, 1)), + boxShadow: hasGlow + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.25), + blurRadius: 4, + ), + ] + : null, + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/analytics_overview.dart b/workout-logger/lib/screens/widgets/analytics_overview.dart new file mode 100644 index 0000000..cb2a286 --- /dev/null +++ b/workout-logger/lib/screens/widgets/analytics_overview.dart @@ -0,0 +1,762 @@ +// analytics_overview.dart — Analytics "Overview" tab +// +// Top-down hierarchy: weekly Volume Trend (with range toggle) → unified +// Muscle Focus (body map + per-muscle rows, tappable drill-down) → Frequency. + +import 'dart:math' show max; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:intl/intl.dart'; + +import '../../models/models.dart'; +import '../../services/workout_provider.dart'; +import '../../services/settings_provider.dart'; +import '../../services/ml_service.dart' show MuscleRecoveryStatus; +import '../../data/exercise_database.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; +import 'body_heatmap.dart'; +import 'muscle_detail_sheet.dart'; + +class AnalyticsOverviewTab extends StatelessWidget { + const AnalyticsOverviewTab({super.key}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + return LayoutBuilder( + builder: (context, constraints) { + final hp = AppBreakpoints.hPadding(constraints.maxWidth); + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: AppBreakpoints.contentMaxWidth, + ), + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.fromLTRB(hp, 0, hp, 100), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _VolumeTrendCard(), + const SizedBox(height: 12), + _MuscleFocusCard(provider: provider), + const SizedBox(height: 12), + _FrequencyGrid(provider: provider), + ], + ), + ), + ), + ); + }, + ); + } +} + +// ── Volume trend (weekly aggregate + range toggle) ───────────────────────────── + +enum _Range { w4, w12, all } + +extension on _Range { + String get label => switch (this) { + _Range.w4 => '4W', + _Range.w12 => '12W', + _Range.all => 'All', + }; + int? get weeks => switch (this) { + _Range.w4 => 4, + _Range.w12 => 12, + _Range.all => null, // computed from history (capped) + }; +} + +class _VolumeTrendCard extends StatefulWidget { + const _VolumeTrendCard(); + + @override + State<_VolumeTrendCard> createState() => _VolumeTrendCardState(); +} + +class _VolumeTrendCardState extends State<_VolumeTrendCard> { + _Range _range = _Range.w12; + + static const int _allCap = 26; // keep the chart readable for long histories + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final settings = context.watch(); + final sessions = provider.sessions; + + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final currentWeekStart = today.subtract(Duration(days: today.weekday - 1)); + + // Resolve number of weeks to display. + int weeks; + if (_range.weeks != null) { + weeks = _range.weeks!; + } else if (sessions.isEmpty) { + weeks = 0; + } else { + final earliest = sessions + .map((s) => s.date) + .reduce((a, b) => a.isBefore(b) ? a : b); + final earliestWeekStart = DateTime(earliest.year, earliest.month, earliest.day) + .subtract(Duration(days: earliest.weekday - 1)); + final span = currentWeekStart.difference(earliestWeekStart).inDays ~/ 7 + 1; + weeks = span.clamp(1, _allCap); + } + + double weeklyVolume(int weekIndex) { + // weekIndex 0 == oldest week shown, weeks-1 == current week. + final wStart = + currentWeekStart.subtract(Duration(days: (weeks - 1 - weekIndex) * 7)); + final wEnd = wStart.add(const Duration(days: 7)); + final raw = sessions + .where((s) => !s.date.isBefore(wStart) && s.date.isBefore(wEnd)) + .fold(0, (sum, s) => sum + s.totalVolume); + return settings.toDisplay(raw); + } + + final spots = [ + for (int i = 0; i < weeks; i++) FlSpot(i.toDouble(), weeklyVolume(i)), + ]; + final hasData = spots.any((s) => s.y > 0); + final bestVol = spots.isEmpty ? 0.0 : spots.map((s) => s.y).reduce(max); + + // Period delta: current shown period vs equal-length previous period. + final curSum = spots.fold(0, (sum, s) => sum + s.y); + double prevSum = 0; + for (int i = 0; i < weeks; i++) { + final wStart = + currentWeekStart.subtract(Duration(days: (weeks + i) * 7)); + final wEnd = wStart.add(const Duration(days: 7)); + prevSum += settings.toDisplay(sessions + .where((s) => !s.date.isBefore(wStart) && s.date.isBefore(wEnd)) + .fold(0, (sum, s) => sum + s.totalVolume)); + } + final double? deltaPct = + prevSum > 0 ? ((curSum - prevSum) / prevSum * 100) : null; + + DateTime weekStartFor(int i) => + currentWeekStart.subtract(Duration(days: (weeks - 1 - i) * 7)); + final labelInterval = weeks <= 1 ? 1.0 : (weeks / 6).ceilToDouble(); + + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Volume Trend', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + '${settings.unitLabel} · per week', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + _RangeToggle( + value: _range, + onChanged: (r) => setState(() => _range = r), + ), + ], + ), + if (deltaPct != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon( + deltaPct >= 0 + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + size: 13, + color: deltaPct >= 0 ? AppColors.success : AppColors.error, + ), + const SizedBox(width: 2), + Text( + '${deltaPct.abs().toStringAsFixed(0)}% vs prev ${weeks}w', + style: TextStyle(fontFamily: 'GeistMono', + color: deltaPct >= 0 ? AppColors.success : AppColors.error, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + if (!hasData) ...[ + const SizedBox(height: 24), + const _EmptyChart(), + const SizedBox(height: 8), + ] else ...[ + const SizedBox(height: 14), + LayoutBuilder( + builder: (context, constraints) => SizedBox( + height: AppBreakpoints.chartHeight(constraints.maxWidth), + child: LineChart( + LineChartData( + backgroundColor: Colors.transparent, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (_) => + FlLine(color: AppColors.glassBorder, strokeWidth: 1), + ), + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (_) => AppColors.cardHigh, + getTooltipItems: (touched) => touched.map((spot) { + final i = spot.x.toInt(); + final volStr = _fmtK(spot.y); + final ws = weekStartFor(i); + return LineTooltipItem( + '$volStr ${settings.unitLabel}', + TextStyle(fontFamily: 'GeistMono', + color: AppColors.primary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + children: [ + TextSpan( + text: + '\nwk of ${DateFormat('MMM d').format(ws)}', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.normal, + ), + ), + ], + ); + }).toList(), + ), + ), + titlesData: FlTitlesData( + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + interval: labelInterval, + getTitlesWidget: (v, _) { + final i = v.toInt(); + if (i < 0 || i >= weeks) return const Text(''); + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + DateFormat('d/M').format(weekStartFor(i)), + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textMuted, + fontSize: 9, + ), + ), + ); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: (v, _) => Text( + _fmtK(v), + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textMuted, + fontSize: 9, + ), + ), + ), + ), + ), + borderData: FlBorderData(show: false), + extraLinesData: ExtraLinesData( + horizontalLines: [ + if (bestVol > 0) + HorizontalLine( + y: bestVol, + color: AppColors.warning.withValues(alpha: 0.55), + strokeWidth: 1, + dashArray: [6, 4], + label: HorizontalLineLabel( + show: true, + direction: LabelDirection.horizontal, + alignment: Alignment.topRight, + padding: const EdgeInsets.only(right: 6, bottom: 2), + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.warning, + fontSize: 9, + fontWeight: FontWeight.w600, + ), + labelResolver: (_) => 'BEST ${_fmtK(bestVol)}', + ), + ), + ], + ), + lineBarsData: [ + LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.3, + color: AppColors.primary, + barWidth: 2.5, + isStrokeCapRound: true, + dotData: FlDotData( + show: weeks <= 12, + getDotPainter: (_, __, ___, ____) => + FlDotCirclePainter( + radius: 3.5, + color: AppColors.primary, + strokeWidth: 1.5, + strokeColor: AppColors.surface, + ), + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + AppColors.primary.withValues(alpha: 0.25), + AppColors.primary.withValues(alpha: 0.0), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ], + ), + ), + ), + ), + ], + ], + ), + ); + } +} + +class _RangeToggle extends StatelessWidget { + const _RangeToggle({required this.value, required this.onChanged}); + final _Range value; + final ValueChanged<_Range> onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: _Range.values.map((r) { + final active = r == value; + return GestureDetector( + onTap: () => onChanged(r), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: active ? AppColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(7), + ), + child: Text( + r.label, + style: TextStyle(fontFamily: 'GeistMono', + fontSize: 11, + fontWeight: FontWeight.w700, + color: active ? Colors.white : AppColors.textMuted, + ), + ), + ), + ); + }).toList(), + ), + ); + } +} + +// ── Muscle Focus (unified volume + recovery + growth, drill-down) ────────────── + +class _MuscleFocusCard extends StatelessWidget { + const _MuscleFocusCard({required this.provider}); + final WorkoutProvider provider; + + static const _muscleOrder = [ + 'chest', 'back', 'shoulders', 'quads', 'hamstrings', + 'glutes', 'biceps', 'triceps', 'abs', 'calves', + ]; + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final byMuscle = provider.getWeeklyVolumeByMuscle(); + final recovery = provider.getMuscleRecoveryScores(); + final growth = provider.getMuscleGrowthModels(); + + if (byMuscle.isEmpty && recovery.isEmpty) { + return _ChartCard( + title: 'Muscle Focus', + isEmpty: true, + child: const SizedBox.shrink(), + ); + } + + // Union of muscles with volume or recovery data, ordered by volume desc. + final ids = {...byMuscle.keys, ...recovery.keys}.toList() + ..sort((a, b) { + final cmp = (byMuscle[b] ?? 0).compareTo(byMuscle[a] ?? 0); + if (cmp != 0) return cmp; + return _muscleOrder.indexOf(a).compareTo(_muscleOrder.indexOf(b)); + }); + final top = ids.take(8).toList(); + final maxVol = byMuscle.values.isEmpty + ? 0.0 + : byMuscle.values.reduce(max); + + final normalized = { + if (maxVol > 0) + for (final e in byMuscle.entries) e.key: e.value / maxVol, + }; + + return _ChartCard( + title: 'Muscle Focus', + subtitle: 'Weekly volume · recovery · trend — tap a muscle', + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BodyHeatmapWidget(muscleVolumes: normalized), + const SizedBox(width: 14), + Expanded( + child: Column( + children: [ + for (final id in top) + _MuscleFocusRow( + muscleId: id, + weeklyVolume: byMuscle[id] ?? 0, + fraction: maxVol > 0 ? (byMuscle[id] ?? 0) / maxVol : 0, + recovery: recovery[id], + growth: growth[id], + settings: settings, + onTap: () => _openDrillDown(context, id), + ), + ], + ), + ), + ], + ), + ); + } + + void _openDrillDown(BuildContext context, String muscleId) { + showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => MuscleDetailSheet( + muscleId: muscleId, + provider: provider, + ), + ); + } +} + +class _MuscleFocusRow extends StatelessWidget { + const _MuscleFocusRow({ + required this.muscleId, + required this.weeklyVolume, + required this.fraction, + required this.recovery, + required this.growth, + required this.settings, + required this.onTap, + }); + + final String muscleId; + final double weeklyVolume; + final double fraction; + final MuscleRecoveryStatus? recovery; + final GrowthModel? growth; + final SettingsProvider settings; + final VoidCallback onTap; + + ({Color color, IconData icon}) get _trend { + final model = growth; + if (model == null) { + return (color: AppColors.textFaint, icon: Icons.remove_rounded); + } + // Relative weekly growth so small muscles (low effective volume) use the + // same bar as large ones — +2 %/week is strong progress on any muscle. + final weekly = model.weeklyGrowthPercent; + if (weekly > 2) { + return (color: AppColors.success, icon: Icons.trending_up_rounded); + } + if (weekly > 0.5) { + return (color: AppColors.secondary, icon: Icons.trending_up_rounded); + } + if (weekly < -2) { + return (color: AppColors.error, icon: Icons.trending_down_rounded); + } + return (color: AppColors.warning, icon: Icons.trending_flat_rounded); + } + + Color get _recoveryColor { + final r = recovery; + if (r == null) return AppColors.textFaint; + if (r.recoveryFraction >= 0.90) return AppColors.success; + if (r.recoveryFraction >= 0.70) return AppColors.warning; + return AppColors.error; + } + + @override + Widget build(BuildContext context) { + final name = MuscleGroups.names[muscleId] ?? muscleId; + final color = AppColors.muscle(muscleId); + final trend = _trend; + final displayVol = settings.toDisplay(weeklyVolume); + + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + name, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + '${_fmtK(displayVol)} ${settings.unitLabel}', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textMuted, + fontSize: 11, + ), + ), + const SizedBox(width: 4), + Icon(Icons.chevron_right_rounded, + size: 14, color: AppColors.textFaint), + ], + ), + const SizedBox(height: 5), + Row( + children: [ + Expanded( + child: RFProgressBar( + value: fraction, + color: color, + height: 5, + showGlow: false, + ), + ), + if (recovery != null) ...[ + const SizedBox(width: 8), + Text( + '${recovery!.recoveryPercent}%', + style: TextStyle(fontFamily: 'GeistMono', + fontSize: 10, + fontWeight: FontWeight.w600, + color: _recoveryColor, + ), + ), + ], + const SizedBox(width: 6), + Icon(trend.icon, size: 13, color: trend.color), + ], + ), + ], + ), + ), + ); + } +} + +// ── Weekly frequency grid ────────────────────────────────────────────────────── + +class _FrequencyGrid extends StatelessWidget { + const _FrequencyGrid({required this.provider}); + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final now = DateTime.now(); + final weeks = {0: 0, 1: 0, 2: 0, 3: 0}; + for (final s in provider.sessions) { + final w = now.difference(s.date).inDays ~/ 7; + if (w >= 0 && w < 4) weeks[w] = (weeks[w] ?? 0) + 1; + } + + return _ChartCard( + title: 'Workout Frequency', + subtitle: 'Sessions per week', + child: LayoutBuilder( + builder: (context, constraints) { + final boxSize = ((constraints.maxWidth - 48) / 4).clamp(40.0, 64.0); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: weeks.entries.map((e) { + final count = e.value; + final label = e.key == 0 ? 'This wk' : '${e.key} wk'; + final active = count > 0; + return Column( + children: [ + Container( + width: boxSize, + height: boxSize, + decoration: BoxDecoration( + color: active + ? AppColors.primary.withValues(alpha: 0.12 + count * 0.06) + : AppColors.glass2, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: active + ? AppColors.primary.withValues(alpha: 0.4) + : AppColors.glassBorder, + ), + boxShadow: active + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.2), + blurRadius: 12, + ) + ] + : null, + ), + child: Center( + child: Text( + '$count', + style: TextStyle(fontFamily: 'GeistMono', + color: active ? AppColors.primary : AppColors.textMuted, + fontSize: 22, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(height: 6), + Text( + label, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 10, + ), + ), + ], + ); + }).toList(), + ); + }, + ), + ); + } +} + +// ── Reusable chart card ──────────────────────────────────────────────────────── + +class _ChartCard extends StatelessWidget { + const _ChartCard({ + required this.title, + required this.child, + this.subtitle, + this.isEmpty = false, + }); + + final String title; + final String? subtitle; + final Widget child; + final bool isEmpty; + + @override + Widget build(BuildContext context) { + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 11), + ), + ], + if (isEmpty) ...[ + const SizedBox(height: 24), + const _EmptyChart(), + ] else ...[ + const SizedBox(height: 14), + child, + ], + ], + ), + ); + } +} + +class _EmptyChart extends StatelessWidget { + const _EmptyChart(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + children: [ + const Icon(Icons.show_chart_rounded, size: 32, color: AppColors.textFaint), + const SizedBox(height: 8), + Text('No data yet', + style: TextStyle(fontFamily: 'Geist', fontSize: 13, color: AppColors.textMuted)), + Text('Complete workouts to see progress', + style: TextStyle(fontFamily: 'Geist', fontSize: 11, color: AppColors.textFaint)), + ], + ), + ); + } +} + +String _fmtK(double v) => + v >= 1000 ? '${(v / 1000).toStringAsFixed(1)}k' : v.toStringAsFixed(0); diff --git a/workout-logger/lib/screens/widgets/body_heatmap.dart b/workout-logger/lib/screens/widgets/body_heatmap.dart new file mode 100644 index 0000000..79ac8f7 --- /dev/null +++ b/workout-logger/lib/screens/widgets/body_heatmap.dart @@ -0,0 +1,164 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import '../../theme/app_theme.dart'; + +/// Stylised human body silhouette with muscle heat overlays. +/// [muscleVolumes] maps muscle group id → relative volume 0–1. +class BodyHeatmapWidget extends StatelessWidget { + const BodyHeatmapWidget({ + super.key, + this.muscleVolumes = const {}, + this.width = 74, + this.height = 148, + }); + + final Map muscleVolumes; + final double width; + final double height; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: CustomPaint( + painter: _BodyPainter(muscleVolumes: muscleVolumes), + ), + ); + } +} + +class _BodyPainter extends CustomPainter { + const _BodyPainter({required this.muscleVolumes}); + final Map muscleVolumes; + + @override + void paint(Canvas canvas, Size size) { + final sx = size.width / 74; + final sy = size.height / 148; + + final baseFill = Paint() + ..color = const Color(0x0FFFFFFF) + ..style = PaintingStyle.fill; + final baseStroke = Paint() + ..color = const Color(0x1AFFFFFF) + ..style = PaintingStyle.stroke + ..strokeWidth = 0.7; + + // ── Body outline shapes ────────────────────────────────────── + // Head + canvas.drawCircle(Offset(37 * sx, 12 * sy), 9 * sx, baseFill); + canvas.drawCircle(Offset(37 * sx, 12 * sy), 9 * sx, baseStroke); + + // Torso + final torso = Path() + ..moveTo(22 * sx, 24 * sy) + ..lineTo(52 * sx, 24 * sy) + ..lineTo(54 * sx, 50 * sy) + ..lineTo(52 * sx, 72 * sy) + ..lineTo(22 * sx, 72 * sy) + ..lineTo(20 * sx, 50 * sy) + ..close(); + canvas.drawPath(torso, baseFill); + canvas.drawPath(torso, baseStroke); + + // Left arm + final leftArm = Path() + ..moveTo(20 * sx, 28 * sy) + ..lineTo(12 * sx, 32 * sy) + ..lineTo(8 * sx, 60 * sy) + ..lineTo(12 * sx, 70 * sy) + ..lineTo(18 * sx, 50 * sy) + ..close(); + canvas.drawPath(leftArm, baseFill); + canvas.drawPath(leftArm, baseStroke); + + // Right arm + final rightArm = Path() + ..moveTo(54 * sx, 28 * sy) + ..lineTo(62 * sx, 32 * sy) + ..lineTo(66 * sx, 60 * sy) + ..lineTo(62 * sx, 70 * sy) + ..lineTo(56 * sx, 50 * sy) + ..close(); + canvas.drawPath(rightArm, baseFill); + canvas.drawPath(rightArm, baseStroke); + + // Left leg + final leftLeg = Path() + ..moveTo(24 * sx, 73 * sy) + ..lineTo(34 * sx, 73 * sy) + ..lineTo(33 * sx, 110 * sy) + ..lineTo(30 * sx, 140 * sy) + ..lineTo(23 * sx, 140 * sy) + ..lineTo(22 * sx, 105 * sy) + ..close(); + canvas.drawPath(leftLeg, baseFill); + canvas.drawPath(leftLeg, baseStroke); + + // Right leg + final rightLeg = Path() + ..moveTo(40 * sx, 73 * sy) + ..lineTo(50 * sx, 73 * sy) + ..lineTo(52 * sx, 105 * sy) + ..lineTo(51 * sx, 140 * sy) + ..lineTo(44 * sx, 140 * sy) + ..lineTo(41 * sx, 110 * sy) + ..close(); + canvas.drawPath(rightLeg, baseFill); + canvas.drawPath(rightLeg, baseStroke); + + // ── Heat overlays ──────────────────────────────────────────── + _drawHeat(canvas: canvas, sx: sx, sy: sy, muscle: 'chest', + path: _ellipse(cx: 37, cy: 38, rx: 13, ry: 9, sx: sx, sy: sy), color: AppColors.primary, baseOpacity: 0.55); + _drawHeat(canvas: canvas, sx: sx, sy: sy, muscle: 'shoulders', + path: _circle(cx: 22, cy: 28, r: 5, sx: sx, sy: sy), color: AppColors.primary, baseOpacity: 0.42); + _drawHeat(canvas: canvas, sx: sx, sy: sy, muscle: 'shoulders', + path: _circle(cx: 52, cy: 28, r: 5, sx: sx, sy: sy), color: AppColors.primary, baseOpacity: 0.42); + _drawHeat(canvas: canvas, sx: sx, sy: sy, muscle: 'biceps', + path: _ellipse(cx: 14, cy: 46, rx: 3.5, ry: 8, sx: sx, sy: sy), color: AppColors.secondary, baseOpacity: 0.45); + _drawHeat(canvas: canvas, sx: sx, sy: sy, muscle: 'biceps', + path: _ellipse(cx: 60, cy: 46, rx: 3.5, ry: 8, sx: sx, sy: sy), color: AppColors.secondary, baseOpacity: 0.45); + _drawHeat(canvas: canvas, sx: sx, sy: sy, muscle: 'quads', + path: _ellipse(cx: 28, cy: 92, rx: 5, ry: 11, sx: sx, sy: sy), color: AppColors.warning, baseOpacity: 0.18); + _drawHeat(canvas: canvas, sx: sx, sy: sy, muscle: 'quads', + path: _ellipse(cx: 46, cy: 92, rx: 5, ry: 11, sx: sx, sy: sy), color: AppColors.warning, baseOpacity: 0.18); + } + + void _drawHeat({ + required Canvas canvas, + required double sx, + required double sy, + required String muscle, + required Path path, + required Color color, + required double baseOpacity, + }) { + final vol = muscleVolumes[muscle] ?? 0.0; + final opacity = (baseOpacity * vol).clamp(0.0, 1.0).toDouble(); + canvas.drawPath(path, Paint()..color = color.withValues(alpha: opacity)); + } + + Path _ellipse({ + required double cx, + required double cy, + required double rx, + required double ry, + required double sx, + required double sy, + }) { + return Path() + ..addOval(Rect.fromCenter( + center: Offset(cx * sx, cy * sy), + width: rx * 2 * sx, + height: ry * 2 * sy, + )); + } + + Path _circle({required double cx, required double cy, required double r, required double sx, required double sy}) => + _ellipse(cx: cx, cy: cy, rx: r, ry: r, sx: sx, sy: sy); + + @override + bool shouldRepaint(_BodyPainter old) => + !mapEquals(old.muscleVolumes, muscleVolumes); +} diff --git a/workout-logger/lib/screens/widgets/calendar_grid.dart b/workout-logger/lib/screens/widgets/calendar_grid.dart new file mode 100644 index 0000000..0959025 --- /dev/null +++ b/workout-logger/lib/screens/widgets/calendar_grid.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_theme.dart'; + +class CalendarDayData { + const CalendarDayData({required this.intensity, this.hasPr = false}); + final int intensity; // 0–3: 0 = no workout, 1–3 = intensity levels + final bool hasPr; +} + +/// Calendar month grid with intensity shading and PR dot indicators. +class CalendarMonthGrid extends StatelessWidget { + const CalendarMonthGrid({ + super.key, + required this.year, + required this.month, + required this.workoutDays, + this.selectedDay, + this.onDayTap, + }); + + final int year; + final int month; + final Map workoutDays; + final int? selectedDay; + final ValueChanged? onDayTap; + + @override + Widget build(BuildContext context) { + final firstDay = DateTime(year, month, 1); + final startOffset = (firstDay.weekday - 1) % 7; + final daysInMonth = DateTime(year, month + 1, 0).day; + final today = DateTime.now(); + final isCurrentMonth = today.year == year && today.month == month; + final todayDay = isCurrentMonth ? today.day : -1; + final totalCells = ((startOffset + daysInMonth) / 7).ceil() * 7; + + return Column( + children: [ + Row( + children: ['M', 'T', 'W', 'T', 'F', 'S', 'S'] + .map((d) => Expanded( + child: Center( + child: Text( + d, + style: TextStyle(fontFamily: 'Geist', + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.textFaint, + letterSpacing: 0.4, + ), + ), + ), + )) + .toList(), + ), + const SizedBox(height: 8), + GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + childAspectRatio: 1, + ), + itemCount: totalCells, + itemBuilder: (context, i) { + final day = i - startOffset + 1; + if (day < 1 || day > daysInMonth) { + return const SizedBox.shrink(); + } + final data = workoutDays[day]; + final isToday = day == todayDay; + final isSelected = day == selectedDay; + final isFuture = isCurrentMonth && day > today.day; + + return _DayCell( + day: day, + data: data, + isToday: isToday, + isSelected: isSelected, + isFuture: isFuture, + onTap: data != null ? () => onDayTap?.call(day) : null, + ); + }, + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 5, + height: 5, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: AppColors.success, + ), + ), + const SizedBox(width: 4), + const Text('PR', + style: TextStyle(fontSize: 10, color: AppColors.textFaint)), + ], + ), + Row( + children: [ + const Text('Less', + style: TextStyle(fontSize: 10, color: AppColors.textFaint)), + const SizedBox(width: 6), + ...List.generate(4, (i) { + final Color c; + if (i == 0) { + c = Colors.transparent; + } else if (i == 1) { + c = AppColors.primary.withValues(alpha: 0.15); + } else if (i == 2) { + c = AppColors.primary.withValues(alpha: 0.55); + } else { + c = AppColors.primary; + } + return Container( + width: 9, + height: 9, + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: c, + border: i == 0 + ? Border.all(color: AppColors.glassBorder) + : null, + ), + ); + }), + const SizedBox(width: 6), + const Text('More', + style: TextStyle(fontSize: 10, color: AppColors.textFaint)), + ], + ), + ], + ), + ], + ); + } +} + +class _DayCell extends StatelessWidget { + const _DayCell({ + required this.day, + required this.data, + required this.isToday, + required this.isSelected, + required this.isFuture, + this.onTap, + }); + + final int day; + final CalendarDayData? data; + final bool isToday; + final bool isSelected; + final bool isFuture; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final intensity = data?.intensity ?? 0; + final Color bg; + if (intensity == 0) { + bg = Colors.transparent; + } else if (intensity == 1) { + bg = AppColors.primary.withValues(alpha: 0.15); + } else if (intensity == 2) { + bg = AppColors.primary.withValues(alpha: 0.55); + } else { + bg = AppColors.primary; + } + + final Border? border; + if (isSelected) { + border = Border.all(color: AppColors.primary, width: 1.5); + } else if (isToday) { + border = Border.all(color: AppColors.textMuted, width: 1); + } else { + border = null; + } + + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: bg, + border: border, + boxShadow: isSelected + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.25), + blurRadius: 8, + ), + ] + : null, + ), + child: Stack( + children: [ + Center( + child: Text( + '$day', + style: TextStyle(fontFamily: 'GeistMono', + fontSize: 12, + fontWeight: + intensity > 0 ? FontWeight.w600 : FontWeight.w400, + color: intensity > 1 + ? Colors.white + : isFuture + ? AppColors.textFaint + : AppColors.textPrimary, + ), + ), + ), + if (data?.hasPr == true) + Positioned( + top: 2, + right: 2, + child: Container( + width: 4, + height: 4, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.success, + boxShadow: [ + BoxShadow( + color: AppColors.success.withValues(alpha: 0.5), + blurRadius: 4, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/dashboard_widgets.dart b/workout-logger/lib/screens/widgets/dashboard_widgets.dart new file mode 100644 index 0000000..b20bd4c --- /dev/null +++ b/workout-logger/lib/screens/widgets/dashboard_widgets.dart @@ -0,0 +1,265 @@ +// dashboard_widgets.dart — Dashboard-specific helper widgets for home_screen. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/models.dart'; +import '../../services/settings_provider.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; +import 'rf_cards.dart'; + +// ── WeekActivityStrip ───────────────────────────────────────────────────────── +// 7-dot strip showing which days this week had a workout. +class WeekActivityStrip extends StatelessWidget { + const WeekActivityStrip({super.key, required this.sessions}); + + final List sessions; + + @override + Widget build(BuildContext context) { + final today = DateTime.now(); + // Weekday 1=Mon … 7=Sun; align strip Mon→Sun + final todayMidnight = DateTime(today.year, today.month, today.day); + final startOfWeek = todayMidnight.subtract(Duration(days: today.weekday - 1)); + final startOfNextWeek = startOfWeek.add(const Duration(days: 7)); + final trainedDays = sessions + .where((s) => !s.date.isBefore(startOfWeek) && s.date.isBefore(startOfNextWeek)) + .map((s) => s.date.weekday) + .toSet(); + + const labels = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(7, (i) { + final weekday = i + 1; + final trained = trainedDays.contains(weekday); + final isToday = weekday == today.weekday; + + return Column( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: 30, + height: 30, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: trained + ? AppColors.primary + : isToday + ? AppColors.primary.withValues(alpha: 0.15) + : AppColors.card, + border: Border.all( + color: isToday + ? AppColors.primary + : trained + ? AppColors.primary + : AppColors.glassBorder, + width: isToday ? 2 : 1, + ), + boxShadow: trained + ? [ + BoxShadow( + color: AppColors.primaryGlow(0.4), + blurRadius: 8, + ), + ] + : null, + ), + child: trained + ? const Icon(Icons.check_rounded, size: 14, color: Colors.white) + : null, + ), + const SizedBox(height: 4), + Text( + labels[i], + style: TextStyle( + color: isToday ? AppColors.primary : AppColors.textMuted, + fontSize: 10, + fontWeight: isToday ? FontWeight.w700 : FontWeight.w500, + ), + ), + ], + ); + }), + ); + } +} + +// ── StatGrid ────────────────────────────────────────────────────────────────── +// 2×2 grid of StatGridCards from quick stats map. +class StatGrid extends StatelessWidget { + const StatGrid({super.key, required this.stats}); + + final Map stats; + + static String _formatVolume(double v) { + if (v >= 1000) return '${(v / 1000).toStringAsFixed(1)}k'; + return v.toStringAsFixed(0); + } + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final weeklyWorkouts = stats['weeklyWorkouts'] ?? 0; + final weeklyVolume = (stats['weeklyVolume'] ?? 0.0).toDouble(); + final exercisesThisWeek = stats['exercisesThisWeek'] ?? 0; + final totalWorkouts = stats['totalWorkouts'] ?? 0; + + return Column( + children: [ + Row( + children: [ + Expanded( + child: StatGridCard( + icon: Icons.fitness_center_rounded, + value: '$weeklyWorkouts', + label: 'This Week', + color: AppColors.primary, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: StatGridCard( + icon: Icons.trending_up_rounded, + value: _formatVolume(settings.toDisplay(weeklyVolume)), + label: 'Volume (${settings.unitLabel})', + color: AppColors.success, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: StatGridCard( + icon: Icons.bar_chart_rounded, + value: '$exercisesThisWeek', + label: 'Exercises', + color: AppColors.secondary, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: StatGridCard( + icon: Icons.emoji_events_rounded, + value: '$totalWorkouts', + label: 'All Time', + color: AppColors.warning, + ), + ), + ], + ), + ], + ); + } +} + +// ── QuickActionTile ─────────────────────────────────────────────────────────── +class QuickActionTile extends StatelessWidget { + const QuickActionTile({ + super.key, + required this.icon, + required this.label, + required this.onTap, + this.color, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final Color? color; + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.primary; + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.md, + horizontal: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: c.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Icon(icon, color: c, size: 22), + ), + const SizedBox(height: AppSpacing.sm), + Text( + label, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + +// ── RecentWorkoutsSection ───────────────────────────────────────────────────── +class RecentWorkoutsSection extends StatelessWidget { + const RecentWorkoutsSection({ + super.key, + required this.sessions, + required this.getExerciseName, + required this.onSeeAll, + required this.onTap, + }); + + final List sessions; + final String Function(String) getExerciseName; + final VoidCallback onSeeAll; + final void Function(WorkoutSession) onTap; + + @override + Widget build(BuildContext context) { + if (sessions.isEmpty) { + return RFEmptyState( + icon: Icons.fitness_center_outlined, + title: 'No workouts yet', + subtitle: 'Start your first workout to see it here', + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RFSectionHeader( + 'Recent Workouts', + trailing: TextButton( + onPressed: onSeeAll, + child: const Text( + 'See All', + style: TextStyle(color: AppColors.primary, fontSize: 13), + ), + ), + ), + ...sessions.map( + (s) => RecentSessionTile( + session: s, + getExerciseName: getExerciseName, + onTap: () => onTap(s), + ), + ), + ], + ); + } +} diff --git a/workout-logger/lib/screens/widgets/editable_exercise_card.dart b/workout-logger/lib/screens/widgets/editable_exercise_card.dart new file mode 100644 index 0000000..7911a82 --- /dev/null +++ b/workout-logger/lib/screens/widgets/editable_exercise_card.dart @@ -0,0 +1,629 @@ +// editable_exercise_card.dart — Editable exercise card + set/drop rows for edit screen + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../models/models.dart'; +import '../../theme/app_theme.dart'; + +typedef OnSetChanged = void Function({ + required int setIndex, + required double weight, + required int reps, + required bool isDropset, + List? drops, +}); + +// ── Shared mutable data classes ─────────────────────────────────────────────── +class EditableExerciseLog { + final String exerciseId; + final List sets; + final String? notes; + + EditableExerciseLog({ + required this.exerciseId, + required this.sets, + this.notes, + }); +} + +class EditableSet { + double weight; + int reps; + bool isDropset; + List? drops; + int? timeTaken; + DateTime timestamp; + + EditableSet({ + required this.weight, + required this.reps, + required this.timestamp, + this.isDropset = false, + this.drops, + this.timeTaken, + }); +} + +// ── Editable exercise card ──────────────────────────────────────────────────── +class EditableExerciseCard extends StatelessWidget { + const EditableExerciseCard({ + super.key, + required this.exerciseName, + required this.editableLog, + required this.onSetChanged, + required this.onAddSet, + required this.onDeleteSet, + required this.onDeleteExercise, + }); + + final String exerciseName; + final EditableExerciseLog editableLog; + final OnSetChanged onSetChanged; + final VoidCallback onAddSet; + final void Function(int setIndex) onDeleteSet; + final VoidCallback onDeleteExercise; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.sm, + AppSpacing.sm, + ), + child: Row( + children: [ + Expanded( + child: Text( + exerciseName, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + tooltip: 'Remove exercise', + onPressed: () => _confirmDelete(context), + style: IconButton.styleFrom( + backgroundColor: AppColors.error.withValues(alpha: 0.1), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + padding: const EdgeInsets.all(6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const Icon( + Icons.delete_outline_rounded, + size: 16, + color: AppColors.error, + ), + ), + ], + ), + ), + + const Divider(height: 1, color: AppColors.divider), + + // Set rows + Padding( + padding: const EdgeInsets.all(AppSpacing.sm), + child: Column( + children: editableLog.sets.asMap().entries.map((entry) { + final i = entry.key; + final set = entry.value; + return EditableSetRow( + key: ValueKey(set.timestamp), + setNumber: i + 1, + weight: set.weight, + reps: set.reps, + isDropset: set.isDropset, + drops: set.drops, + onWeightChanged: (w) => onSetChanged( + setIndex: i, weight: w, reps: set.reps, isDropset: set.isDropset, drops: set.drops), + onRepsChanged: (r) => onSetChanged( + setIndex: i, weight: set.weight, reps: r, isDropset: set.isDropset, drops: set.drops), + onIsDropsetChanged: (d) => onSetChanged( + setIndex: i, weight: set.weight, reps: set.reps, isDropset: d, + drops: d ? (set.drops ?? []) : set.drops), + onDropsChanged: (drops) => onSetChanged( + setIndex: i, weight: set.weight, reps: set.reps, isDropset: set.isDropset, drops: drops), + onDelete: () => onDeleteSet(i), + ); + }).toList(), + ), + ), + + // Add set button + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), + child: GestureDetector( + onTap: onAddSet, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.2), + ), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add_rounded, size: 14, color: AppColors.primary), + SizedBox(width: 4), + Text( + 'Add Set', + style: TextStyle( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + void _confirmDelete(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + title: const Text( + 'Remove Exercise?', + style: TextStyle(color: AppColors.textPrimary), + ), + content: Text( + 'Remove "$exerciseName" from this workout?', + style: const TextStyle(color: AppColors.textSoft), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text( + 'Cancel', + style: TextStyle(color: AppColors.textSoft), + ), + ), + TextButton( + onPressed: () { + Navigator.of(ctx).pop(); + onDeleteExercise(); + }, + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Remove'), + ), + ], + ), + ); + } +} + +// ── Editable set row ────────────────────────────────────────────────────────── +class EditableSetRow extends StatefulWidget { + const EditableSetRow({ + super.key, + required this.setNumber, + required this.weight, + required this.reps, + required this.onWeightChanged, + required this.onRepsChanged, + required this.onIsDropsetChanged, + required this.onDropsChanged, + required this.onDelete, + this.isDropset = false, + this.drops, + }); + + final int setNumber; + final double weight; + final int reps; + final bool isDropset; + final List? drops; + final void Function(double) onWeightChanged; + final void Function(int) onRepsChanged; + final void Function(bool) onIsDropsetChanged; + final void Function(List) onDropsChanged; + final VoidCallback onDelete; + + @override + State createState() => _EditableSetRowState(); +} + +class _EditableSetRowState extends State { + late TextEditingController _weightCtrl; + late TextEditingController _repsCtrl; + final _weightFocus = FocusNode(); + final _repsFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _weightCtrl = TextEditingController(text: widget.weight.toString()); + _repsCtrl = TextEditingController(text: widget.reps.toString()); + } + + @override + void didUpdateWidget(covariant EditableSetRow old) { + super.didUpdateWidget(old); + if (widget.weight != old.weight && !_weightFocus.hasFocus) { + if (double.tryParse(_weightCtrl.text) != widget.weight) { + _weightCtrl.text = widget.weight.toString(); + } + } + if (widget.reps != old.reps && !_repsFocus.hasFocus) { + if (int.tryParse(_repsCtrl.text) != widget.reps) { + _repsCtrl.text = widget.reps.toString(); + } + } + } + + @override + void dispose() { + _weightCtrl.dispose(); + _repsCtrl.dispose(); + _weightFocus.dispose(); + _repsFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: Column( + children: [ + Row( + children: [ + // Set number badge + Container( + width: 26, + height: 26, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${widget.setNumber}', + style: const TextStyle( + color: AppColors.primary, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + + // Weight + _NumField( + controller: _weightCtrl, + focusNode: _weightFocus, + suffix: 'kg', + decimal: true, + width: 78, + onChanged: (v) => + widget.onWeightChanged(double.tryParse(v) ?? 0), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text('×', style: TextStyle(color: AppColors.textMuted)), + ), + + // Reps + _NumField( + controller: _repsCtrl, + focusNode: _repsFocus, + suffix: 'reps', + width: 72, + onChanged: (v) => + widget.onRepsChanged(int.tryParse(v) ?? 0), + ), + + const Spacer(), + + // Dropset toggle + GestureDetector( + onTap: () => + widget.onIsDropsetChanged(!widget.isDropset), + child: Icon( + widget.isDropset + ? Icons.layers_rounded + : Icons.layers_outlined, + size: 18, + color: widget.isDropset + ? AppColors.primary + : AppColors.textMuted, + ), + ), + const SizedBox(width: AppSpacing.sm), + + // Delete + GestureDetector( + onTap: widget.onDelete, + child: const Icon( + Icons.close_rounded, + size: 16, + color: AppColors.textMuted, + ), + ), + ], + ), + + // Drop rows + if (widget.isDropset && widget.drops != null) ...[ + ...widget.drops!.asMap().entries.map((e) { + final i = e.key; + final drop = e.value; + return EditableDropRow( + key: ValueKey(drop.id), + dropNumber: i + 1, + weight: drop.weight, + reps: drop.reps, + onWeightChanged: (w) { + final updated = List.from(widget.drops!); + updated[i] = DropsetEntry(id: drop.id, weight: w, reps: drop.reps); + widget.onDropsChanged(updated); + }, + onRepsChanged: (r) { + final updated = List.from(widget.drops!); + updated[i] = DropsetEntry(id: drop.id, weight: drop.weight, reps: r); + widget.onDropsChanged(updated); + }, + onDelete: () { + final updated = List.from(widget.drops!) + ..removeAt(i); + widget.onDropsChanged(updated); + }, + ); + }), + // Add drop + GestureDetector( + onTap: () { + final existing = widget.drops ?? []; + final initW = existing.isEmpty + ? widget.weight * 0.8 + : existing.last.weight * 0.8; + final rounded = (initW * 2).round() / 2; + widget.onDropsChanged([ + ...existing, + DropsetEntry(weight: rounded, reps: widget.reps), + ]); + }, + child: Padding( + padding: const EdgeInsets.only(left: 34, top: 4), + child: Row( + children: [ + Icon( + Icons.add_circle_outline_rounded, + size: 13, + color: AppColors.primary.withValues(alpha: 0.7), + ), + const SizedBox(width: 4), + Text( + 'Add Drop', + style: TextStyle( + color: AppColors.primary.withValues(alpha: 0.7), + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ], + ), + ); + } +} + +// ── Drop row ────────────────────────────────────────────────────────────────── +class EditableDropRow extends StatefulWidget { + const EditableDropRow({ + super.key, + required this.dropNumber, + required this.weight, + required this.reps, + required this.onWeightChanged, + required this.onRepsChanged, + required this.onDelete, + }); + + final int dropNumber; + final double weight; + final int reps; + final void Function(double) onWeightChanged; + final void Function(int) onRepsChanged; + final VoidCallback onDelete; + + @override + State createState() => _EditableDropRowState(); +} + +class _EditableDropRowState extends State { + late TextEditingController _weightCtrl; + late TextEditingController _repsCtrl; + final _weightFocus = FocusNode(); + final _repsFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _weightCtrl = TextEditingController(text: widget.weight.toString()); + _repsCtrl = TextEditingController(text: widget.reps.toString()); + } + + @override + void didUpdateWidget(covariant EditableDropRow old) { + super.didUpdateWidget(old); + if (widget.weight != old.weight && !_weightFocus.hasFocus) { + if (double.tryParse(_weightCtrl.text) != widget.weight) { + _weightCtrl.text = widget.weight.toString(); + } + } + if (widget.reps != old.reps && !_repsFocus.hasFocus) { + if (int.tryParse(_repsCtrl.text) != widget.reps) { + _repsCtrl.text = widget.reps.toString(); + } + } + } + + @override + void dispose() { + _weightCtrl.dispose(); + _repsCtrl.dispose(); + _weightFocus.dispose(); + _repsFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 34, top: 4, bottom: 4), + child: Row( + children: [ + Icon( + Icons.subdirectory_arrow_right_rounded, + size: 14, + color: AppColors.textMuted.withValues(alpha: 0.5), + ), + const SizedBox(width: 6), + Text( + 'Drop ${widget.dropNumber}', + style: const TextStyle(color: AppColors.textMuted, fontSize: 11), + ), + const SizedBox(width: AppSpacing.sm), + _NumField( + controller: _weightCtrl, + focusNode: _weightFocus, + suffix: 'kg', + decimal: true, + width: 68, + height: 30, + onChanged: (v) => + widget.onWeightChanged(double.tryParse(v) ?? 0), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text( + '×', + style: TextStyle(color: AppColors.textMuted, fontSize: 12), + ), + ), + _NumField( + controller: _repsCtrl, + focusNode: _repsFocus, + suffix: 'reps', + width: 60, + height: 30, + onChanged: (v) => widget.onRepsChanged(int.tryParse(v) ?? 0), + ), + const Spacer(), + GestureDetector( + onTap: widget.onDelete, + child: const Icon( + Icons.close_rounded, + size: 14, + color: AppColors.textMuted, + ), + ), + ], + ), + ); + } +} + +// ── Shared number text field ────────────────────────────────────────────────── +class _NumField extends StatelessWidget { + const _NumField({ + required this.controller, + required this.focusNode, + required this.suffix, + required this.onChanged, + this.decimal = false, + this.width = 80, + this.height = 36, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final String suffix; + final bool decimal; + final double width; + final double height; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: TextField( + controller: controller, + focusNode: focusNode, + keyboardType: decimal + ? const TextInputType.numberWithOptions(decimal: true) + : TextInputType.number, + inputFormatters: decimal + ? [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$'))] + : [FilteringTextInputFormatter.digitsOnly], + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + ), + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 0), + suffixText: suffix, + suffixStyle: const TextStyle( + color: AppColors.textMuted, + fontSize: 10, + ), + filled: true, + fillColor: AppColors.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + borderSide: BorderSide.none, + ), + ), + onChanged: onChanged, + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/exercise_details_sheet.dart b/workout-logger/lib/screens/widgets/exercise_details_sheet.dart new file mode 100644 index 0000000..2ae2d08 --- /dev/null +++ b/workout-logger/lib/screens/widgets/exercise_details_sheet.dart @@ -0,0 +1,321 @@ +// exercise_details_sheet.dart — Bottom sheet showing exercise detail & stats + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/models.dart'; +import '../../services/workout_provider.dart'; +import '../../services/settings_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../data/exercise_database.dart'; +import 'rf_widgets.dart'; + +class ExerciseDetailsSheet extends StatelessWidget { + const ExerciseDetailsSheet({ + super.key, + required this.exercise, + required this.provider, + }); + + final Exercise exercise; + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final lastSession = provider.getLastSessionForExercise(exercise.id); + final growthModel = provider.getGrowthModel(exercise.id); + final color = exercise.isCustom ? AppColors.warning : AppColors.primary; + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.lg, + AppSpacing.xxl, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Handle + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.glassBorder, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header + Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Icon( + exercise.category == 'compound' + ? Icons.fitness_center_rounded + : Icons.accessibility_new_rounded, + color: color, + size: 26, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + exercise.name, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + Row( + children: [ + RFChip( + label: exercise.category, + small: true, + color: AppColors.primary, + ), + if (exercise.isCustom) ...[ + const SizedBox(width: 4), + const RFChip( + label: 'Custom', + small: true, + color: AppColors.warning, + ), + ], + ], + ), + ], + ), + ), + if (exercise.isCustom) + GestureDetector( + onTap: () => _confirmDelete(context), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: const Icon( + Icons.delete_outline_rounded, + color: AppColors.error, + size: 20, + ), + ), + ), + ], + ), + + const SizedBox(height: AppSpacing.lg), + + // Muscle activations + const RFSectionHeader('Muscle Activation'), + const SizedBox(height: AppSpacing.sm), + ...exercise.muscleActivations.map((a) { + final muscleColor = AppColors.muscle(a.muscleGroupId); + final name = MuscleGroups.names[a.muscleGroupId] ?? a.muscleGroupId; + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Row( + children: [ + Container( + width: 10, + height: 10, + margin: const EdgeInsets.only(right: AppSpacing.sm), + decoration: BoxDecoration( + color: muscleColor, + shape: BoxShape.circle, + ), + ), + Expanded( + child: Text( + name, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 13, + ), + ), + ), + SizedBox( + width: 100, + child: RFProgressBar( + value: a.activationPercentage / 100, + color: muscleColor, + height: 6, + showGlow: false, + ), + ), + const SizedBox(width: AppSpacing.sm), + SizedBox( + width: 32, + child: Text( + '${a.activationPercentage}%', + textAlign: TextAlign.right, + style: TextStyle( + color: muscleColor, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + }), + + // Last session + if (lastSession != null) ...[ + const SizedBox(height: AppSpacing.md), + const RFSectionHeader('Last Session'), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: 6, + runSpacing: 6, + children: lastSession.sets.map((s) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.glassBorder), + ), + child: Text( + '${settings.toDisplay(s.weight).toStringAsFixed(settings.toDisplay(s.weight) == settings.toDisplay(s.weight).truncateToDouble() ? 0 : 1)}${settings.unitLabel} × ${s.reps}', + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ); + }).toList(), + ), + ], + + // Growth trend + if (growthModel != null && growthModel.r2 > 0.2) ...[ + const SizedBox(height: AppSpacing.md), + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.success.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: AppColors.success.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + const Icon( + Icons.trending_up_rounded, + color: AppColors.success, + size: 20, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + '+${settings.toDisplay(growthModel.slope * 7).toStringAsFixed(1)} ${settings.unitLabel} volume/week', + style: const TextStyle( + color: AppColors.success, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + 'R² ${(growthModel.r2 * 100).toStringAsFixed(0)}%', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + ], + ], + ), + ); + } + + Future _confirmDelete(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + title: const Text( + 'Delete Exercise?', + style: TextStyle(color: AppColors.textPrimary), + ), + content: Text( + 'Delete "${exercise.name}"? This cannot be undone.', + style: const TextStyle(color: AppColors.textSoft), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text( + 'Cancel', + style: TextStyle(color: AppColors.textSoft), + ), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + final messenger = ScaffoldMessenger.of(context); + final nav = Navigator.of(context); + final success = await provider.deleteCustomExercise(exercise.id); + if (success && context.mounted) { + nav.pop(); + messenger.showSnackBar( + SnackBar( + content: Text('"${exercise.name}" deleted'), + backgroundColor: AppColors.cardHigh, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ); + } else if (context.mounted) { + messenger.showSnackBar( + SnackBar( + content: Text('"${exercise.name}" could not be deleted'), + backgroundColor: AppColors.error, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ); + } + } + } +} diff --git a/workout-logger/lib/screens/widgets/exercise_input_section.dart b/workout-logger/lib/screens/widgets/exercise_input_section.dart new file mode 100644 index 0000000..687809d --- /dev/null +++ b/workout-logger/lib/screens/widgets/exercise_input_section.dart @@ -0,0 +1,944 @@ +// exercise_input_section.dart — Set entry UI for WorkoutFlowScreen + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../models/models.dart'; +import '../../services/settings_provider.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +// ── ExerciseInputSection ────────────────────────────────────────────────────── +// Renders: AI suggestion card, weight/reps inputs, dropset section, +// LOG SET button, previous sets, last session info, program metadata banner. +class ExerciseInputSection extends StatelessWidget { + const ExerciseInputSection({ + super.key, + required this.currentWeight, + required this.currentReps, + required this.isDropset, + required this.drops, + required this.mainWeightController, + required this.mainRepsController, + required this.dropWeightControllers, + required this.dropRepsControllers, + required this.recommendations, + required this.previousSets, + required this.lastSession, + required this.settings, + required this.onWeightChanged, + required this.onRepsChanged, + required this.onDropsetToggled, + required this.onDropAdded, + required this.onDropRemoved, + required this.onDropWeightChanged, + required this.onDropRepsChanged, + required this.onLogSet, + required this.onApplyRecommendation, + this.programSlot, + this.programWeek, + this.exerciseId, + }); + + final double currentWeight; + final int currentReps; + final bool isDropset; + final List drops; + final TextEditingController mainWeightController; + final TextEditingController mainRepsController; + final List dropWeightControllers; + final List dropRepsControllers; + final List recommendations; + final List previousSets; + final ExerciseLog? lastSession; + final SettingsProvider settings; + final ValueChanged onWeightChanged; + final ValueChanged onRepsChanged; + final ValueChanged onDropsetToggled; + final VoidCallback onDropAdded; + 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; + final String? exerciseId; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Program metadata + if (programSlot != null && programWeek != null) + _ProgramMetaBanner(slot: programSlot!, week: programWeek!), + + // AI suggestion + if (recommendations.isNotEmpty) + _RecommendationCard( + rec: recommendations[previousSets.length < recommendations.length + ? previousSets.length + : recommendations.length - 1], + settings: settings, + onApply: onApplyRecommendation, + ), + + const SizedBox(height: AppSpacing.lg), + + // Weight + reps inputs + if (!isDropset) ...[ + _InputRow( + currentWeight: currentWeight, + currentReps: currentReps, + settings: settings, + exerciseId: exerciseId, + onWeightChanged: onWeightChanged, + onRepsChanged: onRepsChanged, + ), + const SizedBox(height: AppSpacing.md), + ], + + // Dropset section + _DropsetSection( + isDropset: isDropset, + drops: drops, + currentWeight: currentWeight, + currentReps: currentReps, + mainWeightController: mainWeightController, + mainRepsController: mainRepsController, + dropWeightControllers: dropWeightControllers, + dropRepsControllers: dropRepsControllers, + settings: settings, + onToggled: onDropsetToggled, + onDropAdded: onDropAdded, + onDropRemoved: onDropRemoved, + onDropWeightChanged: onDropWeightChanged, + onDropRepsChanged: onDropRepsChanged, + ), + + const SizedBox(height: AppSpacing.lg), + + // LOG SET button + GlowButton( + label: 'LOG SET', + icon: Icons.check_rounded, + onPressed: onLogSet, + ), + + // Previous sets + if (previousSets.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.lg), + _PreviousSetsSection(sets: previousSets, settings: settings), + ], + + // Last session + const SizedBox(height: AppSpacing.lg), + _LastSessionSection(lastSession: lastSession, settings: settings), + ], + ); + } +} + +// ── Recommendation Card ──────────────────────────────────────────────────────── +class _RecommendationCard extends StatelessWidget { + const _RecommendationCard({ + required this.rec, + required this.settings, + required this.onApply, + }); + + final SetRecommendation rec; + final SettingsProvider settings; + final VoidCallback onApply; + + @override + Widget build(BuildContext context) { + final displayWeight = settings.toDisplay(rec.weight); + final weightStr = displayWeight == displayWeight.truncateToDouble() + ? displayWeight.toStringAsFixed(0) + : displayWeight.toStringAsFixed(1); + final confidenceColor = rec.confidence == 'high' + ? AppColors.success + : rec.confidence == 'medium' + ? AppColors.warning + : AppColors.textMuted; + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppColors.primary.withValues(alpha: 0.18), + AppColors.secondary.withValues(alpha: 0.08), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: const Icon(Icons.auto_awesome_rounded, + color: AppColors.primary, size: 18), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'AI Suggestion', + style: TextStyle( + color: AppColors.textSoft, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + const SizedBox(width: 6), + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: confidenceColor, + shape: BoxShape.circle, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + '$weightStr ${settings.unitLabel} × ${rec.reps} reps', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + TextButton( + onPressed: () { + onApply(); + HapticFeedback.lightImpact(); + }, + style: TextButton.styleFrom( + foregroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + child: const Text( + 'Apply', + style: TextStyle(fontWeight: FontWeight.w700), + ), + ), + ], + ), + ); + } +} + +// ── Input Row ──────────────────────────────────────────────────────────────── +class _InputRow extends StatelessWidget { + const _InputRow({ + required this.currentWeight, + required this.currentReps, + required this.settings, + required this.onWeightChanged, + required this.onRepsChanged, + this.exerciseId, + }); + + final double currentWeight; + final int currentReps; + final SettingsProvider settings; + final ValueChanged onWeightChanged; + final ValueChanged onRepsChanged; + final String? exerciseId; + + @override + Widget build(BuildContext context) { + final isAssistedBW = + exerciseId == 'pull_ups' || exerciseId == 'chin_ups'; + final weightLabel = + isAssistedBW ? 'Assist (${settings.unitLabel})' : settings.unitLabel; + final displayWeight = settings.toDisplay(currentWeight); + + return Row( + children: [ + Expanded( + child: _NumberInputCard( + label: weightLabel, + value: displayWeight, + step: settings.weightIncrement, + decimals: 1, + onChanged: (v) => onWeightChanged(settings.toStorage(v)), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: _NumberInputCard( + label: 'Reps', + value: currentReps.toDouble(), + step: 1, + decimals: 0, + onChanged: (v) => onRepsChanged(v.toInt()), + ), + ), + ], + ); + } +} + +// ── Number Input Card ───────────────────────────────────────────────────────── +class _NumberInputCard extends StatefulWidget { + const _NumberInputCard({ + required this.label, + required this.value, + required this.step, + required this.decimals, + required this.onChanged, + }); + + final String label; + final double value; + final double step; + final int decimals; + final ValueChanged onChanged; + + @override + State<_NumberInputCard> createState() => _NumberInputCardState(); +} + +class _NumberInputCardState extends State<_NumberInputCard> { + late final TextEditingController _controller; + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: _format()); + } + + @override + void didUpdateWidget(_NumberInputCard old) { + super.didUpdateWidget(old); + // Sync controller when value changes externally (e.g. AI apply, stepper) + // but don't interrupt the user while they're typing. + if (old.value != widget.value && !_focusNode.hasFocus) { + _controller.text = _format(); + } + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + String _format() => widget.decimals > 0 + ? widget.value.toStringAsFixed(widget.decimals) + : widget.value.toInt().toString(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + children: [ + Text( + widget.label, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: AppSpacing.sm), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _StepBtn( + icon: Icons.remove_rounded, + onTap: () => widget.onChanged( + (widget.value - widget.step).clamp(0, 999).toDouble(), + ), + ), + 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, + ), + 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( + icon: Icons.add_rounded, + onTap: () => widget.onChanged( + (widget.value + widget.step).clamp(0, 999).toDouble(), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _StepBtn extends StatelessWidget { + const _StepBtn({required this.icon, required this.onTap}); + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context).width < AppBreakpoints.narrow ? 36.0 : 40.0; + return GestureDetector( + onTap: () { + onTap(); + HapticFeedback.selectionClick(); + }, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.25)), + ), + child: Icon(icon, size: 18, color: AppColors.primary), + ), + ); + } +} + +// ── Dropset Section ─────────────────────────────────────────────────────────── +class _DropsetSection extends StatelessWidget { + const _DropsetSection({ + required this.isDropset, + required this.drops, + required this.currentWeight, + required this.currentReps, + required this.mainWeightController, + required this.mainRepsController, + required this.dropWeightControllers, + required this.dropRepsControllers, + required this.settings, + required this.onToggled, + required this.onDropAdded, + required this.onDropRemoved, + required this.onDropWeightChanged, + required this.onDropRepsChanged, + }); + + final bool isDropset; + final List drops; + final double currentWeight; + final int currentReps; + final TextEditingController mainWeightController; + final TextEditingController mainRepsController; + final List dropWeightControllers; + final List dropRepsControllers; + final SettingsProvider settings; + final ValueChanged onToggled; + final VoidCallback onDropAdded; + final ValueChanged onDropRemoved; + final void Function(int, double) onDropWeightChanged; + final void Function(int, int) onDropRepsChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.trending_down_rounded, + color: AppColors.warning, + size: 18, + ), + const SizedBox(width: 8), + const Text( + 'Dropset', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Switch( + value: isDropset, + onChanged: onToggled, + activeThumbColor: AppColors.warning, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ], + ), + if (isDropset) ...[ + const SizedBox(height: AppSpacing.md), + _DropRow( + label: 'Start', + weightController: mainWeightController, + repsController: mainRepsController, + unitLabel: settings.unitLabel, + onWeightChanged: (v) { + final parsed = double.tryParse(v); + if (parsed != null) onDropWeightChanged(-1, settings.toStorage(parsed)); + }, + onRepsChanged: (v) { + final parsed = int.tryParse(v); + if (parsed != null) onDropRepsChanged(-1, parsed); + }, + ), + ...drops.asMap().entries.map((e) => _DropRow( + label: 'Drop ${e.key + 1}', + weightController: dropWeightControllers[e.key], + repsController: dropRepsControllers[e.key], + unitLabel: settings.unitLabel, + onWeightChanged: (v) { + final parsed = double.tryParse(v); + if (parsed != null) onDropWeightChanged(e.key, settings.toStorage(parsed)); + }, + onRepsChanged: (v) { + final parsed = int.tryParse(v); + if (parsed != null) onDropRepsChanged(e.key, parsed); + }, + onDelete: () => onDropRemoved(e.key), + )), + TextButton.icon( + onPressed: onDropAdded, + icon: const Icon(Icons.add_rounded, size: 16), + label: const Text('Add Drop'), + style: TextButton.styleFrom(foregroundColor: AppColors.warning), + ), + ], + ], + ), + ); + } +} + +class _DropRow extends StatelessWidget { + const _DropRow({ + required this.label, + required this.weightController, + required this.repsController, + required this.unitLabel, + required this.onWeightChanged, + required this.onRepsChanged, + this.onDelete, + }); + + final String label; + final TextEditingController weightController; + final TextEditingController repsController; + final String unitLabel; + final ValueChanged onWeightChanged; + final ValueChanged onRepsChanged; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Row( + children: [ + Expanded( + flex: 3, + child: Text( + label, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ), + Expanded( + flex: 4, + child: TextField( + controller: weightController, + decoration: InputDecoration( + hintText: unitLabel, + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + isDense: true, + ), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), + ], + onChanged: onWeightChanged, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text('×', style: TextStyle(color: AppColors.textMuted)), + ), + Expanded( + flex: 3, + child: TextField( + controller: repsController, + decoration: const InputDecoration( + hintText: 'reps', + contentPadding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + isDense: true, + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: onRepsChanged, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + ), + ), + if (onDelete != null) + IconButton( + icon: const Icon(Icons.close_rounded, size: 16), + color: AppColors.textMuted, + onPressed: onDelete, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ) + else + const SizedBox(width: 32), + ], + ), + ); + } +} + +// ── Previous Sets ───────────────────────────────────────────────────────────── +class _PreviousSetsSection extends StatelessWidget { + const _PreviousSetsSection({required this.sets, required this.settings}); + + final List sets; + final SettingsProvider settings; + + @override + Widget build(BuildContext context) { + 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 SizedBox(height: AppSpacing.sm), + Wrap( + spacing: 6, + runSpacing: 6, + children: sets.asMap().entries.expand((e) { + final i = e.key; + final s = e.value; + final dw = settings.toDisplay(s.weight); + final wStr = dw == dw.truncateToDouble() + ? dw.toStringAsFixed(0) + : dw.toStringAsFixed(1); + final setChip = Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppColors.success.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: AppColors.success.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${i + 1}', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + const SizedBox(width: 4), + Text( + '$wStr × ${s.reps}', + style: const TextStyle( + color: AppColors.success, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + if (s.isDropset) + const Padding( + padding: EdgeInsets.only(left: 4), + child: Icon( + Icons.trending_down_rounded, + size: 12, + color: AppColors.warning, + ), + ), + ], + ), + ); + + if (settings.showAdvancedMetrics && s.reps > 0 && s.weight > 0) { + final orm = s.reps == 1 + ? s.weight + : s.weight * (1 + s.reps / 30.0); + final ormDisplay = settings.toDisplay(orm); + final ormStr = ormDisplay == ormDisplay.truncateToDouble() + ? ormDisplay.toStringAsFixed(0) + : ormDisplay.toStringAsFixed(1); + final ormChip = Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.35), + ), + ), + child: Text( + '~$ormStr${settings.unitLabel} 1RM', + style: const TextStyle( + color: AppColors.primary, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ); + return [setChip, ormChip]; + } + + return [setChip]; + }).toList(), + ), + ], + ); + } +} + +// ── Last Session ────────────────────────────────────────────────────────────── +class _LastSessionSection extends StatelessWidget { + const _LastSessionSection({required this.lastSession, required this.settings}); + + final ExerciseLog? lastSession; + final SettingsProvider settings; + + @override + Widget build(BuildContext context) { + if (lastSession == null) { + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: const Row( + children: [ + 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), + ), + ], + ), + ); + } + + 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 SizedBox(height: AppSpacing.sm), + Wrap( + spacing: 6, + runSpacing: 6, + children: lastSession!.sets.map((s) { + final dw = settings.toDisplay(s.weight); + final wStr = dw == dw.truncateToDouble() + ? dw.toStringAsFixed(0) + : dw.toStringAsFixed(1); + return Chip( + label: Text( + '$wStr × ${s.reps}', + style: const TextStyle(fontSize: 12, color: AppColors.textSoft), + ), + backgroundColor: AppColors.surface, + side: BorderSide(color: AppColors.glassBorder), + padding: const EdgeInsets.symmetric(horizontal: 4), + ); + }).toList(), + ), + ], + ); + } +} + +// ── Program Meta Banner ──────────────────────────────────────────────────────── +class _ProgramMetaBanner extends StatelessWidget { + const _ProgramMetaBanner({required this.slot, required this.week}); + + final ProgramExerciseSlot slot; + final ProgramWeek week; + + @override + Widget build(BuildContext context) { + final displaySets = week.isDeload + ? (slot.sets - week.deloadSetReduction).clamp(1, 99) + : slot.sets; + final repRange = slot.minReps == slot.maxReps + ? '${slot.minReps} reps' + : '${slot.minReps}–${slot.maxReps} reps'; + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: week.isDeload + ? Colors.amber.withValues(alpha: 0.4) + : AppColors.primary.withValues(alpha: 0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (week.isDeload) + const Padding( + padding: EdgeInsets.only(right: 4), + 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, + ), + ), + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: AppSpacing.md, + runSpacing: 4, + children: [ + _metaChip( + icon: Icons.timer_outlined, + label: '${slot.restSeconds}s rest', + color: AppColors.textSoft, + ), + if (slot.tempo != null) + _metaChip(icon: Icons.speed_rounded, label: 'Tempo ${slot.tempo}', color: AppColors.secondary), + if (slot.supersetGroupId != null) + _metaChip(icon: Icons.link_rounded, label: 'Superset', color: AppColors.secondary), + ], + ), + if (slot.notes != null) ...[ + const SizedBox(height: 4), + Text( + slot.notes!, + style: const TextStyle( + fontSize: 11, + color: AppColors.textMuted, + fontStyle: FontStyle.italic), + ), + ], + ], + ), + ); + } + + Widget _metaChip({required IconData icon, required String label, required Color color}) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 11, color: color), + const SizedBox(width: 3), + Text(label, style: TextStyle(fontSize: 11, color: color)), + ], + ); + } +} diff --git a/workout-logger/lib/screens/widgets/exercise_progress_view.dart b/workout-logger/lib/screens/widgets/exercise_progress_view.dart new file mode 100644 index 0000000..b56ec6c --- /dev/null +++ b/workout-logger/lib/screens/widgets/exercise_progress_view.dart @@ -0,0 +1,1523 @@ +// exercise_progress_view.dart — Analytics "Exercises" tab + +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:intl/intl.dart'; + +import '../../models/models.dart'; +import '../../services/workout_provider.dart'; +import '../../services/settings_provider.dart'; +import '../../services/ai/gemini_ai_service.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; +import '../ai_coach_screen.dart'; + +enum _ChartMode { volume, sets } + +class ExerciseProgressView extends StatefulWidget { + const ExerciseProgressView({super.key}); + + @override + State createState() => _ExerciseProgressViewState(); +} + +class _ExerciseProgressViewState extends State { + String? _selectedId; + _ChartMode _chartMode = _ChartMode.volume; + + @override + Widget build(BuildContext context) { + final performed = context.select>( + (p) => {for (final s in p.sessions) for (final e in s.exercises) e.exerciseId}, + ); + final provider = context.read(); + + if (performed.isEmpty) { + return RFEmptyState( + icon: Icons.fitness_center_rounded, + title: 'No Exercise Data', + subtitle: 'Complete workouts to track exercises', + ); + } + + final effectiveId = performed.contains(_selectedId) ? _selectedId : null; + + return Column( + children: [ + _ExerciseDropdown( + ids: performed, + selected: effectiveId, + getExerciseName: provider.getExerciseName, + onChanged: (id) => setState(() { + _selectedId = id; + _chartMode = _ChartMode.volume; + }), + ), + if (effectiveId != null) + Expanded( + child: _ExerciseStats( + exerciseId: effectiveId, + provider: provider, + chartMode: _chartMode, + onChartModeChanged: (m) => setState(() => _chartMode = m), + ), + ) + else + Expanded( + child: Center( + child: Text( + 'Select an exercise above', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 14, + ), + ), + ), + ), + ], + ); + } +} + +// ── Exercise picker — modern sheet with search ──────────────────────────────── + +class _ExerciseDropdown extends StatelessWidget { + const _ExerciseDropdown({ + required this.ids, + required this.selected, + required this.getExerciseName, + required this.onChanged, + }); + + final Set ids; + final String? selected; + final String Function(String) getExerciseName; + final ValueChanged onChanged; + + void _openSheet(BuildContext context) { + final sorted = ids.toList() + ..sort((a, b) => getExerciseName(a).compareTo(getExerciseName(b))); + + showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => _ExercisePickerSheet( + ids: sorted, + selected: selected, + getExerciseName: getExerciseName, + onPicked: (id) { + Navigator.pop(context); + onChanged(id); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + final hasSelection = selected != null && ids.contains(selected); + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + AppSpacing.sm, + ), + child: GestureDetector( + onTap: () => _openSheet(context), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: 14, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: hasSelection + ? AppColors.primary.withValues(alpha: 0.5) + : AppColors.glassBorder, + ), + ), + child: Row( + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: hasSelection + ? AppColors.primary.withValues(alpha: 0.15) + : AppColors.glass2, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.fitness_center_rounded, + size: 15, + color: hasSelection + ? AppColors.primary + : AppColors.textFaint, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + hasSelection + ? getExerciseName(selected!) + : 'Pick an exercise…', + style: TextStyle(fontFamily: 'Geist', + color: hasSelection + ? AppColors.textPrimary + : AppColors.textMuted, + fontSize: 14, + fontWeight: hasSelection + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + ), + Icon( + Icons.keyboard_arrow_down_rounded, + size: 20, + color: hasSelection + ? AppColors.primary + : AppColors.textFaint, + ), + ], + ), + ), + ), + ); + } +} + +class _ExercisePickerSheet extends StatefulWidget { + const _ExercisePickerSheet({ + required this.ids, + required this.selected, + required this.getExerciseName, + required this.onPicked, + }); + final List ids; + final String? selected; + final String Function(String) getExerciseName; + final ValueChanged onPicked; + + @override + State<_ExercisePickerSheet> createState() => _ExercisePickerSheetState(); +} + +class _ExercisePickerSheetState extends State<_ExercisePickerSheet> { + final _search = TextEditingController(); + List _filtered = []; + + @override + void initState() { + super.initState(); + _filtered = widget.ids; + _search.addListener(_onSearch); + } + + void _onSearch() { + final q = _search.text.toLowerCase(); + setState(() { + _filtered = q.isEmpty + ? widget.ids + : widget.ids + .where((id) => + widget.getExerciseName(id).toLowerCase().contains(q)) + .toList(); + }); + } + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + return Container( + // 75% of screen height + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + padding: EdgeInsets.only(bottom: bottomInset), + child: Column( + children: [ + // Handle + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(top: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.glassBorder, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Title + count + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, AppSpacing.md, AppSpacing.lg, 0), + child: Row( + children: [ + Text( + 'Select Exercise', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + Text( + '${widget.ids.length} logged', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 12, + ), + ), + ], + ), + ), + // Search field + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, AppSpacing.md, AppSpacing.lg, AppSpacing.sm), + child: Container( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextField( + controller: _search, + autofocus: true, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + ), + decoration: InputDecoration( + hintText: 'Search…', + hintStyle: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 14, + ), + prefixIcon: const Icon(Icons.search_rounded, + color: AppColors.textFaint, size: 18), + suffixIcon: _search.text.isNotEmpty + ? GestureDetector( + onTap: () => _search.clear(), + child: const Icon(Icons.close_rounded, + color: AppColors.textFaint, size: 16), + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: AppSpacing.sm, + ), + ), + ), + ), + ), + // Divider + Divider( + height: 1, + color: AppColors.glassBorder, + indent: AppSpacing.lg, + endIndent: AppSpacing.lg), + // List + Expanded( + child: _filtered.isEmpty + ? Center( + child: Text( + 'No exercises match', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 13, + ), + ), + ) + : ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.only(bottom: AppSpacing.lg), + itemCount: _filtered.length, + itemBuilder: (context, i) { + final id = _filtered[i]; + final name = widget.getExerciseName(id); + final isSelected = id == widget.selected; + return InkWell( + onTap: () => widget.onPicked(id), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: 14, + ), + decoration: BoxDecoration( + color: isSelected + ? AppColors.primary.withValues(alpha: 0.10) + : Colors.transparent, + border: Border( + bottom: BorderSide( + color: AppColors.glassBorder, + width: 0.5, + ), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + name, + style: TextStyle(fontFamily: 'Geist', + color: isSelected + ? AppColors.primary + : AppColors.textSoft, + fontSize: 14, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + ), + if (isSelected) + const Icon(Icons.check_rounded, + color: AppColors.primary, size: 18), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +// ── Stats view for a selected exercise ──────────────────────────────────────── + +class _ExerciseStats extends StatelessWidget { + const _ExerciseStats({ + required this.exerciseId, + required this.provider, + required this.chartMode, + required this.onChartModeChanged, + }); + + final String exerciseId; + final WorkoutProvider provider; + final _ChartMode chartMode; + final ValueChanged<_ChartMode> onChartModeChanged; + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final progression = provider.getVolumeProgression(exerciseId); + final setProgression = provider.getSetProgression(exerciseId); + final growthModel = provider.getGrowthModel(exerciseId); + final bestOneRM = provider.getBestOneRM(exerciseId); + + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.xxl, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (bestOneRM != null) ...[ + _OneRMCard(oneRM: bestOneRM, settings: settings), + const SizedBox(height: AppSpacing.sm), + ], + if (growthModel != null) ...[ + _GrowthCard(model: growthModel), + const SizedBox(height: AppSpacing.sm), + ], + _ChartSection( + exerciseId: exerciseId, + progression: progression, + setProgression: setProgression, + growthModel: growthModel, + chartMode: chartMode, + onChartModeChanged: onChartModeChanged, + ), + const SizedBox(height: AppSpacing.sm), + _SessionHistory(progression: progression, settings: settings), + const SizedBox(height: AppSpacing.md), + _AskCoachButton( + exerciseName: provider.getExerciseName(exerciseId), + growthModel: growthModel, + ), + ], + ), + ); + } +} + +// ── 1RM card ────────────────────────────────────────────────────────────────── + +class _OneRMCard extends StatelessWidget { + const _OneRMCard({required this.oneRM, required this.settings}); + final double oneRM; + final SettingsProvider settings; + + @override + Widget build(BuildContext context) { + return GlassCard( + padding: const EdgeInsets.all(AppSpacing.md), + glowColor: AppColors.primary, + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: const Icon( + Icons.emoji_events_rounded, + color: AppColors.primary, + size: 26, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Estimated 1RM', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 11, + ), + ), + Text( + settings.formatWeight(oneRM), + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.primary, + fontSize: 28, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Epley formula', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 10, + ), + ), + Text( + 'Best across sets', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 10, + ), + ), + ], + ), + ], + ), + ); + } +} + +// ── Growth trend card ───────────────────────────────────────────────────────── + +class _GrowthCard extends StatelessWidget { + const _GrowthCard({required this.model}); + final GrowthModel model; + + @override + Widget build(BuildContext context) { + final settings = context.read(); + final isGrowing = model.slope > 0; + final color = isGrowing ? AppColors.success : AppColors.warning; + + return GlassCard( + padding: const EdgeInsets.all(AppSpacing.md), + glowColor: color, + child: Row( + children: [ + Icon( + isGrowing + ? Icons.trending_up_rounded + : Icons.trending_flat_rounded, + color: color, + size: 38, + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isGrowing ? 'Growing!' : 'Plateau', + style: TextStyle(fontFamily: 'Geist', + color: color, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + Text( + isGrowing + ? '+${settings.toDisplay(model.slope.abs() * 7).toStringAsFixed(1)} ${settings.unitLabel}/week' + : 'Volume trend is flat', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 12, + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'R² ${(model.r2 * 100).toStringAsFixed(0)}%', + style: TextStyle(fontFamily: 'GeistMono', + color: color, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + Text( + 'model fit', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 10, + ), + ), + ], + ), + ], + ), + ); + } +} + +// ── Chart section (Volume line ↔ Set-progression bars) ─────────────────────── + +class _ChartSection extends StatelessWidget { + const _ChartSection({ + required this.exerciseId, + required this.progression, + required this.setProgression, + required this.growthModel, + required this.chartMode, + required this.onChartModeChanged, + }); + + final String exerciseId; + final List<({DateTime date, double volume})> progression; + final List<({DateTime date, List sets})> setProgression; + final GrowthModel? growthModel; + final _ChartMode chartMode; + final ValueChanged<_ChartMode> onChartModeChanged; + + @override + Widget build(BuildContext context) { + return GlassCard( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + chartMode == _ChartMode.volume + ? 'Volume Progression' + : 'Set Progression', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + _ChartModeToggle( + value: chartMode, + onChanged: onChartModeChanged, + ), + ], + ), + const SizedBox(height: AppSpacing.md), + if (chartMode == _ChartMode.volume) + _VolumeChart(progression: progression, growthModel: growthModel) + else + _SetProgressionChart(setProgression: setProgression), + ], + ), + ); + } +} + +class _ChartModeToggle extends StatelessWidget { + const _ChartModeToggle({required this.value, required this.onChanged}); + final _ChartMode value; + final ValueChanged<_ChartMode> onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final mode in _ChartMode.values) + GestureDetector( + onTap: () => onChanged(mode), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: mode == value ? AppColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(7), + ), + child: Text( + mode == _ChartMode.volume ? 'Volume' : 'Sets', + style: TextStyle(fontFamily: 'GeistMono', + fontSize: 11, + fontWeight: FontWeight.w700, + color: mode == value ? Colors.white : AppColors.textMuted, + ), + ), + ), + ), + ], + ), + ); + } +} + +// ── Volume progression line chart ───────────────────────────────────────────── + +class _VolumeChart extends StatelessWidget { + const _VolumeChart({required this.progression, this.growthModel}); + final List<({DateTime date, double volume})> progression; + final GrowthModel? growthModel; + + @override + Widget build(BuildContext context) { + final settings = context.read(); + final n = progression.length; + + // Chart x is the session index, but the model is trained on days since + // the first session — map each index to its day offset before predicting. + double dayAt(int i) => progression[i] + .date + .difference(progression.first.date) + .inDays + .toDouble(); + final avgGapDays = n > 1 ? dayAt(n - 1) / (n - 1) : 7.0; + + double rse = 0.0; + if (growthModel != null && n >= 3) { + double ssRes = 0.0; + for (int i = 0; i < n; i++) { + final r = progression[i].volume - growthModel!.predict(dayAt(i)); + ssRes += r * r; + } + rse = sqrt(ssRes / (n - 2)); + } + final ci95 = settings.toDisplay(rse * 1.96); + final bestVol = n > 0 + ? settings.toDisplay(progression.map((e) => e.volume).reduce(max)) + : 0.0; + + final actualSpots = List.generate( + n, + (i) => FlSpot(i.toDouble(), settings.toDisplay(progression[i].volume)), + ); + final trendSpots = (growthModel != null && n >= 2) + ? List.generate( + n + 2, + (i) => FlSpot( + i.toDouble(), + settings.toDisplay(growthModel! + .predict(i < n ? dayAt(i) : dayAt(n - 1) + avgGapDays * (i - n + 1)) + .clamp(0.0, double.infinity)), + ), + ) + : []; + final upperSpots = (ci95 > 0 && trendSpots.isNotEmpty) + ? trendSpots.map((s) => FlSpot(s.x, s.y + ci95)).toList() + : []; + final lowerSpots = (ci95 > 0 && trendSpots.isNotEmpty) + ? trendSpots.map((s) => FlSpot(s.x, max(0.0, s.y - ci95))).toList() + : []; + + final lineBars = [ + LineChartBarData( + spots: actualSpots, + isCurved: true, + curveSmoothness: 0.3, + color: AppColors.secondary, + barWidth: 2.5, + dotData: FlDotData( + show: true, + getDotPainter: (_, __, ___, ____) => FlDotCirclePainter( + radius: 3, + color: AppColors.secondary, + strokeWidth: 1.5, + strokeColor: AppColors.surface, + ), + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + AppColors.secondary.withValues(alpha: 0.22), + AppColors.secondary.withValues(alpha: 0.0), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + if (trendSpots.isNotEmpty) + LineChartBarData( + spots: trendSpots, + isCurved: false, + color: AppColors.primary.withValues(alpha: 0.5), + barWidth: 1.5, + dashArray: [8, 5], + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + if (upperSpots.isNotEmpty) + LineChartBarData( + spots: upperSpots, + color: Colors.transparent, + barWidth: 0, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + if (lowerSpots.isNotEmpty) + LineChartBarData( + spots: lowerSpots, + color: Colors.transparent, + barWidth: 0, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + ]; + + final hasTrend = trendSpots.isNotEmpty; + final hasCi = upperSpots.isNotEmpty; + final betweenBars = (hasTrend && hasCi) + ? [ + BetweenBarsData( + fromIndex: 2, + toIndex: 3, + color: AppColors.primary.withValues(alpha: 0.08), + ), + ] + : []; + + if (progression.isEmpty) { + return const Padding( + padding: EdgeInsets.all(AppSpacing.lg), + child: Center( + child: Text('No data', + style: TextStyle(color: AppColors.textMuted)), + ), + ); + } + + return SizedBox( + height: 160, + child: LineChart( + LineChartData( + backgroundColor: Colors.transparent, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (_) => + FlLine(color: AppColors.glassBorder, strokeWidth: 1), + ), + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (_) => AppColors.cardHigh, + getTooltipItems: (spots) => spots.map((spot) { + if (spot.barIndex != 0) return null; + final v = spot.y; + final volStr = v >= 1000 + ? '${(v / 1000).toStringAsFixed(1)}k' + : v.toStringAsFixed(0); + final i = spot.x.toInt(); + final dateStr = (i >= 0 && i < n) + ? DateFormat('MMM d').format(progression[i].date) + : ''; + return LineTooltipItem( + '$volStr ${settings.unitLabel}', + TextStyle(fontFamily: 'GeistMono', + color: AppColors.secondary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + children: [ + TextSpan( + text: '\n$dateStr', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.normal, + ), + ), + ], + ); + }).toList(), + ), + ), + titlesData: FlTitlesData( + rightTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 38, + getTitlesWidget: (v, _) { + final label = v >= 1000 + ? '${(v / 1000).toStringAsFixed(1)}k' + : v.toStringAsFixed(0); + return Text( + label, + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textMuted, + fontSize: 9, + ), + ); + }, + ), + ), + ), + borderData: FlBorderData(show: false), + extraLinesData: ExtraLinesData( + horizontalLines: [ + if (bestVol > 0) + HorizontalLine( + y: bestVol, + color: AppColors.warning.withValues(alpha: 0.5), + strokeWidth: 1, + dashArray: [6, 4], + label: HorizontalLineLabel( + show: true, + direction: LabelDirection.horizontal, + alignment: Alignment.topRight, + padding: const EdgeInsets.only(right: 4, bottom: 2), + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.warning, + fontSize: 9, + fontWeight: FontWeight.w600, + ), + labelResolver: (line) => + 'BEST ${bestVol.toStringAsFixed(0)}', + ), + ), + ], + ), + betweenBarsData: betweenBars, + lineBarsData: lineBars, + ), + ), + ); + } +} + +// ── Set progression — grouped dual-colour bars (weight + reps per set) ─────── +// +// Layout per session group: [w₁ r₁ | w₂ r₂ | w₃ r₃ …] — purple = weight, +// cyan = reps (scaled to same axis via factor = maxWeight / maxReps). +// Left axis labels show weight (kg/lbs), right axis shows reps. + +// ── Set progression — grouped dual-colour bars, interactive legend + auto-fit ─ + +enum _SetViewMode { recent, weekly } + +class _SetProgressionChart extends StatefulWidget { + const _SetProgressionChart({required this.setProgression}); + final List<({DateTime date, List sets})> setProgression; + + static const _maxSetsPerSession = 4; + static const _weightColor = AppColors.primary; + static const _repsColor = AppColors.secondary; + + @override + State<_SetProgressionChart> createState() => _SetProgressionChartState(); +} + +class _SetProgressionChartState extends State<_SetProgressionChart> { + bool _showWeight = true; + bool _showReps = true; + _SetViewMode _mode = _SetViewMode.recent; + + static const _wc = _SetProgressionChart._weightColor; + static const _rc = _SetProgressionChart._repsColor; + static const _maxSets = _SetProgressionChart._maxSetsPerSession; + + // ── Data helpers ───────────────────────────────────────────────────────────── + + static DateTime _weekStart(DateTime d) { + final n = DateTime(d.year, d.month, d.day); + return n.subtract(Duration(days: n.weekday - 1)); + } + + /// How many session groups fit given available chart pixel width. + int _maxFit(double chartWidth) { + final setsPerGroup = _mode == _SetViewMode.weekly ? 1 : _maxSets; + final visTypes = (_showWeight ? 1 : 0) + (_showReps ? 1 : 0); + final rodsPerGroup = setsPerGroup * max(1, visTypes); + const rodW = 6.0, gap = 2.0, groupGap = 12.0; + final groupW = rodsPerGroup * rodW + (rodsPerGroup - 1) * gap + groupGap; + return max(3, (chartWidth / groupW).floor()); + } + + List<({DateTime date, List sets})> _buildSessions( + SettingsProvider settings, int maxFit) { + final raw = widget.setProgression; + + if (_mode == _SetViewMode.recent) { + final slice = raw.length > maxFit + ? raw.sublist(raw.length - maxFit) + : raw; + return slice.map((e) => ( + date: e.date, + sets: e.sets.take(_maxSets).toList(), + )).toList(); + } + + // Weekly aggregation — one synthetic set (avg weight, avg reps) per week. + final byWeek = >{}; + for (final s in raw) { + byWeek.putIfAbsent(_weekStart(s.date), () => []).addAll(s.sets); + } + final sorted = byWeek.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + final visible = sorted.length > maxFit + ? sorted.sublist(sorted.length - maxFit) + : sorted; + return visible.map((e) { + final sets = e.value; + final avgW = sets.fold(0.0, (s, x) => s + x.weight) / sets.length; + final avgR = (sets.fold(0.0, (s, x) => s + x.reps) / sets.length).round(); + return (date: e.key, sets: [WorkoutSet(weight: avgW, reps: avgR)]); + }).toList(); + } + + // ── Bar group builder ───────────────────────────────────────────────────────── + + List _buildGroups( + List<({DateTime date, List sets})> sessions, + SettingsProvider settings, + double scale, + ) { + return [ + for (int si = 0; si < sessions.length; si++) + () { + final rods = []; + for (final set in sessions[si].sets) { + final w = settings.toDisplay(set.weight); + if (_showWeight) { + rods.add(BarChartRodData( + toY: w, + width: 6, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(3)), + gradient: LinearGradient( + colors: [_wc, _wc.withValues(alpha: 0.55)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + )); + } + if (_showReps) { + rods.add(BarChartRodData( + toY: set.reps * scale, + width: 6, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(3)), + gradient: LinearGradient( + colors: [_rc, _rc.withValues(alpha: 0.50)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + )); + } + } + // Always emit at least an invisible rod so x-axis label stays. + if (rods.isEmpty) { + rods.add(BarChartRodData( + toY: 0, width: 0, color: Colors.transparent)); + } + return BarChartGroupData(x: si, barRods: rods, barsSpace: 2); + }(), + ]; + } + + // ── Tooltip ─────────────────────────────────────────────────────────────────── + + BarTooltipItem? _tooltip( + int groupIndex, + int rodIndex, + List<({DateTime date, List sets})> sessions, + SettingsProvider settings, + ) { + if (groupIndex < 0 || groupIndex >= sessions.length) return null; + final session = sessions[groupIndex]; + + // Map rodIndex back to (setIndex, isWeight) based on visible toggles. + int setIndex; + bool isWeight; + if (_showWeight && _showReps) { + setIndex = rodIndex ~/ 2; + isWeight = rodIndex.isEven; + } else if (_showWeight) { + setIndex = rodIndex; + isWeight = true; + } else { + setIndex = rodIndex; + isWeight = false; + } + if (setIndex >= session.sets.length) return null; + final set = session.sets[setIndex]; + + final dateLabel = _mode == _SetViewMode.weekly + ? 'wk of ${DateFormat('MMM d').format(session.date)}' + : DateFormat('MMM d').format(session.date); + final setLabel = + _mode == _SetViewMode.weekly ? 'Avg' : 'Set ${setIndex + 1}'; + + if (isWeight) { + final w = settings.toDisplay(set.weight); + final wStr = + w % 1 == 0 ? w.toStringAsFixed(0) : w.toStringAsFixed(1); + return BarTooltipItem( + '$setLabel $wStr ${settings.unitLabel}', + TextStyle(fontFamily: 'GeistMono', + color: _wc, fontSize: 12, fontWeight: FontWeight.w700), + children: [ + TextSpan( + text: '\n$dateLabel', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 10, + fontWeight: FontWeight.normal), + ) + ], + ); + } else { + return BarTooltipItem( + '$setLabel ${set.reps} reps', + TextStyle(fontFamily: 'GeistMono', + color: _rc, fontSize: 12, fontWeight: FontWeight.w700), + children: [ + TextSpan( + text: '\n$dateLabel', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 10, + fontWeight: FontWeight.normal), + ) + ], + ); + } + } + + // ── Build ───────────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final settings = context.read(); + final raw = widget.setProgression; + + if (raw.isEmpty) { + return const Padding( + padding: EdgeInsets.all(AppSpacing.lg), + child: Center( + child: + Text('No data', style: TextStyle(color: AppColors.textMuted)), + ), + ); + } + + // Compute global maxes from full history so axes don't jump on toggle. + double maxW = 0, maxR = 0; + for (final s in raw) { + for (final set in s.sets) { + final w = settings.toDisplay(set.weight); + if (w > maxW) maxW = w; + if (set.reps > maxR) maxR = set.reps.toDouble(); + } + } + if (maxW == 0) maxW = 1; + if (maxR == 0) maxR = 1; + final scale = maxW / maxR; + final chartMaxY = maxW * 1.15; + + return LayoutBuilder(builder: (context, constraints) { + // Reserve left(36) + right(28) axis widths from total. + final chartWidth = (constraints.maxWidth - 64).clamp(60.0, double.infinity); + final maxFit = _maxFit(chartWidth); + final sessions = _buildSessions(settings, maxFit); + final barGroups = _buildGroups(sessions, settings, scale); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Controls row: tappable legend + mode toggle + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + _ToggleLegend( + color: _wc, + label: 'Weight', + active: _showWeight, + onTap: () => setState(() => _showWeight = !_showWeight), + ), + const SizedBox(width: 14), + _ToggleLegend( + color: _rc, + label: 'Reps', + active: _showReps, + onTap: () => setState(() => _showReps = !_showReps), + ), + ], + ), + _SetModeToggle( + value: _mode, + onChanged: (m) => setState(() => _mode = m), + ), + ], + ), + const SizedBox(height: 10), + SizedBox( + height: 180, + child: BarChart( + BarChartData( + maxY: chartMaxY, + groupsSpace: 12, + backgroundColor: Colors.transparent, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (_) => + FlLine(color: AppColors.glassBorder, strokeWidth: 1), + ), + borderData: FlBorderData(show: false), + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipColor: (_) => AppColors.cardHigh, + getTooltipItem: (group, gi, rod, ri) => + _tooltip(gi, ri, sessions, settings), + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + axisNameWidget: Text( + settings.unitLabel, + style: TextStyle(fontFamily: 'GeistMono', + color: _wc, + fontSize: 9, + fontWeight: FontWeight.w700), + ), + axisNameSize: 16, + sideTitles: SideTitles( + showTitles: _showWeight, + reservedSize: 36, + getTitlesWidget: (v, _) => Text( + v >= 1000 + ? '${(v / 1000).toStringAsFixed(1)}k' + : v.toStringAsFixed(0), + style: TextStyle(fontFamily: 'GeistMono', + color: _wc.withValues(alpha: 0.7), + fontSize: 9), + ), + ), + ), + rightTitles: AxisTitles( + axisNameWidget: Text( + 'reps', + style: TextStyle(fontFamily: 'GeistMono', + color: _rc, + fontSize: 9, + fontWeight: FontWeight.w700), + ), + axisNameSize: 16, + sideTitles: SideTitles( + showTitles: _showReps, + reservedSize: 28, + getTitlesWidget: (v, _) { + final r = (v / scale).round(); + if (r <= 0) return const Text(''); + return Text('$r', + style: TextStyle(fontFamily: 'GeistMono', + color: _rc.withValues(alpha: 0.7), + fontSize: 9)); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 22, + getTitlesWidget: (v, _) { + final i = v.toInt(); + if (i < 0 || i >= sessions.length) { + return const Text(''); + } + final label = _mode == _SetViewMode.weekly + ? DateFormat('d/M') + .format(sessions[i].date) + : DateFormat('d/M').format(sessions[i].date); + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text(label, + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textMuted, + fontSize: 9)), + ); + }, + ), + ), + ), + barGroups: barGroups, + ), + ), + ), + ], + ); + }); + } +} + +// ── Interactive legend dot ──────────────────────────────────────────────────── + +class _ToggleLegend extends StatelessWidget { + const _ToggleLegend({ + required this.color, + required this.label, + required this.active, + required this.onTap, + }); + final Color color; + final String label; + final bool active; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: active ? 1.0 : 0.32, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 10, + height: 10, + decoration: BoxDecoration( + color: active ? color : AppColors.textFaint, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Text( + label, + style: TextStyle(fontFamily: 'GeistMono', + color: active ? AppColors.textSoft : AppColors.textFaint, + fontSize: 11, + ), + ), + ], + ), + ), + ); + } +} + +// ── Recent / Weekly mode toggle ─────────────────────────────────────────────── + +class _SetModeToggle extends StatelessWidget { + const _SetModeToggle({required this.value, required this.onChanged}); + final _SetViewMode value; + final ValueChanged<_SetViewMode> onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final mode in _SetViewMode.values) + GestureDetector( + onTap: () => onChanged(mode), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: mode == value ? AppColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(5), + ), + child: Text( + mode == _SetViewMode.recent ? 'Recent' : 'Weekly', + style: TextStyle(fontFamily: 'GeistMono', + fontSize: 10, + fontWeight: FontWeight.w700, + color: mode == value ? Colors.white : AppColors.textMuted, + ), + ), + ), + ), + ], + ), + ); + } +} + +// ── Session history list ─────────────────────────────────────────────────────── + +class _SessionHistory extends StatelessWidget { + const _SessionHistory({ + required this.progression, + required this.settings, + }); + + final List<({DateTime date, double volume})> progression; + final SettingsProvider settings; + + @override + Widget build(BuildContext context) { + if (progression.isEmpty) return const SizedBox.shrink(); + + return GlassCard( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Session History', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.md), + ...progression.take(10).map((entry) { + final displayVol = settings.toDisplay(entry.volume); + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + DateFormat('MMM d, yyyy').format(entry.date), + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 13, + ), + ), + Text( + '${displayVol.toStringAsFixed(0)} ${settings.unitLabel}', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + }), + ], + ), + ); + } +} + +// ── Ask Coach button ────────────────────────────────────────────────────────── + +class _AskCoachButton extends StatelessWidget { + const _AskCoachButton({ + required this.exerciseName, + required this.growthModel, + }); + + final String exerciseName; + final GrowthModel? growthModel; + + @override + Widget build(BuildContext context) { + final gemini = context.watch(); + if (!gemini.isConfigured) return const SizedBox.shrink(); + + final isPlateauing = + growthModel != null && growthModel!.weeklyGrowthPercent < 0.5; + final seed = isPlateauing + ? 'I\'ve been plateauing on $exerciseName. How can I break through and start progressing again?' + : 'How can I continue to progress on $exerciseName and make the most of my current momentum?'; + + return OutlineGlowButton( + label: 'Ask Coach about $exerciseName', + icon: Icons.auto_awesome_rounded, + color: AppColors.primary, + fullWidth: true, + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AiCoachScreen(seedPrompt: seed), + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/health_bar_chart.dart b/workout-logger/lib/screens/widgets/health_bar_chart.dart new file mode 100644 index 0000000..de0b1b2 --- /dev/null +++ b/workout-logger/lib/screens/widgets/health_bar_chart.dart @@ -0,0 +1,616 @@ +// health_bar_chart.dart — aggregated vertical bar chart for the Week / Month / +// Year tabs of the Sleep & Heart-rate detail screens. +// +// Two public widgets share one interactive painter: +// • SleepBarsChart — stacked sleep-stage duration bars + 8h goal line. +// • HrRangeChart — daily/monthly min–max range bars + resting-HR markers. +// Both highlight bars that fall on a logged-workout day. + +import 'dart:math' show max; + +import 'package:flutter/material.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../../theme/app_theme.dart'; +import 'sleep_hr_charts.dart' show kSleepStageColors; + +/// Default sleep goal used for the dashed reference line (8h). +const int kSleepGoalMinutes = 480; + +// ── Shared bar model ────────────────────────────────────────────────────────── + +class _Segment { + final Color color; + final double from; + final double to; + const _Segment(this.color, this.from, this.to); +} + +class _AggBar { + final String label; + final List<_Segment> segments; // drawn against the value axis + final double? marker; // e.g. resting-HR dot + final bool isWorkout; + final bool hasData; + final List tooltip; + + const _AggBar({ + required this.label, + required this.segments, + required this.tooltip, + this.marker, + this.isWorkout = false, + this.hasData = true, + }); +} + +String _hm(int minutes) { + final h = minutes ~/ 60; + final m = minutes % 60; + return m == 0 ? '${h}h' : '${h}h${m.toString().padLeft(2, '0')}'; +} + +// ── Sleep stacked bars ──────────────────────────────────────────────────────── + +class SleepBarsChart extends StatelessWidget { + const SleepBarsChart({ + super.key, + required this.bars, + required this.workoutDays, + this.goalMinutes = kSleepGoalMinutes, + this.height = 180, + }); + + final List bars; + final Set workoutDays; + final int goalMinutes; + final double height; + + @override + Widget build(BuildContext context) { + final aggBars = bars.map((b) { + final light = b.lightMin.toDouble(); + final rem = b.remMin.toDouble(); + final deep = b.deepMin.toDouble(); + // Stack order from baseline up: deep, rem, light. + final segs = <_Segment>[ + _Segment(kSleepStageColors['deep']!, 0, deep), + _Segment(kSleepStageColors['rem']!, deep, deep + rem), + _Segment(kSleepStageColors['light']!, deep + rem, deep + rem + light), + ]; + return _AggBar( + label: _labelFor(b.date), + segments: segs, + hasData: b.totalMinutes > 0, + isWorkout: workoutDays.contains(_key(b.date)), + tooltip: [ + _labelFor(b.date), + '${_hm(b.totalMinutes)} total', + 'Deep ${_hm(b.deepMin)} · REM ${_hm(b.remMin)}', + 'Light ${_hm(b.lightMin)}', + ], + ); + }).toList(); + + final maxTotal = bars.fold(0, (m, b) => max(m, b.totalMinutes)); + final axisMax = (max(maxTotal, goalMinutes) / 60).ceil() * 60.0 + 30; + + return _AggBarChart( + bars: aggBars, + axisMin: 0, + axisMax: axisMax, + gridStep: 120, // every 2h + axisLabel: (v) => '${v ~/ 60}h', + goalLine: goalMinutes.toDouble(), + height: height, + ); + } + + String _labelFor(DateTime d) => + d.day == 1 && _isMonthBar(d) ? _months[d.month - 1] : '${d.day}'; + + // Year bars use the first-of-month date; show month initials there. + bool _isMonthBar(DateTime d) => bars.length == 12; + + static const _months = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; +} + +// ── HR range bars ───────────────────────────────────────────────────────────── + +class HrRangeChart extends StatelessWidget { + const HrRangeChart({ + super.key, + required this.bars, + required this.workoutDays, + this.height = 180, + }); + + final List bars; + final Set workoutDays; + final double height; + + @override + Widget build(BuildContext context) { + final withData = bars.where((b) => b.maxBpm > 0).toList(); + final dataMin = withData.isEmpty + ? 40 + : withData.map((b) => b.minBpm).reduce((a, b) => a < b ? a : b); + final dataMax = withData.isEmpty + ? 160 + : withData.map((b) => b.maxBpm).reduce((a, b) => a > b ? a : b); + final axisMin = (dataMin / 10).floor() * 10.0 - 5; + final axisMax = (dataMax / 10).ceil() * 10.0 + 5; + + final aggBars = bars.map((b) { + final hasData = b.maxBpm > 0; + return _AggBar( + label: b.label, + hasData: hasData, + isWorkout: workoutDays.contains(_key(b.date)), + marker: b.restingBpm?.toDouble(), + segments: hasData + ? [_Segment(AppColors.primary, b.minBpm.toDouble(), b.maxBpm.toDouble())] + : const [], + tooltip: hasData + ? [ + b.label, + '${b.minBpm}–${b.maxBpm} bpm', + if (b.restingBpm != null) 'resting ${b.restingBpm}', + ] + : [b.label, 'no data'], + ); + }).toList(); + + return _AggBarChart( + bars: aggBars, + axisMin: axisMin, + axisMax: axisMax, + gridStep: 30, + axisLabel: (v) => '${v.round()}', + rangeGradient: true, + height: height, + ); + } +} + +String _key(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + +// ── All-day HR (Day tab) ────────────────────────────────────────────────────── + +/// ~30-minute min–max HR bars across one day, with a dashed resting line. +class HrDayChart extends StatefulWidget { + const HrDayChart({super.key, required this.snapshot, this.height = 180}); + + final HrDaySnapshot snapshot; + final double height; + + @override + State createState() => _HrDayChartState(); +} + +class _HrDayChartState extends State { + int? _hovered; + static const _padLeft = 26.0; + + int? _indexAt(Offset local, double width) { + final buckets = widget.snapshot.buckets; + final chartW = width - _padLeft - 4; + final x = local.dx - _padLeft; + if (x < 0 || x > chartW || buckets.isEmpty) return null; + return (x / chartW * buckets.length).floor().clamp(0, buckets.length - 1); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: widget.height, + child: LayoutBuilder( + builder: (_, constraints) { + final width = constraints.maxWidth; + return GestureDetector( + onTapDown: (d) => setState(() => _hovered = _indexAt(d.localPosition, width)), + onTapUp: (_) => setState(() => _hovered = null), + onPanUpdate: (d) => setState(() => _hovered = _indexAt(d.localPosition, width)), + onPanEnd: (_) => setState(() => _hovered = null), + onPanCancel: () => setState(() => _hovered = null), + child: CustomPaint( + size: Size(width, widget.height), + painter: _HrDayPainter(widget.snapshot, _hovered), + ), + ); + }, + ), + ); + } +} + +class _HrDayPainter extends CustomPainter { + _HrDayPainter(this.snap, this.hovered); + final HrDaySnapshot snap; + final int? hovered; + + static const _padLeft = 26.0; + static const _padTop = 8.0; + static const _padBottom = 20.0; + + @override + void paint(Canvas canvas, Size size) { + final buckets = snap.buckets; + if (buckets.isEmpty) return; + + final axisMin = (snap.minBpm / 10).floor() * 10.0 - 5; + final axisMax = (snap.maxBpm / 10).ceil() * 10.0 + 5; + final chartW = size.width - _padLeft - 4; + final chartH = size.height - _padTop - _padBottom; + + double yFor(double v) => + _padTop + chartH - ((v - axisMin) / (axisMax - axisMin)) * chartH; + + final gridPaint = Paint() + ..color = AppColors.glassBorder + ..strokeWidth = 0.5; + final yStyle = TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8); + for (var v = (axisMin / 30).ceil() * 30.0; v <= axisMax; v += 30) { + final y = yFor(v); + canvas.drawLine(Offset(_padLeft, y), Offset(size.width - 4, y), gridPaint); + final tp = TextPainter( + text: TextSpan(text: '${v.round()}', style: yStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(_padLeft - tp.width - 3, y - tp.height / 2)); + } + + final slotW = chartW / buckets.length; + final barW = (slotW - 1).clamp(1.4, slotW); + + for (var i = 0; i < buckets.length; i++) { + final b = buckets[i]; + final x = _padLeft + i * slotW; + final dim = hovered != null && hovered != i; + final rect = Rect.fromLTWH(x + 0.5, yFor(b.maxBpm.toDouble()), barW, + max(yFor(b.minBpm.toDouble()) - yFor(b.maxBpm.toDouble()), 2)); + final paint = Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.accent, AppColors.secondary], + ).createShader(rect) + ..color = Colors.white.withValues(alpha: dim ? 0.3 : 0.8); + canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(1.5)), paint); + } + + // Resting line. + if (snap.restingBpm != null) { + final ry = yFor(snap.restingBpm!.toDouble()); + final p = Paint() + ..color = AppColors.secondary.withValues(alpha: 0.7) + ..strokeWidth = 1; + for (var x = _padLeft; x < size.width - 4; x += 8) { + canvas.drawLine(Offset(x, ry), Offset(x + 5, ry), p); + } + } + + // X-axis time labels (12a / 6a / 12p / 6p / 11p). + final labelStyle = TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8); + const marks = ['12a', '6a', '12p', '6p', '11p']; + for (var i = 0; i < marks.length; i++) { + final x = _padLeft + (i / (marks.length - 1)) * chartW; + final tp = TextPainter( + text: TextSpan(text: marks[i], style: labelStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset((x - tp.width / 2).clamp(0, size.width - tp.width), size.height - _padBottom + 5)); + } + + // Tooltip. + if (hovered != null) { + final b = buckets[hovered!]; + final t = b.windowStart.toUtc().add(const Duration(hours: 5, minutes: 30)); + final h12 = t.hour == 0 ? 12 : (t.hour > 12 ? t.hour - 12 : t.hour); + final mm = t.minute.toString().padLeft(2, '0'); + final ap = t.hour < 12 ? 'AM' : 'PM'; + final lines = ['$h12:$mm $ap', '${b.minBpm}–${b.maxBpm} bpm', 'avg ${b.avgBpm.round()}']; + final lineStyle = TextStyle(fontFamily: 'GeistMono', color: Colors.white, fontSize: 9.5); + final painters = lines + .map((l) => TextPainter(text: TextSpan(text: l, style: lineStyle), textDirection: TextDirection.ltr)..layout()) + .toList(); + const padH = 8.0, padV = 6.0, lineH = 14.0; + final ttW = painters.map((p) => p.width).reduce(max) + padH * 2; + final ttH = painters.length * lineH + padV * 2; + final cx = _padLeft + hovered! * slotW + slotW / 2; + final ttX = (cx - ttW / 2).clamp(_padLeft, size.width - 4 - ttW); + const ttY = _padTop + 2.0; + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint()..color = const Color(0xFF1E1E2E), + ); + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint() + ..color = AppColors.secondary.withValues(alpha: 0.6) + ..style = PaintingStyle.stroke + ..strokeWidth = 1, + ); + for (var i = 0; i < painters.length; i++) { + painters[i].paint(canvas, Offset(ttX + padH, ttY + padV + i * lineH)); + } + } + } + + @override + bool shouldRepaint(_HrDayPainter old) => old.snap != snap || old.hovered != hovered; +} + +// ── Interactive chart shell + painter ───────────────────────────────────────── + +class _AggBarChart extends StatefulWidget { + const _AggBarChart({ + required this.bars, + required this.axisMin, + required this.axisMax, + required this.gridStep, + required this.axisLabel, + required this.height, + this.goalLine, + this.rangeGradient = false, + }); + + final List<_AggBar> bars; + final double axisMin; + final double axisMax; + final double gridStep; + final String Function(double) axisLabel; + final double? goalLine; + final bool rangeGradient; + final double height; + + @override + State<_AggBarChart> createState() => _AggBarChartState(); +} + +class _AggBarChartState extends State<_AggBarChart> { + int? _hovered; + + static const _padLeft = 26.0; + + int? _indexAt(Offset local, double width) { + final chartW = width - _padLeft - 4; + final x = local.dx - _padLeft; + if (x < 0 || x > chartW || widget.bars.isEmpty) return null; + final idx = (x / chartW * widget.bars.length).floor(); + return idx.clamp(0, widget.bars.length - 1); + } + + @override + Widget build(BuildContext context) { + if (widget.bars.isEmpty) { + return SizedBox( + height: widget.height, + child: Center( + child: Text( + 'No data for this range.', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 12), + ), + ), + ); + } + return SizedBox( + height: widget.height, + child: LayoutBuilder( + builder: (_, constraints) { + final width = constraints.maxWidth; + return GestureDetector( + onTapDown: (d) => setState(() => _hovered = _indexAt(d.localPosition, width)), + onTapUp: (_) => setState(() => _hovered = null), + onPanUpdate: (d) => setState(() => _hovered = _indexAt(d.localPosition, width)), + onPanEnd: (_) => setState(() => _hovered = null), + onPanCancel: () => setState(() => _hovered = null), + child: CustomPaint( + size: Size(width, widget.height), + painter: _AggPainter( + bars: widget.bars, + axisMin: widget.axisMin, + axisMax: widget.axisMax, + gridStep: widget.gridStep, + axisLabel: widget.axisLabel, + goalLine: widget.goalLine, + rangeGradient: widget.rangeGradient, + hovered: _hovered, + ), + ), + ); + }, + ), + ); + } +} + +class _AggPainter extends CustomPainter { + _AggPainter({ + required this.bars, + required this.axisMin, + required this.axisMax, + required this.gridStep, + required this.axisLabel, + required this.goalLine, + required this.rangeGradient, + required this.hovered, + }); + + final List<_AggBar> bars; + final double axisMin; + final double axisMax; + final double gridStep; + final String Function(double) axisLabel; + final double? goalLine; + final bool rangeGradient; + final int? hovered; + + static const _padLeft = 26.0; + static const _padTop = 8.0; + static const _padBottom = 20.0; + + @override + void paint(Canvas canvas, Size size) { + final chartW = size.width - _padLeft - 4; + final chartH = size.height - _padTop - _padBottom; + final n = bars.length; + final slotW = chartW / n; + final gap = (slotW * 0.32).clamp(2.0, 7.0); + final barW = slotW - gap; + + double yFor(double v) => + _padTop + chartH - ((v - axisMin) / (axisMax - axisMin)) * chartH; + + // Grid + Y labels. + final gridPaint = Paint() + ..color = AppColors.glassBorder + ..strokeWidth = 0.5; + final yStyle = TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8); + for (var v = (axisMin / gridStep).ceil() * gridStep; v <= axisMax; v += gridStep) { + final y = yFor(v); + canvas.drawLine(Offset(_padLeft, y), Offset(size.width - 4, y), gridPaint); + final tp = TextPainter( + text: TextSpan(text: axisLabel(v), style: yStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(_padLeft - tp.width - 3, y - tp.height / 2)); + } + + // Goal line (sleep). + if (goalLine != null && goalLine! >= axisMin && goalLine! <= axisMax) { + final gy = yFor(goalLine!); + final p = Paint() + ..color = kSleepStageColors['awake']!.withValues(alpha: 0.8) + ..strokeWidth = 1; + for (var x = _padLeft; x < size.width - 4; x += 7) { + canvas.drawLine(Offset(x, gy), Offset(x + 4, gy), p); + } + } + + final baselineY = yFor(axisMin); + final labelStyle = TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8); + final labelEvery = n > 16 ? 5 : (n > 10 ? 2 : 1); + + for (var i = 0; i < n; i++) { + final bar = bars[i]; + final x = _padLeft + i * slotW + gap / 2; + final dim = hovered != null && hovered != i; + + if (bar.hasData) { + for (final seg in bar.segments) { + final yTop = yFor(seg.to); + final yBot = yFor(seg.from); + final paint = Paint()..style = PaintingStyle.fill; + if (rangeGradient) { + paint.shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.accent, AppColors.secondary], + ).createShader(Rect.fromLTWH(x, yTop, barW, max(yBot - yTop, 2))); + paint.color = Colors.white.withValues(alpha: dim ? 0.3 : 0.85); + } else { + paint.color = seg.color.withValues(alpha: dim ? 0.3 : 0.88); + } + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x, yTop, barW, max(yBot - yTop, 2)), + const Radius.circular(2), + ), + paint, + ); + } + + // Resting marker dot. + if (bar.marker != null) { + final my = yFor(bar.marker!); + canvas.drawCircle( + Offset(x + barW / 2, my), + 2.6, + Paint()..color = AppColors.secondary.withValues(alpha: dim ? 0.4 : 1), + ); + canvas.drawCircle( + Offset(x + barW / 2, my), + 2.6, + Paint() + ..color = AppColors.background + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2, + ); + } + } + + // Workout-day highlight underline. + if (bar.isWorkout) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x - 1, baselineY + 2, barW + 2, 2.5), + const Radius.circular(1), + ), + Paint()..color = AppColors.accent.withValues(alpha: 0.9), + ); + } + + // X label (subset). + if (i % labelEvery == 0) { + final tp = TextPainter( + text: TextSpan(text: bar.label, style: labelStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint( + canvas, + Offset(x + barW / 2 - tp.width / 2, size.height - _padBottom + 5), + ); + } + } + + // Tooltip. + if (hovered != null) { + _paintTooltip(canvas, size, hovered!, slotW, yFor); + } + } + + void _paintTooltip(Canvas canvas, Size size, int idx, double slotW, double Function(double) yFor) { + final bar = bars[idx]; + final lineStyle = TextStyle(fontFamily: 'GeistMono', color: Colors.white, fontSize: 9.5); + final painters = bar.tooltip + .map((l) => TextPainter( + text: TextSpan(text: l, style: lineStyle), + textDirection: TextDirection.ltr, + )..layout()) + .toList(); + + const padH = 8.0, padV = 6.0, lineH = 14.0; + final ttW = painters.map((p) => p.width).reduce(max) + padH * 2; + final ttH = painters.length * lineH + padV * 2; + + final barCx = _padLeft + idx * slotW + slotW / 2; + var ttX = (barCx - ttW / 2).clamp(_padLeft, size.width - 4 - ttW); + var ttY = _padTop + 2.0; + + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint() + ..color = Colors.black.withValues(alpha: 0.4) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4), + ); + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint()..color = const Color(0xFF1E1E2E), + ); + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint() + ..color = AppColors.primary.withValues(alpha: 0.6) + ..style = PaintingStyle.stroke + ..strokeWidth = 1, + ); + for (var i = 0; i < painters.length; i++) { + painters[i].paint(canvas, Offset(ttX + padH, ttY + padV + i * lineH)); + } + } + + @override + bool shouldRepaint(_AggPainter old) => old.bars != bars || old.hovered != hovered; +} diff --git a/workout-logger/lib/screens/widgets/health_detail_shell.dart b/workout-logger/lib/screens/widgets/health_detail_shell.dart new file mode 100644 index 0000000..fbac10c --- /dev/null +++ b/workout-logger/lib/screens/widgets/health_detail_shell.dart @@ -0,0 +1,204 @@ +// health_detail_shell.dart — shared scaffold for the Sleep & Heart-rate detail +// screens: ambient background, back button, title, prev/next date nav, and the +// Day/Week/Month/Year granularity toggle. The body is supplied by each screen. + +import 'package:flutter/material.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +class HealthDetailShell extends StatelessWidget { + const HealthDetailShell({ + super.key, + required this.title, + required this.icon, + required this.iconColor, + required this.dateLabel, + required this.granularity, + required this.onGranularityChanged, + required this.onPrev, + required this.onNext, + required this.canGoNext, + required this.child, + }); + + final String title; + final IconData icon; + final Color iconColor; + final String dateLabel; + final HealthGranularity granularity; + final ValueChanged onGranularityChanged; + final VoidCallback onPrev; + final VoidCallback onNext; + final bool canGoNext; + final Widget child; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: Stack( + children: [ + const Positioned.fill(child: AmbientGlow()), + SafeArea( + child: Column( + children: [ + _header(context), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _GranularityToggle( + value: granularity, + onChanged: onGranularityChanged, + ), + ), + const SizedBox(height: 12), + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 32), + child: child, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _header(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.arrow_back_rounded, color: AppColors.textSoft), + tooltip: 'Back', + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _NavArrow(icon: Icons.chevron_left_rounded, onTap: onPrev), + const SizedBox(width: 12), + Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: iconColor), + const SizedBox(width: 5), + Text( + title, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + ], + ), + const SizedBox(height: 1), + Text( + dateLabel, + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 11), + ), + ], + ), + const SizedBox(width: 12), + _NavArrow( + icon: Icons.chevron_right_rounded, + onTap: canGoNext ? onNext : null, + ), + ], + ), + ), + const SizedBox(width: 40), // balance the back button + ], + ), + ); + } +} + +class _NavArrow extends StatelessWidget { + const _NavArrow({required this.icon, this.onTap}); + final IconData icon; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + return GestureDetector( + onTap: onTap, + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: AppColors.glass2, + border: Border.all(color: AppColors.glassBorder), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + icon, + size: 18, + color: enabled ? AppColors.textMuted : AppColors.textFaint.withValues(alpha: 0.4), + ), + ), + ); + } +} + +class _GranularityToggle extends StatelessWidget { + const _GranularityToggle({required this.value, required this.onChanged}); + + final HealthGranularity value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: HealthGranularity.values.map((g) { + final active = g == value; + return Expanded( + child: GestureDetector( + onTap: () => onChanged(g), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: active ? AppColors.primary.withValues(alpha: 0.16) : Colors.transparent, + borderRadius: BorderRadius.circular(9), + border: active + ? Border.all(color: AppColors.primary.withValues(alpha: 0.5)) + : Border.all(color: Colors.transparent), + ), + alignment: Alignment.center, + child: Text( + g.label, + style: TextStyle(fontFamily: 'Geist', + fontSize: 12, + fontWeight: FontWeight.w600, + color: active ? AppColors.textPrimary : AppColors.textMuted, + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/heart_rate_card.dart b/workout-logger/lib/screens/widgets/heart_rate_card.dart new file mode 100644 index 0000000..ce77cf3 --- /dev/null +++ b/workout-logger/lib/screens/widgets/heart_rate_card.dart @@ -0,0 +1,192 @@ +// HeartRateCard — compact all-day HR summary on the dashboard. +// +// Self-hiding: renders SizedBox.shrink() when ReadinessManager has no +// HrDaySnapshot, mirroring SleepHrCard. + +import 'dart:math' show max, min; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../../services/managers/readiness_manager.dart'; +import '../../theme/app_theme.dart'; +import '../heart_rate_detail_screen.dart'; +import 'rf_widgets.dart'; + +class HeartRateCard extends StatelessWidget { + const HeartRateCard({super.key}); + + @override + Widget build(BuildContext context) { + final manager = context.watch(); + final snap = manager.hrDaySnapshot; + if (snap == null) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: GlassCard( + borderColor: AppColors.secondary.withValues(alpha: 0.20), + onTap: () => Navigator.of(context).push( + slideRoute(const HeartRateDetailScreen()), + ), + semanticsLabel: 'Heart rate, resting ${snap.restingBpm ?? '--'} bpm', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.favorite_rounded, size: 13, color: AppColors.accent), + const SizedBox(width: 5), + Text( + 'Heart rate', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + 'Today · all-day', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11), + ), + ], + ), + const Icon(Icons.chevron_right_rounded, color: AppColors.textFaint, size: 20), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + _MiniStat( + label: 'Resting', + value: snap.restingBpm?.toString() ?? '—', + unit: 'bpm', + color: AppColors.secondary, + ), + _MiniStat(label: 'Min', value: '${snap.minBpm}', unit: 'bpm', color: AppColors.textMuted), + _MiniStat(label: 'Max', value: '${snap.maxBpm}', unit: 'bpm', color: AppColors.accent), + _MiniStat(label: 'Avg', value: '${snap.avgBpm.round()}', unit: 'bpm', color: AppColors.primary), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 44, + child: CustomPaint( + size: const Size(double.infinity, 44), + painter: _HrSparkline(snap), + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: ['12a', '6a', '12p', '6p', 'now'] + .map((l) => Text(l, style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8))) + .toList(), + ), + ], + ), + ), + ); + } +} + +class _MiniStat extends StatelessWidget { + const _MiniStat({required this.label, required this.value, required this.unit, required this.color}); + + final String label; + final String value; + final String unit; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), + const SizedBox(height: 1), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: value, + style: TextStyle(fontFamily: 'GeistMono', + color: color, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + TextSpan( + text: ' $unit', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Compact all-day HR sparkline: min–max range bars + resting baseline. +class _HrSparkline extends CustomPainter { + const _HrSparkline(this.snap); + + final HrDaySnapshot snap; + + @override + void paint(Canvas canvas, Size size) { + final buckets = snap.buckets; + if (buckets.isEmpty) return; + + final lo = snap.minBpm.toDouble() - 4; + final hi = snap.maxBpm.toDouble() + 4; + double yFor(double v) => size.height - ((v - lo) / (hi - lo)) * size.height; + + final n = buckets.length; + final barW = size.width / n; + + for (var i = 0; i < n; i++) { + final b = buckets[i]; + final x = i * barW; + final yTop = yFor(b.maxBpm.toDouble()); + final yBot = yFor(b.minBpm.toDouble()); + final rect = Rect.fromLTWH(x + 0.5, yTop, max(barW - 1, 1), max(yBot - yTop, 2)); + final paint = Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.accent, AppColors.secondary], + ).createShader(rect) + ..color = Colors.white.withValues(alpha: 0.7); + canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(1)), paint); + } + + if (snap.restingBpm != null) { + final ry = yFor(snap.restingBpm!.toDouble()); + final p = Paint() + ..color = AppColors.secondary.withValues(alpha: 0.6) + ..strokeWidth = 1; + for (var x = 0.0; x < size.width; x += 6) { + canvas.drawLine(Offset(x, ry), Offset(min(x + 3, size.width), ry), p); + } + } + } + + @override + bool shouldRepaint(_HrSparkline old) => old.snap != snap; +} diff --git a/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart b/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart new file mode 100644 index 0000000..1270ca1 --- /dev/null +++ b/workout-logger/lib/screens/widgets/muscle_detail_sheet.dart @@ -0,0 +1,723 @@ +// muscle_detail_sheet.dart — Drill-down sheet for a muscle group. +// Shows weekly contributing exercises (volume + growth trend) and an +// on-demand AI insight via GeminiService.generateInsight. + +import 'dart:math' show max; + +import 'package:flutter/material.dart'; +import 'package:flutter/widget_previews.dart'; +import 'package:provider/provider.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:intl/intl.dart'; + +import '../../services/workout_provider.dart'; +import '../../services/settings_provider.dart'; +import '../../services/ai/gemini_ai_service.dart'; +import '../../services/interfaces/ml_service_interface.dart'; +import '../../data/exercise_database.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; +import '../ai_coach_screen.dart'; + +class MuscleDetailSheet extends StatelessWidget { + const MuscleDetailSheet({ + super.key, + required this.muscleId, + required this.provider, + }); + + final String muscleId; + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final recovery = provider.getMuscleRecoveryScores()[muscleId]; + final name = MuscleGroups.names[muscleId] ?? muscleId; + final color = AppColors.muscle(muscleId); + final exercises = provider.getMuscleExerciseBreakdown(muscleId); + + Color recoveryColor = AppColors.textFaint; + String recoveryLabel = '—'; + if (recovery != null) { + recoveryLabel = '${recovery.recoveryPercent}%'; + if (recovery.recoveryFraction >= 0.90) { + recoveryColor = AppColors.success; + } else if (recovery.recoveryFraction >= 0.70) { + recoveryColor = AppColors.warning; + } else { + recoveryColor = AppColors.error; + } + } + + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.lg, + MediaQuery.of(context).viewInsets.bottom + AppSpacing.xxl, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Handle + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.glassBorder, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header row: muscle name + recovery badge + Row( + children: [ + Container( + width: 12, + height: 12, + margin: const EdgeInsets.only(right: AppSpacing.sm), + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + Expanded( + child: Text( + name, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w800, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: recoveryColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: recoveryColor.withValues(alpha: 0.35)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: recoveryColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Text( + recoveryLabel, + style: TextStyle(fontFamily: 'GeistMono', + color: recoveryColor, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + + if (recovery != null) ...[ + const SizedBox(height: 4), + Text( + recovery.isRecovered + ? 'Ready to train' + : recovery.isUnderRecovered + ? 'Still fatigued — consider rest' + : 'Recovering', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + + const SizedBox(height: AppSpacing.lg), + RFSectionHeader('Contributing this week', bottomPad: false), + const SizedBox(height: AppSpacing.sm), + + if (exercises.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg), + child: Text( + 'No sessions logged for this muscle in the last 7 days.', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 13, + ), + ), + ) + else + ...exercises.map((ex) { + final displayVol = settings.toDisplay(ex.volume); + final volStr = displayVol >= 1000 + ? '${(displayVol / 1000).toStringAsFixed(1)}k' + : displayVol.toStringAsFixed(0); + final growth = ex.growth; + Color trendColor = AppColors.textFaint; + IconData trendIcon = Icons.remove_rounded; + if (growth != null) { + if (growth.slope > 2) { + trendColor = AppColors.success; + trendIcon = Icons.trending_up_rounded; + } else if (growth.slope > 0) { + trendColor = AppColors.secondary; + trendIcon = Icons.trending_up_rounded; + } else if (growth.slope < -2) { + trendColor = AppColors.error; + trendIcon = Icons.trending_down_rounded; + } else { + trendColor = AppColors.warning; + trendIcon = Icons.trending_flat_rounded; + } + } + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + Expanded( + child: Text( + ex.name, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + '$volStr ${settings.unitLabel}', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Icon(trendIcon, size: 16, color: trendColor), + ], + ), + ); + }), + + const SizedBox(height: AppSpacing.lg), + _MuscleVolumeTrendChart( + muscleId: muscleId, + provider: provider, + ), + + const SizedBox(height: AppSpacing.lg), + _RecentMuscleSessionsSection( + muscleId: muscleId, + provider: provider, + ), + + const SizedBox(height: AppSpacing.md), + _AiInsightSection( + muscleId: muscleId, + muscleName: name, + provider: provider, + ), + ], + ), + ); + } +} + +// ── Volume trend chart ──────────────────────────────────────────────────────── + +// Provider-aware shell — fetches data, delegates rendering to _VolumeTrendChartView. +class _MuscleVolumeTrendChart extends StatelessWidget { + const _MuscleVolumeTrendChart({ + required this.muscleId, + required this.provider, + }); + + final String muscleId; + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + return _VolumeTrendChartView( + muscleId: muscleId, + series: provider.getMuscleWeeklyVolumeSeries(muscleId, weeks: 8), + toDisplay: settings.toDisplay, + unitLabel: settings.unitLabel, + ); + } +} + +// Pure presentation widget — no provider dependencies; previewable. +class _VolumeTrendChartView extends StatelessWidget { + const _VolumeTrendChartView({ + required this.muscleId, + required this.series, + required this.toDisplay, + required this.unitLabel, + }); + + final String muscleId; + final List<({DateTime weekStart, double volume})> series; + final double Function(double) toDisplay; + final String unitLabel; + + @override + Widget build(BuildContext context) { + final color = AppColors.muscle(muscleId); + final hasData = series.any((p) => p.volume > 0); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RFSectionHeader('Volume trend (8 wk)', bottomPad: false), + const SizedBox(height: AppSpacing.sm), + if (!hasData) + Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + child: Text( + 'Not enough data yet.', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 13, + ), + ), + ) + else + SizedBox( + height: 96, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: series.map((p) => toDisplay(p.volume)).fold(0.0, max) * 1.25, + barTouchData: BarTouchData(enabled: false), + titlesData: FlTitlesData( + show: true, + leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + final i = value.toInt(); + if (i < 0 || i >= series.length) return const SizedBox.shrink(); + if (i != 0 && i != series.length - 1) return const SizedBox.shrink(); + return Text( + DateFormat('MMM d').format(series[i].weekStart), + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textFaint, + fontSize: 9, + ), + ); + }, + reservedSize: 16, + ), + ), + ), + gridData: const FlGridData(show: false), + borderData: FlBorderData(show: false), + barGroups: series.asMap().entries.map((entry) { + final vol = toDisplay(entry.value.volume); + return BarChartGroupData( + x: entry.key, + barRods: [ + BarChartRodData( + toY: vol, + color: vol > 0 + ? color.withValues(alpha: 0.85) + : AppColors.glass2, + width: 10, + borderRadius: BorderRadius.circular(3), + ), + ], + ); + }).toList(), + ), + ), + ), + ], + ); + } +} + +// ── Recent sessions for this muscle ─────────────────────────────────────────── + +// Provider-aware shell — fetches data, delegates rendering to _RecentSessionsView. +class _RecentMuscleSessionsSection extends StatelessWidget { + const _RecentMuscleSessionsSection({ + required this.muscleId, + required this.provider, + }); + + final String muscleId; + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + return _RecentSessionsView( + sessions: provider.getRecentMuscleSessionSummaries(muscleId), + toDisplay: settings.toDisplay, + unitLabel: settings.unitLabel, + ); + } +} + +// Pure presentation widget — no provider dependencies; previewable. +class _RecentSessionsView extends StatelessWidget { + const _RecentSessionsView({ + required this.sessions, + required this.toDisplay, + required this.unitLabel, + }); + + final List<({DateTime date, List exerciseNames, double volume})> sessions; + final double Function(double) toDisplay; + final String unitLabel; + + String _relativeDate(DateTime date) { + final diff = DateTime.now().difference(date).inDays; + if (diff == 0) return 'Today'; + if (diff == 1) return 'Yesterday'; + if (diff < 7) return '$diff days ago'; + if (diff < 14) return '1 week ago'; + return DateFormat('MMM d').format(date); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RFSectionHeader('Recent sessions', bottomPad: false), + const SizedBox(height: AppSpacing.sm), + if (sessions.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + child: Text( + 'No sessions recorded for this muscle yet.', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 13, + ), + ), + ) + else + ...sessions.map((s) { + final displayVol = toDisplay(s.volume); + final volStr = displayVol >= 1000 + ? '${(displayVol / 1000).toStringAsFixed(1)}k' + : displayVol.toStringAsFixed(0); + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _relativeDate(s.date), + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + s.exerciseNames.join(' · '), + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Text( + '$volStr $unitLabel', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + }), + ], + ); + } +} + +// ── Widget previews ─────────────────────────────────────────────────────────── + +Widget _previewScaffold(Widget child) => MaterialApp( + debugShowCheckedModeBanner: false, + theme: AppTheme.darkTheme, + home: Scaffold( + backgroundColor: AppColors.background, + body: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ); + +List<({DateTime weekStart, double volume})> _stubSeries() { + final now = DateTime.now(); + const vols = [1200.0, 1450.0, 980.0, 1600.0, 1750.0, 1400.0, 1900.0, 2100.0]; + return List.generate( + 8, + (i) => ( + weekStart: now.subtract(Duration(days: (8 - i) * 7)), + volume: vols[i], + ), + ); +} + +List<({DateTime date, List exerciseNames, double volume})> + _stubSessions() { + final now = DateTime.now(); + return [ + (date: now, exerciseNames: ['Bench Press', 'Incline Dumbbell Press'], volume: 4200), + (date: now.subtract(const Duration(days: 3)), exerciseNames: ['Cable Fly'], volume: 1800), + (date: now.subtract(const Duration(days: 7)), exerciseNames: ['Bench Press', 'Push-Up'], volume: 3900), + (date: now.subtract(const Duration(days: 14)), exerciseNames: ['Bench Press'], volume: 3600), + ]; +} + +@Preview(name: 'Volume Trend – growing', group: 'MuscleDetailSheet') +Widget previewVolumeTrend() => _previewScaffold( + _VolumeTrendChartView( + muscleId: 'chest', + series: _stubSeries(), + toDisplay: (v) => v, + unitLabel: 'kg', + ), + ); + +@Preview(name: 'Volume Trend – empty', group: 'MuscleDetailSheet') +Widget previewVolumeTrendEmpty() => _previewScaffold( + _VolumeTrendChartView( + muscleId: 'chest', + series: List.generate( + 8, + (i) => (weekStart: DateTime.now().subtract(Duration(days: (8 - i) * 7)), volume: 0.0), + ), + toDisplay: (v) => v, + unitLabel: 'kg', + ), + ); + +@Preview(name: 'Recent Sessions – with data', group: 'MuscleDetailSheet') +Widget previewRecentSessions() => _previewScaffold( + _RecentSessionsView( + sessions: _stubSessions(), + toDisplay: (v) => v, + unitLabel: 'kg', + ), + ); + +@Preview(name: 'Recent Sessions – empty', group: 'MuscleDetailSheet') +Widget previewRecentSessionsEmpty() => _previewScaffold( + _RecentSessionsView( + sessions: const [], + toDisplay: (v) => v, + unitLabel: 'kg', + ), + ); + +// ── AI insight section ───────────────────────────────────────────────────────── + +class _AiInsightSection extends StatefulWidget { + const _AiInsightSection({ + required this.muscleId, + required this.muscleName, + required this.provider, + }); + + final String muscleId; + final String muscleName; + final WorkoutProvider provider; + + @override + State<_AiInsightSection> createState() => _AiInsightSectionState(); +} + +class _AiInsightSectionState extends State<_AiInsightSection> { + String? _insight; + bool _loading = false; + + Future _fetchInsight() async { + setState(() => _loading = true); + final gemini = context.read(); + final settings = context.read(); + final mlService = context.read(); + final provider = widget.provider; + + final exerciseMap = {for (final e in provider.allExercises) e.id: e}; + final recovery = mlService.computeMuscleRecoveryScores( + provider.sessions, + exerciseMap, + ); + final exercises = provider.getMuscleExerciseBreakdown(widget.muscleId); + final recoveryScore = recovery[widget.muscleId]; + + final contextText = StringBuffer() + ..writeln('Muscle: ${widget.muscleName}') + ..writeln( + 'Recovery: ${recoveryScore != null ? "${recoveryScore.recoveryPercent}% (${recoveryScore.isRecovered ? "ready" : recoveryScore.isUnderRecovered ? "fatigued" : "recovering"})" : "no data"}') + ..writeln('Weekly contributing exercises:'); + for (final ex in exercises) { + final vol = settings.toDisplay(ex.volume); + contextText.writeln( + ' ${ex.name}: ${vol.toStringAsFixed(0)} ${settings.unitLabel}'); + } + + const system = + 'You are an expert personal trainer. Give a specific, actionable 2–3 sentence insight about this muscle group — cover training readiness, volume, and one practical tip. Be concise and direct.'; + + final insight = + await gemini.generateInsight(system, contextText.toString()); + if (mounted) setState(() { _insight = insight; _loading = false; }); + } + + void _openCoach(BuildContext context) { + final seed = + 'Give me advice on training my ${widget.muscleName}. ' + 'What should I focus on in my next session?'; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AiCoachScreen(seedPrompt: seed), + ), + ); + } + + @override + Widget build(BuildContext context) { + final gemini = context.watch(); + if (!gemini.isConfigured) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_insight == null && !_loading) ...[ + Row( + children: [ + Expanded( + child: OutlineGlowButton( + label: 'Get AI Insight', + icon: Icons.auto_awesome_rounded, + color: AppColors.primary, + fullWidth: true, + small: true, + onPressed: _fetchInsight, + ), + ), + const SizedBox(width: AppSpacing.sm), + OutlineGlowButton( + label: 'Ask Coach', + icon: Icons.chat_bubble_outline_rounded, + color: AppColors.secondary, + small: true, + onPressed: () => _openCoach(context), + ), + ], + ), + ] else if (_loading) ...[ + const Center(child: RFLoadingDots()), + ] else ...[ + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.auto_awesome_rounded, + size: 13, color: AppColors.primary), + const SizedBox(width: 5), + Text( + 'AI Insight', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.primary, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + _insight!, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 13, + height: 1.5, + ), + ), + const SizedBox(height: AppSpacing.sm), + GestureDetector( + onTap: () => _openCoach(context), + child: Text( + 'Continue in Coach →', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.secondary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ], + ); + } +} diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart new file mode 100644 index 0000000..55a0c0e --- /dev/null +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -0,0 +1,1181 @@ +// profile_sections.dart — Section widgets for ProfileScreen + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +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_widgets.dart'; + +const String _createdBy = 'Devasy Patel'; + +// ── Section container ───────────────────────────────────────────────────────── +class _ProfileSection extends StatelessWidget { + const _ProfileSection({ + required this.icon, + required this.iconColor, + required this.title, + required this.subtitle, + required this.child, + this.trailing, + }); + + final IconData icon; + final Color iconColor; + final String title; + final String subtitle; + final Widget child; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return GlassCard( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: iconColor.withValues(alpha: 0.25), + width: 1, + ), + ), + child: Icon(icon, color: iconColor, size: 18), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + fontSize: 14, + letterSpacing: -0.2, + ), + ), + Text( + subtitle, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + if (trailing != null) trailing!, + ], + ), + const SizedBox(height: AppSpacing.md), + const Divider(color: AppColors.glassBorder, height: 1), + const SizedBox(height: AppSpacing.md), + child, + ], + ), + ); + } +} + +// ── Preferences section ─────────────────────────────────────────────────────── +class PreferencesSection extends StatelessWidget { + const PreferencesSection({ + super.key, + required this.settings, + required this.onHaptic, + }); + + final SettingsProvider settings; + final VoidCallback onHaptic; + + @override + Widget build(BuildContext context) { + return _ProfileSection( + icon: Icons.tune_rounded, + iconColor: AppColors.primary, + title: 'Preferences', + subtitle: 'Customize weight display and input steps', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel('WEIGHT UNIT'), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: _UnitToggleButton( + label: 'kg', + selected: settings.weightUnit == WeightUnit.kg, + onTap: () { + onHaptic(); + settings.setWeightUnit(WeightUnit.kg); + }, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _UnitToggleButton( + label: 'lbs', + selected: settings.weightUnit == WeightUnit.lbs, + onTap: () { + onHaptic(); + settings.setWeightUnit(WeightUnit.lbs); + }, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + const _SectionLabel('WEIGHT INCREMENT'), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: 8, + runSpacing: 8, + children: settings.availableIncrements.map((inc) { + final selected = settings.weightIncrement == inc; + final label = inc == inc.truncateToDouble() + ? '${inc.toStringAsFixed(0)} ${settings.unitLabel}' + : '${inc.toStringAsFixed(2).replaceAll(RegExp(r'0+$'), '')} ${settings.unitLabel}'; + return GestureDetector( + onTap: () { + HapticFeedback.selectionClick(); + settings.setWeightIncrement(inc); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric( + horizontal: 14, + 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, + ), + ), + child: Text( + label, + style: TextStyle(fontFamily: 'GeistMono', + color: selected ? AppColors.primary : AppColors.textSoft, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + fontSize: 12, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: AppSpacing.md), + const Divider(color: AppColors.glassBorder, height: 1), + const SizedBox(height: AppSpacing.md), + const _SectionLabel('ADVANCED METRICS'), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Show estimated 1RM', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Display 1-rep max badge on completed sets', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + Switch( + value: settings.showAdvancedMetrics, + onChanged: (v) { + onHaptic(); + settings.setShowAdvancedMetrics(v); + }, + activeThumbColor: AppColors.primary, + activeTrackColor: AppColors.primary.withValues(alpha: 0.35), + ), + ], + ), + ], + ), + ); + } +} + +// ── Health Connect section ──────────────────────────────────────────────────── +class HealthConnectSection extends StatelessWidget { + const HealthConnectSection({ + super.key, + required this.settings, + required this.isLoading, + required this.onToggle, + required this.isReadinessLoading, + required this.onReadinessToggle, + }); + + final SettingsProvider settings; + final bool isLoading; + final Future Function(bool) onToggle; + final bool isReadinessLoading; + final Future Function(bool) onReadinessToggle; + + static const _hcColor = Color(0xFF00BFA5); + + @override + Widget build(BuildContext context) { + final enabled = settings.healthConnectEnabled; + return _ProfileSection( + icon: Icons.monitor_heart_outlined, + iconColor: _hcColor, + title: 'Health Connect', + subtitle: 'Sync workouts to Android Health Connect', + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sync workouts after finishing', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Writes session + per-set reps to Health Connect', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + Switch( + value: enabled, + onChanged: isLoading ? null : (v) => onToggle(v), + activeThumbColor: _hcColor, + activeTrackColor: _hcColor.withValues(alpha: 0.35), + ), + ], + ), + if (enabled) ...[ + const SizedBox(height: AppSpacing.sm), + const Divider(color: AppColors.glassBorder, height: 1), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + const Icon(Icons.check_circle_outline, color: _hcColor, size: 15), + const SizedBox(width: 8), + Text( + 'Connected — syncing after each workout', + style: TextStyle(fontFamily: 'Geist', color: _hcColor, fontSize: 12), + ), + ], + ), + ], + const SizedBox(height: AppSpacing.sm), + const Divider(color: AppColors.glassBorder, height: 1), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Readiness insights', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Reads sleep & heart data to score daily recovery', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + Switch( + value: settings.readinessEnabled, + onChanged: + isReadinessLoading ? null : (v) => onReadinessToggle(v), + activeThumbColor: _hcColor, + activeTrackColor: _hcColor.withValues(alpha: 0.35), + ), + ], + ), + ], + ), + ); + } +} + +// ── Data Management section ─────────────────────────────────────────────────── +class DataManagementSection extends StatelessWidget { + const DataManagementSection({ + super.key, + required this.isExporting, + required this.isImporting, + required this.isBackingUp, + required this.onExport, + required this.onImport, + required this.onCloudBackup, + }); + + final bool isExporting; + final bool isImporting; + final bool isBackingUp; + final VoidCallback? onExport; + final VoidCallback? onImport; + final VoidCallback? onCloudBackup; + + @override + Widget build(BuildContext context) { + return _ProfileSection( + icon: Icons.storage_rounded, + iconColor: AppColors.secondary, + title: 'Data Management', + subtitle: 'Export, import, or backup your workout data', + child: Column( + children: [ + _ActionTile( + icon: Icons.upload_file_rounded, + iconColor: AppColors.secondary, + title: 'Export Backup', + subtitle: 'Save a local .json backup file', + loading: isExporting, + onTap: onExport, + ), + const _SectionDivider(), + _ActionTile( + icon: Icons.download_rounded, + iconColor: AppColors.secondary, + title: 'Import Backup', + subtitle: 'Merge data from a .json backup', + loading: isImporting, + onTap: onImport, + ), + const _SectionDivider(), + _ActionTile( + icon: Icons.cloud_upload_outlined, + iconColor: AppColors.primary, + title: 'Cloud Backup', + subtitle: 'Sync to RepForge cloud (requires account)', + loading: isBackingUp, + onTap: onCloudBackup, + ), + ], + ), + ); + } +} + +// ── Cloud Sync section (placeholder) ───────────────────────────────────────── +class CloudSyncSection extends StatelessWidget { + const CloudSyncSection({super.key}); + + @override + Widget build(BuildContext context) { + return _ProfileSection( + icon: Icons.sync_rounded, + iconColor: AppColors.warning, + title: 'Cloud Sync', + subtitle: 'Sync your data across devices', + trailing: const _ComingSoonBadge(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel('MONGODB CONNECTION STRING'), + const SizedBox(height: AppSpacing.sm), + Container( + decoration: BoxDecoration( + color: AppColors.glass, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextField( + enabled: false, + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textFaint, + fontSize: 12, + ), + decoration: InputDecoration( + hintText: 'mongodb+srv://user:pass@cluster.mongodb.net/db', + hintStyle: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textFaint, + fontSize: 12, + ), + prefixIcon: const Icon( + Icons.link_rounded, + color: AppColors.textFaint, + size: 16, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 4, + ), + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Cloud sync with custom MongoDB will be available in a future update.', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ); + } +} + +// ── About section ───────────────────────────────────────────────────────────── +class AboutSection extends StatefulWidget { + const AboutSection({super.key, required this.appVersion}); + final String appVersion; + + @override + State createState() => _AboutSectionState(); +} + +class _AboutSectionState extends State { + int _versionTaps = 0; + + void _onVersionTap() { + _versionTaps++; + if (_versionTaps >= 5) { + _versionTaps = 0; + _showDebugLogs(context); + } + } + + void _showDebugLogs(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)), + ), + builder: (_) => const _DebugLogSheet(), + ); + } + + @override + Widget build(BuildContext context) { + return _ProfileSection( + icon: Icons.info_outline_rounded, + iconColor: AppColors.textSoft, + title: 'About', + subtitle: 'RepForge Workout Logger', + child: Column( + children: [ + GestureDetector( + onTap: _onVersionTap, + child: _InfoTile( + label: 'Version', + value: widget.appVersion, + icon: Icons.tag_rounded, + ), + ), + const _SectionDivider(), + _InfoTile( + label: 'Created by', + value: _createdBy, + icon: Icons.person_rounded, + ), + const _SectionDivider(), + _InfoTile( + label: 'Platform', + value: 'Android', + icon: Icons.phone_android_rounded, + ), + const _SectionDivider(), + _InfoTile( + label: 'Package', + value: 'com.devasy.repforge', + icon: Icons.inventory_2_outlined, + ), + ], + ), + ); + } +} + +// ── Private helpers ─────────────────────────────────────────────────────────── + +class _SectionLabel extends StatelessWidget { + const _SectionLabel(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text, + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textFaint, + fontSize: 9, + fontWeight: FontWeight.w600, + letterSpacing: 1.4, + ), + ); + } +} + +class _UnitToggleButton extends StatelessWidget { + const _UnitToggleButton({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(vertical: 11), + decoration: BoxDecoration( + color: selected + ? AppColors.primary.withValues(alpha: 0.15) + : AppColors.glass, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: selected + ? AppColors.primary.withValues(alpha: 0.5) + : AppColors.glassBorder, + width: selected ? 1.5 : 1, + ), + boxShadow: selected + ? [ + BoxShadow( + color: AppColors.primaryGlow(0.20), + blurRadius: 12, + spreadRadius: -4, + ), + ] + : null, + ), + child: Center( + child: Text( + label, + style: TextStyle(fontFamily: 'Geist', + color: selected ? AppColors.primary : AppColors.textSoft, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + fontSize: 14, + ), + ), + ), + ), + ); + } +} + +class _ActionTile extends StatelessWidget { + const _ActionTile({ + required this.icon, + required this.iconColor, + required this.title, + required this.subtitle, + required this.loading, + this.onTap, + }); + + final IconData icon; + final Color iconColor; + final String title; + final String subtitle; + final bool loading; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.sm), + splashColor: AppColors.primary.withValues(alpha: 0.06), + highlightColor: AppColors.primary.withValues(alpha: 0.04), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: iconColor.withValues(alpha: 0.22), + width: 1, + ), + ), + child: Icon(icon, color: iconColor, size: 18), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + Text( + subtitle, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + if (loading) + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 1.5, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ) + else + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textFaint, + size: 18, + ), + ], + ), + ), + ); + } +} + +class _InfoTile extends StatelessWidget { + const _InfoTile({ + required this.label, + required this.value, + required this.icon, + }); + + final String label; + final String value; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 9), + child: Row( + children: [ + Icon(icon, color: AppColors.textFaint, size: 16), + const SizedBox(width: 12), + Text( + label, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 13, + ), + ), + const Spacer(), + Text( + value, + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} + +class _SectionDivider extends StatelessWidget { + const _SectionDivider(); + + @override + Widget build(BuildContext context) { + return const Divider( + color: AppColors.glassBorder, + height: 1, + indent: 40, + ); + } +} + +// ── AI Features section ─────────────────────────────────────────────────────── +class AiSettingsSection extends StatefulWidget { + const AiSettingsSection({super.key}); + + @override + State createState() => _AiSettingsSectionState(); +} + +class _AiSettingsSectionState extends State { + late TextEditingController _ctrl; + bool _obscure = true; + bool _saving = false; + + @override + void initState() { + super.initState(); + _ctrl = TextEditingController( + text: context.read().geminiApiKey, + ); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() => _saving = true); + final key = _ctrl.text.trim(); + final settings = context.read(); + final gemini = context.read(); + try { + await settings.setGeminiApiKey(key); + gemini.updateApiKey(key); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _selectModel(String modelId) async { + final settings = context.read(); + final gemini = context.read(); + await settings.setGeminiModel(modelId); + gemini.updateModel(modelId); + } + + @override + Widget build(BuildContext context) { + final gemini = context.watch(); + final settings = context.watch(); + return _ProfileSection( + icon: Icons.auto_awesome_rounded, + iconColor: AppColors.primary, + title: 'AI Features', + subtitle: 'Gemini-powered coach, program builder & insights', + trailing: gemini.isConfigured + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.success.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.success.withValues(alpha: 0.35)), + ), + child: Text( + 'Active', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.success, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ) + : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel('GEMINI API KEY'), + const SizedBox(height: AppSpacing.sm), + Container( + decoration: BoxDecoration( + color: AppColors.glass, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorderStrong), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _ctrl, + obscureText: _obscure, + enableSuggestions: false, + autocorrect: false, + keyboardType: TextInputType.visiblePassword, + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textPrimary, + fontSize: 12, + ), + decoration: InputDecoration( + hintText: 'AIza…', + hintStyle: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textFaint, + fontSize: 12, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 4, + ), + ), + ), + ), + GestureDetector( + onTap: () => setState(() => _obscure = !_obscure), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), + child: Icon( + _obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined, + color: AppColors.textFaint, + size: 18, + ), + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Get a free key at aistudio.google.com. Stored locally on-device.', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + 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: const Duration(milliseconds: 150), + 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, + ), + ), + child: Text( + label, + style: TextStyle(fontFamily: 'GeistMono', + color: selected ? AppColors.primary : AppColors.textSoft, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + fontSize: 11, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + 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: TextButton( + onPressed: _saving ? null : _save, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + ), + child: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 1.5, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ) + : Text( + 'Save API Key', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.primary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + const _SectionLabel('TOKEN USAGE'), + const Spacer(), + if (gemini.aiRequestCount > 0) + GestureDetector( + onTap: () => context.read().resetUsage(), + child: Text( + 'Reset', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.accent, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.glass, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + children: [ + _UsageRow(label: 'Total tokens', value: _formatInt(gemini.totalTokensUsed)), + const SizedBox(height: 6), + _UsageRow(label: 'Input (prompt)', value: _formatInt(gemini.promptTokensUsed)), + const SizedBox(height: 6), + _UsageRow(label: 'Output (response)', value: _formatInt(gemini.responseTokensUsed)), + const SizedBox(height: 6), + _UsageRow(label: 'Requests', value: _formatInt(gemini.aiRequestCount)), + ], + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Cumulative billable tokens across coach, program builder & insights.', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ); + } +} + +/// One label/value line in the token-usage card. +class _UsageRow extends StatelessWidget { + const _UsageRow({required this.label, required this.value}); + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12), + ), + Text( + value, + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +/// Format an int with thousands separators (e.g. 12345 → "12,345"). +String _formatInt(int n) { + final s = n.toString(); + final buf = StringBuffer(); + for (var i = 0; i < s.length; i++) { + if (i > 0 && (s.length - i) % 3 == 0) buf.write(','); + buf.write(s[i]); + } + return buf.toString(); +} + +// ── Debug log viewer (tap version 5× to open) ──────────────────────────────── +class _DebugLogSheet extends StatelessWidget { + const _DebugLogSheet(); + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.75, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(AppSpacing.md, AppSpacing.sm, AppSpacing.sm, 0), + child: Row( + children: [ + Text( + 'Debug Logs', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + const Spacer(), + TextButton( + onPressed: () => DebugLogBuffer.instance.clear(), + child: Text( + 'Clear', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.accent, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, color: AppColors.textMuted, size: 18), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(color: AppColors.glassBorder, height: 1), + Expanded( + child: ListenableBuilder( + listenable: DebugLogBuffer.instance, + builder: (context, _) { + final lines = DebugLogBuffer.instance.lines; + if (lines.isEmpty) { + return Center( + child: Text( + 'No logs yet', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 13), + ), + ); + } + return ListView.builder( + controller: scrollController, + reverse: true, + padding: const EdgeInsets.all(AppSpacing.sm), + itemCount: lines.length, + itemBuilder: (context, i) { + final line = lines[lines.length - 1 - i]; + final isHc = line.contains('[HC]'); + final isReadiness = line.contains('[Readiness]'); + final color = isHc + ? AppColors.secondary + : isReadiness + ? AppColors.primary + : AppColors.textSoft; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + line, + style: TextStyle(fontFamily: 'GeistMono', fontSize: 10, color: color), + ), + ); + }, + ); + }, + ), + ), + ], + ); + }, + ); + } +} + +class _ComingSoonBadge extends StatelessWidget { + const _ComingSoonBadge(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.warning.withValues(alpha: 0.35)), + ), + child: Text( + 'Soon', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.warning, + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/program_week_editor.dart b/workout-logger/lib/screens/widgets/program_week_editor.dart new file mode 100644 index 0000000..ff67ed8 --- /dev/null +++ b/workout-logger/lib/screens/widgets/program_week_editor.dart @@ -0,0 +1,409 @@ +// program_week_editor.dart — Step 2 week structure editor + shared stepper widget + +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; + +import '../../models/models.dart'; +import '../../theme/app_theme.dart'; + +// ── Shared primitive: number stepper ───────────────────────────────────────── +class ProgramNumberStepper extends StatelessWidget { + const ProgramNumberStepper({ + super.key, + required this.label, + required this.value, + required this.min, + required this.max, + required this.onChanged, + this.step = 1, + }); + + final String label; + final int value; + final int min; + final int max; + final int step; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: const TextStyle(fontSize: 13, color: AppColors.textSoft), + ), + ), + IconButton( + icon: const Icon(Icons.remove_rounded, size: 18), + color: AppColors.textSoft, + onPressed: value > min + ? () => onChanged((value - step).clamp(min, max)) + : null, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + SizedBox( + width: 40, + child: Text( + '$value', + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + fontSize: 14, + ), + ), + ), + IconButton( + icon: const Icon(Icons.add_rounded, size: 18), + color: AppColors.textSoft, + onPressed: value < max + ? () => onChanged((value + step).clamp(min, max)) + : null, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ), + ); + } +} + +// ── Shared primitive: section header ───────────────────────────────────────── +class ProgramSectionHeader extends StatelessWidget { + const ProgramSectionHeader(this.text, {super.key}); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Text( + text.toUpperCase(), + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + ); + } +} + +// ── Step 2: Week structure editor ───────────────────────────────────────────── +class ProgramWeekEditorStep extends StatefulWidget { + const ProgramWeekEditorStep({ + super.key, + required this.weeks, + required this.onWeeksChanged, + }); + + final List weeks; + final void Function(List) onWeeksChanged; + + @override + State createState() => _ProgramWeekEditorStepState(); +} + +class _ProgramWeekEditorStepState extends State { + final _uuid = const Uuid(); + late List _weeks; + + @override + void initState() { + super.initState(); + _weeks = List.from(widget.weeks); + } + + @override + void didUpdateWidget(ProgramWeekEditorStep old) { + super.didUpdateWidget(old); + if (widget.weeks != old.weeks) { + _weeks = List.from(widget.weeks); + } + } + + void _update(List weeks) { + setState(() => _weeks = weeks); + widget.onWeeksChanged(weeks); + } + + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: _weeks.length + 1, + itemBuilder: (context, index) { + if (index == 0) return const ProgramSectionHeader('Weeks & Days'); + return _buildWeekEditor(index - 1, _weeks[index - 1]); + }, + ); + } + + Widget _buildWeekEditor(int idx, ProgramWeek week) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all( + color: week.isDeload + ? Colors.amber.withValues(alpha: 0.35) + : AppColors.glassBorder, + ), + ), + child: ExpansionTile( + leading: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: week.isDeload + ? Colors.amber.withValues(alpha: 0.15) + : AppColors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + alignment: Alignment.center, + child: Text( + 'W${week.weekNumber}', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: week.isDeload ? Colors.amber : AppColors.primary, + ), + ), + ), + title: Text( + week.isDeload + ? 'Week ${week.weekNumber} — Deload' + : 'Week ${week.weekNumber}', + style: TextStyle( + fontWeight: FontWeight.w600, + color: week.isDeload ? Colors.amber : AppColors.textPrimary, + fontSize: 14, + ), + ), + subtitle: Text( + '${week.days.length} day${week.days.length != 1 ? 's' : ''}', + style: const TextStyle(fontSize: 12, color: AppColors.textSoft), + ), + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile.adaptive( + dense: true, + contentPadding: EdgeInsets.zero, + title: const Text( + 'Deload Week', + style: TextStyle(color: AppColors.textPrimary, fontSize: 13), + ), + value: week.isDeload, + activeColor: Colors.amber, + onChanged: (v) { + final updated = List.from(_weeks); + updated[idx] = week.copyWith(isDeload: v); + _update(updated); + }, + ), + if (week.isDeload) ...[ + ProgramNumberStepper( + label: 'Intensity factor (%)', + value: (week.deloadIntensityFactor * 100).round(), + min: 50, + max: 95, + onChanged: (v) { + final updated = List.from(_weeks); + updated[idx] = week.copyWith( + deloadIntensityFactor: v / 100.0, + ); + _update(updated); + }, + ), + ProgramNumberStepper( + label: 'Sets reduced by', + value: week.deloadSetReduction, + min: 0, + max: 3, + onChanged: (v) { + final updated = List.from(_weeks); + updated[idx] = week.copyWith(deloadSetReduction: v); + _update(updated); + }, + ), + ], + Divider(color: AppColors.glassBorder), + ...week.days.asMap().entries.map( + (e) => _buildDayChip(idx, e.key, e.value), + ), + OutlinedButton.icon( + onPressed: () => _addDay(idx), + icon: const Icon(Icons.add_rounded, size: 16), + label: const Text('Add Day'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDayChip(int weekIdx, int dayIdx, ProgramDay day) { + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.drag_handle_rounded, color: AppColors.textMuted), + title: Text( + day.name, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 13), + ), + subtitle: Text( + '${day.exercises.length} exercise${day.exercises.length != 1 ? 's' : ''}', + style: const TextStyle(fontSize: 11, color: AppColors.textSoft), + ), + trailing: IconButton( + icon: const Icon( + Icons.delete_outline_rounded, + color: AppColors.error, + size: 18, + ), + onPressed: () { + final days = List.from(_weeks[weekIdx].days) + ..removeAt(dayIdx); + final updated = List.from(_weeks); + updated[weekIdx] = updated[weekIdx].copyWith(days: days); + _update(updated); + }, + ), + ); + } + + void _addDay(int weekIdx) { + showDialog( + context: context, + builder: (_) { + final nameCtrl = TextEditingController(); + int? dow; + return StatefulBuilder( + builder: (ctx, setDlg) => AlertDialog( + backgroundColor: AppColors.cardHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + title: const Text( + 'Add Day', + style: TextStyle(color: AppColors.textPrimary), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _styledField(nameCtrl, 'Day Name', hint: 'e.g. Push, Pull, Legs'), + const SizedBox(height: AppSpacing.md), + DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Day of Week (optional)', + labelStyle: TextStyle(color: AppColors.textSoft), + ), + dropdownColor: AppColors.cardHigh, + style: const TextStyle(color: AppColors.textPrimary), + initialValue: dow, + items: [ + const DropdownMenuItem( + value: null, + child: Text('Unscheduled'), + ), + ...List.generate(7, (i) => i + 1).map( + (d) => DropdownMenuItem( + value: d, + child: Text( + ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][d - 1], + ), + ), + ), + ], + onChanged: (v) => setDlg(() => dow = v), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text( + 'Cancel', + style: TextStyle(color: AppColors.textSoft), + ), + ), + TextButton( + onPressed: () { + final newDay = ProgramDay( + id: _uuid.v4(), + name: nameCtrl.text.isEmpty ? 'Day' : nameCtrl.text, + dayOfWeek: dow, + exercises: [], + ); + final days = List.from(_weeks[weekIdx].days) + ..add(newDay); + final updated = List.from(_weeks); + updated[weekIdx] = updated[weekIdx].copyWith(days: days); + _update(updated); + Navigator.pop(ctx); + }, + style: TextButton.styleFrom(foregroundColor: AppColors.primary), + child: const Text('Add'), + ), + ], + ), + ); + }, + ); + } + + Widget _styledField( + TextEditingController ctrl, + String label, { + String? hint, + int maxLines = 1, + }) { + return TextField( + controller: ctrl, + maxLines: maxLines, + style: const TextStyle(color: AppColors.textPrimary), + decoration: InputDecoration( + labelText: label, + hintText: hint, + labelStyle: const TextStyle(color: AppColors.textSoft), + hintStyle: const TextStyle(color: AppColors.textMuted), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + borderSide: const BorderSide(color: AppColors.glassBorder), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + borderSide: const BorderSide(color: AppColors.primary), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/program_week_tile.dart b/workout-logger/lib/screens/widgets/program_week_tile.dart new file mode 100644 index 0000000..ffdfd83 --- /dev/null +++ b/workout-logger/lib/screens/widgets/program_week_tile.dart @@ -0,0 +1,441 @@ +// program_week_tile.dart — Collapsible week card for ProgramDetailScreen + +import 'package:flutter/material.dart'; +import '../../models/models.dart'; +import '../../services/workout_provider.dart'; +import '../../theme/app_theme.dart'; + +// Phase colors shared across programs UI +const List kProgramPhaseColors = [ + AppColors.primary, + AppColors.secondary, + Colors.orange, + Colors.pink, + Colors.green, +]; + +class ProgramWeekTile extends StatefulWidget { + const ProgramWeekTile({ + super.key, + required this.week, + required this.weekIndex, + required this.program, + required this.provider, + required this.onStartDay, + }); + + final ProgramWeek week; + final int weekIndex; + final TrainingProgram program; + final WorkoutProvider provider; + final void Function(ProgramDay, ProgramWeek) onStartDay; + + @override + State createState() => _ProgramWeekTileState(); +} + +class _ProgramWeekTileState extends State { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final week = widget.week; + final phase = widget.program.phaseForWeek(week.weekNumber); + final phaseIdx = phase == null + ? 0 + : widget.program.phases.indexWhere((p) => p.id == phase.id); + final phaseColor = phaseIdx >= 0 + ? kProgramPhaseColors[phaseIdx % kProgramPhaseColors.length] + : AppColors.primary; + + return Container( + margin: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all( + color: week.isDeload + ? Colors.amber.withValues(alpha: 0.4) + : AppColors.glassBorder, + ), + ), + child: Column( + children: [ + _buildHeader(week: week, phase: phase, phaseColor: phaseColor), + if (_expanded) ...[ + Divider(color: AppColors.glassBorder, height: 1), + ...week.days.map((day) => _buildDaySection(day, week)), + if (week.notes != null) + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), + child: Row( + children: [ + const Icon( + Icons.info_outline_rounded, + size: 14, + color: AppColors.textMuted, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + week.notes!, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSoft, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ); + } + + Widget _buildHeader({ + required ProgramWeek week, + required TrainingPhase? phase, + required Color phaseColor, + }) { + return InkWell( + onTap: () => setState(() => _expanded = !_expanded), + borderRadius: BorderRadius.circular(AppRadius.lg), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: phaseColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + alignment: Alignment.center, + child: Text( + 'W${week.weekNumber}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: phaseColor, + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (week.isDeload) ...[ + const Icon( + Icons.battery_charging_full_rounded, + size: 14, + color: Colors.amber, + ), + const SizedBox(width: 4), + const Text( + 'DELOAD ', + style: TextStyle( + fontSize: 11, + color: Colors.amber, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), + ), + ], + if (phase != null) + Text( + phase.name, + style: TextStyle( + fontSize: 12, + color: phaseColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + Text( + '${week.days.length} day${week.days.length != 1 ? 's' : ''}', + style: const TextStyle( + fontSize: 12, + color: AppColors.textSoft, + ), + ), + ], + ), + ), + if (week.isDeload) + Padding( + padding: const EdgeInsets.only(right: AppSpacing.sm), + child: Text( + '${((week.deloadIntensityFactor) * 100).round()}%', + style: const TextStyle( + fontSize: 12, + color: Colors.amber, + fontWeight: FontWeight.w700, + ), + ), + ), + Icon( + _expanded ? Icons.expand_less_rounded : Icons.expand_more_rounded, + color: AppColors.textMuted, + size: 20, + ), + ], + ), + ), + ); + } + + Widget _buildDaySection(ProgramDay day, ProgramWeek week) { + final runs = >[]; + String? currentRunKey; + List currentRun = []; + for (final slot in day.exercises) { + final key = slot.supersetGroupId; + if (key == null) { + if (currentRun.isNotEmpty) { + runs.add(currentRun); + currentRun = []; + currentRunKey = null; + } + runs.add([slot]); + } else if (key == currentRunKey) { + currentRun.add(slot); + } else { + if (currentRun.isNotEmpty) runs.add(currentRun); + currentRunKey = key; + currentRun = [slot]; + } + } + if (currentRun.isNotEmpty) runs.add(currentRun); + + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Text( + day.name.toUpperCase(), + style: const TextStyle( + fontSize: 11, + color: AppColors.primary, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), + ), + ), + if (day.dayOfWeek != null) ...[ + const SizedBox(width: AppSpacing.sm), + Text( + _dayName(day.dayOfWeek!), + style: const TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + ], + ], + ), + const SizedBox(height: AppSpacing.sm), + ...runs.map((slots) { + final isSuperset = + slots.length > 1 || slots.first.supersetGroupId != null; + if (isSuperset) { + return _buildSupersetGroup(slots: slots, week: week); + } + return _buildExerciseRow(slot: slots.first, week: week); + }), + const SizedBox(height: AppSpacing.sm), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => widget.onStartDay(day, week), + icon: const Icon(Icons.play_arrow_rounded, size: 18), + label: Text('Start ${day.name}'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + ], + ), + ); + } + + Widget _buildSupersetGroup({ + required List slots, + required ProgramWeek week, + }) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: AppColors.secondary.withValues(alpha: 0.5), + width: 3, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(left: AppSpacing.sm, bottom: 2), + child: Text( + 'SUPERSET', + style: TextStyle( + fontSize: 10, + color: AppColors.secondary, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + ), + ), + ), + ...slots.map( + (slot) => _buildExerciseRow(slot: slot, week: week, indent: true), + ), + ], + ), + ); + } + + Widget _buildExerciseRow({ + required ProgramExerciseSlot slot, + required ProgramWeek week, + bool indent = false, + }) { + final exercise = widget.provider.getExercise(slot.exerciseId); + final name = exercise?.name ?? slot.exerciseId; + final displaySets = + week.isDeload ? (slot.sets - week.deloadSetReduction).clamp(1, 99) : slot.sets; + final displayIntensity = week.isDeload ? week.deloadIntensityFactor : 1.0; + final repRange = slot.minReps == slot.maxReps + ? '${slot.minReps}' + : '${slot.minReps}–${slot.maxReps}'; + + return Padding( + padding: EdgeInsets.only( + left: indent ? AppSpacing.md : 0, + bottom: AppSpacing.sm, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: Text( + '$displaySets × $repRange', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Wrap( + spacing: AppSpacing.sm, + runSpacing: 2, + children: [ + _infoChip( + Icons.timer_outlined, + '${slot.restSeconds}s rest', + ), + if (slot.tempo != null) _infoChip(Icons.speed, slot.tempo!), + if (slot.weightPercentage != null) + _infoChip( + Icons.fitness_center_rounded, + week.isDeload + ? '${(slot.weightPercentage! * displayIntensity).toStringAsFixed(0)}%' + : '${slot.weightPercentage!.toStringAsFixed(0)}%', + ), + ], + ), + if (slot.notes != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + slot.notes!, + style: const TextStyle( + fontSize: 11, + color: AppColors.textMuted, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _infoChip(IconData icon, String label) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 11, color: AppColors.textMuted), + const SizedBox(width: 2), + Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)), + ], + ); + } + + static const _dayNames = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + static String _dayName(int dow) => dow >= 1 && dow <= 7 ? _dayNames[dow] : ''; +} diff --git a/workout-logger/lib/screens/widgets/readiness_card.dart b/workout-logger/lib/screens/widgets/readiness_card.dart new file mode 100644 index 0000000..521243e --- /dev/null +++ b/workout-logger/lib/screens/widgets/readiness_card.dart @@ -0,0 +1,325 @@ +// ReadinessCard — daily training-readiness summary on the dashboard. +// +// Self-hiding: renders nothing until ReadinessManager has a scored snapshot, +// so the dashboard needs no conditional logic and users without watch data +// (or with the feature disabled) never see an empty state. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/models.dart'; +import '../../services/interfaces/readiness_manager_interface.dart'; +import '../../services/managers/readiness_manager.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +class ReadinessCard extends StatelessWidget { + const ReadinessCard({super.key}); + + // Per-component color thresholds, aligned with ReadinessCalculator bands. + static const int _goodScore = 75; + static const int _okScore = 50; + + @override + Widget build(BuildContext context) { + final manager = context.watch(); + final snapshot = manager.snapshot; + debugPrint('[ReadinessCard] build: status=${manager.status} score=${snapshot?.score} band=${snapshot?.band}'); + + final bool hasScore = manager.status == ReadinessStatus.ready && + snapshot != null && + snapshot.score != null && + snapshot.band != null; + + if (!hasScore) { + return const SizedBox.shrink(); + } + + final color = _bandColor(snapshot.band!); + + return _buildMainCard(context, snapshot, color); + } + + Widget _buildMainCard( + BuildContext context, + ReadinessSnapshot snapshot, + Color color, + ) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: GlassCard( + glowColor: color, + onTap: () => _showDetails(context, snapshot), + semanticsLabel: 'Readiness ${snapshot.score} out of 100', + child: Row( + children: [ + _ScoreRing(score: snapshot.score!, color: color), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _headline(snapshot.band!), + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + const SizedBox(height: 3), + Text( + _subtitle(snapshot), + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textFaint, + size: 20, + ), + ], + ), + ), + ); + } + + static Color _bandColor(ReadinessBand band) => switch (band) { + ReadinessBand.high => AppColors.success, + ReadinessBand.moderate => AppColors.warning, + ReadinessBand.low => AppColors.error, + }; + + static String _headline(ReadinessBand band) => switch (band) { + ReadinessBand.high => 'Primed — good day to push', + ReadinessBand.moderate => 'Train as planned', + ReadinessBand.low => 'Take it easy today', + }; + + /// One line of evidence from the weakest available component. + static String _subtitle(ReadinessSnapshot s) { + final parts = <(int, String)>[ + if (s.sleepScore != null) + ( + s.sleepScore!, + 'Sleep ${_fmtSleep(s.sleepMinutes!)} vs ${_fmtSleep(s.sleepBaselineMinutes!.round())} avg' + ), + if (s.rhrScore != null) + ( + s.rhrScore!, + 'Resting HR ${s.restingHr!.round()} vs ${s.rhrBaseline!.round()} avg' + ), + if (s.hrvScore != null) + ( + s.hrvScore!, + 'HRV ${s.hrvMs!.round()}ms vs ${s.hrvBaseline!.round()}ms avg' + ), + ]; + parts.sort((a, b) => a.$1.compareTo(b.$1)); + return parts.first.$2; + } + + static String _fmtSleep(int minutes) { + final h = minutes ~/ 60; + final m = minutes % 60; + return m == 0 ? '${h}h' : '${h}h ${m.toString().padLeft(2, '0')}m'; + } + + void _showDetails(BuildContext context, ReadinessSnapshot snapshot) { + showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => _ReadinessDetailsSheet(snapshot: snapshot), + ); + } +} + +class _ScoreRing extends StatelessWidget { + const _ScoreRing({required this.score, required this.color}); + + final int score; + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 52, + height: 52, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 52, + height: 52, + child: CircularProgressIndicator( + value: score / 100, + strokeWidth: 4, + strokeCap: StrokeCap.round, + backgroundColor: AppColors.glass3, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + Text( + '$score', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _ReadinessDetailsSheet extends StatelessWidget { + const _ReadinessDetailsSheet({required this.snapshot}); + + final ReadinessSnapshot snapshot; + + @override + Widget build(BuildContext context) { + final time = TimeOfDay.fromDateTime(snapshot.computedAt).format(context); + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: AppColors.glassBorderStrong, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + ), + const SizedBox(height: 18), + Text( + 'Readiness · ${snapshot.score}', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 4), + Text( + 'As of $time, from your watch via Health Connect', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 12), + ), + const SizedBox(height: 18), + if (snapshot.sleepScore != null) + _ComponentRow( + label: 'Sleep', + value: + '${ReadinessCard._fmtSleep(snapshot.sleepMinutes!)} · avg ${ReadinessCard._fmtSleep(snapshot.sleepBaselineMinutes!.round())}', + score: snapshot.sleepScore!, + ), + if (snapshot.rhrScore != null) + _ComponentRow( + label: 'Resting heart rate', + value: + '${snapshot.restingHr!.round()} bpm · avg ${snapshot.rhrBaseline!.round()} bpm', + score: snapshot.rhrScore!, + ), + if (snapshot.hrvScore != null) + _ComponentRow( + label: 'HRV (RMSSD)', + value: + '${snapshot.hrvMs!.round()} ms · avg ${snapshot.hrvBaseline!.round()} ms', + score: snapshot.hrvScore!, + ), + const SizedBox(height: 14), + Text( + 'Each factor compares last night and this morning to your own ' + '14-day average — only dips below your normal lower the score. ' + 'Accuracy improves after about 5 nights of watch data.', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 11, + height: 1.5, + ), + ), + ], + ), + ), + ); + } +} + + +class _ComponentRow extends StatelessWidget { + const _ComponentRow({ + required this.label, + required this.value, + required this.score, + }); + + final String label; + final String value; + final int score; + + @override + Widget build(BuildContext context) { + final color = score >= ReadinessCard._goodScore + ? AppColors.success + : score >= ReadinessCard._okScore + ? AppColors.warning + : AppColors.error; + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + Text( + value, + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.full), + child: LinearProgressIndicator( + value: score / 100, + minHeight: 5, + backgroundColor: AppColors.glass2, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/rest_timer_view.dart b/workout-logger/lib/screens/widgets/rest_timer_view.dart new file mode 100644 index 0000000..b614e6a --- /dev/null +++ b/workout-logger/lib/screens/widgets/rest_timer_view.dart @@ -0,0 +1,144 @@ +// rest_timer_view.dart — Full-screen rest timer overlay for WorkoutFlowScreen + +import 'package:flutter/material.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +class RestTimerView extends StatelessWidget { + const RestTimerView({ + super.key, + required this.remainingSeconds, + required this.totalSeconds, + required this.onAdjust, + required this.onSkip, + this.nextExerciseName, + }); + + final int remainingSeconds; + final int totalSeconds; + final void Function(int delta) onAdjust; + final VoidCallback onSkip; + final String? nextExerciseName; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + color: AppColors.background, + child: SafeArea( + child: Column( + children: [ + // 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, + ), + ), + ), + // Ring + time fills most of the screen + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final ringSize = AppBreakpoints.timerRingSize(constraints.maxWidth); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + RestTimerRing( + remaining: remainingSeconds, + total: totalSeconds, + size: ringSize, + ), + const SizedBox(height: AppSpacing.xl), + // Adjust buttons + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _AdjustButton(label: '−30s', onTap: () => onAdjust(-30)), + const SizedBox(width: AppSpacing.xl), + _AdjustButton(label: '+30s', onTap: () => onAdjust(30)), + ], + ), + 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), + Text( + nextExerciseName!, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ); + }, + ), + ), + // Skip button + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.xl, + ), + child: OutlineGlowButton( + label: 'SKIP REST', + onPressed: onSkip, + color: AppColors.textSoft, + fullWidth: true, + ), + ), + ], + ), + ), + ); + } +} + +class _AdjustButton extends StatelessWidget { + const _AdjustButton({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: AppSpacing.lg, + vertical: AppSpacing.sm + 4, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.glassBorder), + ), + child: Text( + label, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/rf_cards.dart b/workout-logger/lib/screens/widgets/rf_cards.dart new file mode 100644 index 0000000..4e5d35e --- /dev/null +++ b/workout-logger/lib/screens/widgets/rf_cards.dart @@ -0,0 +1,688 @@ +// rf_cards.dart — RepForge card widget variants +// Stateless cards that consume AppColors tokens and rf_widgets primitives. + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../models/models.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +// ── SessionCard ─────────────────────────────────────────────────────────────── +// History list card: date column | main info | volume. +class SessionCard extends StatelessWidget { + const SessionCard({ + super.key, + required this.session, + required this.getExerciseName, + this.onTap, + this.trailing, + this.synced = false, + }); + + final WorkoutSession session; + final String Function(String) getExerciseName; + final VoidCallback? onTap; + final Widget? trailing; + final bool synced; + + @override + Widget build(BuildContext context) { + final day = DateFormat('d').format(session.date); + final month = DateFormat('MMM').format(session.date).toUpperCase(); + final weekday = DateFormat('EEE').format(session.date).toUpperCase(); + final volume = session.totalVolume; + final volStr = volume >= 1000 + ? '${(volume / 1000).toStringAsFixed(1)}k' + : volume.toStringAsFixed(0); + + final exerciseNames = session.exercises + .take(3) + .map((e) => getExerciseName(e.exerciseId)) + .toList(); + + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + // Date column + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 48, maxWidth: 64), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.08), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(AppRadius.lg), + bottomLeft: Radius.circular(AppRadius.lg), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + weekday, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + Text( + day, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + Text( + month, + style: const TextStyle( + color: AppColors.primary, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + // Main info + Expanded( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '${session.exercises.length} exercises · ${session.duration} min', + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + if (synced) ...[ + const SizedBox(width: 6), + const Icon( + Icons.favorite_rounded, + size: 12, + color: Color(0xFF4ECDC4), + ), + ], + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: 4, + runSpacing: 4, + children: exerciseNames + .map( + (n) => RFChip(label: n, small: true), + ) + .toList(), + ), + ], + ), + ), + ), + // Volume + Padding( + padding: const EdgeInsets.only(right: AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '$volStr kg', + style: const TextStyle( + color: AppColors.success, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + if (trailing != null) trailing!, + ], + ), + ), + ], + ), + ), + ); + } +} + +// ── StatGridCard ────────────────────────────────────────────────────────────── +// 2×2 grid tile: icon + value + label. Used on dashboard and summary screen. +class StatGridCard extends StatelessWidget { + const StatGridCard({ + super.key, + required this.icon, + required this.value, + required this.label, + this.color, + this.animate = true, + }); + + final IconData icon; + final String value; + final String label; + final Color? color; + final bool animate; + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.primary; + final numericValue = double.tryParse( + value.replaceAll(RegExp(r'[^0-9.]'), ''), + ); + + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: c.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Icon(icon, color: c, size: 18), + ), + const SizedBox(height: AppSpacing.sm), + animate && numericValue != null + ? AnimatedCounter( + value: numericValue, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + fontFeatures: [FontFeature.tabularFigures()], + ), + suffix: value.replaceAll(RegExp(r'[0-9.]'), ''), + ) + : Text( + value, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} + +// ── RecentSessionTile ───────────────────────────────────────────────────────── +// Compact recent workout row for the dashboard. +class RecentSessionTile extends StatelessWidget { + const RecentSessionTile({ + super.key, + required this.session, + required this.getExerciseName, + this.onTap, + }); + + final WorkoutSession session; + final String Function(String) getExerciseName; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final dateStr = DateFormat('MMM d').format(session.date); + final timeStr = DateFormat('h:mm a').format(session.date); + final volume = session.totalVolume; + final volStr = volume >= 1000 + ? '${(volume / 1000).toStringAsFixed(1)}k kg' + : '${volume.toStringAsFixed(0)} kg'; + + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: const Icon( + Icons.fitness_center_rounded, + color: AppColors.primary, + size: 20, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dateStr, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + Text( + '${session.exercises.length} exercises · $timeStr', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + Text( + volStr, + style: const TextStyle( + color: AppColors.success, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); + } +} + +// ── RoutineCard ─────────────────────────────────────────────────────────────── +class RoutineCard extends StatelessWidget { + const RoutineCard({ + super.key, + required this.routine, + required this.getExerciseName, + required this.onStart, + this.onEdit, + this.onDelete, + }); + + final Routine routine; + final String Function(String) getExerciseName; + final VoidCallback onStart; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + final names = routine.exerciseIds.take(3).map(getExerciseName).toList(); + final extra = routine.exerciseIds.length - names.length; + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.sm, + AppSpacing.sm, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + routine.name, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + '${routine.exerciseIds.length} exercises', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + if (onEdit != null || onDelete != null) + PopupMenuButton( + color: AppColors.cardHigh, + icon: const Icon( + Icons.more_vert_rounded, + color: AppColors.textSoft, + ), + onSelected: (v) { + if (v == 'edit') onEdit?.call(); + if (v == 'delete') onDelete?.call(); + }, + itemBuilder: (_) => [ + if (onEdit != null) + const PopupMenuItem( + value: 'edit', + child: Text('Edit'), + ), + if (onDelete != null) + const PopupMenuItem( + value: 'delete', + child: Text( + 'Delete', + style: TextStyle(color: AppColors.error), + ), + ), + ], + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), + child: Row( + children: [ + Expanded( + child: Wrap( + spacing: 4, + runSpacing: 4, + children: [ + ...names.map((n) => RFChip(label: n, small: true)), + if (extra > 0) + RFChip( + label: '+$extra more', + small: true, + color: AppColors.textSoft, + ), + ], + ), + ), + const SizedBox(width: AppSpacing.sm), + GestureDetector( + onTap: onStart, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(AppRadius.md), + boxShadow: [ + BoxShadow( + color: AppColors.primaryGlow(0.4), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon( + Icons.play_arrow_rounded, + color: Colors.white, + size: 22, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ── TargetCard ──────────────────────────────────────────────────────────────── +class TargetCard extends StatelessWidget { + const TargetCard({ + super.key, + required this.target, + required this.exerciseName, + this.onDelete, + }); + + final Target target; + final String exerciseName; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + final pct = (target.progressPercentage * 100).clamp(0, 100); + final etaStr = target.estimatedCompletionDate != null + ? DateFormat('MMM d, y').format(target.estimatedCompletionDate!) + : null; + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + exerciseName, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + RFChip( + label: target.targetType, + small: true, + color: AppColors.secondary, + ), + if (onDelete != null) ...[ + const SizedBox(width: 4), + GestureDetector( + onTap: onDelete, + child: const Icon( + Icons.close_rounded, + size: 16, + color: AppColors.textMuted, + ), + ), + ], + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${target.currentValue.toStringAsFixed(1)} / ${target.targetValue.toStringAsFixed(1)}', + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 12, + ), + ), + Text( + '${pct.toStringAsFixed(0)}%', + style: const TextStyle( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 6), + RFProgressBar(value: target.progressPercentage), + if (etaStr != null) ...[ + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.schedule_rounded, size: 11, color: AppColors.textMuted), + const SizedBox(width: 4), + Text( + 'Est. $etaStr', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ], + ], + ), + ); + } +} + +// ── ExerciseCard ────────────────────────────────────────────────────────────── +class ExerciseCard extends StatelessWidget { + const ExerciseCard({ + super.key, + required this.exercise, + this.onTap, + this.selected = false, + }); + + final Exercise exercise; + final VoidCallback? onTap; + final bool selected; + + @override + Widget build(BuildContext context) { + final muscleColor = exercise.muscleActivations.isNotEmpty + ? AppColors.muscle(exercise.muscleActivations.first.muscleGroupId) + : AppColors.primary; + + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + color: selected + ? AppColors.primary.withValues(alpha: 0.12) + : Colors.transparent, + border: Border( + bottom: BorderSide(color: AppColors.divider), + ), + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(right: AppSpacing.md), + decoration: BoxDecoration( + color: muscleColor, + shape: BoxShape.circle, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + exercise.name, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + if (exercise.muscleActivations.isNotEmpty) + Text( + exercise.primaryMuscle, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (exercise.isCustom) + const RFChip( + label: 'Custom', + small: true, + color: AppColors.accent, + ), + if (exercise.isCustom) const SizedBox(width: 4), + RFChip( + label: exercise.category, + small: true, + color: AppColors.textSoft, + ), + if (selected) + const Padding( + padding: EdgeInsets.only(left: 8), + child: Icon( + Icons.check_circle_rounded, + size: 20, + color: AppColors.primary, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/rf_inputs.dart b/workout-logger/lib/screens/widgets/rf_inputs.dart new file mode 100644 index 0000000..bfde525 --- /dev/null +++ b/workout-logger/lib/screens/widgets/rf_inputs.dart @@ -0,0 +1,635 @@ +// rf_inputs.dart — RepForge form input widgets + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +// ── RFTextField ─────────────────────────────────────────────────────────────── +// Styled text input using AppColors tokens. +class RFTextField extends StatelessWidget { + const RFTextField({ + super.key, + this.controller, + this.hint, + this.label, + this.prefixIcon, + this.suffixIcon, + this.maxLines = 1, + this.minLines, + this.keyboardType, + this.inputFormatters, + this.onChanged, + this.onSubmitted, + this.autofocus = false, + this.enabled = true, + this.errorText, + this.textCapitalization = TextCapitalization.none, + }); + + final TextEditingController? controller; + final String? hint; + final String? label; + final IconData? prefixIcon; + final Widget? suffixIcon; + final int? maxLines; + final int? minLines; + final TextInputType? keyboardType; + final List? inputFormatters; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final bool autofocus; + final bool enabled; + final String? errorText; + final TextCapitalization textCapitalization; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + maxLines: maxLines, + minLines: minLines, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + onChanged: onChanged, + onSubmitted: onSubmitted, + autofocus: autofocus, + enabled: enabled, + textCapitalization: textCapitalization, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + decoration: InputDecoration( + hintText: hint, + labelText: label, + labelStyle: const TextStyle(color: AppColors.textSoft), + errorText: errorText, + errorStyle: const TextStyle(color: AppColors.error, fontSize: 11), + prefixIcon: prefixIcon != null + ? Icon(prefixIcon, color: AppColors.textMuted, size: 20) + : null, + suffixIcon: suffixIcon, + filled: true, + fillColor: AppColors.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: const BorderSide(color: AppColors.glassBorder), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: const BorderSide(color: AppColors.primary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: const BorderSide(color: AppColors.error), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + ), + ); + } +} + +// ── RFNumberField ───────────────────────────────────────────────────────────── +// Large monospace tap-to-edit number for weights and reps. +// Tap → shows inline text field. Long-press → continuous increment. +class RFNumberField extends StatefulWidget { + const RFNumberField({ + super.key, + required this.value, + required this.onChanged, + this.label, + this.step = 1.0, + this.min = 0.0, + this.max = 9999.0, + this.decimals = 0, + this.color, + }); + + final double value; + final ValueChanged onChanged; + final String? label; + final double step; + final double min; + final double max; + final int decimals; + final Color? color; + + @override + State createState() => _RFNumberFieldState(); +} + +class _RFNumberFieldState extends State { + bool _editing = false; + late final TextEditingController _ctrl; + late final FocusNode _focusNode; + Timer? _longPressTimer; + + @override + void initState() { + super.initState(); + _ctrl = TextEditingController(text: _format(widget.value)); + _focusNode = FocusNode(); + _focusNode.addListener(() { + if (!_focusNode.hasFocus && _editing) _commitEdit(); + }); + } + + @override + void didUpdateWidget(RFNumberField old) { + super.didUpdateWidget(old); + if (!_editing && old.value != widget.value) { + _ctrl.text = _format(widget.value); + } + } + + @override + void dispose() { + _ctrl.dispose(); + _focusNode.dispose(); + _longPressTimer?.cancel(); + super.dispose(); + } + + String _format(double v) => + widget.decimals > 0 ? v.toStringAsFixed(widget.decimals) : v.toInt().toString(); + + void _startIncrement(double dir) { + _step(dir); + _longPressTimer = Timer.periodic(const Duration(milliseconds: 120), (_) { + _step(dir); + }); + } + + void _stopIncrement() { + _longPressTimer?.cancel(); + _longPressTimer = null; + } + + void _step(double dir) { + final next = (widget.value + dir * widget.step).clamp(widget.min, widget.max); + HapticFeedback.selectionClick(); + widget.onChanged(next); + } + + void _commitEdit() { + final parsed = double.tryParse(_ctrl.text); + if (parsed != null) { + widget.onChanged(parsed.clamp(widget.min, widget.max)); + } else { + _ctrl.text = _format(widget.value); + } + setState(() => _editing = false); + } + + @override + Widget build(BuildContext context) { + final c = widget.color ?? AppColors.textPrimary; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Decrement button + GestureDetector( + onTap: () => _step(-1), + onLongPressStart: (_) => _startIncrement(-1), + onLongPressEnd: (_) => _stopIncrement(), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: const Icon( + Icons.remove_rounded, + size: 18, + color: AppColors.textSoft, + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + // Value display / edit + GestureDetector( + onTap: () { + setState(() => _editing = true); + _ctrl.text = _format(widget.value); + _ctrl.selection = TextSelection( + baseOffset: 0, + extentOffset: _ctrl.text.length, + ); + }, + child: Container( + width: 80, + height: 52, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: _editing ? AppColors.primary : AppColors.glassBorder, + width: _editing ? 2 : 1, + ), + ), + child: _editing + ? TextField( + controller: _ctrl, + focusNode: _focusNode, + autofocus: true, + textAlign: TextAlign.center, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + style: TextStyle( + color: c, + fontSize: 22, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + ), + onSubmitted: (_) => _commitEdit(), + onEditingComplete: _commitEdit, + ) + : Text( + _format(widget.value), + style: TextStyle( + color: c, + fontSize: 22, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + // Increment button + GestureDetector( + onTap: () => _step(1), + onLongPressStart: (_) => _startIncrement(1), + onLongPressEnd: (_) => _stopIncrement(), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: const Icon( + Icons.add_rounded, + size: 18, + color: AppColors.textSoft, + ), + ), + ), + ], + ), + if (widget.label != null) ...[ + const SizedBox(height: 4), + Text( + widget.label!, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + ), + ), + ], + ], + ); + } +} + +// ── RFToggle ────────────────────────────────────────────────────────────────── +// Two-option segmented toggle (e.g. kg / lbs, Compound / Isolation). +class RFToggle extends StatelessWidget { + const RFToggle({ + super.key, + required this.options, + required this.selectedIndex, + required this.onChanged, + this.color, + }); + + final List options; + final int selectedIndex; + final ValueChanged onChanged; + final Color? color; + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.primary; + return Container( + height: 44, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(options.length, (i) { + final selected = i == selectedIndex; + return GestureDetector( + onTap: () => onChanged(i), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeInOut, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + decoration: BoxDecoration( + color: selected ? c : Colors.transparent, + borderRadius: BorderRadius.circular(AppRadius.sm), + boxShadow: selected + ? [ + BoxShadow( + color: c.withValues(alpha: 0.35), + blurRadius: 8, + ), + ] + : null, + ), + child: Text( + options[i], + style: TextStyle( + color: selected ? Colors.white : AppColors.textSoft, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + }), + ), + ); + } +} + +// ── RFDropdown ──────────────────────────────────────────────────────────────── +class RFDropdown extends StatelessWidget { + const RFDropdown({ + super.key, + required this.value, + required this.items, + required this.onChanged, + this.hint, + this.labelBuilder, + }); + + final T? value; + final List items; + final ValueChanged onChanged; + final String? hint; + final String Function(T)? labelBuilder; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: DropdownButton( + value: value, + onChanged: onChanged, + isExpanded: true, + underline: const SizedBox.shrink(), + dropdownColor: AppColors.cardHigh, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + hint: hint != null + ? Text(hint!, style: const TextStyle(color: AppColors.textMuted)) + : null, + icon: const Icon(Icons.keyboard_arrow_down_rounded, + color: AppColors.textSoft), + items: items.map((item) { + final label = + labelBuilder != null ? labelBuilder!(item) : item.toString(); + return DropdownMenuItem(value: item, child: Text(label)); + }).toList(), + ), + ); + } +} + +// ── NumberPickerSheet ───────────────────────────────────────────────────────── +// Bottom sheet with large +/- stepper — extracted from workout_flow logic. +class NumberPickerSheet extends StatefulWidget { + const NumberPickerSheet({ + super.key, + required this.title, + required this.initial, + required this.step, + required this.min, + required this.max, + this.decimals = 0, + this.suffix = '', + }); + + final String title; + final double initial; + final double step; + final double min; + final double max; + final int decimals; + final String suffix; + + static Future show( + BuildContext context, { + required String title, + required double initial, + required double step, + required double min, + required double max, + int decimals = 0, + String suffix = '', + }) { + return showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => NumberPickerSheet( + title: title, + initial: initial, + step: step, + min: min, + max: max, + decimals: decimals, + suffix: suffix, + ), + ); + } + + @override + State createState() => _NumberPickerSheetState(); +} + +class _NumberPickerSheetState extends State { + late double _value; + Timer? _holdTimer; + + @override + void initState() { + super.initState(); + _value = widget.initial; + } + + @override + void dispose() { + _holdTimer?.cancel(); + super.dispose(); + } + + void _step(double dir) { + setState(() { + _value = (_value + dir * widget.step).clamp(widget.min, widget.max); + }); + HapticFeedback.selectionClick(); + } + + String get _display => widget.decimals > 0 + ? _value.toStringAsFixed(widget.decimals) + : _value.toInt().toString(); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.textMuted, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + Text( + widget.title, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 13, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: AppSpacing.lg), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _StepButton( + icon: Icons.remove_rounded, + onTap: () => _step(-1), + onLongPress: () { + _holdTimer?.cancel(); + _holdTimer = Timer.periodic( + const Duration(milliseconds: 100), + (_) { if (mounted) _step(-1); }, + ); + }, + onLongPressEnd: () { + _holdTimer?.cancel(); + _holdTimer = null; + }, + ), + const SizedBox(width: AppSpacing.xl), + Text( + '$_display${widget.suffix}', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 48, + fontWeight: FontWeight.w800, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + const SizedBox(width: AppSpacing.xl), + _StepButton( + icon: Icons.add_rounded, + onTap: () => _step(1), + onLongPress: () { + _holdTimer?.cancel(); + _holdTimer = Timer.periodic( + const Duration(milliseconds: 100), + (_) { if (mounted) _step(1); }, + ); + }, + onLongPressEnd: () { + _holdTimer?.cancel(); + _holdTimer = null; + }, + ), + ], + ), + const SizedBox(height: AppSpacing.xl), + GlowButton( + label: 'Confirm', + onPressed: () => Navigator.pop(context, _value), + ), + const SizedBox(height: AppSpacing.sm), + ], + ), + ), + ); + } +} + +class _StepButton extends StatelessWidget { + const _StepButton({ + required this.icon, + required this.onTap, + required this.onLongPress, + this.onLongPressEnd, + }); + + final IconData icon; + final VoidCallback onTap; + final VoidCallback onLongPress; + final VoidCallback? onLongPressEnd; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + onLongPressEnd: (_) => onLongPressEnd?.call(), + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppColors.cardHigh, + shape: BoxShape.circle, + border: Border.all(color: AppColors.glassBorder), + ), + child: Icon(icon, color: AppColors.textPrimary, size: 28), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/rf_question_card.dart b/workout-logger/lib/screens/widgets/rf_question_card.dart new file mode 100644 index 0000000..719131a --- /dev/null +++ b/workout-logger/lib/screens/widgets/rf_question_card.dart @@ -0,0 +1,254 @@ +// rf_question_card.dart — Reusable AI question card (option chips + custom input). +// Used by RoutineOptimizerScreen when the AI calls ask_user_questions. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../models/models.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +/// Renders a list of [QuestionSpec]s as interactive chip cards and calls +/// [onSubmit] with all answers when the user taps "Continue". +class RFQuestionCard extends StatefulWidget { + const RFQuestionCard({ + super.key, + required this.questions, + required this.onSubmit, + }); + + final List questions; + final ValueChanged> onSubmit; + + @override + State createState() => _RFQuestionCardState(); +} + +class _RFQuestionCardState extends State { + late final List> _selected; + late final List _customCtrls; + + @override + void initState() { + super.initState(); + _selected = List.generate(widget.questions.length, (_) => {}); + _customCtrls = List.generate( + widget.questions.length, + (_) => TextEditingController(), + ); + } + + @override + void dispose() { + for (final c in _customCtrls) { + c.dispose(); + } + super.dispose(); + } + + void _submit() { + HapticFeedback.mediumImpact(); + final answers = [ + for (var i = 0; i < widget.questions.length; i++) + AnswerSpec( + question: widget.questions[i].question, + selected: _selected[i].toList(), + custom: _customCtrls[i].text.trim().isEmpty + ? null + : _customCtrls[i].text.trim(), + ), + ]; + widget.onSubmit(answers); + } + + @override + Widget build(BuildContext context) { + return GlassCard( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.help_outline_rounded, + size: 14, color: AppColors.secondary), + const SizedBox(width: AppSpacing.xs), + Text( + 'QUICK QUESTIONS', + style: TextStyle(fontFamily: 'Geist', + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: AppColors.secondary, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + for (var i = 0; i < widget.questions.length; i++) ...[ + if (i > 0) ...[ + const SizedBox(height: 1), + const Divider(color: AppColors.glassBorder, height: 24), + ], + _QuestionBlock( + spec: widget.questions[i], + selected: _selected[i], + controller: _customCtrls[i], + onToggle: (opt) => setState(() { + final spec = widget.questions[i]; + if (spec.multiSelect) { + if (_selected[i].contains(opt)) { + _selected[i].remove(opt); + } else { + _selected[i].add(opt); + } + } else { + _selected[i] = {opt}; + } + }), + ), + ], + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: GlowButton( + label: 'Continue', + onPressed: _submit, + ), + ), + ], + ), + ); + } +} + +class _QuestionBlock extends StatelessWidget { + const _QuestionBlock({ + required this.spec, + required this.selected, + required this.controller, + required this.onToggle, + }); + + final QuestionSpec spec; + final Set selected; + final TextEditingController controller; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + spec.question, + style: TextStyle(fontFamily: 'Geist', + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + height: 1.4, + ), + ), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.xs, + runSpacing: AppSpacing.xs, + children: [ + for (final opt in spec.options) + _OptionChip( + label: opt, + selected: selected.contains(opt), + onTap: () => onToggle(opt), + ), + ], + ), + if (spec.allowCustom) ...[ + const SizedBox(height: AppSpacing.sm), + TextField( + controller: controller, + style: TextStyle(fontFamily: 'Geist', + fontSize: 13, + color: AppColors.textPrimary, + ), + decoration: InputDecoration( + hintText: 'Or type your own answer…', + hintStyle: TextStyle(fontFamily: 'Geist', + fontSize: 12, + color: AppColors.textFaint, + ), + filled: true, + fillColor: AppColors.glass2, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + borderSide: const BorderSide(color: AppColors.glassBorder), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + borderSide: const BorderSide(color: AppColors.glassBorder), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + borderSide: + const BorderSide(color: AppColors.secondary, width: 1.5), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + isDense: true, + ), + ), + ], + ], + ); + } +} + +class _OptionChip extends StatelessWidget { + const _OptionChip({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + HapticFeedback.selectionClick(); + onTap(); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 6, + ), + decoration: BoxDecoration( + color: selected + ? AppColors.secondary.withValues(alpha: 0.18) + : AppColors.glass2, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: selected + ? AppColors.secondary.withValues(alpha: 0.6) + : AppColors.glassBorder, + width: selected ? 1.5 : 1, + ), + ), + child: Text( + label, + style: TextStyle(fontFamily: 'Geist', + fontSize: 12, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + color: selected ? AppColors.secondary : AppColors.textSoft, + ), + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/rf_widgets.dart b/workout-logger/lib/screens/widgets/rf_widgets.dart new file mode 100644 index 0000000..14ae34e --- /dev/null +++ b/workout-logger/lib/screens/widgets/rf_widgets.dart @@ -0,0 +1,1061 @@ +// rf_widgets.dart — RepForge primitive widget library +// All widgets consume AppColors/AppSpacing/AppRadius tokens only. + +import 'dart:math' as math; +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../theme/app_theme.dart'; + +// ── Route helper ────────────────────────────────────────────────────────────── +// Right-to-left slide push, shared by the home screen and detail entry points. +PageRouteBuilder slideRoute(Widget page) { + return PageRouteBuilder( + pageBuilder: (_, __, ___) => page, + transitionsBuilder: (_, anim, __, child) => SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)), + child: child, + ), + transitionDuration: const Duration(milliseconds: 300), + ); +} + +// ── GlassCard ─────────────────────────────────────────────────────────────── +// Soft-futurist glass card — gradient top-to-bottom + subtle inner highlight. +class GlassCard extends StatelessWidget { + const GlassCard({ + super.key, + required this.child, + this.padding, + this.margin, + this.borderRadius, + this.glowColor, + this.borderColor, + this.accentBorder = false, + this.onTap, + this.semanticsLabel, + }); + + final Widget child; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + 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; + final String? semanticsLabel; + + @override + Widget build(BuildContext context) { + final radius = borderRadius ?? BorderRadius.circular(AppRadius.xl); + final effectiveBorderColor = accentBorder + ? AppColors.primary + : (borderColor ?? AppColors.glassBorder); + + final decoration = BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x09FFFFFF), Color(0x04FFFFFF)], + ), + borderRadius: radius, + border: Border.all(color: effectiveBorderColor, width: 1), + boxShadow: glowColor != null + ? [ + BoxShadow( + color: glowColor!.withValues(alpha: 0.25), + blurRadius: 24, + spreadRadius: -4, + ), + ] + : null, + ); + + final content = Container( + padding: padding ?? const EdgeInsets.all(AppSpacing.md), + margin: margin, + decoration: decoration, + child: child, + ); + + if (onTap == null) return content; + return Semantics( + button: true, + label: semanticsLabel, + child: GestureDetector( + onTap: onTap, + child: content, + ), + ); + } +} + +// ── AmbientGlow ────────────────────────────────────────────────────────────── +// Decorative ambient gradient wash — place inside a Stack as first child. +// Matches the design's rf-ambient pseudo-elements. +class AmbientGlow extends StatelessWidget { + const AmbientGlow({super.key, this.showBottom = true}); + final bool showBottom; + + @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], + ), + ), + ), + ), + ), + // Bottom cyan wash + if (showBottom) + Positioned( + bottom: -200, + right: -100, + child: Container( + width: 400, + height: 400, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.secondary.withValues(alpha: 0.20), + Colors.transparent, + ], + stops: const [0, 0.6], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +// ── RFNavBar ───────────────────────────────────────────────────────────────── +// Premium floating glassmorphic bottom navigation bar with perfect rounded blur, +// deep drop shadow, and clean transparent padding so it sits elegantly above the content. +class RFNavBar extends StatelessWidget { + const RFNavBar({ + super.key, + required this.currentIndex, + required this.onTap, + required this.items, + }); + + final int currentIndex; + final ValueChanged onTap; + final List items; + + @override + Widget build(BuildContext context) { + final bottomPadding = MediaQuery.of(context).padding.bottom; + return Container( + color: Colors.transparent, // Completely transparent outer container + padding: EdgeInsets.fromLTRB( + 16, + 8, + 16, + bottomPadding > 0 ? bottomPadding + 8 : 16, + ), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.xxl), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 28, + spreadRadius: -4, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.xxl), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: AppColors.surface.withValues(alpha: 0.8), // Sleek transparent surface + borderRadius: BorderRadius.circular(AppRadius.xxl), + border: Border.all( + color: AppColors.glassBorderStrong, + width: 1.5, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: List.generate(items.length, (i) { + final active = i == currentIndex; + return _NavItem( + item: items[i], + active: active, + onTap: () => onTap(i), + ); + }), + ), + ), + ), + ), + ), + ); + } +} + +class RFNavItem { + const RFNavItem({required this.icon, required this.label}); + final IconData icon; + final String label; +} + +class _NavItem extends StatelessWidget { + const _NavItem({ + required this.item, + required this.active, + required this.onTap, + }); + + final RFNavItem item; + final bool active; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: item.label, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: SizedBox( + width: 60, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Accent indicator above icon + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: active ? 18 : 0, + height: 2, + margin: const EdgeInsets.only(bottom: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: AppColors.primary, + boxShadow: active + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.6), + blurRadius: 6, + ), + ] + : null, + ), + ), + Icon( + item.icon, + size: 19, + color: active ? AppColors.textPrimary : AppColors.textMuted, + ), + const SizedBox(height: 4), + Text( + item.label, + style: TextStyle(fontFamily: 'Geist', + fontSize: 10, + fontWeight: active ? FontWeight.w600 : FontWeight.w500, + color: active ? AppColors.textPrimary : AppColors.textMuted, + letterSpacing: 0.2, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── GlowButton ────────────────────────────────────────────────────────────── +// Full-width primary action button with glow shadow + haptic feedback. +class GlowButton extends StatefulWidget { + const GlowButton({ + super.key, + required this.label, + required this.onPressed, + this.color, + this.icon, + this.fullWidth = true, + this.small = false, + }); + + final String label; + final VoidCallback? onPressed; + final Color? color; + final IconData? icon; + final bool fullWidth; + final bool small; + + @override + State createState() => _GlowButtonState(); +} + +class _GlowButtonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _scale; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 100), + reverseDuration: const Duration(milliseconds: 200), + lowerBound: 0.95, + upperBound: 1.0, + value: 1.0, + ); + _scale = _ctrl; + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + Future _onTapDown(TapDownDetails _) async { + await _ctrl.reverse(); + } + + Future _onTapUp(TapUpDetails _) async { + HapticFeedback.heavyImpact(); + await _ctrl.forward(); + if (!mounted) return; + widget.onPressed?.call(); + } + + Future _onTapCancel() async { + await _ctrl.forward(); + if (!mounted) return; + } + + @override + Widget build(BuildContext context) { + final color = widget.color ?? AppColors.primary; + final disabled = widget.onPressed == null; + final vPad = widget.small ? 12.0 : 18.0; + + return AnimatedBuilder( + animation: _scale, + builder: (context, child) => Transform.scale( + scale: _scale.value, + child: child, + ), + child: Semantics( + button: true, + label: widget.label, + child: GestureDetector( + onTapDown: disabled ? null : _onTapDown, + onTapUp: disabled ? null : _onTapUp, + onTapCancel: disabled ? null : _onTapCancel, + child: Container( + width: widget.fullWidth ? double.infinity : null, + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: vPad, + ), + decoration: BoxDecoration( + color: disabled ? AppColors.glass2 : color, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: disabled + ? Border.all(color: AppColors.glassBorder) + : Border.all( + color: Colors.white.withValues(alpha: 0.18), + width: 1, + ), + boxShadow: disabled + ? null + : [ + BoxShadow( + color: color.withValues(alpha: 0.35), + blurRadius: 32, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisSize: + widget.fullWidth ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.icon != null) ...[ + Icon( + widget.icon, + color: disabled ? AppColors.textMuted : Colors.white, + size: widget.small ? 18 : 20, + ), + 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, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +// ── OutlineGlowButton ──────────────────────────────────────────────────────── +class OutlineGlowButton extends StatelessWidget { + const OutlineGlowButton({ + super.key, + required this.label, + required this.onPressed, + this.color, + this.icon, + this.fullWidth = false, + this.small = false, + }); + + final String label; + final VoidCallback? onPressed; + final Color? color; + final IconData? icon; + final bool fullWidth; + final bool small; + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.primary; + final vPad = small ? 10.0 : 14.0; + return SizedBox( + width: fullWidth ? double.infinity : null, + child: OutlinedButton.icon( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + foregroundColor: c, + side: BorderSide(color: c, width: 1.5), + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: vPad, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + ), + icon: icon != null + ? Icon(icon, size: small ? 16 : 18) + : const SizedBox.shrink(), + label: Text( + label, + style: TextStyle( + fontSize: small ? 13 : 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} + +// ── RFChip ─────────────────────────────────────────────────────────────────── +// Pill-shaped label chip — muscle tags, category badges, etc. +class RFChip extends StatelessWidget { + const RFChip({ + super.key, + required this.label, + this.color, + this.small = false, + }); + + final String label; + final Color? color; + final bool small; + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.primary; + return Container( + padding: EdgeInsets.symmetric( + horizontal: small ? 8 : 10, + vertical: small ? 3 : 5, + ), + decoration: BoxDecoration( + color: c.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: c.withValues(alpha: 0.4), width: 1), + ), + child: Text( + label, + style: TextStyle( + color: c, + fontSize: small ? 10 : 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + ); + } +} + +// ── RFSectionHeader ────────────────────────────────────────────────────────── +class RFSectionHeader extends StatelessWidget { + const RFSectionHeader( + this.title, { + super.key, + this.trailing, + this.bottomPad = true, + }); + + final String title; + final Widget? trailing; + final bool bottomPad; + + @override + Widget build(BuildContext context) { + return Padding( + 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, + ), + ), + if (trailing != null) trailing!, + ], + ), + ); + } +} + +// ── RFStatBox ──────────────────────────────────────────────────────────────── +class RFStatBox extends StatelessWidget { + const RFStatBox({ + super.key, + required this.value, + required this.label, + this.color, + this.delta, + }); + + final String value; + final String label; + final Color? color; + final double? delta; // positive = up, negative = down, null = no arrow + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.textPrimary; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + value, + style: TextStyle( + color: c, + fontSize: 22, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + if (delta != null) ...[ + const SizedBox(width: 4), + Icon( + delta! >= 0 + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + size: 14, + color: delta! >= 0 ? AppColors.success : AppColors.error, + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + ), + ), + ], + ); + } +} + +// ── AnimatedCounter ────────────────────────────────────────────────────────── +// Smoothly animates a number from 0 to [value] on first build. +class AnimatedCounter extends StatelessWidget { + const AnimatedCounter({ + super.key, + required this.value, + this.style, + this.decimals = 0, + this.suffix = '', + this.duration = const Duration(milliseconds: 800), + }); + + final double value; + final TextStyle? style; + final int decimals; + final String suffix; + final Duration duration; + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: value), + duration: duration, + curve: Curves.easeOutCubic, + builder: (context, v, _) { + final display = decimals > 0 + ? v.toStringAsFixed(decimals) + : v.toInt().toString(); + return Text( + '$display$suffix', + style: style ?? + const TextStyle( + color: AppColors.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w800, + fontFeatures: [FontFeature.tabularFigures()], + ), + ); + }, + ); + } +} + +// ── MetricHero ─────────────────────────────────────────────────────────────── +// Large monospace number + small label — for weights, reps, PRs. +class MetricHero extends StatelessWidget { + const MetricHero({ + super.key, + required this.value, + required this.unit, + this.color, + this.size = 48, + }); + + final String value; + final String unit; + final Color? color; + final double size; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + value, + style: TextStyle( + color: color ?? AppColors.textPrimary, + fontSize: size, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + height: 1.0, + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 6, left: 4), + child: Text( + unit, + style: TextStyle( + color: (color ?? AppColors.textPrimary).withValues(alpha: 0.6), + fontSize: size * 0.35, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ); + } +} + +// ── RFDivider ──────────────────────────────────────────────────────────────── +class RFDivider extends StatelessWidget { + const RFDivider({super.key, this.indent = 0}); + final double indent; + + @override + Widget build(BuildContext context) { + return Divider( + color: AppColors.divider, + thickness: 1, + height: 1, + indent: indent, + ); + } +} + +// ── RFEmptyState ───────────────────────────────────────────────────────────── +class RFEmptyState extends StatelessWidget { + const RFEmptyState({ + super.key, + required this.icon, + required this.title, + this.subtitle, + this.action, + }); + + final IconData icon; + final String title; + final String? subtitle; + final Widget? action; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.card, + shape: BoxShape.circle, + border: Border.all(color: AppColors.glassBorder), + ), + child: Icon(icon, size: 32, color: AppColors.textMuted), + ), + const SizedBox(height: AppSpacing.md), + Text( + title, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + if (subtitle != null) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + subtitle!, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 13, + ), + textAlign: TextAlign.center, + ), + ], + if (action != null) ...[ + const SizedBox(height: AppSpacing.lg), + action!, + ], + ], + ), + ), + ); + } +} + +// ── RFLoadingDots ───────────────────────────────────────────────────────────── +class RFLoadingDots extends StatefulWidget { + const RFLoadingDots({super.key, this.color}); + final Color? color; + + @override + State createState() => _RFLoadingDotsState(); +} + +class _RFLoadingDotsState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final c = widget.color ?? AppColors.primary; + return AnimatedBuilder( + animation: _ctrl, + builder: (_, __) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + final phase = (_ctrl.value - i * 0.2).clamp(0.0, 1.0); + final opacity = math.sin(phase * math.pi).clamp(0.2, 1.0); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: c.withValues(alpha: opacity), + shape: BoxShape.circle, + ), + ), + ); + }), + ); + }, + ); + } +} + +// ── RFProgressBar ───────────────────────────────────────────────────────────── +class RFProgressBar extends StatelessWidget { + const RFProgressBar({ + super.key, + required this.value, // 0.0 – 1.0 + this.color, + this.height = 6, + this.showGlow = true, + }); + + final double value; + final Color? color; + final double height; + final bool showGlow; + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.primary; + final clamped = value.clamp(0.0, 1.0); + return Container( + height: height, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 600), + curve: Curves.easeOutCubic, + width: constraints.maxWidth * clamped, + decoration: BoxDecoration( + 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)] + : null, + ), + ), + ], + ); + }, + ), + ); + } +} + +// ── RestTimerRing ──────────────────────────────────────────────────────────── +// Circular countdown ring for rest timer. +class RestTimerRing extends StatelessWidget { + const RestTimerRing({ + super.key, + required this.remaining, + required this.total, + this.size = 200, + }); + + final int remaining; + final int total; + final double size; + + @override + Widget build(BuildContext context) { + 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'; + + return SizedBox( + width: size, + height: size, + child: CustomPaint( + painter: _RingPainter(progress: progress), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + color: AppColors.textPrimary, + fontSize: size * 0.22, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + Text( + 'REST', + style: TextStyle( + color: AppColors.textMuted, + fontSize: size * 0.08, + fontWeight: FontWeight.w600, + letterSpacing: 2, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _RingPainter extends CustomPainter { + const _RingPainter({required this.progress}); + final double progress; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2 - 8; + const strokeWidth = 8.0; + + // Track + canvas.drawCircle( + center, + radius, + Paint() + ..color = AppColors.card + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth, + ); + + // Progress arc + final sweep = 2 * math.pi * progress; + final paint = Paint() + ..color = AppColors.secondary + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, + sweep, + false, + paint, + ); + } + + @override + bool shouldRepaint(_RingPainter old) => old.progress != progress; +} + +// ── SkeletonBox ────────────────────────────────────────────────────────────── +class SkeletonBox extends StatefulWidget { + const SkeletonBox({ + super.key, + required this.width, + required this.height, + this.borderRadius, + }); + + final double width; + final double height; + final double? borderRadius; + + @override + State createState() => _SkeletonBoxState(); +} + +class _SkeletonBoxState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (_, __) => Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + color: Color.lerp(AppColors.card, AppColors.cardHigh, _ctrl.value), + borderRadius: BorderRadius.circular( + widget.borderRadius ?? AppRadius.sm, + ), + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/routine_creator.dart b/workout-logger/lib/screens/widgets/routine_creator.dart new file mode 100644 index 0000000..97fc6b5 --- /dev/null +++ b/workout-logger/lib/screens/widgets/routine_creator.dart @@ -0,0 +1,560 @@ +// routine_creator.dart — CreateRoutineScreen, RoutineDetailScreen, start helper + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/models.dart'; +import '../../services/workout_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../data/exercise_database.dart'; +import '../workout_flow_screen.dart'; +import 'rf_widgets.dart'; +import 'rf_cards.dart'; +import 'workout_conflict_dialog.dart'; + +// ── Start routine workout (shared helper) ───────────────────────────────────── +Future startRoutineWorkoutFlow( + BuildContext context, + Routine routine, +) async { + final provider = context.read(); + StartWorkoutConflictAction conflictAction = StartWorkoutConflictAction.cancel; + + final started = await provider.startWorkoutSafely( + routine: routine, + onConflict: () async { + final action = await showWorkoutConflictDialog( + context, + workoutStartTime: provider.workoutStartTime ?? DateTime.now(), + ); + conflictAction = action ?? StartWorkoutConflictAction.cancel; + return conflictAction; + }, + ); + + if (!context.mounted) return; + if (started || conflictAction == StartWorkoutConflictAction.resume) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => WorkoutFlowScreen(routine: routine)), + ); + } +} + +// ── Create / Edit Routine Screen ────────────────────────────────────────────── +class CreateRoutineScreen extends StatefulWidget { + const CreateRoutineScreen({super.key, this.routine}); + final Routine? routine; + + @override + State createState() => _CreateRoutineScreenState(); +} + +class _CreateRoutineScreenState extends State { + final _nameController = TextEditingController(); + final List _selectedIds = []; + + @override + void initState() { + super.initState(); + if (widget.routine != null) { + _nameController.text = widget.routine!.name; + _selectedIds.addAll(widget.routine!.exerciseIds); + } + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final isEditing = widget.routine != null; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.surface, + title: Text( + isEditing ? 'Edit Routine' : 'New Routine', + style: const TextStyle(color: AppColors.textPrimary), + ), + iconTheme: const IconThemeData(color: AppColors.textSoft), + actions: [ + TextButton( + onPressed: _save, + child: const Text( + 'Save', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Container( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextField( + controller: _nameController, + style: const TextStyle(color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: 'Routine name (e.g. Push Day)', + hintStyle: TextStyle(color: AppColors.textMuted), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Row( + children: [ + Text( + 'Exercises (${_selectedIds.length})', + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (_selectedIds.isNotEmpty) + GestureDetector( + onTap: () => setState(() => _selectedIds.clear()), + child: const Text( + 'Clear All', + style: TextStyle( + color: AppColors.error, + fontSize: 12, + ), + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.sm), + Expanded( + child: ReorderableListView.builder( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), + itemCount: _selectedIds.length + 1, + onReorder: (old, next) { + if (old >= _selectedIds.length || + next >= _selectedIds.length + 1) { + return; + } + setState(() { + if (next > old) next--; + final item = _selectedIds.removeAt(old); + _selectedIds.insert(next, item); + }); + }, + itemBuilder: (_, i) { + if (i == _selectedIds.length) { + return Padding( + key: const ValueKey('add_btn'), + padding: const EdgeInsets.only(top: AppSpacing.sm), + child: OutlineGlowButton( + label: 'Add Exercises', + onPressed: () => + _showPicker(context, provider.allExercises), + fullWidth: true, + ), + ); + } + final id = _selectedIds[i]; + final ex = provider.getExercise(id); + return Container( + key: ValueKey(id), + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + ReorderableDragStartListener( + index: i, + child: const Icon( + Icons.drag_handle_rounded, + color: AppColors.textMuted, + size: 20, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ex?.name ?? 'Unknown', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + if (ex != null) + Text( + ex.category, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + GestureDetector( + onTap: () => + setState(() => _selectedIds.removeAt(i)), + child: const Icon( + Icons.remove_circle_outline_rounded, + color: AppColors.error, + size: 20, + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } + + void _showPicker(BuildContext context, List all) { + String query = ''; + final List temp = []; + + showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setModal) { + final filtered = all.where((ex) { + if (_selectedIds.contains(ex.id)) return false; + return query.isEmpty || + ex.name.toLowerCase().contains(query.toLowerCase()); + }).toList(); + + final grouped = >{}; + for (final ex in filtered) { + grouped.putIfAbsent(ex.primaryMuscle, () => []).add(ex); + } + + return DraggableScrollableSheet( + initialChildSize: 0.8, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (_, sc) => Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.lg, + AppSpacing.md, + ), + child: Column( + children: [ + Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.glassBorder, + borderRadius: BorderRadius.circular(2), + ), + ), + Row( + children: [ + const Expanded( + child: Text( + 'Add Exercises', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ), + if (temp.isNotEmpty) + GlowButton( + label: 'Add ${temp.length}', + icon: Icons.check_rounded, + fullWidth: false, + small: true, + onPressed: () { + setState(() => _selectedIds.addAll(temp)); + Navigator.of(ctx).pop(); + }, + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Container( + height: 40, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextField( + onChanged: (v) => setModal(() => query = v), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + ), + decoration: const InputDecoration( + hintText: 'Search exercises…', + hintStyle: TextStyle( + color: AppColors.textMuted, + fontSize: 14, + ), + prefixIcon: Icon( + Icons.search_rounded, + color: AppColors.textMuted, + size: 18, + ), + border: InputBorder.none, + contentPadding: + EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + ], + ), + ), + Expanded( + child: ListView( + controller: sc, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + ), + children: [ + ...grouped.entries.map((entry) { + final muscleName = + MuscleGroups.names[entry.key] ?? entry.key; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.sm, + ), + child: RFSectionHeader(muscleName), + ), + ...entry.value.map((ex) { + final sel = temp.contains(ex.id); + return ExerciseCard( + exercise: ex, + selected: sel, + onTap: () => setModal(() { + if (sel) { + temp.remove(ex.id); + } else { + temp.add(ex.id); + } + }), + ); + }), + ], + ); + }), + const SizedBox(height: AppSpacing.xl), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } + + Future _save() async { + if (_nameController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a routine name')), + ); + return; + } + if (_selectedIds.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please add at least one exercise')), + ); + return; + } + + final provider = context.read(); + try { + if (widget.routine != null) { + final updated = Routine( + id: widget.routine!.id, + name: _nameController.text.trim(), + exerciseIds: _selectedIds, + createdAt: widget.routine!.createdAt, + ); + await provider.updateRoutine(updated); + } else { + await provider.createRoutine( + _nameController.text.trim(), + _selectedIds, + ); + } + if (mounted) Navigator.of(context).pop(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to save routine: $e')), + ); + } + } + } +} + +// ── Routine Detail Screen ───────────────────────────────────────────────────── +class RoutineDetailScreen extends StatelessWidget { + const RoutineDetailScreen({super.key, required this.routine}); + final Routine routine; + + @override + Widget build(BuildContext context) { + final provider = context.read(); + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.surface, + title: Text( + routine.name, + style: const TextStyle(color: AppColors.textPrimary), + ), + iconTheme: const IconThemeData(color: AppColors.textSoft), + actions: [ + IconButton( + icon: const Icon(Icons.edit_outlined, color: AppColors.textSoft), + onPressed: () => Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => CreateRoutineScreen(routine: routine), + ), + ), + ), + ], + ), + body: ListView.builder( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + 100, + ), + itemCount: routine.exerciseIds.length, + itemBuilder: (_, i) { + final ex = provider.getExercise(routine.exerciseIds[i]); + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${i + 1}', + style: const TextStyle( + color: AppColors.primary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ex?.name ?? 'Unknown', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + if (ex != null) + Text( + '${ex.category} · ${MuscleGroups.names[ex.primaryMuscle] ?? ex.primaryMuscle}', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => startRoutineWorkoutFlow(context, routine), + backgroundColor: AppColors.primary, + elevation: 0, + icon: const Icon(Icons.play_arrow_rounded, color: Colors.white), + label: const Text( + 'Start Workout', + style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700), + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/session_details_sheet.dart b/workout-logger/lib/screens/widgets/session_details_sheet.dart new file mode 100644 index 0000000..2e9fb71 --- /dev/null +++ b/workout-logger/lib/screens/widgets/session_details_sheet.dart @@ -0,0 +1,468 @@ +// session_details_sheet.dart — Bottom sheet showing full workout session detail + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import '../../models/models.dart'; +import '../../services/workout_provider.dart'; +import '../../services/settings_provider.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; +import 'workout_hr_section.dart'; + +const Color _hcColor = Color(0xFF4ECDC4); + +class SessionDetailsSheet extends StatelessWidget { + const SessionDetailsSheet({ + super.key, + required this.session, + required this.provider, + required this.scrollController, + required this.onEdit, + required this.onDelete, + }); + + final WorkoutSession session; + final WorkoutProvider provider; + final ScrollController scrollController; + final VoidCallback onEdit; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final dateStr = DateFormat('EEEE, MMMM d, yyyy').format(session.date); + final timeStr = DateFormat('h:mm a').format(session.date); + final totalSets = session.exercises.fold(0, (s, e) => s + e.sets.length); + final volume = settings.toDisplay(session.totalVolume); + final volStr = volume >= 1000 + ? '${(volume / 1000).toStringAsFixed(1)}k' + : volume.toStringAsFixed(0); + + return ListView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.lg, + AppSpacing.xxl, + ), + children: [ + // Handle + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.glassBorder, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Date + actions row + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dateStr, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Text( + '$timeStr · ${session.duration} min', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 13, + ), + ), + if (session.hcSyncedAt != null) ...[ + const SizedBox(width: 6), + Tooltip( + message: + 'Synced ${DateFormat('MMM d, h:mm a').format(session.hcSyncedAt!)}', + child: const Icon( + Icons.favorite_rounded, + size: 13, + color: _hcColor, + ), + ), + ], + ], + ), + ], + ), + ), + Row( + children: [ + _ActionChip( + icon: Icons.edit_outlined, + label: 'Edit', + color: AppColors.primary, + onTap: onEdit, + ), + const SizedBox(width: AppSpacing.sm), + _ActionChip( + icon: Icons.delete_outline, + label: 'Delete', + color: AppColors.error, + onTap: onDelete, + ), + ], + ), + ], + ), + + const SizedBox(height: AppSpacing.lg), + + // Stat banner + Row( + children: [ + Expanded( + child: _StatBannerBox( + value: '${session.exercises.length}', + label: 'Exercises', + color: AppColors.primary, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _StatBannerBox( + value: '$totalSets', + label: 'Sets', + color: AppColors.secondary, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _StatBannerBox( + value: volStr, + label: 'Volume ${settings.unitLabel}', + color: AppColors.success, + ), + ), + ], + ), + + const SizedBox(height: AppSpacing.lg), + const RFSectionHeader('Exercises'), + const SizedBox(height: AppSpacing.sm), + + ...session.exercises.map( + (log) => _ExerciseDetailCard(log: log, provider: provider), + ), + + // HR + rest-recovery breakdown (self-hides when no HR data). + WorkoutHrSection(session: session, provider: provider), + + if (session.notes != null && session.notes!.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.md), + const RFSectionHeader('Notes'), + const SizedBox(height: AppSpacing.sm), + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Text( + session.notes!, + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 14, + height: 1.5, + ), + ), + ), + ], + ], + ); + } +} + +// ── Stat banner box ─────────────────────────────────────────────────────────── +class _StatBannerBox extends StatelessWidget { + const _StatBannerBox({ + required this.value, + required this.label, + required this.color, + }); + + final String value; + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.md, + horizontal: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: color.withValues(alpha: 0.2)), + ), + child: Column( + children: [ + Text( + value, + style: TextStyle( + color: color, + fontSize: 22, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +// ── Action chip button ──────────────────────────────────────────────────────── +class _ActionChip extends StatelessWidget { + const _ActionChip({ + required this.icon, + required this.label, + required this.color, + required this.onTap, + }); + + final IconData icon; + final String label; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm + 2, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: color), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} + +// ── Exercise detail card ─────────────────────────────────────────────────────── +class _ExerciseDetailCard extends StatelessWidget { + const _ExerciseDetailCard({required this.log, required this.provider}); + + final ExerciseLog log; + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final exercise = provider.getExercise(log.exerciseId); + final name = exercise?.name ?? 'Unknown Exercise'; + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Exercise name header + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + AppSpacing.sm, + ), + child: Row( + children: [ + Expanded( + child: Text( + name, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + '${log.sets.length} sets', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + const Divider(height: 1, color: AppColors.divider), + // Set rows + Padding( + padding: const EdgeInsets.all(AppSpacing.sm), + child: Column( + children: log.sets.asMap().entries.map((entry) { + return _SetRow(index: entry.key, set: entry.value); + }).toList(), + ), + ), + // Total + Container( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.md, + ), + decoration: BoxDecoration( + color: AppColors.success.withValues(alpha: 0.05), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(AppRadius.md), + bottomRight: Radius.circular(AppRadius.md), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Text( + 'Total ', + style: TextStyle(color: AppColors.textMuted, fontSize: 12), + ), + Text( + '${settings.toDisplay(log.totalVolume).toStringAsFixed(0)} ${settings.unitLabel}', + style: const TextStyle( + color: AppColors.success, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ── Individual set row ──────────────────────────────────────────────────────── +class _SetRow extends StatelessWidget { + const _SetRow({required this.index, required this.set}); + final int index; + final WorkoutSet set; + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final dw = settings.toDisplay(set.weight); + final wStr = dw == dw.truncateToDouble() + ? dw.toStringAsFixed(0) + : dw.toStringAsFixed(1); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: AppSpacing.sm), + child: Row( + children: [ + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${index + 1}', + style: const TextStyle( + color: AppColors.primary, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + '$wStr ${settings.unitLabel} × ${set.reps} reps', + style: const TextStyle( + color: AppColors.textSoft, + fontSize: 13, + ), + ), + ), + if (set.isDropset) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + margin: const EdgeInsets.only(right: AppSpacing.sm), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'DROP', + style: TextStyle( + color: AppColors.warning, + fontSize: 9, + fontWeight: FontWeight.w800, + letterSpacing: 0.5, + ), + ), + ), + Text( + '${set.volume.toStringAsFixed(0)} kg', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/sleep_hr_card.dart b/workout-logger/lib/screens/widgets/sleep_hr_card.dart new file mode 100644 index 0000000..e60ad59 --- /dev/null +++ b/workout-logger/lib/screens/widgets/sleep_hr_card.dart @@ -0,0 +1,259 @@ +// SleepHrCard — compact overnight-HR summary on the dashboard. +// +// Self-hiding: renders SizedBox.shrink() when ReadinessManager has no +// SleepHrSnapshot, so the dashboard needs no conditional logic. + +import 'dart:math' show min; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../../services/managers/readiness_manager.dart'; +import '../../theme/app_theme.dart'; +import '../sleep_detail_screen.dart'; +import 'rf_widgets.dart'; +import 'sleep_hr_charts.dart' show kSleepStageColors; + +class SleepHrCard extends StatelessWidget { + const SleepHrCard({super.key}); + + @override + Widget build(BuildContext context) { + final manager = context.watch(); + final snap = manager.sleepHrSnapshot; + if (snap == null) return const SizedBox.shrink(); + + final remAvg = snap.statsFor('rem')?.avgBpm; + final deepAvg = snap.statsFor('deep')?.avgBpm; + + final startFmt = _fmtTime(_toIst(snap.sleepStart)); + final endFmt = _fmtTime(_toIst(snap.sleepEnd)); + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: GlassCard( + onTap: () => _openSheet(context, snap), + semanticsLabel: 'Sleep heart rate, P95 ${snap.p95Bpm} bpm', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sleep heart rate', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + const SizedBox(height: 2), + Text( + 'Last night · $startFmt – $endFmt', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 11, + ), + ), + ], + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textFaint, + size: 20, + ), + ], + ), + const SizedBox(height: 10), + // Mini-stats row + Row( + children: [ + _MiniStat( + label: 'P5', + value: '${snap.p5Bpm}', + unit: 'bpm', + color: AppColors.success, + ), + _MiniStat( + label: 'P95', + value: '${snap.p95Bpm}', + unit: 'bpm', + color: AppColors.primary, + ), + if (deepAvg != null) + _MiniStat( + label: 'Deep avg', + value: deepAvg.round().toString(), + unit: 'bpm', + color: kSleepStageColors['deep']!, + ), + if (remAvg != null) + _MiniStat( + label: 'REM avg', + value: remAvg.round().toString(), + unit: 'bpm', + color: kSleepStageColors['rem']!, + ), + ], + ), + const SizedBox(height: 8), + // Sparkline + SizedBox( + height: 44, + child: CustomPaint( + size: const Size(double.infinity, 44), + painter: _SparklinePainter(snap.segments), + ), + ), + ], + ), + ), + ); + } + + void _openSheet(BuildContext context, SleepHrSnapshot snap) { + // Land on the night this snapshot represents (handles the watch-not-synced + // fallback where it's the night before last). + Navigator.of(context).push( + slideRoute(SleepDetailScreen(initialDate: snap.sleepEnd)), + ); + } + + static DateTime _toIst(DateTime dt) => + dt.toUtc().add(const Duration(hours: 5, minutes: 30)); + + static String _fmtTime(DateTime dt) { + final h = dt.hour == 0 ? 12 : dt.hour > 12 ? dt.hour - 12 : dt.hour; + final m = dt.minute.toString().padLeft(2, '0'); + final period = dt.hour < 12 ? 'AM' : 'PM'; + return '$h:$m $period'; + } +} + +class _MiniStat extends StatelessWidget { + const _MiniStat({ + required this.label, + required this.value, + required this.unit, + required this.color, + }); + + final String label; + final String value; + final String unit; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 10, + ), + ), + const SizedBox(height: 1), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: value, + style: TextStyle(fontFamily: 'GeistMono', + color: color, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + TextSpan( + text: ' $unit', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 10, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Draws the compact sparkline: coloured low/high bars + moving-average line. +class _SparklinePainter extends CustomPainter { + const _SparklinePainter(this.segments); + + final List segments; + + @override + void paint(Canvas canvas, Size size) { + if (segments.isEmpty) return; + + final allBpms = segments.expand((s) => [s.minBpm, s.maxBpm]); + final bpmMin = allBpms.reduce(min).toDouble() - 4; + final bpmMax = segments.map((s) => s.maxBpm).reduce((a, b) => a > b ? a : b).toDouble() + 4; + + double yFor(double bpm) => + size.height - ((bpm - bpmMin) / (bpmMax - bpmMin)) * size.height; + + final n = segments.length; + final barW = size.width / n; + + // Draw bars + for (var i = 0; i < n; i++) { + final seg = segments[i]; + final color = kSleepStageColors[seg.stage] ?? AppColors.primary; + final paint = Paint() + ..color = color.withValues(alpha: 0.75) + ..style = PaintingStyle.fill; + final x = i * barW; + final yTop = yFor(seg.maxBpm.toDouble()); + final yBot = yFor(seg.minBpm.toDouble()); + final rect = RRect.fromRectAndRadius( + Rect.fromLTWH(x + 0.5, yTop, barW - 1, (yBot - yTop).clamp(2, double.infinity)), + const Radius.circular(1.5), + ); + canvas.drawRRect(rect, paint); + } + + // Moving-average trend line (window = 5) + final linePaint = Paint() + ..color = AppColors.secondary.withValues(alpha: 0.85) + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + final path = Path(); + for (var i = 0; i < n; i++) { + final start = (i - 4).clamp(0, n - 1); + final slice = segments.sublist(start, i + 1); + final ma = slice.map((s) => s.avgBpm).reduce((a, b) => a + b) / slice.length; + final x = i * barW + barW / 2; + final y = yFor(ma); + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + + // Draw as solid for the compact sparkline — dashes not worth the complexity at 44dp. + canvas.drawPath(path, linePaint..style = PaintingStyle.stroke); + } + + @override + bool shouldRepaint(_SparklinePainter old) => old.segments != segments; +} diff --git a/workout-logger/lib/screens/widgets/sleep_hr_charts.dart b/workout-logger/lib/screens/widgets/sleep_hr_charts.dart new file mode 100644 index 0000000..f50cd95 --- /dev/null +++ b/workout-logger/lib/screens/widgets/sleep_hr_charts.dart @@ -0,0 +1,707 @@ +// sleep_hr_charts.dart — reusable overnight-HR chart widgets. +// +// Extracted from the old SleepHrSheet so the Day tab of SleepDetailScreen and +// the dashboard card can share the same painters. `SleepHrDayView` composes the +// full day breakdown (stat pills + interactive bar chart + stage timeline + +// legend + HR-range-by-stage distribution). + +import 'dart:math' show min, max; + +import 'package:flutter/material.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../../theme/app_theme.dart'; + +/// Stage colour map — shared across the sleep widgets. +const Map kSleepStageColors = { + 'deep': Color(0xFF4C8EFF), + 'rem': Color(0xFFA78BFA), + 'light': Color(0xFF34D399), + 'awake': Color(0xFFF59E0B), +}; + +const _stageOrder = ['awake', 'rem', 'light', 'deep']; +const _stageLabels = { + 'awake': 'Awake', + 'rem': 'REM', + 'light': 'Light', + 'deep': 'Deep', +}; + +DateTime _toIst(DateTime dt) => dt.toUtc().add(const Duration(hours: 5, minutes: 30)); + +/// Full Day-view breakdown for one overnight HR snapshot. +class SleepHrDayView extends StatelessWidget { + const SleepHrDayView({super.key, required this.snapshot}); + + final SleepHrSnapshot snapshot; + + @override + Widget build(BuildContext context) { + final remAvg = snapshot.statsFor('rem')?.avgBpm; + final deepAvg = snapshot.statsFor('deep')?.avgBpm; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Stat pills row + Row( + children: [ + _StatPill(label: 'P5', value: '${snapshot.p5Bpm} bpm', color: AppColors.success), + const SizedBox(width: 6), + _StatPill(label: 'P95', value: '${snapshot.p95Bpm} bpm', color: AppColors.primary), + if (deepAvg != null) ...[ + const SizedBox(width: 6), + _StatPill( + label: 'Deep avg', + value: '${deepAvg.round()} bpm', + color: kSleepStageColors['deep']!, + ), + ], + if (remAvg != null) ...[ + const SizedBox(width: 6), + _StatPill( + label: 'REM avg', + value: '${remAvg.round()} bpm', + color: kSleepStageColors['rem']!, + ), + ], + ], + ), + const SizedBox(height: 20), + + Text( + 'Heart rate during sleep · 10-min bars', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + ), + const SizedBox(height: 8), + _InteractiveBarChart(segments: snapshot.segments), + const SizedBox(height: 6), + _StageTimelineStrip(segments: snapshot.segments), + const SizedBox(height: 8), + _Legend(), + const SizedBox(height: 24), + + Text( + 'HR range by stage', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, letterSpacing: 0.3), + ), + const SizedBox(height: 10), + _StageDistributionChart( + stats: snapshot.stageStats, + stageOrder: _stageOrder, + stageLabels: _stageLabels, + ), + const SizedBox(height: 10), + _DistLegend(), + ], + ); + } +} + +// ── Stat pill ───────────────────────────────────────────────────────────────── + +class _StatPill extends StatelessWidget { + const _StatPill({required this.label, required this.value, required this.color}); + + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: AppColors.glass2, + border: Border.all(color: AppColors.glassBorder), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 9, letterSpacing: 0.5), + ), + const SizedBox(height: 2), + Text( + value, + style: TextStyle(fontFamily: 'GeistMono', color: color, fontSize: 14, fontWeight: FontWeight.w700), + ), + ], + ), + ), + ); + } +} + +// ── Interactive bar chart ───────────────────────────────────────────────────── + +class _InteractiveBarChart extends StatefulWidget { + const _InteractiveBarChart({required this.segments}); + final List segments; + + @override + State<_InteractiveBarChart> createState() => _InteractiveBarChartState(); +} + +class _InteractiveBarChartState extends State<_InteractiveBarChart> { + int? _hoveredIndex; + + static const _chartHeight = 160.0; + static const _padLeft = 28.0; + + int? _indexAt(Offset local, double width) { + final chartW = width - _padLeft - 4; + final x = local.dx - _padLeft; + if (x < 0 || x > chartW) return null; + final idx = (x / chartW * widget.segments.length).floor(); + return idx.clamp(0, widget.segments.length - 1); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: _chartHeight, + child: LayoutBuilder( + builder: (_, constraints) { + final width = constraints.maxWidth; + return GestureDetector( + onTapDown: (d) => setState(() => _hoveredIndex = _indexAt(d.localPosition, width)), + onTapUp: (_) => setState(() => _hoveredIndex = null), + onPanUpdate: (d) => setState(() => _hoveredIndex = _indexAt(d.localPosition, width)), + onPanEnd: (_) => setState(() => _hoveredIndex = null), + onPanCancel: () => setState(() => _hoveredIndex = null), + child: CustomPaint( + size: Size(width, _chartHeight), + painter: _BarChartPainter( + segments: widget.segments, + hoveredIndex: _hoveredIndex, + ), + ), + ); + }, + ), + ); + } +} + +class _BarChartPainter extends CustomPainter { + const _BarChartPainter({required this.segments, this.hoveredIndex}); + + final List segments; + final int? hoveredIndex; + + static const _padLeft = 28.0; + static const _padTop = 6.0; + static const _padBottom = 22.0; + + @override + void paint(Canvas canvas, Size size) { + if (segments.isEmpty) return; + + final allBpms = segments.expand((s) => [s.minBpm, s.maxBpm]); + final rawMin = allBpms.reduce(min).toDouble(); + final rawMax = segments.map((s) => s.maxBpm).reduce((a, b) => a > b ? a : b).toDouble(); + final bpmMin = (rawMin / 10).floor() * 10.0 - 5; + final bpmMax = (rawMax / 10).ceil() * 10.0 + 5; + + final chartW = size.width - _padLeft - 4; + final chartH = size.height - _padTop - _padBottom; + final n = segments.length; + final barW = chartW / n; + + double yFor(double bpm) => + _padTop + chartH - ((bpm - bpmMin) / (bpmMax - bpmMin)) * chartH; + + final gridPaint = Paint() + ..color = AppColors.glassBorder + ..strokeWidth = 0.5; + final yLabelStyle = TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8); + + final gridBpms = []; + for (var b = (bpmMin ~/ 10) * 10; b <= bpmMax; b += 10) { + gridBpms.add(b); + } + for (final bpm in gridBpms) { + final y = yFor(bpm.toDouble()); + canvas.drawLine(Offset(_padLeft, y), Offset(size.width - 4, y), gridPaint); + final tp = TextPainter( + text: TextSpan(text: '$bpm', style: yLabelStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(_padLeft - tp.width - 3, y - tp.height / 2)); + } + + for (var i = 0; i < n; i++) { + final seg = segments[i]; + final color = kSleepStageColors[seg.stage] ?? AppColors.primary; + final alpha = (hoveredIndex == null || hoveredIndex == i) ? 0.78 : 0.28; + final paint = Paint() + ..color = color.withValues(alpha: alpha) + ..style = PaintingStyle.fill; + final x = _padLeft + i * barW; + final yTop = yFor(seg.maxBpm.toDouble()); + final yBot = yFor(seg.minBpm.toDouble()); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x + 0.5, yTop, barW - 1, max(yBot - yTop, 2)), + const Radius.circular(1.5), + ), + paint, + ); + } + + final avgPaint = Paint() + ..color = AppColors.secondary.withValues(alpha: 0.9) + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + final path = Path(); + for (var i = 0; i < n; i++) { + final sl = segments.sublist(max(0, i - 4), i + 1); + final ma = sl.map((s) => s.avgBpm).reduce((a, b) => a + b) / sl.length; + final x = _padLeft + i * barW + barW / 2; + final y = yFor(ma); + i == 0 ? path.moveTo(x, y) : path.lineTo(x, y); + } + canvas.drawPath(path, avgPaint); + + final xLabelStyle = TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8); + for (var i = 0; i < n; i += 6) { + final t = _toIst(segments[i].windowStart); + final h = t.hour == 0 ? 12 : t.hour > 12 ? t.hour - 12 : t.hour; + final m = t.minute.toString().padLeft(2, '0'); + final tp = TextPainter( + text: TextSpan(text: '$h:$m', style: xLabelStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint( + canvas, + Offset(_padLeft + i * barW + barW / 2 - tp.width / 2, size.height - _padBottom + 5), + ); + } + + if (hoveredIndex != null) { + final idx = hoveredIndex!; + final seg = segments[idx]; + final color = kSleepStageColors[seg.stage] ?? AppColors.primary; + final barX = _padLeft + idx * barW; + final yTop = yFor(seg.maxBpm.toDouble()); + final yBot = yFor(seg.minBpm.toDouble()); + + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(barX + 0.5, yTop, barW - 1, max(yBot - yTop, 2)), + const Radius.circular(1.5), + ), + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5, + ); + + final t = _toIst(seg.windowStart); + final tEnd = _toIst(seg.windowStart.add(const Duration(minutes: 10))); + final th = t.hour == 0 ? 12 : t.hour > 12 ? t.hour - 12 : t.hour; + final tm = t.minute.toString().padLeft(2, '0'); + final eh = tEnd.hour == 0 ? 12 : tEnd.hour > 12 ? tEnd.hour - 12 : tEnd.hour; + final em = tEnd.minute.toString().padLeft(2, '0'); + final stageName = const { + 'deep': 'Deep', + 'rem': 'REM', + 'light': 'Light', + 'awake': 'Awake', + }[seg.stage] ?? seg.stage; + + final lines = ['$th:$tm–$eh:$em IST', '${seg.minBpm}–${seg.maxBpm} bpm', stageName]; + final lineStyle = TextStyle(fontFamily: 'GeistMono', color: Colors.white, fontSize: 9.5); + final painters = lines + .map((l) => TextPainter( + text: TextSpan(text: l, style: lineStyle), + textDirection: TextDirection.ltr, + )..layout()) + .toList(); + + const ttPadH = 8.0, ttPadV = 6.0, ttLineH = 14.0; + final ttW = painters.map((p) => p.width).reduce(max) + ttPadH * 2; + final ttH = painters.length * ttLineH + ttPadV * 2; + + var ttX = barX + barW / 2 - ttW / 2; + ttX = ttX.clamp(_padLeft, size.width - 4 - ttW); + var ttY = yTop - ttH - 6; + if (ttY < _padTop) ttY = yBot + 6; + + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint() + ..color = Colors.black.withValues(alpha: 0.4) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4), + ); + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint()..color = const Color(0xFF1E1E2E), + ); + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(ttX, ttY, ttW, ttH), const Radius.circular(6)), + Paint() + ..color = color.withValues(alpha: 0.7) + ..style = PaintingStyle.stroke + ..strokeWidth = 1, + ); + + for (var i = 0; i < painters.length; i++) { + final p = painters[i]; + if (i == 2) { + final stagePainter = TextPainter( + text: TextSpan( + text: stageName, + style: TextStyle(fontFamily: 'GeistMono', color: color, fontSize: 9.5, fontWeight: FontWeight.w700), + ), + textDirection: TextDirection.ltr, + )..layout(); + stagePainter.paint(canvas, Offset(ttX + ttPadH, ttY + ttPadV + i * ttLineH)); + } else { + p.paint(canvas, Offset(ttX + ttPadH, ttY + ttPadV + i * ttLineH)); + } + } + } + } + + @override + bool shouldRepaint(_BarChartPainter old) => + old.segments != segments || old.hoveredIndex != hoveredIndex; +} + +// ── Stage timeline strip ────────────────────────────────────────────────────── + +class _StageTimelineStrip extends StatelessWidget { + const _StageTimelineStrip({required this.segments}); + final List segments; + + @override + Widget build(BuildContext context) { + if (segments.isEmpty) return const SizedBox.shrink(); + return SizedBox( + height: 5, + child: Row( + children: segments.map((s) { + final color = kSleepStageColors[s.stage] ?? AppColors.primary; + return Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 0.5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(1), + ), + ), + ); + }).toList(), + ), + ); + } +} + +// ── Legend ──────────────────────────────────────────────────────────────────── + +class _Legend extends StatelessWidget { + @override + Widget build(BuildContext context) { + final items = [ + ('Deep', kSleepStageColors['deep']!), + ('REM', kSleepStageColors['rem']!), + ('Light', kSleepStageColors['light']!), + ('Awake', kSleepStageColors['awake']!), + ]; + return Wrap( + spacing: 12, + runSpacing: 4, + children: [ + ...items.map((e) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: e.$2, borderRadius: BorderRadius.circular(2)), + ), + const SizedBox(width: 4), + Text(e.$1, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), + ], + )), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: 14, height: 10, child: CustomPaint(painter: _DashLinePainter())), + const SizedBox(width: 4), + Text('Avg trend', style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), + ], + ), + ], + ); + } +} + +class _DashLinePainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = AppColors.secondary.withValues(alpha: 0.85) + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke; + final y = size.height / 2; + for (var x = 0.0; x < size.width; x += 4) { + canvas.drawLine(Offset(x, y), Offset(min(x + 2.5, size.width), y), paint); + } + } + + @override + bool shouldRepaint(_DashLinePainter _) => false; +} + +// ── Stage distribution chart ────────────────────────────────────────────────── + +class _StageDistributionChart extends StatelessWidget { + const _StageDistributionChart({ + required this.stats, + required this.stageOrder, + required this.stageLabels, + }); + + final List stats; + final List stageOrder; + final Map stageLabels; + + @override + Widget build(BuildContext context) { + if (stats.isEmpty) { + return Text( + 'No stage HR data available.', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 12), + ); + } + + final allMin = stats.map((s) => s.minBpm).reduce(min).toDouble() - 4; + final allMax = stats.map((s) => s.maxBpm).reduce(max).toDouble() + 4; + + final orderedStats = stageOrder + .map((k) => stats.where((s) => s.stage == k).firstOrNull) + .whereType() + .toList(); + + return Column( + children: [ + ...orderedStats.map((s) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _DistRow( + stats: s, + label: stageLabels[s.stage] ?? s.stage, + color: kSleepStageColors[s.stage] ?? AppColors.primary, + bpmMin: allMin, + bpmMax: allMax, + ), + )), + _DistAxis(bpmMin: allMin, bpmMax: allMax), + ], + ); + } +} + +class _DistRow extends StatelessWidget { + const _DistRow({ + required this.stats, + required this.label, + required this.color, + required this.bpmMin, + required this.bpmMax, + }); + + final SleepStageStats stats; + final String label; + final Color color; + final double bpmMin; + final double bpmMax; + + @override + Widget build(BuildContext context) { + double pct(double bpm) => ((bpm - bpmMin) / (bpmMax - bpmMin)).clamp(0.0, 1.0); + + return Row( + children: [ + SizedBox( + width: 40, + child: Text( + label, + textAlign: TextAlign.right, + style: TextStyle(fontFamily: 'Geist', color: color, fontSize: 10, fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 28, + child: LayoutBuilder( + builder: (_, constraints) { + final w = constraints.maxWidth; + return Stack( + children: [ + Positioned( + left: pct(stats.minBpm.toDouble()) * w, + width: (pct(stats.maxBpm.toDouble()) - pct(stats.minBpm.toDouble())) * w, + top: 7, + height: 14, + child: Container( + decoration: BoxDecoration( + color: color.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(7), + ), + ), + ), + Positioned( + left: pct(stats.p25Bpm.toDouble()) * w, + width: (pct(stats.p75Bpm.toDouble()) - pct(stats.p25Bpm.toDouble())) * w, + top: 7, + height: 14, + child: Container( + decoration: BoxDecoration( + color: color.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(7), + ), + ), + ), + Positioned( + left: pct(stats.avgBpm) * w - 4, + top: 10, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: AppColors.card, width: 1.5), + ), + ), + ), + Positioned( + left: (pct(stats.avgBpm) * w - 16).clamp(0, w - 32), + top: 0, + child: Text( + '${stats.avgBpm.round()} bpm', + style: TextStyle(fontFamily: 'GeistMono', + color: color, + fontSize: 8, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ); + }, + ), + ), + ), + ], + ); + } +} + +class _DistAxis extends StatelessWidget { + const _DistAxis({required this.bpmMin, required this.bpmMax}); + + final double bpmMin; + final double bpmMax; + + @override + Widget build(BuildContext context) { + final ticks = []; + for (var b = (bpmMin / 5).ceil() * 5; b <= bpmMax; b += 5) { + ticks.add(b); + } + return Padding( + padding: const EdgeInsets.only(left: 48), + child: LayoutBuilder( + builder: (_, constraints) { + final w = constraints.maxWidth; + double pct(double bpm) => ((bpm - bpmMin) / (bpmMax - bpmMin)).clamp(0.0, 1.0); + return SizedBox( + height: 16, + child: Stack( + children: ticks + .map((t) => Positioned( + left: (pct(t.toDouble()) * w - 10).clamp(0, w - 20), + child: Text( + '$t', + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8), + ), + )) + .toList(), + ), + ); + }, + ), + ); + } +} + +class _DistLegend extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 12, + runSpacing: 4, + children: [ + _DistLi( + swatch: Container( + width: 16, + height: 8, + decoration: BoxDecoration( + color: AppColors.textMuted.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(4), + ), + ), + label: 'Min–max', + ), + _DistLi( + swatch: Container( + width: 16, + height: 8, + decoration: BoxDecoration( + color: AppColors.textMuted.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(4), + ), + ), + label: 'P25–P75', + ), + _DistLi( + swatch: Container( + width: 8, + height: 8, + decoration: const BoxDecoration(color: AppColors.textSoft, shape: BoxShape.circle), + ), + label: 'Avg', + ), + ], + ); + } +} + +class _DistLi extends StatelessWidget { + const _DistLi({required this.swatch, required this.label}); + final Widget swatch; + final String label; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + swatch, + const SizedBox(width: 4), + Text(label, style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 10)), + ], + ); + } +} diff --git a/workout-logger/lib/screens/widgets/sparkline_painter.dart b/workout-logger/lib/screens/widgets/sparkline_painter.dart new file mode 100644 index 0000000..99200b3 --- /dev/null +++ b/workout-logger/lib/screens/widgets/sparkline_painter.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +class SparklinePainter extends CustomPainter { + const SparklinePainter({ + required this.data, + required this.color, + this.strokeWidth = 1.5, + this.fillOpacity = 0.15, + }); + + final List data; + final Color color; + final double strokeWidth; + final double fillOpacity; + + @override + void paint(Canvas canvas, Size size) { + if (data.length < 2) return; + final minVal = data.reduce((a, b) => a < b ? a : b); + final maxVal = data.reduce((a, b) => a > b ? a : b); + final range = maxVal - minVal == 0 ? 1.0 : maxVal - minVal; + + final points = List.generate(data.length, (i) { + final x = i / (data.length - 1) * size.width; + final y = size.height - ((data[i] - minVal) / range) * (size.height - 4) - 2; + return Offset(x, y); + }); + + final linePath = Path()..moveTo(points[0].dx, points[0].dy); + for (int i = 1; i < points.length; i++) { + linePath.lineTo(points[i].dx, points[i].dy); + } + + final fillPath = Path()..addPath(linePath, Offset.zero); + fillPath.lineTo(size.width, size.height); + fillPath.lineTo(0, size.height); + fillPath.close(); + + canvas.drawPath( + fillPath, + Paint() + ..color = color.withValues(alpha: fillOpacity) + ..style = PaintingStyle.fill, + ); + canvas.drawPath( + linePath, + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round, + ); + } + + @override + bool shouldRepaint(SparklinePainter old) => + old.data != data || old.color != color; +} + +class Sparkline extends StatelessWidget { + const Sparkline({ + super.key, + required this.data, + required this.color, + this.width = 42, + this.height = 16, + this.strokeWidth = 1.5, + }); + + final List data; + final Color color; + final double width; + final double height; + final double strokeWidth; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: CustomPaint( + painter: SparklinePainter( + data: data, + color: color, + strokeWidth: strokeWidth, + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/targets_tab.dart b/workout-logger/lib/screens/widgets/targets_tab.dart new file mode 100644 index 0000000..6cb2978 --- /dev/null +++ b/workout-logger/lib/screens/widgets/targets_tab.dart @@ -0,0 +1,800 @@ +// targets_tab.dart — Analytics "Targets" tab + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; + +import '../../models/models.dart'; +import '../../services/workout_provider.dart'; +import '../../services/settings_provider.dart'; +import '../../services/ai/gemini_ai_service.dart'; +import '../../theme/app_theme.dart'; +import '../../data/exercise_database.dart'; +import 'rf_widgets.dart'; +import 'rf_cards.dart'; +import '../ai_coach_screen.dart'; + +class TargetsTab extends StatelessWidget { + const TargetsTab({super.key}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final targets = provider.targets; + final active = targets.where((t) => !t.isCompleted).toList(); + final completed = targets.where((t) => t.isCompleted).toList(); + + return Scaffold( + backgroundColor: Colors.transparent, + body: targets.isEmpty + ? RFEmptyState( + icon: Icons.flag_rounded, + title: 'No Targets Set', + subtitle: 'Set a goal to track your progress', + ) + : SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + 100, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SummaryHeader(active: active, completed: completed), + const SizedBox(height: AppSpacing.md), + if (active.isNotEmpty) ...[ + const RFSectionHeader('Active'), + ...active.map( + (t) => _TargetCardWithAi( + target: t, + exerciseName: provider.getExerciseName(t.exerciseId), + growthModel: provider.getGrowthModel(t.exerciseId), + onDelete: () => provider.deleteTarget(t.id), + ), + ), + ], + if (completed.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.md), + const RFSectionHeader('Completed'), + ...completed.map( + (t) => TargetCard( + target: t, + exerciseName: provider.getExerciseName(t.exerciseId), + onDelete: () => provider.deleteTarget(t.id), + ), + ), + ], + ], + ), + ), + floatingActionButton: Padding( + padding: const EdgeInsets.only(bottom: AppBreakpoints.navBarClearance), + child: FloatingActionButton.extended( + onPressed: () => _showCreateSheet(context), + backgroundColor: AppColors.primary, + elevation: 0, + icon: const Icon(Icons.add_rounded, color: Colors.white), + label: Text( + 'New Target', + style: TextStyle(fontFamily: 'Geist', + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ); + } + + void _showCreateSheet(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (_) => const _CreateTargetSheet(), + ); + } +} + +// ── Summary header ───────────────────────────────────────────────────────────── + +class _SummaryHeader extends StatelessWidget { + const _SummaryHeader({ + required this.active, + required this.completed, + }); + + final List active; + final List completed; + + @override + Widget build(BuildContext context) { + final onTrack = active + .where((t) => + t.estimatedCompletionDate != null && + t.estimatedCompletionDate!.isAfter(DateTime.now())) + .length; + final stalled = active.length - onTrack; + + return Row( + children: [ + _SummaryChip( + label: '${active.length} active', + color: AppColors.primary, + ), + const SizedBox(width: AppSpacing.sm), + if (onTrack > 0) + _SummaryChip( + label: '$onTrack on track', + color: AppColors.success, + ), + if (stalled > 0) ...[ + const SizedBox(width: AppSpacing.sm), + _SummaryChip( + label: '$stalled stalled', + color: AppColors.warning, + ), + ], + if (completed.isNotEmpty) ...[ + const SizedBox(width: AppSpacing.sm), + _SummaryChip( + label: '${completed.length} done', + color: AppColors.textMuted, + ), + ], + ], + ); + } +} + +class _SummaryChip extends StatelessWidget { + const _SummaryChip({required this.label, required this.color}); + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: color.withValues(alpha: 0.35)), + ), + child: Text( + label, + style: TextStyle(fontFamily: 'GeistMono', + color: color, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +// ── Target card with status word + AI stalled nudge ──────────────────────────── + +class _TargetCardWithAi extends StatefulWidget { + const _TargetCardWithAi({ + required this.target, + required this.exerciseName, + required this.growthModel, + this.onDelete, + }); + + final Target target; + final String exerciseName; + final GrowthModel? growthModel; + final VoidCallback? onDelete; + + @override + State<_TargetCardWithAi> createState() => _TargetCardWithAiState(); +} + +class _TargetCardWithAiState extends State<_TargetCardWithAi> { + String? _nudge; + bool _loadingNudge = false; + + bool get _isStalled { + final t = widget.target; + if (t.estimatedCompletionDate == null) return true; + return t.estimatedCompletionDate!.isBefore(DateTime.now()); + } + + String get _statusWord { + if (widget.target.isCompleted) return 'Done'; + if (!_isStalled) return 'On track'; + return 'Stalled'; + } + + Color get _statusColor { + if (widget.target.isCompleted) return AppColors.success; + if (!_isStalled) return AppColors.success; + return AppColors.warning; + } + + Future _fetchNudge() async { + setState(() => _loadingNudge = true); + final gemini = context.read(); + final settings = context.read(); + final t = widget.target; + + final contextText = + 'Exercise: ${widget.exerciseName}\n' + 'Target: ${t.targetValue} ${settings.unitLabel} (${t.targetType})\n' + 'Current: ${t.currentValue.toStringAsFixed(1)} ${settings.unitLabel} ' + '(${t.progressPercentage.toStringAsFixed(0)}%)\n' + 'Estimated completion: ${t.estimatedCompletionDate != null ? DateFormat('MMM d, y').format(t.estimatedCompletionDate!) : "unknown — no growth trend"}\n' + '${widget.growthModel != null ? "Growth slope: ${widget.growthModel!.slope.toStringAsFixed(2)} per session, R² ${(widget.growthModel!.r2 * 100).toStringAsFixed(0)}%" : "No growth model yet."}'; + + const system = + 'You are a concise personal trainer. Give 1–2 sentences of actionable advice to help the user get this stalled target back on track. Be specific and encouraging.'; + + final nudge = await gemini.generateInsight(system, contextText); + if (mounted) setState(() { _nudge = nudge; _loadingNudge = false; }); + } + + void _openCoach() { + final seed = + 'I\'m stuck on my ${widget.target.targetType} target for ${widget.exerciseName}. ' + 'Currently at ${widget.target.currentValue.toStringAsFixed(1)}, ' + 'aiming for ${widget.target.targetValue}. How do I get unstuck?'; + Navigator.push( + context, + MaterialPageRoute(builder: (_) => AiCoachScreen(seedPrompt: seed)), + ); + } + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final gemini = context.watch(); + final t = widget.target; + final pct = t.progressPercentage.clamp(0.0, 100.0); + final etaStr = t.estimatedCompletionDate != null + ? DateFormat('MMM d, y').format(t.estimatedCompletionDate!) + : null; + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.exerciseName, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + RFChip( + label: t.targetType, + small: true, + color: AppColors.secondary, + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: _statusColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: _statusColor.withValues(alpha: 0.35)), + ), + child: Text( + _statusWord, + style: TextStyle(fontFamily: 'GeistMono', + color: _statusColor, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ), + if (widget.onDelete != null) ...[ + const SizedBox(width: 4), + GestureDetector( + onTap: widget.onDelete, + child: const Icon( + Icons.close_rounded, + size: 16, + color: AppColors.textMuted, + ), + ), + ], + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${settings.toDisplay(t.currentValue).toStringAsFixed(1)} / ' + '${settings.toDisplay(t.targetValue).toStringAsFixed(1)} ${settings.unitLabel}', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 12, + ), + ), + Text( + '${pct.toStringAsFixed(0)}%', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 6), + RFProgressBar(value: t.progressPercentage / 100), + if (etaStr != null) ...[ + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.schedule_rounded, + size: 11, color: AppColors.textMuted), + const SizedBox(width: 4), + Text( + 'Est. $etaStr', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ), + ], + + // AI stalled nudge section + if (gemini.isConfigured && _isStalled) ...[ + const SizedBox(height: AppSpacing.sm), + if (_nudge == null && !_loadingNudge) + Row( + children: [ + Expanded( + child: OutlineGlowButton( + label: 'Why am I stuck?', + icon: Icons.auto_awesome_rounded, + color: AppColors.warning, + fullWidth: true, + small: true, + onPressed: _fetchNudge, + ), + ), + const SizedBox(width: AppSpacing.sm), + OutlineGlowButton( + label: 'Ask Coach', + icon: Icons.chat_bubble_outline_rounded, + color: AppColors.secondary, + small: true, + onPressed: _openCoach, + ), + ], + ) + else if (_loadingNudge) + const Center(child: RFLoadingDots()) + else + Container( + padding: const EdgeInsets.all(AppSpacing.sm + 2), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: AppColors.warning.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.auto_awesome_rounded, + size: 12, color: AppColors.warning), + const SizedBox(width: 4), + Text( + 'AI Tip', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.warning, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + _nudge!, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 12, + height: 1.5, + ), + ), + const SizedBox(height: 4), + GestureDetector( + onTap: _openCoach, + child: Text( + 'Continue in Coach →', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.secondary, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ); + } +} + +// ── Create target bottom sheet ───────────────────────────────────────────────── + +class _CreateTargetSheet extends StatefulWidget { + const _CreateTargetSheet(); + + @override + State<_CreateTargetSheet> createState() => _CreateTargetSheetState(); +} + +class _CreateTargetSheetState extends State<_CreateTargetSheet> { + String? _selectedExerciseId; + String _targetType = 'weight'; + final _valueController = TextEditingController(); + bool _isSubmitting = false; + bool _loadingSuggestion = false; + String? _suggestionText; + + static const _types = [ + ('weight', 'Max Weight'), + ('reps', 'Max Reps'), + ('volume', 'Volume'), + ]; + + @override + void dispose() { + _valueController.dispose(); + super.dispose(); + } + + Future _fetchSuggestion() async { + if (_selectedExerciseId == null) return; + setState(() => _loadingSuggestion = true); + + final provider = context.read(); + final settings = context.read(); + final gemini = context.read(); + final exerciseName = provider.getExerciseName(_selectedExerciseId!); + final growth = provider.getGrowthModel(_selectedExerciseId!); + final oneRM = provider.getBestOneRM(_selectedExerciseId!); + + String contextText = + 'Exercise: $exerciseName\nTarget type: $_targetType\n'; + if (growth != null) { + contextText += + 'Growth slope: ${settings.toDisplay(growth.slope).toStringAsFixed(2)} ${settings.unitLabel}/session\n' + 'R²: ${(growth.r2 * 100).toStringAsFixed(0)}%\n'; + } + if (oneRM != null) { + contextText += + 'Estimated 1RM: ${settings.formatWeight(oneRM)}\n'; + } + + const system = + 'You are a strength coach. Suggest ONE realistic target value and an estimated timeframe (e.g. "100 kg in ~8 weeks based on your current progression"). ' + 'Be concise — one sentence max. State only the number and timeframe.'; + + final suggestion = await gemini.generateInsight(system, contextText); + if (mounted) { + setState(() { + _suggestionText = suggestion; + _loadingSuggestion = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final exercises = ExerciseDatabase.getAll(); + final bottom = MediaQuery.of(context).viewInsets.bottom; + final gemini = context.watch(); + + return Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + bottom + AppSpacing.lg, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.glassBorder, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Text( + 'New Target', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: AppSpacing.lg), + + Text( + 'EXERCISE', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1, + ), + ), + const SizedBox(height: AppSpacing.sm), + Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedExerciseId, + isExpanded: true, + dropdownColor: AppColors.cardHigh, + hint: Text( + 'Select exercise…', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 14, + ), + ), + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + ), + icon: const Icon(Icons.expand_more_rounded, + color: AppColors.textMuted, size: 20), + items: exercises + .map((e) => DropdownMenuItem( + value: e.id, + child: Text(e.name), + )) + .toList(), + onChanged: (v) => setState(() { + _selectedExerciseId = v; + _suggestionText = null; + }), + ), + ), + ), + + const SizedBox(height: AppSpacing.md), + Text( + 'TARGET TYPE', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1, + ), + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: _types.map((t) { + final selected = _targetType == t.$1; + return Expanded( + child: GestureDetector( + onTap: () => setState(() { + _targetType = t.$1; + _suggestionText = null; + }), + child: Container( + margin: const EdgeInsets.only(right: 6), + padding: + const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: selected + ? AppColors.primary.withValues(alpha: 0.15) + : AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: selected + ? AppColors.primary.withValues(alpha: 0.5) + : AppColors.glassBorder, + ), + ), + child: Text( + t.$2, + textAlign: TextAlign.center, + style: TextStyle(fontFamily: 'Geist', + color: selected + ? AppColors.primary + : AppColors.textMuted, + fontSize: 11, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w400, + ), + ), + ), + ), + ); + }).toList(), + ), + + const SizedBox(height: AppSpacing.md), + Text( + 'TARGET VALUE', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1, + ), + ), + const SizedBox(height: AppSpacing.sm), + Container( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: TextField( + controller: _valueController, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'^\d*\.?\d*$')), + ], + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 16, + ), + decoration: InputDecoration( + hintText: 'e.g. 100', + hintStyle: + TextStyle(fontFamily: 'Geist', color: AppColors.textMuted), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + ), + ), + ), + + // AI suggestion + if (gemini.isConfigured && _selectedExerciseId != null) ...[ + const SizedBox(height: AppSpacing.sm), + if (_suggestionText == null && !_loadingSuggestion) + GestureDetector( + onTap: _fetchSuggestion, + child: Row( + children: [ + const Icon(Icons.auto_awesome_rounded, + size: 13, color: AppColors.primary), + const SizedBox(width: 5), + Text( + 'Suggest a target based on my progress', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ) + else if (_loadingSuggestion) + const Center(child: RFLoadingDots()) + else + Container( + padding: const EdgeInsets.all(AppSpacing.sm + 2), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.25)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.auto_awesome_rounded, + size: 13, color: AppColors.primary), + const SizedBox(width: 6), + Expanded( + child: Text( + _suggestionText!, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 12, + height: 1.4, + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: AppSpacing.lg), + GlowButton( + label: 'Create Target', + icon: Icons.flag_rounded, + onPressed: _isSubmitting ? null : _submit, + fullWidth: true, + ), + ], + ), + ); + } + + Future _submit() async { + if (_isSubmitting) return; + if (_selectedExerciseId == null || _valueController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please fill all fields'), + backgroundColor: AppColors.cardHigh, + ), + ); + return; + } + final value = double.tryParse(_valueController.text); + if (value == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Invalid target value'), + backgroundColor: AppColors.error, + ), + ); + return; + } + + setState(() => _isSubmitting = true); + try { + await context.read().createTarget( + exerciseId: _selectedExerciseId!, + type: _targetType, + targetValue: value, + ); + if (mounted) Navigator.of(context).pop(); + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } +} diff --git a/workout-logger/lib/screens/widgets/volume_chart.dart b/workout-logger/lib/screens/widgets/volume_chart.dart new file mode 100644 index 0000000..e744bf7 --- /dev/null +++ b/workout-logger/lib/screens/widgets/volume_chart.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_theme.dart'; + +class VolumeChart extends StatelessWidget { + const VolumeChart({ + super.key, + required this.data, + this.color, + this.height = 130, + this.labels = const [], + }); + + final List data; + final Color? color; + final double height; + final List labels; + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.primary; + return Column( + children: [ + SizedBox( + height: height, + child: CustomPaint( + size: Size.infinite, + painter: _VolumeCurvePainter(data: data, color: c), + ), + ), + if (labels.isNotEmpty) ...[ + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: labels + .map((l) => Text( + l, + style: TextStyle(fontFamily: 'GeistMono', + fontSize: 9, + color: AppColors.textFaint, + ), + )) + .toList(), + ), + ], + ], + ); + } +} + +class _VolumeCurvePainter extends CustomPainter { + const _VolumeCurvePainter({required this.data, required this.color}); + + final List data; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + if (data.length < 2) return; + + final minVal = data.reduce((a, b) => a < b ? a : b); + final maxVal = data.reduce((a, b) => a > b ? a : b); + final range = maxVal - minVal == 0 ? 1.0 : maxVal - minVal; + final w = size.width; + final h = size.height; + + final pts = List.generate(data.length, (i) { + final x = i / (data.length - 1) * w; + final y = h - ((data[i] - minVal) / range) * (h - 16) - 8; + return Offset(x, y); + }); + + // Build smooth bezier path + final smooth = Path()..moveTo(pts[0].dx, pts[0].dy); + for (int i = 1; i < pts.length; i++) { + final p0 = pts[i - 1]; + final p1 = pts[i]; + final cx = (p0.dx + p1.dx) / 2; + smooth.cubicTo(cx, p0.dy, cx, p1.dy, p1.dx, p1.dy); + } + + // Filled area + final area = Path()..addPath(smooth, Offset.zero); + area.lineTo(w, h); + area.lineTo(0, h); + area.close(); + + canvas.drawPath( + area, + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + color.withValues(alpha: 0.4), + color.withValues(alpha: 0), + ], + ).createShader(Rect.fromLTWH(0, 0, w, h)), + ); + + // Gridlines + for (int i = 0; i < 4; i++) { + final y = h * i / 3; + canvas.drawLine( + Offset(0, y), + Offset(w, y), + Paint() + ..color = const Color(0x0AFFFFFF) + ..strokeWidth = 1, + ); + } + + // Line stroke + canvas.drawPath( + smooth, + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round, + ); + + // Data point dots + for (int i = 0; i < pts.length; i++) { + final isLast = i == pts.length - 1; + canvas.drawCircle( + pts[i], + isLast ? 4 : 2.5, + Paint()..color = isLast ? Colors.white : color, + ); + if (isLast) { + canvas.drawCircle( + pts[i], + 4, + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + } + } + } + + @override + bool shouldRepaint(_VolumeCurvePainter old) => + old.data != data || old.color != color; +} diff --git a/workout-logger/lib/screens/widgets/wheel_picker.dart b/workout-logger/lib/screens/widgets/wheel_picker.dart new file mode 100644 index 0000000..7ef8f2a --- /dev/null +++ b/workout-logger/lib/screens/widgets/wheel_picker.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../theme/app_theme.dart'; + +/// Two-column wheel picker for weight + reps input. +class WheelPickerField extends StatelessWidget { + const WheelPickerField({ + super.key, + required this.weight, + required this.reps, + required this.onWeightChanged, + required this.onRepsChanged, + this.weightStep = 2.5, + this.weightMin = 0, + this.weightMax = 200, + this.repsMin = 1, + this.repsMax = 50, + }); + + final double weight; + final int reps; + final ValueChanged onWeightChanged; + final ValueChanged onRepsChanged; + final double weightStep; + final double weightMin; + final double weightMax; + final int repsMin; + final int repsMax; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + flex: 14, + child: _SingleWheel( + label: 'WEIGHT', + unit: 'kg', + color: AppColors.primary, + value: weight, + values: _buildDoubleRange(weightMin, weightMax, weightStep), + formatter: (v) => v == v.truncateToDouble() + ? v.toInt().toString() + : v.toStringAsFixed(1), + onChanged: (v) { + HapticFeedback.selectionClick(); + onWeightChanged(v); + }, + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 10, + child: _SingleWheel( + label: 'REPS', + unit: '', + color: AppColors.secondary, + value: reps, + values: List.generate(repsMax - repsMin + 1, (i) => repsMin + i), + formatter: (v) => v.toString(), + onChanged: (v) { + HapticFeedback.selectionClick(); + onRepsChanged(v); + }, + ), + ), + ], + ); + } + + static List _buildDoubleRange( + double min, double max, double step) { + final result = []; + double v = min; + while (v <= max + 0.001) { + result.add(double.parse(v.toStringAsFixed(2))); + v += step; + } + return result; + } +} + +class _SingleWheel extends StatefulWidget { + const _SingleWheel({ + required this.label, + required this.unit, + required this.color, + required this.value, + required this.values, + required this.formatter, + required this.onChanged, + }); + + final String label; + final String unit; + final Color color; + final T value; + final List values; + final String Function(T) formatter; + final ValueChanged onChanged; + + @override + State<_SingleWheel> createState() => _SingleWheelState(); +} + +class _SingleWheelState extends State<_SingleWheel> { + late FixedExtentScrollController _ctrl; + int _selectedIndex = 0; + + static const double _itemH = 38; + + @override + void initState() { + super.initState(); + _selectedIndex = widget.values.indexOf(widget.value); + if (_selectedIndex < 0) _selectedIndex = 0; + _ctrl = FixedExtentScrollController(initialItem: _selectedIndex); + } + + @override + void didUpdateWidget(_SingleWheel old) { + super.didUpdateWidget(old); + if (old.value != widget.value) { + final idx = widget.values.indexOf(widget.value); + if (idx >= 0 && idx != _selectedIndex) { + _selectedIndex = idx; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_ctrl.hasClients) _ctrl.jumpToItem(idx); + }); + } + } + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x09FFFFFF), Color(0x04FFFFFF)], + ), + border: Border.all(color: AppColors.glassBorder), + borderRadius: BorderRadius.circular(AppRadius.xl), + ), + padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.label, + style: TextStyle(fontFamily: 'Geist', + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + letterSpacing: 0.5, + ), + ), + if (widget.unit.isNotEmpty) + Text( + widget.unit, + style: TextStyle(fontFamily: 'Geist', + fontSize: 10, + color: AppColors.textFaint, + ), + ), + ], + ), + const SizedBox(height: 6), + SizedBox( + height: _itemH * 3, + child: Stack( + children: [ + // Selection band + Positioned( + top: _itemH, + left: -12, + right: -12, + child: Container( + height: _itemH, + decoration: BoxDecoration( + color: const Color(0x06FFFFFF), + border: Border( + top: BorderSide( + color: AppColors.glassBorderStrong, width: 1), + bottom: BorderSide( + color: AppColors.glassBorderStrong, width: 1), + ), + ), + ), + ), + // Wheel + ListWheelScrollView.useDelegate( + controller: _ctrl, + itemExtent: _itemH, + physics: const FixedExtentScrollPhysics(), + diameterRatio: 3, + overAndUnderCenterOpacity: 0.3, + onSelectedItemChanged: (i) { + setState(() => _selectedIndex = i); + widget.onChanged(widget.values[i]); + }, + childDelegate: ListWheelChildBuilderDelegate( + childCount: widget.values.length, + builder: (context, i) { + final isCurrent = i == _selectedIndex; + return Center( + child: Text( + widget.formatter(widget.values[i]), + style: TextStyle(fontFamily: 'GeistMono', + fontSize: isCurrent ? 28 : 16, + fontWeight: FontWeight.w600, + color: isCurrent + ? widget.color + : AppColors.textPrimary, + letterSpacing: + -0.02 * (isCurrent ? 28 : 16), + ), + ), + ); + }, + ), + ), + // Top fade + Positioned( + top: 0, + left: 0, + right: 0, + height: _itemH, + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.surface, + AppColors.surface.withValues(alpha: 0), + ], + ), + ), + ), + ), + ), + // Bottom fade + Positioned( + bottom: 0, + left: 0, + right: 0, + height: _itemH, + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + AppColors.surface, + AppColors.surface.withValues(alpha: 0), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/workout_header.dart b/workout-logger/lib/screens/widgets/workout_header.dart new file mode 100644 index 0000000..729ae49 --- /dev/null +++ b/workout-logger/lib/screens/widgets/workout_header.dart @@ -0,0 +1,256 @@ +// workout_header.dart — Header bar for WorkoutFlowScreen + +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +// ── WorkoutHeader ───────────────────────────────────────────────────────────── +// Shows exercise name, set/exercise progress, elapsed timer, and nav actions. +class WorkoutHeader extends StatefulWidget { + const WorkoutHeader({ + super.key, + required this.exerciseName, + required this.currentExerciseIndex, + required this.totalExercises, + 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, + this.restSeconds = 90, + }); + + final String exerciseName; + final int currentExerciseIndex; + final int totalExercises; + 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; + final int restSeconds; + + @override + State createState() => _WorkoutHeaderState(); +} + +class _WorkoutHeaderState extends State { + late Timer _ticker; + int _elapsedSeconds = 0; + + @override + void initState() { + super.initState(); + _updateElapsed(); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) => _updateElapsed()); + } + + void _updateElapsed() { + if (widget.workoutStartTime == null) return; + setState(() { + _elapsedSeconds = + DateTime.now().difference(widget.workoutStartTime!).inSeconds; + }); + } + + @override + void dispose() { + _ticker.cancel(); + super.dispose(); + } + + String get _elapsedLabel { + final m = _elapsedSeconds ~/ 60; + final s = _elapsedSeconds % 60; + return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF0C0C12), Color(0x000C0C12)], + ), + border: Border(bottom: BorderSide(color: AppColors.glassBorder)), + ), + child: SafeArea( + bottom: false, + child: Column( + 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, + ), + ), + ], + ), + ), + ); + } +} + +// ── Options Menu ────────────────────────────────────────────────────────────── +class _OptionsMenu extends StatelessWidget { + const _OptionsMenu({ + required this.restSeconds, + required this.onRemoveLastSet, + required this.onSetRestTime, + required this.onFinish, + }); + + final int restSeconds; + final VoidCallback onRemoveLastSet; + final void Function(int) onSetRestTime; + final VoidCallback onFinish; + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + color: AppColors.cardHigh, + icon: const Icon( + Icons.more_vert_rounded, + color: AppColors.textSoft, + size: 22, + ), + onSelected: (v) { + if (v == 'remove') onRemoveLastSet(); + if (v == 'finish') onFinish(); + if (v.startsWith('rest_')) { + onSetRestTime(int.parse(v.substring(5))); + } + }, + itemBuilder: (_) => [ + const PopupMenuItem(value: 'remove', child: Text('Remove Last Set')), + const PopupMenuDivider(), + for (final s in [30, 60, 90, 120, 180]) + PopupMenuItem( + value: 'rest_$s', + child: Row( + children: [ + const Icon(Icons.timer_outlined, size: 16), + const SizedBox(width: 8), + Text('Rest: ${s}s'), + if (restSeconds == s) ...[ + const Spacer(), + const Icon(Icons.check_rounded, size: 14), + ], + ], + ), + ), + const PopupMenuDivider(), + const PopupMenuItem( + value: 'finish', + child: Text('Finish Workout', + style: TextStyle(color: AppColors.success)), + ), + ], + ); + } +} diff --git a/workout-logger/lib/screens/widgets/workout_hr_section.dart b/workout-logger/lib/screens/widgets/workout_hr_section.dart new file mode 100644 index 0000000..4dd59d0 --- /dev/null +++ b/workout-logger/lib/screens/widgets/workout_hr_section.dart @@ -0,0 +1,435 @@ +// workout_hr_section.dart — per-workout HR breakdown for the History session +// sheet. Self-hides when Health Connect has no HR data for the workout window. +// +// Shows: avg/peak/min pills, an HR curve with exercise-section flags + rest +// shading (green = HR recovered, amber = didn't), a recovery summary, and an +// expandable per-rest table. + +import 'dart:math' show max, min; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/models.dart'; +import '../../models/workout_hr_models.dart'; +import '../../services/managers/health_history_manager.dart'; +import '../../services/workout_provider.dart'; +import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; + +class WorkoutHrSection extends StatefulWidget { + const WorkoutHrSection({super.key, required this.session, required this.provider}); + + final WorkoutSession session; + final WorkoutProvider provider; + + @override + State createState() => _WorkoutHrSectionState(); +} + +class _WorkoutHrSectionState extends State { + Future? _future; + bool _expanded = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _future ??= context.read().workoutHr(widget.session); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done || snap.data == null) { + // Self-hide while loading and when there's no HR data. + return const SizedBox.shrink(); + } + final a = snap.data!; + final sections = a.exercises + .map((e) => _Section( + label: _shortName(widget.provider.getExercise(e.exerciseId)?.name ?? '—'), + start: e.start, + end: e.end, + )) + .toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: AppSpacing.md), + const RFSectionHeader('Heart rate'), + const SizedBox(height: AppSpacing.sm), + GlassCard( + padding: const EdgeInsets.all(14), + borderColor: AppColors.accent.withValues(alpha: 0.18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _Pill(label: 'Avg', value: '${a.avgBpm}', color: AppColors.primary), + const SizedBox(width: 6), + _Pill(label: 'Peak', value: '${a.peakBpm}', color: AppColors.accent), + const SizedBox(width: 6), + _Pill(label: 'Min', value: '${a.minBpm}', color: AppColors.secondary), + ], + ), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'HR across the session · ⚑ = exercise', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11), + ), + Text('bpm', style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11)), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 150, + child: CustomPaint( + size: const Size(double.infinity, 150), + painter: _CurvePainter(analysis: a, sections: sections), + ), + ), + if (a.hasRestAnalysis && a.restCount > 0) ...[ + const SizedBox(height: 12), + _RecoverySummary(analysis: a), + const SizedBox(height: 10), + GestureDetector( + onTap: () => setState(() => _expanded = !_expanded), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 9), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(9), + border: Border.all(color: AppColors.glassBorder), + ), + alignment: Alignment.center, + child: Text( + _expanded ? 'Hide per-rest breakdown ▴' : 'Show per-rest breakdown ▾', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + if (_expanded) ...[ + const SizedBox(height: 8), + ...a.rests.map((r) => _RestRow(rest: r)), + ], + ] else if (!a.hasRestAnalysis) ...[ + const SizedBox(height: 10), + Text( + 'Per-rest recovery needs per-set timing, which this workout ' + 'didn\'t record.', + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 11, height: 1.4), + ), + ], + ], + ), + ), + ], + ); + }, + ); + } + + static String _shortName(String name) { + if (name.length <= 14) return name; + final words = name.split(' '); + if (words.length >= 2) return '${words.first} ${words[1][0]}.'; + return '${name.substring(0, 12)}…'; + } +} + +class _Section { + final String label; + final DateTime start; + final DateTime end; + const _Section({required this.label, required this.start, required this.end}); +} + +// ── Recovery summary ────────────────────────────────────────────────────────── + +class _RecoverySummary extends StatelessWidget { + const _RecoverySummary({required this.analysis}); + final WorkoutHrAnalysis analysis; + + @override + Widget build(BuildContext context) { + final tooShort = analysis.restsTooShort; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + Text( + '${analysis.restsRecovered}/${analysis.restCount}', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.success, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 12), + Expanded( + child: RichText( + text: TextSpan( + style: TextStyle(fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 11, height: 1.4), + children: [ + const TextSpan( + text: 'rests brought your HR down\n', + style: TextStyle(color: AppColors.textSoft, fontWeight: FontWeight.w600), + ), + TextSpan(text: 'avg '), + TextSpan( + text: '−${analysis.avgRecoveryBpm} bpm', + style: const TextStyle(color: AppColors.success, fontWeight: FontWeight.w700), + ), + TextSpan(text: ' per rest'), + if (tooShort > 0) TextSpan(text: ' · $tooShort too short to drop'), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _RestRow extends StatelessWidget { + const _RestRow({required this.rest}); + final RestRecovery rest; + + @override + Widget build(BuildContext context) { + final ok = rest.recovered; + final color = ok ? AppColors.success : AppColors.warning; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(6), + ), + child: Icon(ok ? Icons.check_rounded : Icons.priority_high_rounded, size: 13, color: color), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'After set ${rest.afterSet} · rest ${rest.durationSec}s', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 1), + Text( + 'peak ${rest.peakBpm} → low ${rest.troughBpm} bpm${ok ? '' : ' · too short'}', + style: TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 10), + ), + ], + ), + ), + Text( + '−${rest.recoveryBpm} bpm', + style: TextStyle(fontFamily: 'GeistMono', color: color, fontSize: 14, fontWeight: FontWeight.w700), + ), + ], + ), + ); + } +} + +// ── Pill ────────────────────────────────────────────────────────────────────── + +class _Pill extends StatelessWidget { + const _Pill({required this.label, required this.value, required this.color}); + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: AppColors.glass2, + border: Border.all(color: AppColors.glassBorder), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 9, letterSpacing: 0.5), + ), + const SizedBox(height: 2), + RichText( + text: TextSpan(children: [ + TextSpan( + text: value, + style: TextStyle(fontFamily: 'GeistMono', color: color, fontSize: 16, fontWeight: FontWeight.w700), + ), + TextSpan(text: ' bpm', style: TextStyle(fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 9)), + ]), + ), + ], + ), + ), + ); + } +} + +// ── Curve painter ───────────────────────────────────────────────────────────── + +class _CurvePainter extends CustomPainter { + _CurvePainter({required this.analysis, required this.sections}); + + final WorkoutHrAnalysis analysis; + final List<_Section> sections; + + static const _padLeft = 24.0; + static const _padTop = 14.0; + static const _padBottom = 16.0; + + @override + void paint(Canvas canvas, Size size) { + final curve = analysis.curve; + if (curve.isEmpty) return; + + final startMs = analysis.start.millisecondsSinceEpoch; + final spanMs = max(analysis.end.millisecondsSinceEpoch - startMs, 1); + final vmin = (analysis.minBpm - 6).toDouble(); + final vmax = (analysis.peakBpm + 6).toDouble(); + + final chartW = size.width - _padLeft - 4; + final chartH = size.height - _padTop - _padBottom; + + double x(DateTime t) => + _padLeft + ((t.millisecondsSinceEpoch - startMs) / spanMs).clamp(0.0, 1.0) * chartW; + double y(double v) => _padTop + chartH - ((v - vmin) / (vmax - vmin)) * chartH; + + // Grid + Y labels. + final grid = Paint() + ..color = AppColors.glassBorder + ..strokeWidth = 0.5; + final yStyle = TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8); + for (var v = (vmin / 20).ceil() * 20; v <= vmax; v += 20) { + final yy = y(v.toDouble()); + canvas.drawLine(Offset(_padLeft, yy), Offset(size.width - 4, yy), grid); + final tp = TextPainter( + text: TextSpan(text: '${v.round()}', style: yStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(_padLeft - tp.width - 3, yy - tp.height / 2)); + } + + // Rest shading (green = recovered, amber = not). + for (final r in analysis.rests) { + final rx = x(r.restStart); + final rEnd = x(r.restStart.add(Duration(seconds: r.durationSec))); + final c = (r.recovered ? AppColors.success : AppColors.warning).withValues(alpha: 0.14); + canvas.drawRect(Rect.fromLTRB(rx, _padTop, max(rEnd, rx + 1), _padTop + chartH), Paint()..color = c); + } + + // Area + line. + final path = Path(); + final area = Path(); + for (var i = 0; i < curve.length; i++) { + final px = x(curve[i].time); + final py = y(curve[i].bpm); + if (i == 0) { + path.moveTo(px, py); + area.moveTo(px, y(vmin)); + area.lineTo(px, py); + } else { + path.lineTo(px, py); + area.lineTo(px, py); + } + } + area.lineTo(x(curve.last.time), y(vmin)); + area.close(); + canvas.drawPath( + area, + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.accent.withValues(alpha: 0.3), AppColors.accent.withValues(alpha: 0.0)], + ).createShader(Rect.fromLTWH(_padLeft, _padTop, chartW, chartH)), + ); + canvas.drawPath( + path, + Paint() + ..color = AppColors.accent + ..style = PaintingStyle.stroke + ..strokeWidth = 1.6 + ..strokeJoin = StrokeJoin.round, + ); + + // Exercise-section flags. + final flagPaint = Paint() + ..color = AppColors.textMuted.withValues(alpha: 0.5) + ..strokeWidth = 1; + for (final s in sections) { + final fx = x(s.start); + canvas.drawLine(Offset(fx, _padTop), Offset(fx, _padTop + chartH), flagPaint); + // Flag label chip at top. + final tp = TextPainter( + text: TextSpan( + text: s.label, + style: TextStyle(fontFamily: 'Geist', color: AppColors.textSoft, fontSize: 8, fontWeight: FontWeight.w600), + ), + textDirection: TextDirection.ltr, + maxLines: 1, + ellipsis: '…', + )..layout(maxWidth: 64); + final lx = min(fx + 3, size.width - 4 - tp.width - 6); + final chip = Rect.fromLTWH(lx, _padTop - 1, tp.width + 6, 11); + canvas.drawRRect( + RRect.fromRectAndRadius(chip, const Radius.circular(3)), + Paint()..color = AppColors.card.withValues(alpha: 0.92), + ); + tp.paint(canvas, Offset(lx + 3, _padTop - 0.5)); + } + + // X labels (minutes). + final xStyle = TextStyle(fontFamily: 'GeistMono', color: AppColors.textFaint, fontSize: 8); + final totalMin = (spanMs / 60000).round(); + final stepMin = totalMin <= 0 ? 1 : (totalMin / 4).ceil(); + for (var m = 0; m <= totalMin; m += stepMin) { + final tx = _padLeft + (m * 60000 / spanMs).clamp(0.0, 1.0) * chartW; + final tp = TextPainter( + text: TextSpan(text: '${m}m', style: xStyle), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset((tx - tp.width / 2).clamp(0, size.width - tp.width), size.height - _padBottom + 4)); + } + } + + @override + bool shouldRepaint(_CurvePainter old) => old.analysis != analysis; +} diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index d2c9f22..cdcab5b 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -1,15 +1,22 @@ -// Workout Flow Screen - Samsung Health-style minimal workout interface +// workout_flow_screen.dart — Active workout session screen import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; + import '../models/models.dart'; import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; +import '../services/managers/pr_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/workout_header.dart'; +import 'widgets/exercise_input_section.dart'; +import 'widgets/rest_timer_view.dart'; class WorkoutFlowScreen extends StatefulWidget { final Routine? routine; @@ -32,139 +39,125 @@ class WorkoutFlowScreen extends StatefulWidget { enum _LeaveAction { discard, keep, cancel } class _WorkoutFlowScreenState extends State { - // Rest timer state + // Rest timer bool _isResting = false; - int _restSeconds = 90; // Default rest time + int _restSeconds = 90; Timer? _restTimer; int _remainingSeconds = 0; - - // Superset cycling: index to return to after rest (null = no return) int? _supersetReturnIndex; - // Input controllers + // Set entry state double _currentWeight = 20; int _currentReps = 10; bool _isDropset = false; final List _drops = []; - // TextEditingControllers for dropset fields (following Flutter best practices) - final TextEditingController _mainWeightController = TextEditingController(); - final TextEditingController _mainRepsController = TextEditingController(); - final List _dropWeightControllers = []; - final List _dropRepsControllers = []; + // Text controllers + final TextEditingController _mainWeightCtrl = TextEditingController(); + final TextEditingController _mainRepsCtrl = TextEditingController(); + final List _dropWeightCtrls = []; + final List _dropRepsCtrls = []; + + // ── Lifecycle ─────────────────────────────────────────────────────────────── @override void initState() { super.initState(); - // Defer initialization until after the first frame to avoid - // calling notifyListeners() during build - WidgetsBinding.instance.addPostFrameCallback((_) { - _initializeWorkout(); - }); + WidgetsBinding.instance.addPostFrameCallback((_) => _initializeWorkout()); + } + + @override + void dispose() { + _restTimer?.cancel(); + _mainWeightCtrl.dispose(); + _mainRepsCtrl.dispose(); + for (final c in _dropWeightCtrls) { + c.dispose(); + } + for (final c in _dropRepsCtrls) { + c.dispose(); + } + super.dispose(); } - ProgramDay? _resolvedProgramDay(WorkoutProvider provider) => - widget.programDay ?? provider.activeProgramDay; + // ── Program helpers ───────────────────────────────────────────────────────── - ProgramWeek? _resolvedProgramWeek(WorkoutProvider provider) => - widget.programWeek ?? provider.activeProgramWeek; + ProgramDay? _resolvedDay(WorkoutProvider p) => + widget.programDay ?? p.activeProgramDay; - ProgramExerciseSlot? _slotForIndex(int idx, {WorkoutProvider? provider}) { - final resolvedProvider = provider ?? context.read(); - final day = _resolvedProgramDay(resolvedProvider); + ProgramWeek? _resolvedWeek(WorkoutProvider p) => + widget.programWeek ?? p.activeProgramWeek; + + ProgramExerciseSlot? _slot(int idx, {WorkoutProvider? p}) { + final provider = p ?? context.read(); + final day = _resolvedDay(provider); if (day == null) return null; - final slots = day.exercises; - return idx < slots.length ? slots[idx] : null; + return idx < day.exercises.length ? day.exercises[idx] : null; } - /// Finds the index of the first exercise in the same superset group, scanning - /// backward from [fromIdx]. - int _supersetGroupStart( - int fromIdx, - String groupId, { - WorkoutProvider? provider, - }) { - int start = fromIdx; + int _supersetGroupStart(int from, String groupId, {WorkoutProvider? p}) { + int start = from; while (start > 0 && - _slotForIndex(start - 1, provider: provider)?.supersetGroupId == - groupId) { + _slot(start - 1, p: p)?.supersetGroupId == groupId) { start--; } return start; } - /// Returns true if any exercise in [startIdx..endIdx] still has fewer sets - /// logged than its target (deload-adjusted). bool _supersetNeedsMoreSets({ required int startIdx, required int endIdx, - required WorkoutProvider provider, + required WorkoutProvider p, }) { - final week = _resolvedProgramWeek(provider); + final week = _resolvedWeek(p); for (int i = startIdx; i <= endIdx; i++) { - final slot = _slotForIndex(i, provider: provider); - if (slot == null) continue; - if (i >= provider.currentExerciseLogs.length) continue; - final targetSets = (week?.isDeload == true) - ? (slot.sets - (week?.deloadSetReduction ?? 0)).clamp(1, 99) - : slot.sets; - final logged = provider.currentExerciseLogs[i].sets.length; - if (logged < targetSets) return true; + final s = _slot(i, p: p); + if (s == null || i >= p.currentExerciseLogs.length) continue; + final target = week?.isDeload == true + ? (s.sets - (week?.deloadSetReduction ?? 0)).clamp(1, 99) + : s.sets; + if (p.currentExerciseLogs[i].sets.length < target) return true; } return false; } - /// Returns true if the single slot at [index] still needs more sets. - bool _slotNeedsMoreSets({ - required int index, - required WorkoutProvider provider, - }) { - final slot = _slotForIndex(index, provider: provider); - if (slot == null) return false; - if (index >= provider.currentExerciseLogs.length) return false; - final week = _resolvedProgramWeek(provider); - final targetSets = (week?.isDeload == true) - ? (slot.sets - (week?.deloadSetReduction ?? 0)).clamp(1, 99) - : slot.sets; - final logged = provider.currentExerciseLogs[index].sets.length; - return logged < targetSets; + bool _slotNeedsMoreSets({required int index, required WorkoutProvider p}) { + final s = _slot(index, p: p); + if (s == null || index >= p.currentExerciseLogs.length) return false; + final week = _resolvedWeek(p); + final target = week?.isDeload == true + ? (s.sets - (week?.deloadSetReduction ?? 0)).clamp(1, 99) + : s.sets; + return p.currentExerciseLogs[index].sets.length < target; } + // ── Init / data load ──────────────────────────────────────────────────────── + void _initializeWorkout() { final provider = context.read(); - final programDay = _resolvedProgramDay(provider); - final programWeek = _resolvedProgramWeek(provider); + final day = _resolvedDay(provider); + final week = _resolvedWeek(provider); if (provider.hasActiveWorkout) { - final slot = _slotForIndex( - provider.currentExerciseIndex, - provider: provider, - ); - if (slot != null) { - _restSeconds = slot.restSeconds; - } + final s = _slot(provider.currentExerciseIndex, p: provider); + if (s != null) _restSeconds = s.restSeconds; _loadLastSessionData(); return; } - if (programDay != null) { - final exerciseIds = programDay.exercises - .map((s) => s.exerciseId) - .toList(); + if (day != null) { provider.startWorkout( - exerciseIds: exerciseIds, - programDay: programDay, - programWeek: programWeek, + exerciseIds: day.exercises.map((s) => s.exerciseId).toList(), + programDay: day, + programWeek: week, ); - // Set initial rest time from first slot - final firstSlot = _slotForIndex(0, provider: provider); - if (firstSlot != null) _restSeconds = firstSlot.restSeconds; + final first = _slot(0, p: provider); + if (first != null) _restSeconds = first.restSeconds; _loadLastSessionData(); } else if (widget.routine != null) { provider.startWorkout(routine: widget.routine); _loadLastSessionData(); } else if (widget.isQuickStart) { - // Will add exercises as we go provider.startWorkout(exerciseIds: []); } } @@ -172,50 +165,34 @@ class _WorkoutFlowScreenState extends State { void _loadLastSessionData() { final provider = context.read(); final settings = context.read(); - final currentExercise = provider.currentExercise; - if (currentExercise == null) return; + final exercise = provider.currentExercise; + if (exercise == null) return; - final lastSession = provider.getLastSessionForExercise(currentExercise.id); - if (lastSession != null && lastSession.sets.isNotEmpty) { - final lastSet = lastSession.sets.last; + final last = provider.getLastSessionForExercise(exercise.id); + if (last != null && last.sets.isNotEmpty) { + final lastSet = last.sets.last; setState(() { - _currentWeight = lastSet.weight; // always stored in kg + _currentWeight = lastSet.weight; _currentReps = lastSet.reps; - // Sync controllers using display unit - final displayWeight = settings.toDisplay(_currentWeight); - _mainWeightController.text = - displayWeight == displayWeight.truncateToDouble() - ? displayWeight.toStringAsFixed(0) - : displayWeight.toStringAsFixed(1); - _mainRepsController.text = _currentReps.toString(); + final dw = settings.toDisplay(_currentWeight); + _mainWeightCtrl.text = dw == dw.truncateToDouble() + ? dw.toStringAsFixed(0) + : dw.toStringAsFixed(1); + _mainRepsCtrl.text = _currentReps.toString(); }); } } - @override - void dispose() { - _restTimer?.cancel(); - // Dispose TextEditingControllers to prevent memory leaks (Flutter best practice) - _mainWeightController.dispose(); - _mainRepsController.dispose(); - for (var controller in _dropWeightControllers) { - controller.dispose(); - } - for (var controller in _dropRepsControllers) { - controller.dispose(); - } - super.dispose(); - } + // ── Build ─────────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { final provider = context.watch(); if (!provider.hasActiveWorkout) { - return const Scaffold(body: Center(child: Text('No active workout'))); + return const Scaffold(body: Center(child: CircularProgressIndicator())); } - // If no exercises yet (quick start), show exercise selector if (provider.currentExerciseLogs.isEmpty) { return _buildExerciseSelector(); } @@ -224,13 +201,11 @@ class _WorkoutFlowScreenState extends State { canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; - unawaited(_handleSystemBack()); + unawaited(_handleBack()); }, child: Scaffold( - backgroundColor: AppTheme.backgroundColor, - body: SafeArea( - child: _isResting ? _buildRestTimerView() : _buildWorkoutView(), - ), + backgroundColor: AppColors.background, + body: _isResting ? _buildRestView(provider) : _buildWorkoutView(provider), ), ); } @@ -240,1091 +215,328 @@ class _WorkoutFlowScreenState extends State { appBar: AppBar( title: const Text('Select Exercises'), leading: IconButton( - icon: const Icon(Icons.close), + icon: const Icon(Icons.close_rounded), onPressed: _showCancelDialog, ), ), body: ExerciseSelectorScreen( selectionMode: true, - onExercisesSelected: _startWithSelectedExercises, + onExercisesSelected: _startWithSelected, + ), + floatingActionButton: Padding( + padding: const EdgeInsets.only(bottom: AppBreakpoints.navBarClearance), + child: FloatingActionButton.extended( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AddCustomExerciseScreen()), + ), + backgroundColor: AppColors.card, + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + side: const BorderSide(color: AppColors.glassBorderStrong), + ), + icon: const Icon(Icons.add_rounded, color: AppColors.primary), + label: const Text( + 'New exercise', + style: TextStyle( + color: AppColors.textSoft, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ), ), ); } - Future _startWithSelectedExercises(List exerciseIds) async { - if (exerciseIds.isEmpty) return; - + Future _startWithSelected(List ids) async { + if (ids.isEmpty) return; final provider = context.read(); - // Restart workout with selected exercises await provider.cancelWorkout(); - provider.startWorkout(exerciseIds: exerciseIds); + provider.startWorkout(exerciseIds: ids); } - Widget _buildWorkoutView() { - final provider = context.watch(); - final currentExercise = provider.currentExercise; - final currentLog = provider.currentExerciseLog; - final recommendations = currentExercise != null - ? provider.getRecommendations(currentExercise.id) + Widget _buildWorkoutView(WorkoutProvider provider) { + final exercise = provider.currentExercise; + final log = provider.currentExerciseLog; + final settings = context.watch(); + final totalExercises = provider.currentExerciseLogs.length; + final idx = provider.currentExerciseIndex; + final isFirst = idx == 0; + final isLast = idx >= totalExercises - 1; + + final recommendations = exercise != null + ? provider.getRecommendations(exercise.id) : []; + final lastSession = exercise != null + ? provider.getLastSessionForExercise(exercise.id) + : null; + return Column( children: [ - // Header - _buildHeader(provider, currentExercise), - - // Main content + WorkoutHeader( + exerciseName: exercise?.name ?? 'Workout', + currentExerciseIndex: idx, + totalExercises: totalExercises, + 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: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Program metadata banner (shown only in program-mode) - _buildProgramMetaBanner(provider), - - // Recommendation card - if (recommendations.isNotEmpty && currentLog != null) - _buildRecommendationCard( - recommendations, - currentLog.sets.length, - ), - - const SizedBox(height: AppSpacing.lg), - - // Weight and reps input - if (!_isDropset) _buildInputSection(provider), - - if (!_isDropset) const SizedBox(height: AppSpacing.md), - - // Dropset toggle - _buildDropsetSection(), - - const SizedBox(height: AppSpacing.lg), - - // Set done button - _buildSetDoneButton(), - - const SizedBox(height: AppSpacing.lg), - - // Previous sets - if (currentLog != null && currentLog.sets.isNotEmpty) - _buildPreviousSets(currentLog.sets), - - const SizedBox(height: AppSpacing.lg), - - // Last session info - if (currentExercise != null) - _buildLastSessionInfo(currentExercise.id), - ], + child: ExerciseInputSection( + currentWeight: _currentWeight, + currentReps: _currentReps, + isDropset: _isDropset, + drops: _drops, + mainWeightController: _mainWeightCtrl, + mainRepsController: _mainRepsCtrl, + dropWeightControllers: _dropWeightCtrls, + dropRepsControllers: _dropRepsCtrls, + recommendations: recommendations, + previousSets: log?.sets ?? [], + lastSession: lastSession, + settings: settings, + exerciseId: exercise?.id, + programSlot: _slot(idx, p: provider), + programWeek: _resolvedWeek(provider), + onWeightChanged: (v) => setState(() => _currentWeight = v), + onRepsChanged: (v) => setState(() => _currentReps = v), + onDropsetToggled: _toggleDropset, + onDropAdded: _addDrop, + onDropRemoved: _removeDrop, + onDropWeightChanged: (i, w) { + if (i == -1) { + _currentWeight = w; + } else if (i < _drops.length) { + _drops[i] = DropsetEntry(weight: w, reps: _drops[i].reps); + } + }, + onDropRepsChanged: (i, r) { + if (i == -1) { + _currentReps = r; + } else if (i < _drops.length) { + _drops[i] = DropsetEntry(weight: _drops[i].weight, reps: r); + } + }, + onLogSet: _completeSet, + onApplyRecommendation: () { + if (recommendations.isEmpty) return; + final setIdx = (log?.sets.length ?? 0) + .clamp(0, recommendations.length - 1); + final rec = recommendations[setIdx]; + setState(() { + _currentWeight = rec.weight; + _currentReps = rec.reps; + final settings = context.read(); + final dw = settings.toDisplay(rec.weight); + _mainWeightCtrl.text = dw == dw.truncateToDouble() + ? dw.toStringAsFixed(0) + : dw.toStringAsFixed(1); + _mainRepsCtrl.text = rec.reps.toString(); + }); + }, ), ), ), - - // Bottom actions - _buildBottomActions(provider), + _buildBottomNav(provider, isFirst, isLast), ], ); } - Widget _buildHeader(WorkoutProvider provider, Exercise? exercise) { - final totalExercises = provider.currentExerciseLogs.length; - final currentIndex = provider.currentExerciseIndex + 1; - final currentLog = provider.currentExerciseLog; - final setNumber = (currentLog?.sets.length ?? 0) + 1; + Widget _buildRestView(WorkoutProvider provider) { + final idx = provider.currentExerciseIndex; + final nextExercise = idx + 1 < provider.currentExerciseLogs.length + ? provider.getExerciseName( + provider.currentExerciseLogs[idx + 1].exerciseId) + : null; - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.surfaceColor, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - children: [ - Row( - children: [ - IconButton( - icon: const Icon(Icons.close), - onPressed: _showCancelDialog, - ), - Expanded( - child: Column( - children: [ - Text( - exercise?.name ?? 'Select Exercise', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 4), - Text( - 'Exercise $currentIndex of $totalExercises • Set $setNumber', - style: const TextStyle( - fontSize: 14, - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.more_vert), - onPressed: _showOptionsMenu, - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - // Progress bar - LinearProgressIndicator( - value: currentIndex / totalExercises, - backgroundColor: AppTheme.cardColor, - valueColor: const AlwaysStoppedAnimation(AppTheme.primaryColor), - borderRadius: BorderRadius.circular(4), - ), - ], - ), + return RestTimerView( + remainingSeconds: _remainingSeconds, + totalSeconds: _restSeconds, + onAdjust: _adjustRest, + onSkip: _skipRest, + nextExerciseName: nextExercise, ); } - Widget _buildRecommendationCard( - List recommendations, - int currentSetIndex, - ) { - if (currentSetIndex >= recommendations.length) return const SizedBox(); - - final rec = recommendations[currentSetIndex]; - final settings = context.watch(); - final displayWeight = settings.toDisplay(rec.weight); - final displayWeightText = displayWeight == displayWeight.truncateToDouble() - ? displayWeight.toStringAsFixed(0) - : displayWeight.toStringAsFixed(1); - final confidenceColor = rec.confidence == 'high' - ? AppTheme.success - : (rec.confidence == 'medium' ? AppTheme.warning : AppTheme.textMuted); - + Widget _buildBottomNav(WorkoutProvider provider, bool isFirst, bool isLast) { + final bottomPad = MediaQuery.of(context).padding.bottom; return Container( - padding: const EdgeInsets.all(AppSpacing.md), + padding: EdgeInsets.fromLTRB(16, 12, 16, 12 + bottomPad), decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppTheme.primaryColor.withOpacity(0.2), - AppTheme.secondaryColor.withOpacity(0.1), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all(color: AppTheme.primaryColor.withOpacity(0.3)), + color: AppColors.surface.withValues(alpha: 0.95), + border: Border(top: BorderSide(color: AppColors.glassBorder)), ), child: Row( children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.lightbulb_outline, - color: AppTheme.primaryColor, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Suggested', - style: TextStyle(color: AppTheme.textSecondary, fontSize: 12), - ), - Text( - '$displayWeightText ${settings.unitLabel} × ${rec.reps} reps', - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - TextButton( - onPressed: () { - setState(() { - _currentWeight = rec.weight; - _currentReps = rec.reps; - }); - HapticFeedback.lightImpact(); - }, - child: const Text('Apply'), - ), - ], - ), - ); - } - - Widget _buildInputSection(WorkoutProvider provider) { - final settings = context.watch(); - final exerciseId = provider.currentExercise?.id ?? ''; - final isAssistedBodyweight = - exerciseId == 'pull_ups' || exerciseId == 'chin_ups'; - final weightLabel = isAssistedBodyweight - ? 'Assist (${settings.unitLabel})' - : 'Weight (${settings.unitLabel})'; - - final displayWeight = settings.toDisplay(_currentWeight); - - return Row( - children: [ - // Weight input - Expanded( - child: _buildNumberInput( - label: weightLabel, - value: displayWeight, - onChanged: (val) => - setState(() => _currentWeight = settings.toStorage(val)), - step: settings.weightIncrement, - decimals: 1, - ), - ), - const SizedBox(width: AppSpacing.md), - // Reps input - Expanded( - child: _buildNumberInput( - label: 'Reps', - value: _currentReps.toDouble(), - onChanged: (val) => setState(() => _currentReps = val.toInt()), - step: 1, - decimals: 0, - ), - ), - ], - ); - } - - Widget _buildNumberInput({ - required String label, - required double value, - required Function(double) onChanged, - required double step, - required int decimals, - }) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - children: [ - Text( - label, - style: const TextStyle(color: AppTheme.textSecondary, fontSize: 14), - ), - const SizedBox(height: AppSpacing.sm), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildCircleButton( - icon: Icons.remove, - onPressed: () { - onChanged((value - step).clamp(0, 999)); - HapticFeedback.selectionClick(); + if (!isFirst) + Flexible( + child: GestureDetector( + onTap: () { + provider.previousExercise(); + _loadLastSessionData(); }, - ), - Expanded( - child: GestureDetector( - onTap: () => _showNumberPicker(value, decimals, onChanged), - child: Text( - decimals == 0 - ? value.toInt().toString() - : value.toStringAsFixed(decimals), - style: const TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - textAlign: TextAlign.center, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.glassBorderStrong), ), - ), - ), - _buildCircleButton( - icon: Icons.add, - onPressed: () { - onChanged((value + step).clamp(0, 999)); - HapticFeedback.selectionClick(); - }, - ), - ], - ), - ], - ), - ); - } - - Widget _buildCircleButton({ - required IconData icon, - required VoidCallback onPressed, - }) { - return Material( - color: AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(20), - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(20), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppTheme.primaryColor.withOpacity(0.5)), - ), - child: Icon(icon, color: AppTheme.primaryColor), - ), - ), - ); - } - - Widget _buildDropsetSection() { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - children: [ - Row( - children: [ - const Icon(Icons.trending_down, color: AppTheme.warning), - const SizedBox(width: 8), - const Text( - 'Dropset', - style: TextStyle( - color: AppTheme.textPrimary, - fontWeight: FontWeight.w500, - ), - ), - const Spacer(), - Switch( - value: _isDropset, - onChanged: (val) { - setState(() { - _isDropset = val; - if (!val) { - // Dispose all drop controllers when turning off dropset mode - for (var controller in _dropWeightControllers) { - controller.dispose(); - } - for (var controller in _dropRepsControllers) { - controller.dispose(); - } - _dropWeightControllers.clear(); - _dropRepsControllers.clear(); - _drops.clear(); - } else { - // Sync main controllers when enabling dropset to match current state values - _mainWeightController.text = _currentWeight.toString(); - _mainRepsController.text = _currentReps.toString(); - } - }); - }, - activeThumbColor: AppTheme.warning, - ), - ], - ), - if (_isDropset) ...[ - const SizedBox(height: AppSpacing.md), - _buildMainSetEntry(), - ..._drops.asMap().entries.map( - (entry) => _buildDropEntry(entry.key), - ), - TextButton.icon( - onPressed: _addDrop, - icon: const Icon(Icons.add), - label: const Text('Add Drop'), - ), - ], - ], - ), - ); - } - - Widget _buildMainSetEntry() { - final settings = context.read(); - // Controllers are initialized in _loadLastSessionData and updated via onChanged - // No controller.text assignments in build to avoid cursor jumps - return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: Row( - children: [ - const Text('Start:', style: TextStyle(color: AppTheme.textSecondary)), - const SizedBox(width: 8), - Expanded( - child: Row( - children: [ - SizedBox( - width: 60, - child: TextFormField( - controller: _mainWeightController, - decoration: InputDecoration( - hintText: settings.unitLabel, - contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, + 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, + ), ), - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), ], - onChanged: (val) { - final parsed = double.tryParse(val); - if (parsed != null) { - // Convert from display unit to kg for storage - _currentWeight = settings.toStorage(parsed); - } - }, ), ), - const Text( - ' × ', - style: TextStyle(color: AppTheme.textSecondary), + ), + ) + 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(14), + boxShadow: [ + BoxShadow( + color: (isLast ? AppColors.success : AppColors.primary) + .withValues(alpha: 0.35), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], ), - SizedBox( - width: 50, - child: TextFormField( - controller: _mainRepsController, - decoration: const InputDecoration( - hintText: 'reps', - contentPadding: EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, + 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, + ), ), ), - keyboardType: const TextInputType.numberWithOptions( - signed: false, - decimal: false, + const SizedBox(width: 6), + Icon( + isLast ? Icons.check_rounded : Icons.arrow_forward_rounded, + size: 16, + color: Colors.white, ), - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - onChanged: (val) { - final parsed = int.tryParse(val); - if (parsed != null) { - _currentReps = parsed; - } - }, - ), + ], ), - ], + ), ), ), - const SizedBox(width: 48), // Align with delete button ], ), ); } - Widget _buildDropEntry(int index) { - // Controllers are created and initialized in _addDrop() - // Build method only READS from controllers, never creates or modifies them - // This prevents cursor jumps and duplicate controller creation + // ── Dropset helpers ───────────────────────────────────────────────────────── - return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: Row( - children: [ - Text( - 'Drop ${index + 1}:', - style: const TextStyle(color: AppTheme.textSecondary), - ), - const SizedBox(width: 8), - Expanded( - child: Row( - children: [ - SizedBox( - width: 60, - child: TextFormField( - controller: _dropWeightControllers[index], - decoration: InputDecoration( - hintText: context.read().unitLabel, - contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, - ), - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), - ], - onChanged: (val) { - final parsed = double.tryParse(val); - if (parsed != null) { - final settings = context.read(); - _drops[index] = DropsetEntry( - weight: settings.toStorage(parsed), - reps: _drops[index].reps, - ); - } - }, - ), - ), - const Text( - ' × ', - style: TextStyle(color: AppTheme.textSecondary), - ), - SizedBox( - width: 50, - child: TextFormField( - controller: _dropRepsControllers[index], - decoration: const InputDecoration( - hintText: 'reps', - contentPadding: EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, - ), - ), - keyboardType: const TextInputType.numberWithOptions( - signed: false, - decimal: false, - ), - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - onChanged: (val) { - final parsed = int.tryParse(val); - if (parsed != null) { - _drops[index] = DropsetEntry( - weight: _drops[index].weight, - reps: parsed, - ); - } - }, - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.close, size: 18), - onPressed: () { - // Dispose controllers for this drop before removing - if (index < _dropWeightControllers.length) { - _dropWeightControllers[index].dispose(); - _dropWeightControllers.removeAt(index); - } - if (index < _dropRepsControllers.length) { - _dropRepsControllers[index].dispose(); - _dropRepsControllers.removeAt(index); - } - setState(() => _drops.removeAt(index)); - }, - ), - ], - ), - ); + void _toggleDropset(bool val) { + setState(() { + _isDropset = val; + if (!val) { + for (final c in _dropWeightCtrls) { + c.dispose(); + } + for (final c in _dropRepsCtrls) { + c.dispose(); + } + _dropWeightCtrls.clear(); + _dropRepsCtrls.clear(); + _drops.clear(); + } else { + final settings = context.read(); + final dw = settings.toDisplay(_currentWeight); + _mainWeightCtrl.text = dw == dw.truncateToDouble() + ? dw.toStringAsFixed(0) + : dw.toStringAsFixed(1); + _mainRepsCtrl.text = _currentReps.toString(); + } + }); } void _addDrop() { + final settings = context.read(); setState(() { final lastWeight = _drops.isEmpty ? _currentWeight : _drops.last.weight; final newWeight = (lastWeight * 0.8).roundToDouble(); - _drops.add(DropsetEntry(weight: newWeight, reps: _currentReps)); - - // Create controllers for the new drop (Flutter best practice) - _dropWeightControllers.add( - TextEditingController(text: newWeight.toString()), - ); - _dropRepsControllers.add( + final dw = settings.toDisplay(newWeight); + final dwStr = dw == dw.truncateToDouble() + ? dw.toStringAsFixed(0) + : dw.toStringAsFixed(1); + _dropWeightCtrls.add(TextEditingController(text: dwStr)); + _dropRepsCtrls.add( TextEditingController(text: _currentReps.toString()), ); }); } - Widget _buildSetDoneButton() { - return SizedBox( - width: double.infinity, - height: 60, - child: ElevatedButton( - onPressed: _completeSet, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.success, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.md), - ), - ), - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.check_circle, size: 28), - SizedBox(width: 12), - Text( - 'SET DONE', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ], - ), - ), - ); - } - - Widget _buildPreviousSets(List sets) { - final settings = context.watch(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'This Session', - style: TextStyle( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: AppSpacing.sm), - ...sets.asMap().entries.map((entry) { - final index = entry.key; - final set = entry.value; - final oneRM = WorkoutProvider.estimateOneRM(set.weight, set.reps); - final displayWeight = settings.toDisplay(set.weight); - final weightStr = displayWeight == displayWeight.truncateToDouble() - ? displayWeight.toStringAsFixed(0) - : displayWeight.toStringAsFixed(1); - return Container( - margin: const EdgeInsets.only(bottom: AppSpacing.sm), - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: Row( - children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: AppTheme.success.withOpacity(0.2), - borderRadius: BorderRadius.circular(14), - ), - child: const Icon( - Icons.check, - color: AppTheme.success, - size: 16, - ), - ), - const SizedBox(width: 12), - Text( - 'Set ${index + 1}', - style: const TextStyle(color: AppTheme.textSecondary), - ), - const Spacer(), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '$weightStr ${settings.unitLabel} × ${set.reps}', - style: const TextStyle( - color: AppTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - if (set.reps > 1) - Text( - '~${settings.formatWeight(oneRM)} 1RM', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 11, - ), - ), - ], - ), - if (set.isDropset) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'DROP', - style: TextStyle( - color: AppTheme.warning, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ], - ), - ); - }), - ], - ); - } - - Widget _buildLastSessionInfo(String exerciseId) { - final provider = context.read(); - final lastSession = provider.getLastSessionForExercise(exerciseId); - - if (lastSession == null) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: const Row( - children: [ - Icon(Icons.info_outline, color: AppTheme.textMuted), - SizedBox(width: 12), - Expanded( - child: Text( - 'First time doing this exercise!', - style: TextStyle(color: AppTheme.textSecondary), - ), - ), - ], - ), - ); + void _removeDrop(int index) { + if (index < _dropWeightCtrls.length) { + _dropWeightCtrls[index].dispose(); + _dropWeightCtrls.removeAt(index); } - - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Last Session', - style: TextStyle( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: 8, - runSpacing: 8, - children: lastSession.sets.asMap().entries.map((entry) { - final set = entry.value; - final settings = context.read(); - return Chip( - label: Text( - '${settings.formatWeight(set.weight)} × ${set.reps}', - style: const TextStyle(fontSize: 12), - ), - backgroundColor: AppTheme.surfaceColor, - ); - }).toList(), - ), - ], - ), - ); - } - - Widget _buildBottomActions(WorkoutProvider provider) { - final isFirst = provider.currentExerciseIndex == 0; - final isLast = - provider.currentExerciseIndex >= - provider.currentExerciseLogs.length - 1; - - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.surfaceColor, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 8, - offset: const Offset(0, -2), - ), - ], - ), - child: Row( - children: [ - if (!isFirst) - Expanded( - child: OutlinedButton.icon( - onPressed: () { - provider.previousExercise(); - _loadLastSessionData(); - }, - icon: const Icon(Icons.arrow_back), - label: const Text('Previous'), - ), - ) - else - const Spacer(), - const SizedBox(width: AppSpacing.md), - Expanded( - child: ElevatedButton.icon( - onPressed: isLast - ? _finishWorkout - : () { - provider.nextExercise(); - _loadLastSessionData(); - }, - icon: Icon(isLast ? Icons.check : Icons.arrow_forward), - label: Text(isLast ? 'Finish' : 'Next'), - ), - ), - ], - ), - ); - } - - // ==================== Program Meta Banner ==================== - - Widget _buildProgramMetaBanner(WorkoutProvider provider) { - final week = _resolvedProgramWeek(provider); - if (_resolvedProgramDay(provider) == null || week == null) { - return const SizedBox.shrink(); + if (index < _dropRepsCtrls.length) { + _dropRepsCtrls[index].dispose(); + _dropRepsCtrls.removeAt(index); } - final slot = _slotForIndex( - provider.currentExerciseIndex, - provider: provider, - ); - if (slot == null) return const SizedBox.shrink(); - - final displaySets = week.isDeload - ? (slot.sets - week.deloadSetReduction).clamp(1, 99) - : slot.sets; - final repRange = slot.minReps == slot.maxReps - ? '${slot.minReps} reps' - : '${slot.minReps}–${slot.maxReps} reps'; - - return Container( - margin: const EdgeInsets.only(bottom: AppSpacing.md), - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all( - color: week.isDeload - ? Colors.amber.withOpacity(0.4) - : AppTheme.primaryColor.withOpacity(0.3), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (week.isDeload) ...[ - const Icon( - Icons.battery_charging_full, - size: 14, - color: Colors.amber, - ), - const SizedBox(width: 4), - const Text( - 'DELOAD ', - style: TextStyle( - fontSize: 11, - color: Colors.amber, - fontWeight: FontWeight.bold, - letterSpacing: 0.6, - ), - ), - ], - Text( - 'Target: $displaySets × $repRange', - style: const TextStyle( - fontSize: 13, - color: AppTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - const SizedBox(height: 6), - Wrap( - spacing: AppSpacing.md, - runSpacing: 4, - children: [ - _programChip( - icon: Icons.timer_outlined, - label: '${slot.restSeconds}s rest', - color: AppTheme.textSecondary, - ), - if (slot.tempo != null) - _programChip( - icon: Icons.speed, - label: 'Tempo ${slot.tempo}', - color: AppTheme.secondaryColor, - ), - if (slot.weightPercentage != null) - _programChip( - icon: Icons.fitness_center, - label: week.isDeload - ? '${(slot.weightPercentage! * week.deloadIntensityFactor).toStringAsFixed(0)}% 1RM' - : '${slot.weightPercentage!.toStringAsFixed(0)}% 1RM', - color: AppTheme.primaryColor, - ), - if (slot.supersetGroupId != null) - _programChip( - icon: Icons.link, - label: 'Superset', - color: AppTheme.secondaryColor, - ), - ], - ), - if (slot.notes != null) ...[ - const SizedBox(height: 4), - Text( - slot.notes!, - style: const TextStyle( - fontSize: 11, - color: AppTheme.textMuted, - fontStyle: FontStyle.italic, - ), - ), - ], - ], - ), - ); - } - - Widget _programChip({ - required IconData icon, - required String label, - required Color color, - }) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 11, color: color), - const SizedBox(width: 3), - Text(label, style: TextStyle(fontSize: 11, color: color)), - ], - ); - } - - // ==================== Rest Timer View ==================== - - Widget _buildRestTimerView() { - final minutes = _remainingSeconds ~/ 60; - final seconds = _remainingSeconds % 60; - - return Container( - width: double.infinity, - color: AppTheme.backgroundColor, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'REST TIME', - style: TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - letterSpacing: 2, - ), - ), - const SizedBox(height: AppSpacing.lg), - Text( - '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}', - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 72, - fontWeight: FontWeight.w200, - ), - ), - const SizedBox(height: AppSpacing.xl), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildTimerButton( - icon: Icons.remove_circle_outline, - label: '-30s', - onPressed: () => _adjustRestTime(-30), - ), - const SizedBox(width: AppSpacing.lg), - _buildTimerButton( - icon: Icons.add_circle_outline, - label: '+30s', - onPressed: () => _adjustRestTime(30), - ), - ], - ), - const SizedBox(height: AppSpacing.xxl), - SizedBox( - width: 200, - child: ElevatedButton( - onPressed: _skipRest, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryColor, - padding: const EdgeInsets.symmetric(vertical: 16), - ), - child: const Text( - 'SKIP', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - ), - ], - ), - ); - } - - Widget _buildTimerButton({ - required IconData icon, - required String label, - required VoidCallback onPressed, - }) { - return GestureDetector( - onTap: onPressed, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Row( - children: [ - Icon(icon, color: AppTheme.textSecondary), - const SizedBox(width: 8), - Text( - label, - style: const TextStyle( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ); + setState(() => _drops.removeAt(index)); } - // ==================== Actions ==================== + // ── Set completion ────────────────────────────────────────────────────────── void _completeSet() { final provider = context.read(); - final currentIdx = provider.currentExerciseIndex; - final currentSlot = _slotForIndex(currentIdx, provider: provider); - final nextSlot = _slotForIndex(currentIdx + 1, provider: provider); + final idx = provider.currentExerciseIndex; + final currentSlot = _slot(idx, p: provider); + final nextSlot = _slot(idx + 1, p: provider); final set = WorkoutSet( weight: _currentWeight, @@ -1336,56 +548,40 @@ class _WorkoutFlowScreenState extends State { provider.addSet(set); HapticFeedback.heavyImpact(); - // Update rest time from current slot - if (currentSlot != null) { - _restSeconds = currentSlot.restSeconds; - } + if (currentSlot != null) _restSeconds = currentSlot.restSeconds; - // Reset dropset state and dispose controllers to prevent memory leaks setState(() { _isDropset = false; - // Dispose all drop controllers before clearing - for (var controller in _dropWeightControllers) { - controller.dispose(); + for (final c in _dropWeightCtrls) { + c.dispose(); } - for (var controller in _dropRepsControllers) { - controller.dispose(); + for (final c in _dropRepsCtrls) { + c.dispose(); } - _dropWeightControllers.clear(); - _dropRepsControllers.clear(); + _dropWeightCtrls.clear(); + _dropRepsCtrls.clear(); _drops.clear(); }); - // Superset auto-advance: if next exercise is in the same superset group - // AND the next slot still needs more sets, advance immediately. - final isSupersetPair = - currentSlot?.supersetGroupId != null && + // Superset auto-advance + final isSupersetPair = currentSlot?.supersetGroupId != null && nextSlot?.supersetGroupId == currentSlot?.supersetGroupId; if (isSupersetPair && - _slotNeedsMoreSets(index: currentIdx + 1, provider: provider)) { + _slotNeedsMoreSets(index: idx + 1, p: provider)) { provider.nextExercise(); _loadLastSessionData(); - // Apply the next slot's rest time so the subsequent rest is correct - final newSlot = _slotForIndex( - provider.currentExerciseIndex, - provider: provider, - ); + final newSlot = _slot(provider.currentExerciseIndex, p: provider); if (newSlot != null) setState(() => _restSeconds = newSlot.restSeconds); } else { - // Detect if we just finished the last exercise in a superset group. - // If the group still needs more sets, schedule a return after rest. final groupId = currentSlot?.supersetGroupId; if (groupId != null) { - final groupStart = _supersetGroupStart( - currentIdx, - groupId, - provider: provider, - ); + final groupStart = + _supersetGroupStart(idx, groupId, p: provider); if (_supersetNeedsMoreSets( startIdx: groupStart, - endIdx: currentIdx, - provider: provider, + endIdx: idx, + p: provider, )) { _supersetReturnIndex = groupStart; } @@ -1394,13 +590,14 @@ class _WorkoutFlowScreenState extends State { } } + // ── Rest timer ────────────────────────────────────────────────────────────── + void _startRestTimer() { setState(() { _isResting = true; _remainingSeconds = _restSeconds; }); - - _restTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + _restTimer = Timer.periodic(const Duration(seconds: 1), (t) { if (_remainingSeconds <= 0) { _skipRest(); } else { @@ -1417,182 +614,49 @@ class _WorkoutFlowScreenState extends State { _remainingSeconds = 0; _supersetReturnIndex = null; }); - - // If in a superset cycle, auto-return to the first exercise in the group if (returnIdx != null) { final provider = context.read(); provider.goToExercise(returnIdx); _loadLastSessionData(); - final slot = _slotForIndex(returnIdx, provider: provider); - if (slot != null) setState(() => _restSeconds = slot.restSeconds); + final s = _slot(returnIdx, p: provider); + if (s != null) setState(() => _restSeconds = s.restSeconds); } - HapticFeedback.lightImpact(); } - void _adjustRestTime(int seconds) { + void _adjustRest(int delta) { setState(() { - _remainingSeconds = (_remainingSeconds + seconds).clamp(0, 600).toInt(); - _restSeconds = (_restSeconds + seconds).clamp(30, 600).toInt(); + _remainingSeconds = (_remainingSeconds + delta).clamp(0, 600); + _restSeconds = (_restSeconds + delta).clamp(30, 600); }); HapticFeedback.selectionClick(); } - void _showNumberPicker( - double currentValue, - int decimals, - Function(double) onChanged, - ) { - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardColor, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => _NumberPickerContent( - initialValue: currentValue, - decimals: decimals, - onChanged: onChanged, - ), - ); - } - - void _showOptionsMenu() { - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => Container( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.timer), - title: const Text('Set Rest Timer'), - subtitle: Text('Currently: ${_restSeconds}s'), - onTap: () { - Navigator.pop(context); - _showRestTimerSettings(); - }, - ), - ListTile( - leading: const Icon(Icons.note_add), - title: const Text('Add Notes'), - onTap: () { - Navigator.pop(context); - // Show notes dialog - }, - ), - ListTile( - leading: const Icon(Icons.undo), - title: const Text('Remove Last Set'), - onTap: () { - context.read().removeLastSet(); - Navigator.pop(context); - }, - ), - ], - ), - ), - ); - } - - void _showRestTimerSettings() { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Default Rest Time'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [30, 60, 90, 120, 150, 180].map((seconds) { - return ListTile( - title: Text('$seconds seconds'), - trailing: _restSeconds == seconds - ? const Icon(Icons.check, color: AppTheme.primaryColor) - : null, - onTap: () { - setState(() => _restSeconds = seconds); - Navigator.pop(context); - }, - ); - }).toList(), - ), - ), - ); - } - - Future _handleSystemBack() async { - final action = await _showLeaveDialog(); - if (!mounted) return; - - if (action == _LeaveAction.discard) { - await context.read().cancelWorkout(); - if (mounted) { - Navigator.of(context).pop(); - } - return; - } - - if (action == _LeaveAction.keep) { - Navigator.of(context).pop(); - } - } - - Future<_LeaveAction> _showLeaveDialog() async { - final action = await showDialog<_LeaveAction>( - context: context, - builder: (context) => AlertDialog( - title: const Text('Leave workout?'), - content: const Text( - 'Your progress is saved. You can resume it next time you start a workout.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(_LeaveAction.discard), - style: TextButton.styleFrom(foregroundColor: AppTheme.error), - child: const Text('Discard workout'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(_LeaveAction.keep), - child: const Text('Keep & exit'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(_LeaveAction.cancel), - child: const Text('Cancel'), - ), - ], - ), - ); - - return action ?? _LeaveAction.cancel; - } + // ── Dialogs ───────────────────────────────────────────────────────────────── void _showCancelDialog() { showDialog( context: context, - builder: (dialogContext) => AlertDialog( + 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(dialogContext), - child: const Text('Continue Workout'), + 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; - Navigator.pop(dialogContext); // Close dialog - Navigator.pop(context); // Close workout screen + ctxNav.pop(); + nav.pop(); }, - child: const Text( - 'Cancel Workout', - style: TextStyle(color: AppTheme.error), - ), + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Discard'), ), ], ), @@ -1602,126 +666,75 @@ class _WorkoutFlowScreenState extends State { void _finishWorkout() { showDialog( context: context, - builder: (context) => AlertDialog( + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.cardHigh, title: const Text('Finish Workout?'), - content: const Text('Save this workout session?'), + content: const Text('Ready to save this session?'), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.pop(ctx), child: const Text('Continue'), ), ElevatedButton( onPressed: () async { - Navigator.pop(context); // Close dialog - await context.read().finishWorkout(); - if (mounted) { - Navigator.pop(context); // Close workout screen - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Workout saved! Great job! 💪'), - backgroundColor: AppTheme.success, - ), - ); - } + 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.pop(); + nav.push(MaterialPageRoute( + builder: (_) => WorkoutSummaryScreen( + session: session, + newPRs: newPRs, + ), + )); }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.success, + ), child: const Text('Save & Finish'), ), ], ), ); } -} - -/// A StatefulWidget for the number picker content that properly manages -/// its TextEditingController lifecycle to avoid memory leaks. -class _NumberPickerContent extends StatefulWidget { - final double initialValue; - final int decimals; - final Function(double) onChanged; - - const _NumberPickerContent({ - required this.initialValue, - required this.decimals, - required this.onChanged, - }); - - @override - State<_NumberPickerContent> createState() => _NumberPickerContentState(); -} - -class _NumberPickerContentState extends State<_NumberPickerContent> { - late final TextEditingController _controller; - late final FocusNode _focusNode; - - @override - void initState() { - super.initState(); - _controller = TextEditingController( - text: widget.decimals == 0 - ? widget.initialValue.toInt().toString() - : widget.initialValue.toStringAsFixed(widget.decimals), - ); - _focusNode = FocusNode(); - // Request focus after the bottom sheet is fully rendered - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNode.requestFocus(); - }); - } - - @override - void dispose() { - _controller.dispose(); - _focusNode.dispose(); - super.dispose(); - } - void _submit() { - final parsed = double.tryParse(_controller.text); - if (parsed != null) { - widget.onChanged(parsed); - } - Navigator.pop(context); - } - - @override - Widget build(BuildContext context) { - // Pad bottom so content shifts up above the keyboard - final bottomInset = MediaQuery.of(context).viewInsets.bottom; - return Padding( - padding: EdgeInsets.fromLTRB( - AppSpacing.lg, - AppSpacing.lg, - AppSpacing.lg, - AppSpacing.lg + bottomInset, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: _controller, - focusNode: _focusNode, - keyboardType: widget.decimals > 0 - ? const TextInputType.numberWithOptions(decimal: true) - : const TextInputType.numberWithOptions( - signed: false, - decimal: false, - ), - inputFormatters: widget.decimals > 0 - ? [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$'))] - : [FilteringTextInputFormatter.digitsOnly], - decoration: const InputDecoration(labelText: 'Enter value'), - onSubmitted: (_) => _submit(), + 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.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, _LeaveAction.discard), + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Discard'), ), - const SizedBox(height: AppSpacing.md), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _submit, - child: const Text('Done'), - ), + TextButton( + onPressed: () => Navigator.pop(ctx, _LeaveAction.keep), + child: const Text('Keep & exit'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, _LeaveAction.cancel), + child: const Text('Cancel'), ), ], ), ); + + if (!mounted) return; + if (action == _LeaveAction.discard) { + await context.read().cancelWorkout(); + if (mounted) Navigator.pop(context); + } else if (action == _LeaveAction.keep) { + Navigator.pop(context); + } } } diff --git a/workout-logger/lib/screens/workout_summary_screen.dart b/workout-logger/lib/screens/workout_summary_screen.dart new file mode 100644 index 0000000..0bd87e4 --- /dev/null +++ b/workout-logger/lib/screens/workout_summary_screen.dart @@ -0,0 +1,370 @@ +// workout_summary_screen.dart — Post-workout celebration & summary screen + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; + +import '../models/models.dart'; +import '../services/workout_provider.dart'; +import '../services/managers/pr_manager.dart'; +import '../services/settings_provider.dart'; +import '../theme/app_theme.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/rf_cards.dart'; + +class WorkoutSummaryScreen extends StatelessWidget { + const WorkoutSummaryScreen({ + super.key, + required this.session, + this.newPRs = const [], + }); + + final WorkoutSession session; + final List newPRs; + + @override + Widget build(BuildContext context) { + final provider = context.read(); + final settings = context.read(); + final totalSets = session.exercises.fold( + 0, + (sum, e) => sum + e.sets.length, + ); + final volume = settings.toDisplay(session.totalVolume); + final volStr = volume >= 1000 + ? '${(volume / 1000).toStringAsFixed(1)}k' + : volume.toStringAsFixed(0); + + // Collect unique muscles from all exercises + final muscles = {}; + for (final log in session.exercises) { + final exercise = provider.getExercise(log.exerciseId); + if (exercise != null) { + for (final activation in exercise.muscleActivations) { + muscles.add(activation.muscleGroupId); + } + } + } + + 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), + ], + 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), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTrophyHeader(BuildContext context) { + final dateStr = DateFormat('EEEE, MMM d').format(session.date); + return Column( + children: [ + // Glowing trophy icon + Container( + width: 96, + height: 96, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.warning.withValues(alpha: 0.3), + AppColors.warning.withValues(alpha: 0.05), + ], + ), + border: Border.all( + color: AppColors.warning.withValues(alpha: 0.5), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: AppColors.warning.withValues(alpha: 0.4), + blurRadius: 32, + spreadRadius: 4, + ), + ], + ), + child: const Icon( + Icons.emoji_events_rounded, + size: 48, + color: AppColors.warning, + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + 'Workout Complete!', + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + dateStr, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 13, + ), + ), + ], + ); + } + + Widget _buildStatGrid( + int duration, + String volume, + int sets, + int exercises, + String unitLabel, + ) { + return Column( + children: [ + Row( + children: [ + Expanded( + child: StatGridCard( + icon: Icons.timer_outlined, + value: '${duration}m', + label: 'Duration', + color: AppColors.secondary, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: StatGridCard( + icon: Icons.trending_up_rounded, + value: volume, + label: 'Volume ($unitLabel)', + color: AppColors.success, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: StatGridCard( + icon: Icons.repeat_rounded, + value: '$sets', + label: 'Sets Logged', + color: AppColors.primary, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: StatGridCard( + icon: Icons.fitness_center_rounded, + value: '$exercises', + label: 'Exercises', + color: AppColors.warning, + ), + ), + ], + ), + ], + ); + } + + Widget _buildPRSection(List prs, WorkoutProvider provider) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const RFSectionHeader('New Personal Records'), + const SizedBox(height: AppSpacing.sm), + ...prs.map((pr) { + final name = provider.getExerciseName(pr.exerciseId); + final badges = pr.types.map((t) { + final (label, color) = switch (t) { + 'weight' => ('Best Weight', AppColors.warning), + 'reps' => ('Best Reps', AppColors.secondary), + _ => ('Best Volume', AppColors.success), + }; + return RFChip(label: label, color: color); + }).toList(); + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppColors.warning.withValues(alpha: 0.08), + AppColors.warning.withValues(alpha: 0.03), + ], + ), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: AppColors.warning.withValues(alpha: 0.35), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.emoji_events_rounded, + color: AppColors.warning, + size: 16, + ), + const SizedBox(width: 6), + Text( + name, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + Wrap(spacing: 6, runSpacing: 6, children: badges), + ], + ), + ); + }), + ], + ); + } + + Widget _buildMusclesSection(Set muscles, WorkoutProvider provider) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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), + ); + }).toList(), + ), + ], + ); + } + + Widget _buildExerciseSummary( + WorkoutSession session, + WorkoutProvider provider, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const RFSectionHeader('Exercise Breakdown'), + const SizedBox(height: AppSpacing.sm), + ...session.exercises.map( + (log) => _ExerciseSummaryRow(log: log, provider: provider), + ), + ], + ); + } +} + +class _ExerciseSummaryRow extends StatelessWidget { + const _ExerciseSummaryRow({ + required this.log, + required this.provider, + }); + + final ExerciseLog log; + final WorkoutProvider provider; + + @override + Widget build(BuildContext context) { + final name = provider.getExerciseName(log.exerciseId); + final volume = log.totalVolume; + final volStr = volume >= 1000 + ? '${(volume / 1000).toStringAsFixed(1)}k' + : volume.toStringAsFixed(0); + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + '${log.sets.length} sets', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + Text( + '$volStr kg', + style: const TextStyle( + color: AppColors.success, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/services/ai/coach_tool_service.dart b/workout-logger/lib/services/ai/coach_tool_service.dart new file mode 100644 index 0000000..1e929cf --- /dev/null +++ b/workout-logger/lib/services/ai/coach_tool_service.dart @@ -0,0 +1,872 @@ +// coach_tool_service.dart — DB-backed function-calling tools for the AI coach. +// +// Exposes a set of read-only query functions the model can call to ground its +// answers in the user's real data. Every tool reuses existing parameterized +// query methods on WorkoutProvider / PRManager — no new analytics logic lives +// here, only the schema + arg parsing + JSON shaping. + +import 'package:google_generative_ai/google_generative_ai.dart'; + +import '../../models/models.dart'; +import '../workout_provider.dart'; +import '../managers/pr_manager.dart'; + +class AmbiguousMatchException implements Exception { + const AmbiguousMatchException(this.candidates); + final List candidates; +} + +class CoachToolService { + final WorkoutProvider _wp; + final PRManager _pr; + + CoachToolService(this._wp, this._pr); + + /// Tool declaration for the optimizer screen's `ask_user_questions` flow. + /// NOT included in the coach's tool list — only the optimizer adds it. + static FunctionDeclaration get askUserQuestionsDeclaration => + FunctionDeclaration( + 'ask_user_questions', + 'Ask the user 1–3 clarifying questions before proceeding. ' + 'Provide an optional preamble (short context sentence shown above the ' + 'questions). Each question has 3–4 option chips; set multiSelect:true ' + 'when the user should be able to pick multiple options. ' + 'allowCustom is always treated as true.', + Schema.object( + properties: { + 'preamble': Schema.string( + description: + 'Optional. A short sentence shown above the questions, ' + 'e.g. "Before I analyse your routine, I have a few quick ' + 'questions."', + nullable: true, + ), + 'questions': Schema.array( + items: Schema.object( + properties: { + 'question': Schema.string( + description: 'The question text, e.g. "What is your primary goal?"', + ), + 'options': Schema.array( + items: Schema.string(), + description: '3–4 answer chips, e.g. ["Strength","Hypertrophy","Fat loss","Endurance"].', + ), + 'multiSelect': Schema.boolean( + description: + 'If true the user can select multiple chips. ' + 'Use for confirmation questions (e.g. "Which changes should I apply?").', + nullable: true, + ), + }, + requiredProperties: ['question', 'options'], + ), + description: '1–3 questions to display.', + ), + }, + requiredProperties: ['questions'], + ), + ); + + /// Tool declarations advertised to the model. + List buildTools() => [ + Tool(functionDeclarations: [ + FunctionDeclaration( + 'get_exercise_performance', + 'Get how a specific exercise has progressed: per-session volume ' + 'trend, the full per-session weight×reps set history, growth ' + 'slope, best estimated 1RM, last logged sets, and personal ' + 'record. Use for questions like "how is my bench press ' + 'progressing" or "what weight and reps did I do for squats ' + 'last month".', + Schema.object( + properties: { + 'exercise_name': Schema.string( + description: + 'Name of the exercise, e.g. "Bench Press" or "Squat".', + ), + 'days': Schema.integer( + description: + 'Optional. Only consider sessions from the last N days.', + nullable: true, + ), + 'limit': Schema.integer( + description: + 'Optional. Max number of most-recent sessions to return ' + 'in set_history and volume_trend. Use a small value (e.g. ' + '1–5) when you only need recent sessions, to save tokens. ' + 'Defaults to 20; capped at 40.', + nullable: true, + ), + }, + requiredProperties: ['exercise_name'], + ), + ), + FunctionDeclaration( + 'get_workouts_in_range', + 'Summarize workouts in a date range: session count, total volume, ' + 'and a per-session breakdown. Use for "what did I do last week" ' + 'or "how many workouts in the last 3 months".', + Schema.object( + properties: { + 'start_date': Schema.string( + description: 'Optional ISO date (YYYY-MM-DD) range start.', + nullable: true, + ), + 'end_date': Schema.string( + description: 'Optional ISO date (YYYY-MM-DD) range end.', + nullable: true, + ), + 'days': Schema.integer( + description: + 'Optional. Last N days; overrides start/end when set. ' + 'Defaults to 30 if no dates are provided.', + nullable: true, + ), + 'limit': Schema.integer( + description: + 'Optional. Max number of most-recent sessions to include ' + 'in the per-session breakdown. The session_count and ' + 'total_volume totals always cover the full range. Use a ' + 'small value to save tokens. Defaults to 40; capped at 40.', + nullable: true, + ), + }, + ), + ), + FunctionDeclaration( + 'get_routine_performance', + 'Get how a named routine is performing: number of sessions logged ' + 'against it, total volume, volume trend over time, and the ' + 'exercises it contains.', + Schema.object( + properties: { + 'routine_name': Schema.string( + description: 'Name of the routine, e.g. "Push Day".', + ), + 'days': Schema.integer( + description: + 'Optional. Only consider sessions from the last N days.', + nullable: true, + ), + 'limit': Schema.integer( + description: + 'Optional. Max number of most-recent points to include in ' + 'volume_over_time. session_count and total_volume always ' + 'cover all matching sessions. Defaults to 40; capped at 40.', + nullable: true, + ), + }, + requiredProperties: ['routine_name'], + ), + ), + FunctionDeclaration( + 'get_personal_records', + 'Get personal records (best weight, reps, and single-set volume). ' + 'Pass an exercise name for one exercise, or omit for all PRs.', + Schema.object( + properties: { + 'exercise_name': Schema.string( + description: 'Optional exercise name to filter to.', + nullable: true, + ), + }, + ), + ), + FunctionDeclaration( + 'get_goal_progress', + 'Get progress toward training goals/targets: current vs target ' + 'value, percent complete, and estimated completion date.', + Schema.object( + properties: { + 'exercise_name': Schema.string( + description: 'Optional exercise name to filter goals to.', + nullable: true, + ), + }, + ), + ), + FunctionDeclaration( + 'get_muscle_recovery', + 'Get current per-muscle-group recovery status (percent recovered ' + 'and whether each is ready, recovering, or fatigued). Use for ' + '"what can I train today".', + Schema.object(properties: {}), + ), + FunctionDeclaration( + 'get_all_routines', + 'List all saved routines with their exercise names and count. ' + 'Use when the user asks what routines they have or wants to ' + 'pick one to view or modify.', + Schema.object(properties: {}), + ), + FunctionDeclaration( + 'create_routine', + 'Create a new workout routine with a name and an ordered list of ' + 'exercises. Exercises are matched by name from the catalogue.', + Schema.object( + properties: { + 'name': Schema.string( + description: 'Name for the new routine, e.g. "Push Day".', + ), + 'exercise_names': Schema.array( + items: Schema.string(), + description: + 'Ordered list of exercise names to include in the routine.', + ), + }, + requiredProperties: ['name', 'exercise_names'], + ), + ), + FunctionDeclaration( + 'update_routine', + 'Modify an existing routine: add exercises, remove exercises, or ' + 'reorder them. Specify the routine by name. Exercises are ' + 'matched by name from the catalogue.', + Schema.object( + properties: { + 'routine_name': Schema.string( + description: 'Name of the routine to update.', + ), + 'add_exercise_names': Schema.array( + items: Schema.string(), + description: 'Optional. Exercise names to add.', + nullable: true, + ), + 'remove_exercise_names': Schema.array( + items: Schema.string(), + description: 'Optional. Exercise names to remove.', + nullable: true, + ), + 'reorder_exercise_names': Schema.array( + items: Schema.string(), + description: + 'Optional. Full new ordering of all exercise names in ' + 'the routine. Must include every exercise you want to keep.', + nullable: true, + ), + }, + requiredProperties: ['routine_name'], + ), + ), + FunctionDeclaration( + 'add_custom_exercise', + 'Create a new custom exercise in the catalogue when the one the user ' + 'wants does not already exist. Match the muscle to an existing ' + 'muscle group (call get_muscle_recovery or list routines first ' + 'if unsure of the available muscle names). After creating it you ' + 'can reference it by name in create_routine / update_routine.', + Schema.object( + properties: { + 'name': Schema.string( + description: 'Name of the new exercise, e.g. "Cable Crossover".', + ), + 'category': Schema.string( + description: + 'Either "compound" (multi-joint) or "isolation" (single-joint).', + ), + 'primary_muscle': Schema.string( + description: + 'Primary muscle group this exercise targets, e.g. "Chest" ' + 'or "Biceps". Must match an existing muscle group.', + ), + }, + requiredProperties: ['name', 'category', 'primary_muscle'], + ), + ), + ]), + ]; + + /// Dispatch a model function call to the matching query and return a + /// JSON-serializable result map. + Future> handleCall(FunctionCall call) async { + switch (call.name) { + case 'get_exercise_performance': + return _exercisePerformance(call.args); + case 'get_workouts_in_range': + return _workoutsInRange(call.args); + case 'get_routine_performance': + return _routinePerformance(call.args); + case 'get_personal_records': + return _personalRecords(call.args); + case 'get_goal_progress': + return _goalProgress(call.args); + case 'get_muscle_recovery': + return _muscleRecovery(); + case 'get_all_routines': + return _getAllRoutines(); + case 'create_routine': + return _createRoutine(call.args); + case 'update_routine': + return await _updateRoutine(call.args); + case 'add_custom_exercise': + return await _addCustomExercise(call.args); + default: + return {'error': 'Unknown tool: ${call.name}'}; + } + } + + // ── Tool implementations ─────────────────────────────────────────────────── + + Map _exercisePerformance(Map args) { + final name = (args['exercise_name'] as String?)?.trim() ?? ''; + final Exercise exercise; + try { + final resolved = _resolveExercise(name); + if (resolved == null) { + return { + 'error': 'No exercise found matching "$name".', + 'available_examples': _exampleExerciseNames(), + }; + } + exercise = resolved; + } on AmbiguousMatchException catch (e) { + return { + 'error': 'Multiple exercises match "$name". Did you mean one of:', + 'ambiguous_matches': e.candidates, + }; + } + + final days = (args['days'] as num?)?.toInt(); + final cutoff = + days != null ? DateTime.now().subtract(Duration(days: days)) : null; + + final progression = _wp + .getVolumeProgression(exercise.id) + .where((p) => cutoff == null || !p.date.isBefore(cutoff)) + .toList(); + + final growth = _wp.getGrowthModel(exercise.id); + final lastLog = _wp.getLastSessionForExercise(exercise.id); + final pr = _pr.getRecord(exercise.id); + + // Optional model-supplied cap; defaults preserve prior behaviour + // (40 trend points, 20 set-history sessions). + final hasLimit = args['limit'] != null; + final trendCap = hasLimit ? _limitArg(args, 40) : 40; + final setCap = hasLimit ? _limitArg(args, 20) : 20; + + return { + 'exercise': exercise.name, + 'session_count': progression.length, + if (days != null) 'window_days': days, + 'volume_trend': [ + for (final p in progression.length > trendCap + ? progression.sublist(progression.length - trendCap) + : progression) + {'date': _d(p.date), 'volume': _round(p.volume)}, + ], + // Per-session weight×reps breakdown (most recent first), so the model can + // answer "what weight/reps did I do" rather than only volume totals. + 'set_history': _setHistory(exercise.id, cutoff, setCap), + 'growth': growth == null + ? null + : { + 'slope_per_day': _round(growth.slope), + 'weekly_growth_percent': _round(growth.weeklyGrowthPercent), + 'curve': growth.curve.name, + 'r2': _round(growth.r2), + 'trend': growth.weeklyGrowthPercent > 0.5 + ? 'improving' + : growth.weeklyGrowthPercent < -2 + ? 'declining' + : 'plateauing', + }, + 'best_estimated_1rm': _roundOrNull(_wp.getBestOneRM(exercise.id)), + 'last_session': lastLog == null + ? null + : [ + for (final s in lastLog.sets) + {'weight': _round(s.weight), 'reps': s.reps}, + ], + 'personal_record': pr == null + ? null + : { + 'best_weight': _round(pr.bestWeight), + 'best_reps': pr.bestReps, + 'best_volume': _round(pr.bestVolume), + 'achieved_at': _d(pr.achievedAt), + }, + }; + } + + Map _workoutsInRange(Map args) { + final now = DateTime.now(); + final days = (args['days'] as num?)?.toInt(); + final startArg = DateTime.tryParse((args['start_date'] as String?) ?? ''); + final endArg = DateTime.tryParse((args['end_date'] as String?) ?? ''); + + final DateTime start; + final DateTime end; + if (days != null) { + start = now.subtract(Duration(days: days)); + end = now; + } else if (startArg != null || endArg != null) { + start = startArg ?? now.subtract(const Duration(days: 30)); + end = endArg ?? now; + } else { + start = now.subtract(const Duration(days: 30)); + end = now; + } + + final sessions = _wp.sessions + .where((s) => !s.date.isBefore(start) && !s.date.isAfter(end)) + .toList() + ..sort((a, b) => b.date.compareTo(a.date)); + + final totalVolume = + sessions.fold(0, (sum, s) => sum + s.totalVolume); + + return { + 'start_date': _d(start), + 'end_date': _d(end), + 'session_count': sessions.length, + 'total_volume': _round(totalVolume), + 'sessions': [ + for (final s in sessions.take(_limitArg(args, 40))) + { + 'date': _d(s.date), + 'duration_min': s.duration, + 'exercise_count': s.exercises.length, + 'volume': _round(s.totalVolume), + 'exercises': [ + for (final e in s.exercises) _wp.getExerciseName(e.exerciseId), + ], + }, + ], + }; + } + + Map _routinePerformance(Map args) { + final name = (args['routine_name'] as String?)?.trim() ?? ''; + final Routine routine; + try { + final resolved = _resolveRoutine(name); + if (resolved == null) { + return { + 'error': 'No routine found matching "$name".', + 'available_routines': [for (final r in _wp.routines) r.name], + }; + } + routine = resolved; + } on AmbiguousMatchException catch (e) { + return { + 'error': 'Multiple routines match "$name". Did you mean one of:', + 'ambiguous_matches': e.candidates, + }; + } + + final days = (args['days'] as num?)?.toInt(); + final cutoff = + days != null ? DateTime.now().subtract(Duration(days: days)) : null; + + final sessions = _wp.sessions + .where((s) => s.routineId == routine.id) + .where((s) => cutoff == null || !s.date.isBefore(cutoff)) + .toList() + ..sort((a, b) => a.date.compareTo(b.date)); + + final totalVolume = + sessions.fold(0, (sum, s) => sum + s.totalVolume); + + return { + 'routine': routine.name, + 'exercises': [for (final id in routine.exerciseIds) _wp.getExerciseName(id)], + 'session_count': sessions.length, + if (days != null) 'window_days': days, + 'total_volume': _round(totalVolume), + 'volume_over_time': [ + for (final s in sessions.length > _limitArg(args, 40) + ? sessions.sublist(sessions.length - _limitArg(args, 40)) + : sessions) + {'date': _d(s.date), 'volume': _round(s.totalVolume)}, + ], + }; + } + + Map _personalRecords(Map args) { + final name = (args['exercise_name'] as String?)?.trim(); + if (name != null && name.isNotEmpty) { + final Exercise exercise; + try { + final resolved = _resolveExercise(name); + if (resolved == null) { + return {'error': 'No exercise found matching "$name".'}; + } + exercise = resolved; + } on AmbiguousMatchException catch (e) { + return { + 'error': 'Multiple exercises match "$name". Did you mean one of:', + 'ambiguous_matches': e.candidates, + }; + } + final pr = _pr.getRecord(exercise.id); + return { + 'exercise': exercise.name, + 'personal_record': pr == null + ? null + : { + 'best_weight': _round(pr.bestWeight), + 'best_reps': pr.bestReps, + 'best_volume': _round(pr.bestVolume), + 'achieved_at': _d(pr.achievedAt), + }, + }; + } + + return { + 'records': [ + for (final pr in _pr.allRecords) + { + 'exercise': _wp.getExerciseName(pr.exerciseId), + 'best_weight': _round(pr.bestWeight), + 'best_reps': pr.bestReps, + 'best_volume': _round(pr.bestVolume), + 'achieved_at': _d(pr.achievedAt), + }, + ], + }; + } + + Map _goalProgress(Map args) { + final name = (args['exercise_name'] as String?)?.trim(); + Iterable targets = _wp.targets; + if (name != null && name.isNotEmpty) { + final Exercise exercise; + try { + final resolved = _resolveExercise(name); + if (resolved == null) { + return {'error': 'No exercise found matching "$name".'}; + } + exercise = resolved; + } on AmbiguousMatchException catch (e) { + return { + 'error': 'Multiple exercises match "$name". Did you mean one of:', + 'ambiguous_matches': e.candidates, + }; + } + targets = targets.where((t) => t.exerciseId == exercise.id); + } + + return { + 'goals': [ + for (final t in targets) + { + 'exercise': _wp.getExerciseName(t.exerciseId), + 'type': t.targetType, + 'current_value': _round(t.currentValue), + 'target_value': _round(t.targetValue), + 'progress_percent': _round(t.progressPercentage), + 'completed': t.isCompleted, + 'estimated_completion': t.estimatedCompletionDate == null + ? null + : _d(t.estimatedCompletionDate!), + }, + ], + }; + } + + Map _muscleRecovery() { + final scores = _wp.getMuscleRecoveryScores(); + final entries = scores.entries.toList() + ..sort((a, b) => a.value.recoveryPercent.compareTo(b.value.recoveryPercent)); + return { + 'muscles': [ + for (final e in entries) + { + 'muscle': _wp.getMuscleGroupName(e.key), + 'recovery_percent': e.value.recoveryPercent, + 'status': e.value.isRecovered + ? 'ready' + : e.value.isUnderRecovered + ? 'fatigued' + : 'recovering', + }, + ], + }; + } + + // ── Routine CRUD tools ──────────────────────────────────────────────────── + + Map _getAllRoutines() { + return { + 'routines': [ + for (final r in _wp.routines) + { + 'id': r.id, + 'name': r.name, + 'exercise_count': r.exerciseIds.length, + 'exercises': [for (final id in r.exerciseIds) _wp.getExerciseName(id)], + }, + ], + }; + } + + Future> _createRoutine(Map args) async { + final name = ((args['name'] as String?)?.trim()) ?? ''; + if (name.isEmpty) return {'error': 'Routine name cannot be empty.'}; + + final rawNames = (args['exercise_names'] as List?)?.cast() ?? []; + final resolvedIds = []; + final unresolved = []; + + for (final n in rawNames) { + try { + final ex = _resolveExercise(n.trim()); + if (ex == null) { + unresolved.add(n); + } else { + resolvedIds.add(ex.id); + } + } on AmbiguousMatchException catch (e) { + return { + 'error': 'Ambiguous exercise name "$n". Did you mean one of:', + 'candidates': e.candidates, + }; + } + } + + if (unresolved.isNotEmpty) { + return { + 'error': 'Could not find exercises: $unresolved', + 'available_examples': _exampleExerciseNames(), + }; + } + + await _wp.createRoutine(name, resolvedIds); + return { + 'created': true, + 'routine_name': name, + 'exercise_count': resolvedIds.length, + 'exercises': [for (final id in resolvedIds) _wp.getExerciseName(id)], + }; + } + + Future> _updateRoutine(Map args) async { + final routineName = (args['routine_name'] as String?)?.trim() ?? ''; + final Routine routine; + try { + final resolved = _resolveRoutine(routineName); + if (resolved == null) { + return {'error': 'No routine found matching "$routineName".'}; + } + routine = resolved; + } on AmbiguousMatchException catch (e) { + return { + 'error': 'Multiple routines match "$routineName". Did you mean one of:', + 'ambiguous_matches': e.candidates, + }; + } + + var ids = List.from(routine.exerciseIds); + + // Reorder (full replacement of order) + final reorderNames = (args['reorder_exercise_names'] as List?)?.cast(); + if (reorderNames != null && reorderNames.isNotEmpty) { + final reorderedIds = []; + for (final n in reorderNames) { + try { + final ex = _resolveExercise(n.trim()); + if (ex != null) reorderedIds.add(ex.id); + } on AmbiguousMatchException { + // skip ambiguous entries in reorder + } + } + if (reorderedIds.isNotEmpty) ids = reorderedIds; + } + + // Remove exercises + final removeNames = (args['remove_exercise_names'] as List?)?.cast(); + if (removeNames != null) { + for (final n in removeNames) { + try { + final ex = _resolveExercise(n.trim()); + if (ex != null) ids.remove(ex.id); + } on AmbiguousMatchException { + // skip ambiguous entries + } + } + } + + // Add exercises + final addNames = (args['add_exercise_names'] as List?)?.cast(); + if (addNames != null) { + for (final n in addNames) { + try { + final ex = _resolveExercise(n.trim()); + if (ex != null && !ids.contains(ex.id)) ids.add(ex.id); + } on AmbiguousMatchException { + // skip ambiguous entries + } + } + } + + final updated = Routine( + id: routine.id, + name: routine.name, + exerciseIds: ids, + createdAt: routine.createdAt, + ); + await _wp.updateRoutine(updated); + + return { + 'updated': true, + 'routine_name': routine.name, + 'exercise_count': ids.length, + 'exercises': [for (final id in ids) _wp.getExerciseName(id)], + }; + } + + Future> _addCustomExercise( + Map args) async { + final name = (args['name'] as String?)?.trim() ?? ''; + if (name.isEmpty) return {'error': 'Exercise name cannot be empty.'}; + + // Reject duplicates so the model reuses the existing exercise instead. + final existing = _wp.allExercises.where( + (e) => e.name.toLowerCase() == name.toLowerCase(), + ); + if (existing.isNotEmpty) { + return { + 'error': 'An exercise named "${existing.first.name}" already exists. ' + 'Use it by name instead of creating a duplicate.', + }; + } + + final category = (args['category'] as String?)?.trim().toLowerCase() ?? ''; + if (category != 'compound' && category != 'isolation') { + return { + 'error': 'category must be "compound" or "isolation", got "$category".', + }; + } + + final muscleName = (args['primary_muscle'] as String?)?.trim() ?? ''; + final MuscleGroup muscle; + try { + final resolved = _resolveMuscleGroup(muscleName); + if (resolved == null) { + return { + 'error': 'No muscle group found matching "$muscleName".', + 'available_muscles': [for (final m in _wp.muscleGroups) m.name], + }; + } + muscle = resolved; + } on AmbiguousMatchException catch (e) { + return { + 'error': 'Multiple muscle groups match "$muscleName". Did you mean:', + 'ambiguous_matches': e.candidates, + }; + } + + try { + await _wp.addCustomExercise( + name: name, + category: category, + primaryMuscleGroupId: muscle.id, + ); + } catch (e) { + return {'error': 'Could not create exercise: $e'}; + } + + return { + 'created': true, + 'exercise_name': name, + 'category': category, + 'primary_muscle': muscle.name, + }; + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + /// Per-session weight×reps breakdown for [exerciseId], newest first. + /// Bounded to the most recent [limit] sessions (after the optional [cutoff]) + /// to keep the tool payload small. + List> _setHistory( + String exerciseId, DateTime? cutoff, int limit) { + final sessions = _wp.sessions + .where((s) => cutoff == null || !s.date.isBefore(cutoff)) + .where((s) => s.exercises.any((e) => e.exerciseId == exerciseId)) + .toList() + ..sort((a, b) => b.date.compareTo(a.date)); + + return [ + for (final s in sessions.take(limit)) + { + 'date': _d(s.date), + 'sets': [ + for (final log in s.exercises.where((e) => e.exerciseId == exerciseId)) + for (final set in log.sets) + { + 'weight': _round(set.weight), + 'reps': set.reps, + if (set.isDropset) 'dropset': true, + if (set.isDropset && set.drops != null) + 'drops': [ + for (final d in set.drops!) + {'weight': _round(d.weight), 'reps': d.reps}, + ], + }, + ], + }, + ]; + } + + Exercise? _resolveExercise(String query) { + final q = query.toLowerCase().trim(); + if (q.isEmpty) return null; + final all = _wp.allExercises; + for (final e in all) { + if (e.name.toLowerCase() == q) return e; + } + final partials = [for (final e in all) if (e.name.toLowerCase().contains(q)) e]; + if (partials.isEmpty) return null; + if (partials.length == 1) return partials.first; + throw AmbiguousMatchException([for (final e in partials) e.name]); + } + + Routine? _resolveRoutine(String query) { + final q = query.toLowerCase().trim(); + if (q.isEmpty) return null; + for (final r in _wp.routines) { + if (r.name.toLowerCase() == q) return r; + } + final partials = [ + for (final r in _wp.routines) if (r.name.toLowerCase().contains(q)) r + ]; + if (partials.isEmpty) return null; + if (partials.length == 1) return partials.first; + throw AmbiguousMatchException([for (final r in partials) r.name]); + } + + MuscleGroup? _resolveMuscleGroup(String query) { + final q = query.toLowerCase().trim(); + if (q.isEmpty) return null; + for (final m in _wp.muscleGroups) { + if (m.name.toLowerCase() == q) return m; + } + final partials = [ + for (final m in _wp.muscleGroups) if (m.name.toLowerCase().contains(q)) m + ]; + if (partials.isEmpty) return null; + if (partials.length == 1) return partials.first; + throw AmbiguousMatchException([for (final m in partials) m.name]); + } + + List _exampleExerciseNames() => + _wp.allExercises.take(8).map((e) => e.name).toList(); + + String _d(DateTime dt) { + final m = dt.month.toString().padLeft(2, '0'); + final d = dt.day.toString().padLeft(2, '0'); + return '${dt.year}-$m-$d'; + } + + double _round(double v) => (v * 10).round() / 10; + double? _roundOrNull(double? v) => v == null ? null : _round(v); + + /// Read an optional `limit` arg, clamped to [1, 40]; [fallback] when absent. + int _limitArg(Map args, int fallback) { + final n = (args['limit'] as num?)?.toInt(); + if (n == null) return fallback; + return n.clamp(1, 40); + } +} diff --git a/workout-logger/lib/services/ai/gemini_ai_service.dart b/workout-logger/lib/services/ai/gemini_ai_service.dart new file mode 100644 index 0000000..de8729c --- /dev/null +++ b/workout-logger/lib/services/ai/gemini_ai_service.dart @@ -0,0 +1,544 @@ +// gemini_ai_service.dart — google_generative_ai implementation of IAiService. +// +// Backs the AI coach chat (streaming + tool calling), program generation, and +// insights. Uses a user-supplied Google AI Studio API key (free-tier friendly). +// Implements [IAiService] so the backend can be swapped without touching consumers. +// +// Uses direct HTTP calls (rather than the SDK's chat helpers) so we can pass +// thinkingConfig: {thinkingBudget: 0} and avoid the SDK crashing on the +// `thoughtSignature` parts that Gemini 3.x models return when thinking is active. +// The SDK is still used for its type definitions (Content, Tool, FunctionCall) +// and their toJson() serialisers which are part of the public API. + +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:google_generative_ai/google_generative_ai.dart' + show Content, FunctionCall, Tool; +import 'package:http/http.dart' as http; +import 'package:uuid/uuid.dart'; + +import '../../models/models.dart'; +import '../interfaces/ai_service_interface.dart'; +import '../interfaces/storage_service_interface.dart'; + +// Ordered list of available Gemini models shown in the picker. +const kGeminiModels = [ + ('gemini-2.5-flash', 'Gemini 2.5 Flash'), + ('gemini-2.5-flash-lite', 'Gemini 2.5 Flash Lite'), + ('gemini-3.1-flash-lite', 'Gemini 3.1 Flash Lite'), + ('gemini-3.5-flash', 'Gemini 3.5 Flash'), +]; + +// Default to the latest GA model. +const kDefaultGeminiModel = 'gemini-3.5-flash'; + +// Upper bound on tool-resolution rounds per user turn, to bound runaway loops. +const int _kMaxToolRounds = 5; + +// Retry policy for transient (5xx / 429) errors. Total attempts = 1 + retries. +const int _kMaxRetries = 2; + +const String _apiBase = + 'https://generativelanguage.googleapis.com/v1beta/models'; + +// 429 (rate limit) and 5xx (server/overload, e.g. 503 "high demand") are +// transient and worth retrying; 4xx (bad key, bad request) are not. +bool _isRetryableStatus(int code) => code == 429 || (code >= 500 && code < 600); + +// Exponential backoff: 500ms, 1s, 2s … +Duration _retryBackoff(int attempt) => + Duration(milliseconds: 500 * (1 << attempt)); + +// Gemini error bodies look like {"error":{"code":503,"message":"…","status":"…"}}. +// Surface just the human-readable message rather than the whole JSON blob. +String _errorMessage(int code, String body) { + try { + final decoded = jsonDecode(body); + if (decoded is Map && decoded['error'] is Map) { + final msg = (decoded['error'] as Map)['message']; + if (msg is String && msg.isNotEmpty) return msg; + } + } catch (_) { + // Body wasn't JSON — fall through to a generic message. + } + return 'request failed (HTTP $code).'; +} + +class GeminiAiService extends ChangeNotifier implements IAiService { + // Optional storage so cumulative token usage survives restarts. + final IStorageService? _storage; + + GeminiAiService({IStorageService? storage}) : _storage = storage; + + static const String _usageKey = 'aiTokenUsage'; + + String _apiKey = ''; + String _model = kDefaultGeminiModel; + + // Cumulative token usage across all AI calls (persisted). + int _promptTokens = 0; + int _responseTokens = 0; + int _totalTokens = 0; + int _requestCount = 0; + + @override + bool get isConfigured => _apiKey.isNotEmpty; + + @override + String get currentModel => _model; + + /// Cumulative input (prompt) tokens billed across all AI calls. + int get promptTokensUsed => _promptTokens; + + /// Cumulative output (response) tokens across all AI calls. + int get responseTokensUsed => _responseTokens; + + /// Cumulative total tokens (prompt + response) across all AI calls. + int get totalTokensUsed => _totalTokens; + + /// Number of AI requests recorded. + int get aiRequestCount => _requestCount; + + void init(String apiKey, {String model = kDefaultGeminiModel}) { + _apiKey = apiKey.trim(); + _model = model; + } + + /// Load persisted cumulative token usage (call once at startup). + Future loadUsage() async { + final raw = await _storage?.getSetting(_usageKey); + if (raw == null || raw.isEmpty) return; + try { + final m = jsonDecode(raw) as Map; + _promptTokens = (m['prompt'] as num?)?.toInt() ?? 0; + _responseTokens = (m['response'] as num?)?.toInt() ?? 0; + _totalTokens = (m['total'] as num?)?.toInt() ?? 0; + _requestCount = (m['requests'] as num?)?.toInt() ?? 0; + notifyListeners(); + } catch (_) { + // Ignore corrupt usage data. + } + } + + /// Reset cumulative token usage to zero. + Future resetUsage() async { + _promptTokens = 0; + _responseTokens = 0; + _totalTokens = 0; + _requestCount = 0; + await _persistUsage(); + notifyListeners(); + } + + /// Accumulate one request's token counts. Exposed for testing; normally + /// fed from the raw usageMetadata JSON via [_recordRawUsage]. + @visibleForTesting + Future recordUsage({ + required int prompt, + required int response, + required int total, + }) async { + _promptTokens += prompt; + _responseTokens += response; + _totalTokens += total; + _requestCount += 1; + await _persistUsage(); + notifyListeners(); + } + + void _recordRawUsage(Map? usage) { + if (usage == null) return; + final p = (usage['promptTokenCount'] as num?)?.toInt() ?? 0; + final r = (usage['candidatesTokenCount'] as num?)?.toInt() ?? 0; + final t = (usage['totalTokenCount'] as num?)?.toInt() ?? (p + r); + recordUsage(prompt: p, response: r, total: t); + } + + Future _persistUsage() async { + final storage = _storage; + if (storage == null) return; + await storage.saveSetting( + _usageKey, + jsonEncode({ + 'prompt': _promptTokens, + 'response': _responseTokens, + 'total': _totalTokens, + 'requests': _requestCount, + }), + ); + } + + void updateApiKey(String key) { + _apiKey = key.trim(); + notifyListeners(); + } + + void updateModel(String model) { + _model = model; + notifyListeners(); + } + + // ── Raw HTTP helpers ──────────────────────────────────────────────────────── + + Map _makeBody({ + required List contents, + String? system, + List? tools, + bool jsonMode = false, + }) => + { + 'contents': contents, + if (system != null) + 'systemInstruction': { + 'parts': [ + {'text': system} + ] + }, + if (tools != null) 'tools': tools.map((t) => t.toJson()).toList(), + 'generationConfig': { + // Disable thinking tokens so SDK-incompatible thoughtSignature parts + // are never returned by Gemini 3.x models. + 'thinkingConfig': {'thinkingBudget': 0}, + if (jsonMode) 'responseMimeType': 'application/json', + }, + }; + + // Extracts non-thought text strings from a candidate object. + Iterable _textFromCandidate(Map candidate) sync* { + final content = candidate['content'] as Map?; + final parts = content?['parts'] as List? ?? []; + for (final part in parts) { + if (part is Map && + part.containsKey('text') && + part['thought'] != true) { + final t = part['text'] as String? ?? ''; + if (t.isNotEmpty) yield t; + } + } + } + + // Streams parsed SSE chunks from the streamGenerateContent endpoint. + Stream> _streamSse(Map body) async* { + final uri = Uri.parse( + '$_apiBase/$_model:streamGenerateContent?alt=sse&key=$_apiKey', + ); + + // Establish the connection with retries. Retrying is only safe here — + // before any bytes are yielded — so a transient 503 never reaches the user, + // but a mid-stream failure is not retried (it would duplicate output). + http.Client client = http.Client(); + http.StreamedResponse streamed; + for (var attempt = 0;; attempt++) { + final request = http.Request('POST', uri) + ..headers['Content-Type'] = 'application/json' + ..body = jsonEncode(body); + final resp = await client.send(request); + if (resp.statusCode == 200) { + streamed = resp; + break; + } + final err = await resp.stream.bytesToString(); + if (_isRetryableStatus(resp.statusCode) && attempt < _kMaxRetries) { + client.close(); + await Future.delayed(_retryBackoff(attempt)); + client = http.Client(); + continue; + } + client.close(); + throw Exception(_errorMessage(resp.statusCode, err)); + } + + try { + final lineBuf = StringBuffer(); + await for (final raw in streamed.stream.transform(utf8.decoder)) { + lineBuf.write(raw); + final text = lineBuf.toString(); + final lines = text.split('\n'); + lineBuf + ..clear() + ..write(lines.last); // keep potentially incomplete last line + for (var i = 0; i < lines.length - 1; i++) { + final line = lines[i].trim(); + if (!line.startsWith('data: ')) continue; + final payload = line.substring(6).trim(); + if (payload.isEmpty || payload == '[DONE]') continue; + yield jsonDecode(payload) as Map; + } + } + // Flush any remaining buffered line. + final tail = lineBuf.toString().trim(); + if (tail.startsWith('data: ')) { + final payload = tail.substring(6).trim(); + if (payload.isNotEmpty && payload != '[DONE]') { + yield jsonDecode(payload) as Map; + } + } + } finally { + client.close(); + } + } + + // Single-shot (non-streaming) generateContent call, with retry on 5xx/429. + Future> _generate(Map body) async { + final uri = Uri.parse('$_apiBase/$_model:generateContent?key=$_apiKey'); + final payload = jsonEncode(body); + for (var attempt = 0;; attempt++) { + final response = await http.post( + uri, + headers: {'Content-Type': 'application/json'}, + body: payload, + ); + if (response.statusCode == 200) { + return jsonDecode(response.body) as Map; + } + if (_isRetryableStatus(response.statusCode) && attempt < _kMaxRetries) { + await Future.delayed(_retryBackoff(attempt)); + continue; + } + throw Exception(_errorMessage(response.statusCode, response.body)); + } + } + + String _textFromResponse(Map data) { + final candidates = data['candidates'] as List? ?? []; + if (candidates.isEmpty) return ''; + return _textFromCandidate(candidates[0] as Map).join(); + } + + // ── Coach chat (streaming + optional tool-call loop) ─────────────────────── + // [history] is the prior conversation as alternating user/model Content. + // When [tools] + [onToolCall] are supplied, function calls the model emits + // are dispatched and their results fed back until a text answer is produced. + @override + Stream streamCoachReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) async* { + if (!isConfigured) { + yield 'Please add your Gemini API key in Profile → AI Features to get started.'; + return; + } + try { + // Build the mutable contents list; grows with each tool-call round. + final contents = [ + ...history.map((c) => c.toJson()), + Content.text(userMessage).toJson(), + ]; + + for (var round = 0; round < _kMaxToolRounds; round++) { + final body = _makeBody( + contents: contents, + system: systemPrompt, + tools: tools, + ); + + // Raw parts from the model turn — preserved verbatim so that any + // thought_signature fields on functionCall parts are not dropped when + // we echo this turn back to the API in the next round. + final rawModelParts = >[]; + final calls = []; + Map? lastUsage; + + await for (final chunk in _streamSse(body)) { + final candidates = chunk['candidates'] as List? ?? []; + for (final raw in candidates) { + final c = raw as Map; + for (final t in _textFromCandidate(c)) { + yield t; + } + // Collect raw parts for the model-turn echo. + final content = c['content'] as Map?; + final parts = content?['parts'] as List? ?? []; + for (final part in parts) { + if (part is! Map) continue; + rawModelParts.add(part); + if (part.containsKey('functionCall')) { + final fc = part['functionCall'] as Map; + calls.add(FunctionCall( + fc['name'] as String, + (fc['args'] as Map? ?? {}) + .cast(), + )); + } + } + } + if (chunk['usageMetadata'] != null) { + lastUsage = chunk['usageMetadata'] as Map; + } + } + _recordRawUsage(lastUsage); + + // No tools requested (or no handler) → the streamed text is the answer. + if (calls.isEmpty || onToolCall == null) return; + + // Echo the model turn back verbatim (preserves thought_signature). + contents.add({'role': 'model', 'parts': rawModelParts}); + + // Resolve every call and feed the results back as one function turn. + final responseParts = >[]; + for (final call in calls) { + try { + final result = await onToolCall(call); + responseParts.add({ + 'functionResponse': {'name': call.name, 'response': result} + }); + } catch (e) { + responseParts.add({ + 'functionResponse': { + 'name': call.name, + 'response': {'error': '$e'} + } + }); + } + } + contents.add({'role': 'function', 'parts': responseParts}); + } + // Exhausted the tool-round budget without a final text answer. + yield '\n\n_(Stopped after $_kMaxToolRounds tool steps — try rephrasing.)_'; + } catch (e) { + yield 'Error: $e'; + } + } + + // ── Program generator (structured JSON output) ──────────────────────────── + @override + Future generateProgram({ + required String userPrompt, + required List allExercises, + }) async { + if (!isConfigured) { + throw StateError('Gemini API key not configured.'); + } + + final exerciseList = allExercises + .map((e) => ' "${e.id}": "${e.name} [${e.primaryMuscle}]"') + .join('\n'); + + const systemPrompt = '''You are a certified strength and conditioning coach creating structured training programs for RepForge. +Return ONLY raw JSON — no markdown fences, no comments, no explanation text. +Use ONLY exercise IDs from the provided list as exerciseId values. + +Required JSON schema (follow exactly): +{ + "id": "unique-string", + "name": "Program Name", + "description": "Brief description", + "totalWeeks": , + "author": "AI Coach", + "isImported": true, + "createdAt": "", + "phases": [ + {"id":"phase-1","name":"Phase Name","startWeek":1,"endWeek":,"notes":"...","colorHex":null} + ], + "weeks": [ + { + "weekNumber": 1, + "isDeload": false, + "deloadIntensityFactor": 1.0, + "deloadSetReduction": 0, + "phaseId": "phase-1", + "notes": null, + "days": [ + { + "id": "w1-d1", + "name": "Day Name", + "dayOfWeek": 1, + "notes": null, + "exercises": [ + { + "exerciseId": "", + "sets": 3, + "minReps": 8, + "maxReps": 12, + "restSeconds": 90, + "tempo": "2-1-1", + "weightPercentage": null, + "notes": null, + "supersetGroupId": null + } + ] + } + ] + } + ] +}'''; + + final prompt = + 'Available exercises (ID: name [primary muscle]):\n$exerciseList\n\nUser request: $userPrompt'; + + try { + final data = await _generate( + _makeBody( + contents: [Content.text(prompt).toJson()], + system: systemPrompt, + jsonMode: true, + ), + ); + _recordRawUsage(data['usageMetadata'] as Map?); + final raw = _textFromResponse(data); + if (raw.isEmpty) throw const FormatException('Empty response from Gemini.'); + + final map = jsonDecode(raw) as Map; + // Ensure a fresh UUID so it never collides with an existing program. + map['id'] = const Uuid().v4(); + map['isImported'] = true; + map['author'] = 'AI Coach'; + return TrainingProgram.fromJson(map); + } on FormatException catch (e) { + throw Exception('Could not parse program JSON: $e'); + } catch (e) { + throw Exception('Gemini API error: $e'); + } + } + + // ── Weekly insights (single-shot text) ──────────────────────────────────── + @override + Future generateWeeklyInsights(String contextText) async { + if (!isConfigured) { + return 'Add your Gemini API key in Profile → AI Features to unlock insights.'; + } + const systemPrompt = + 'You are a performance coach giving weekly training feedback for RepForge users. ' + 'Write 3–4 sentences in a conversational, encouraging tone. ' + 'Be specific — reference actual exercise names and numbers from the data. ' + 'Cover: biggest win, one thing to watch, one tip for next week. ' + 'No bullet points, no headers — natural flowing prose only.'; + try { + final data = await _generate( + _makeBody( + contents: [Content.text(contextText).toJson()], + system: systemPrompt, + ), + ); + _recordRawUsage(data['usageMetadata'] as Map?); + final text = _textFromResponse(data).trim(); + return text.isNotEmpty ? text : 'No insights generated.'; + } catch (e) { + return 'Could not generate insights: $e'; + } + } + + // ── Generic one-shot insight (contextual) ───────────────────────────────── + @override + Future generateInsight(String system, String context) async { + if (!isConfigured) { + return 'Add your Gemini API key in Profile → AI Features to unlock insights.'; + } + try { + final data = await _generate( + _makeBody( + contents: [Content.text(context).toJson()], + system: system, + ), + ); + _recordRawUsage(data['usageMetadata'] as Map?); + final text = _textFromResponse(data).trim(); + return text.isNotEmpty ? text : 'No insight generated.'; + } catch (e) { + return 'Could not generate insight: $e'; + } + } +} diff --git a/workout-logger/lib/services/debug_log_buffer.dart b/workout-logger/lib/services/debug_log_buffer.dart new file mode 100644 index 0000000..edff98b --- /dev/null +++ b/workout-logger/lib/services/debug_log_buffer.dart @@ -0,0 +1,35 @@ +import 'package:flutter/foundation.dart'; + +/// Captures every [debugPrint] call into a fixed-size circular buffer. +/// Wire up once in main() via [DebugLogBuffer.attach]. +class DebugLogBuffer extends ChangeNotifier { + DebugLogBuffer._(); + static final instance = DebugLogBuffer._(); + + static const _maxLines = 500; + final List _lines = []; + + List get lines => List.unmodifiable(_lines); + + static void attach() { + final original = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + original(message, wrapWidth: wrapWidth); + instance._append(message ?? ''); + }; + } + + void _append(String line) { + final ts = DateTime.now(); + final stamp = + '${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}'; + _lines.add('[$stamp] $line'); + if (_lines.length > _maxLines) _lines.removeAt(0); + notifyListeners(); + } + + void clear() { + _lines.clear(); + notifyListeners(); + } +} diff --git a/workout-logger/lib/services/gemini_context_builder.dart b/workout-logger/lib/services/gemini_context_builder.dart new file mode 100644 index 0000000..083ce5d --- /dev/null +++ b/workout-logger/lib/services/gemini_context_builder.dart @@ -0,0 +1,172 @@ +// gemini_context_builder.dart — Builds rich context strings from app data for Gemini prompts. + +import '../models/models.dart'; + +class GeminiContextBuilder { + const GeminiContextBuilder._(); + + // ── Coach system prompt ──────────────────────────────────────────────────── + // + // Deliberately STATIC (no per-turn workout data) so the prefix stays + // byte-identical across a conversation and Gemini's implicit prompt caching + // can engage. All live data is fetched on demand via the coach tools + // (see CoachToolService), not embedded here. + static String buildCoachSystemPrompt({ + String? userName, + String unitLabel = 'kg', + DateTime? now, + }) { + final n = now ?? DateTime.now(); + final today = '${n.year}-${n.month.toString().padLeft(2, '0')}-' + '${n.day.toString().padLeft(2, '0')}'; + + final buf = StringBuffer() + ..writeln( + 'You are an expert personal trainer embedded in RepForge, a workout tracking app.', + ) + ..writeln( + 'Answer concisely (under 180 words unless a plan is requested). ' + 'Be encouraging and specific.', + ) + ..writeln('Today is $today. Use this when interpreting relative dates ' + '("last week", "3 months ago").') + ..writeln( + 'This prompt contains NO workout data. To answer anything about the ' + 'user\'s training — exercise progression, workouts in a date range, ' + 'routine performance, personal records, goal progress, or muscle ' + 'recovery — CALL THE PROVIDED TOOLS rather than guessing or inventing ' + 'numbers. Pass ISO dates (YYYY-MM-DD) or a day count to the tools.', + ) + ..writeln( + 'You can also MODIFY the user\'s data with tools: create or update ' + 'routines, and add a new custom exercise when one does not already ' + 'exist. You do not need to ask permission before calling a write tool ' + 'the user clearly requested, but confirm what you did in your reply. ' + 'If a routine needs an exercise that is not in the catalogue, create it ' + 'with add_custom_exercise first, then reference it by name.', + ) + ..writeln( + 'Weights are in $unitLabel. Format replies with Markdown (lists, bold, ' + 'tables) where it aids clarity.', + ); + + if (userName != null && userName.isNotEmpty) { + buf.writeln('\nThe user\'s name is $userName.'); + } + + return buf.toString(); + } + + // ── Routine optimizer system prompt ─────────────────────────────────────── + static String buildOptimizerSystemPrompt({ + String? userName, + String unitLabel = 'kg', + DateTime? now, + }) { + final n = now ?? DateTime.now(); + final today = '${n.year}-${n.month.toString().padLeft(2, '0')}-' + '${n.day.toString().padLeft(2, '0')}'; + + final buf = StringBuffer() + ..writeln( + 'You are a specialized routine optimizer embedded in RepForge. ' + 'Your only job is to analyse and improve a specific workout routine ' + 'based on the user\'s real performance data and stated preferences.', + ) + ..writeln('Today is $today. Weights are in $unitLabel.') + ..writeln() + ..writeln('STRICT WORKFLOW — execute in this order every time:') + ..writeln( + '1. FETCH DATA FIRST: Before saying anything or asking anything, ' + 'call get_routine_performance for the routine, then call ' + 'get_exercise_performance for EVERY exercise in that routine (use ' + 'the exercise list from the routine response), and call ' + 'get_muscle_recovery. Never skip this step and never invent numbers.', + ) + ..writeln( + '2. ANALYSE SILENTLY: Identify issues — stalling or declining ' + 'exercises (negative slope or r²<0.5), missing muscle groups, ' + 'recovery conflicts, poor ordering. Do not output this analysis.', + ) + ..writeln( + '3. ASK ONLY IF AMBIGUOUS: Call ask_user_questions only if ' + 'the data alone cannot determine the best changes — e.g. the user ' + 'goal (strength vs hypertrophy) would flip which exercise to suggest, ' + 'or you need to know which exercises they want to keep. ' + 'Skip this step entirely if the data makes the answer obvious. ' + 'Never ask questions whose answers would not change your recommendations.', + ) + ..writeln( + '4. PROPOSE CHANGES: List proposed changes as short bullets with ' + 'specific numbers from the data (e.g. "Overhead Press slope −0.3 kg/session"): ' + 'reorder (give full new order), replace (which → which and why), ' + 'add (specific exercise to fill a muscle gap). Under 150 words.', + ) + ..writeln( + '5. CONFIRM: Call ask_user_questions with multiSelect:true listing ' + 'each proposed change as a chip. The user picks which to apply.', + ) + ..writeln( + '6. APPLY: Call update_routine exactly once with only the confirmed ' + 'changes. Then confirm in one sentence what was changed.', + ) + ..writeln() + ..writeln( + 'Format replies with Markdown bold for exercise names. ' + 'Be specific — reference actual exercise names and trend numbers.', + ); + + if (userName != null && userName.isNotEmpty) { + buf.writeln('\nThe user\'s name is $userName.'); + } + + return buf.toString(); + } + + // ── Weekly insights context ──────────────────────────────────────────────── + static String buildWeeklyInsightsContext({ + required List thisWeek, + required List lastWeek, + required Map exerciseMap, + String unitLabel = 'kg', + }) { + final buf = StringBuffer(); + + buf.writeln( + 'THIS WEEK — ${thisWeek.length} sessions, ' + '${_totalVol(thisWeek)}$unitLabel total volume:', + ); + for (final s in thisWeek) { + final day = _weekday(s.date.weekday); + final parts = s.exercises.map((e) { + final name = exerciseMap[e.exerciseId]?.name ?? e.exerciseId; + final sets = e.sets.length; + final vol = e.totalVolume.toStringAsFixed(0); + return '$name $sets×sets ($vol$unitLabel vol)'; + }); + buf.writeln(' $day: ${parts.join(', ')}'); + } + + buf.writeln( + '\nLAST WEEK — ${lastWeek.length} sessions, ' + '${_totalVol(lastWeek)}$unitLabel total volume:', + ); + for (final s in lastWeek) { + final day = _weekday(s.date.weekday); + final names = + s.exercises.map((e) => exerciseMap[e.exerciseId]?.name ?? e.exerciseId); + buf.writeln(' $day: ${names.join(", ")}'); + } + + return buf.toString(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + static String _totalVol(List sessions) => + sessions.fold(0, (sum, s) => sum + s.totalVolume).toStringAsFixed(0); + + static String _weekday(int wd) { + const d = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + return d[wd.clamp(1, 7)]; + } +} diff --git a/workout-logger/lib/services/health_connect_service.dart b/workout-logger/lib/services/health_connect_service.dart index 8327d16..b278b66 100644 --- a/workout-logger/lib/services/health_connect_service.dart +++ b/workout-logger/lib/services/health_connect_service.dart @@ -62,8 +62,10 @@ class HealthConnectService implements IHealthConnectService { Future isAvailable() async { try { final status = await HealthConnector.getHealthPlatformStatus(); + debugPrint('[HC] isAvailable: platform status = $status'); return status == HealthPlatformStatus.available; - } catch (_) { + } catch (e) { + debugPrint('[HC] isAvailable: exception = $e'); return false; } } @@ -74,6 +76,7 @@ class HealthConnectService implements IHealthConnectService { _connector ??= await HealthConnector.create(); final results = await _connector!.requestPermissions([ HealthDataType.exerciseSession.writePermission, + HealthDataType.exerciseSession.readPermission, ]); return results.every((r) => r.status == PermissionStatus.granted); } catch (e) { @@ -95,6 +98,196 @@ class HealthConnectService implements IHealthConnectService { } } + static final Map _readPermissions = { + HealthReadType.sleep: HealthDataType.sleepSession.readPermission, + // heartRateSeries maps to Android HeartRateRecord (series with samples). + // heartRate is iOS-only and throws UNSUPPORTED_OPERATION on Health Connect. + HealthReadType.heartRate: HealthDataType.heartRateSeries.readPermission, + HealthReadType.restingHeartRate: + HealthDataType.restingHeartRate.readPermission, + HealthReadType.hrv: HealthDataType.heartRateVariabilityRMSSD.readPermission, + }; + + @override + Future requestReadPermissions() async { + debugPrint('[HC] requestReadPermissions: requesting ${_readPermissions.length} permissions individually'); + _connector ??= await HealthConnector.create(); + var anyGranted = false; + for (final entry in _readPermissions.entries) { + try { + final results = await _connector!.requestPermissions([entry.value]); + final granted = results.any((r) => r.status == PermissionStatus.granted); + debugPrint('[HC] requestReadPermissions: ${entry.key} → granted=$granted'); + if (granted) anyGranted = true; + } catch (e) { + debugPrint('[HC] requestReadPermissions: ${entry.key} unsupported, skipping ($e)'); + } + } + debugPrint('[HC] requestReadPermissions: anyGranted = $anyGranted'); + return anyGranted; + } + + @override + Future> grantedReadTypes() async { + _connector ??= await HealthConnector.create(); + final granted = {}; + for (final entry in _readPermissions.entries) { + try { + final status = await _connector!.getPermissionStatus(entry.value); + debugPrint('[HC] grantedReadTypes: ${entry.key} → $status'); + if (status == PermissionStatus.granted) granted.add(entry.key); + } catch (e) { + debugPrint('[HC] grantedReadTypes: ${entry.key} unsupported, skipping ($e)'); + } + } + debugPrint('[HC] grantedReadTypes: result = $granted'); + return granted; + } + + @override + Future> readSleepSessions( + DateTime start, + DateTime end, + ) async { + try { + _connector ??= await HealthConnector.create(); + final response = await _connector!.readRecords( + HealthDataType.sleepSession.readInTimeRange( + startTime: start, + endTime: end, + ), + ); + final result = response.records.map((r) { + // Tally stage durations from embedded SleepStageSamples and build + // an ordered stage timeline for HR segment colouring. + var light = 0, deep = 0, rem = 0, awake = 0; + final timeline = []; + var cursor = r.startTime; + for (final s in r.samples) { + final segEnd = cursor.add(s.duration); + final mins = s.duration.inMinutes; + switch (s.stageType) { + case SleepStage.light: + case SleepStage.sleeping: // generic "asleep" — count as light + light += mins; + timeline.add(SleepStageInterval(start: cursor, end: segEnd, stage: 'light')); + case SleepStage.deep: + deep += mins; + timeline.add(SleepStageInterval(start: cursor, end: segEnd, stage: 'deep')); + case SleepStage.rem: + rem += mins; + timeline.add(SleepStageInterval(start: cursor, end: segEnd, stage: 'rem')); + case SleepStage.awake: + case SleepStage.outOfBed: + case SleepStage.inBed: + awake += mins; + timeline.add(SleepStageInterval(start: cursor, end: segEnd, stage: 'awake')); + case SleepStage.unknown: + break; + } + cursor = segEnd; + } + final hasStages = r.samples.isNotEmpty; + final period = SleepPeriod( + start: r.startTime, + end: r.endTime, + lightMinutes: hasStages ? light : null, + deepMinutes: hasStages ? deep : null, + remMinutes: hasStages ? rem : null, + awakeMinutes: hasStages ? awake : null, + stageTimeline: timeline, + ); + debugPrint('[HC] sleep ${r.startTime.toLocal().hour}:${r.startTime.toLocal().minute.toString().padLeft(2, '0')}' + '→${r.endTime.toLocal().hour}:${r.endTime.toLocal().minute.toString().padLeft(2, '0')}' + ' actual=${period.minutes}min' + '${hasStages ? " (L=$light D=$deep R=$rem A=$awake)" : " (no stages)"}'); + return period; + }).toList(); + debugPrint('[HC] readSleepSessions [$start → $end]: ${result.length} records'); + return result; + } catch (e) { + debugPrint('[HC] readSleepSessions failed: $e'); + return const []; + } + } + + @override + Future> readRestingHeartRate( + DateTime start, + DateTime end, + ) async { + try { + _connector ??= await HealthConnector.create(); + final response = await _connector!.readRecords( + HealthDataType.restingHeartRate.readInTimeRange( + startTime: start, + endTime: end, + ), + ); + final result = response.records + .map((r) => HealthSample(time: r.time, value: r.rate.inPerMinute)) + .toList(); + debugPrint('[HC] readRestingHeartRate [$start → $end]: ${result.length} records'); + return result; + } catch (e) { + debugPrint('[HC] readRestingHeartRate failed: $e'); + return const []; + } + } + + @override + Future> readHrvRmssd(DateTime start, DateTime end) async { + try { + _connector ??= await HealthConnector.create(); + final response = await _connector!.readRecords( + HealthDataType.heartRateVariabilityRMSSD.readInTimeRange( + startTime: start, + endTime: end, + ), + ); + final result = response.records + .map((r) => HealthSample(time: r.time, value: r.rmssd.inMilliseconds)) + .toList(); + debugPrint('[HC] readHrvRmssd [$start → $end]: ${result.length} records'); + return result; + } catch (e) { + debugPrint('[HC] readHrvRmssd failed: $e'); + return const []; + } + } + + @override + Future> readHeartRateSamples( + DateTime start, + DateTime end, + ) async { + try { + _connector ??= await HealthConnector.create(); + // heartRateSeries = Android HeartRateRecord (container with BPM samples). + // heartRate is iOS-only and throws UNSUPPORTED_OPERATION on Health Connect. + final response = await _connector!.readRecords( + HealthDataType.heartRateSeries.readInTimeRange( + startTime: start, + endTime: end, + pageSize: 5000, + ), + ); + final samples = response.records + .expand( + (r) => r.samples.map( + (s) => HealthSample(time: s.time, value: s.rate.inPerMinute), + ), + ) + .toList(); + debugPrint('[HC] readHeartRateSamples [$start → $end]: ' + '${response.records.length} series records, ${samples.length} samples'); + return samples; + } catch (e) { + debugPrint('[HC] readHeartRateSamples failed: $e'); + return const []; + } + } + @override Future syncWorkoutSession(WorkoutSession session, {String? title}) async { try { @@ -116,6 +309,20 @@ class HealthConnectService implements IHealthConnectService { ); await _connector!.writeRecords([record]); + + // DEBUG: read back to verify weight is stored — remove after confirming. + final response = await _connector!.readRecords( + HealthDataType.exerciseSession.readInTimeRange( + startTime: sessionStart, + endTime: sessionEnd, + ), + ); + for (final r in response.records.whereType()) { + for (final e in r.events.whereType()) { + debugPrint('[HC debug] segment=${e.segmentType} reps=${e.repetitions} weight=${e.weight}'); + } + } + return true; } catch (e) { debugPrint('Health Connect sync failed: $e'); @@ -135,15 +342,15 @@ class HealthConnectService implements IHealthConnectService { DateTime sessionStart, DateTime sessionEnd, ) { - // Collect (segmentType, reps, timestamp) for every valid set. - final allSets = <(ExerciseSegmentType, int, DateTime)>[]; + // Collect (segmentType, reps, timestamp, weightKg) for every valid set. + final allSets = <(ExerciseSegmentType, int, DateTime, double)>[]; for (final log in session.exercises) { final type = _segmentTypeMap[log.exerciseId] ?? ExerciseSegmentType.otherWorkout; for (final set in log.sets) { if (set.reps > 0) { - allSets.add((type, set.reps, set.timestamp)); + allSets.add((type, set.reps, set.timestamp, set.weight)); } } } @@ -163,7 +370,7 @@ class HealthConnectService implements IHealthConnectService { var ts = s.$3; if (ts.isBefore(sessionStart)) ts = sessionStart; if (ts.isAfter(sessionEnd)) ts = sessionEnd; - return (s.$1, s.$2, ts); + return (s.$1, s.$2, ts, s.$4); }) .toList(); @@ -192,6 +399,7 @@ class HealthConnectService implements IHealthConnectService { endTime: end, segmentType: clampedSets[i].$1, repetitions: clampedSets[i].$2, + weight: clampedSets[i].$4 > 0 ? Mass.kilograms(clampedSets[i].$4) : null, ); }); } @@ -206,6 +414,7 @@ class HealthConnectService implements IHealthConnectService { endTime: end, segmentType: clampedSets[i].$1, repetitions: clampedSets[i].$2, + weight: clampedSets[i].$4 > 0 ? Mass.kilograms(clampedSets[i].$4) : null, )); } return segments; diff --git a/workout-logger/lib/services/interfaces/ai_service_interface.dart b/workout-logger/lib/services/interfaces/ai_service_interface.dart new file mode 100644 index 0000000..4a840ec --- /dev/null +++ b/workout-logger/lib/services/interfaces/ai_service_interface.dart @@ -0,0 +1,53 @@ +// Abstract AI Service Interface (Dependency Inversion Principle) +// +// Defines the contract for the conversational AI / generation backend. +// High-level modules (the coach ViewModel, program generator) depend on this +// abstraction rather than a concrete SDK, so the backend can be swapped (e.g. +// google_generative_ai today → firebase_ai later) without touching consumers. +// +// The signatures intentionally use the google_generative_ai content model +// (Content / Tool / FunctionCall). firebase_ai exposes an almost identical +// shape, so a future backend swap is a mechanical adapter rather than a rewrite. + +import 'package:google_generative_ai/google_generative_ai.dart'; + +import '../../models/models.dart'; + +/// Contract for the AI backend used across RepForge (coach chat, program +/// generation, insights). Implemented by [GeminiAiService] today. +abstract class IAiService { + /// True once an API key (or equivalent credential) has been supplied. + bool get isConfigured; + + /// The model identifier currently in use (e.g. `gemini-3.1-flash-lite`). + String get currentModel; + + /// Stream a coach reply token-by-token. + /// + /// When [tools] and [onToolCall] are provided, the implementation runs a + /// tool-call loop: any function calls the model emits are dispatched through + /// [onToolCall] and their results fed back, until the model produces a final + /// natural-language answer. Only text is yielded to the caller. + Stream streamCoachReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }); + + /// Generate a structured multi-week training program from a natural-language + /// prompt, constrained to the provided exercise catalogue. + Future generateProgram({ + required String userPrompt, + required List allExercises, + }); + + /// One-shot weekly training summary in conversational prose. + Future generateWeeklyInsights(String contextText); + + /// Generic one-shot contextual insight given a [system] instruction and + /// [context] payload. + Future generateInsight(String system, String context); + +} diff --git a/workout-logger/lib/services/interfaces/health_connect_service_interface.dart b/workout-logger/lib/services/interfaces/health_connect_service_interface.dart index 14b6c9a..ae62973 100644 --- a/workout-logger/lib/services/interfaces/health_connect_service_interface.dart +++ b/workout-logger/lib/services/interfaces/health_connect_service_interface.dart @@ -1,8 +1,27 @@ import '../../models/models.dart'; +/// Read-side Health Connect data categories used for readiness scoring. +enum HealthReadType { sleep, heartRate, restingHeartRate, hrv } + abstract class IHealthConnectService { Future isAvailable(); Future requestPermissions(); Future hasPermissions(); Future syncWorkoutSession(WorkoutSession session, {String? title}); + + /// Requests all readiness read permissions (sleep, HR, resting HR, HRV) + /// in one dialog. Returns true if at least one was granted — partial + /// grants are usable because readiness components are independent. + Future requestReadPermissions(); + + /// The subset of readiness read permissions currently granted. + Future> grantedReadTypes(); + + Future> readSleepSessions(DateTime start, DateTime end); + Future> readRestingHeartRate(DateTime start, DateTime end); + Future> readHrvRmssd(DateTime start, DateTime end); + + /// Raw heart-rate samples. Only used as a morning-RHR fallback over a + /// narrow window when no [readRestingHeartRate] records exist. + Future> readHeartRateSamples(DateTime start, DateTime end); } diff --git a/workout-logger/lib/services/interfaces/interfaces.dart b/workout-logger/lib/services/interfaces/interfaces.dart index 55819d6..ce56ee8 100644 --- a/workout-logger/lib/services/interfaces/interfaces.dart +++ b/workout-logger/lib/services/interfaces/interfaces.dart @@ -8,3 +8,4 @@ export 'storage_service_interface.dart'; export 'ml_service_interface.dart'; export 'health_connect_service_interface.dart'; export 'health_sync_manager_interface.dart'; +export 'readiness_manager_interface.dart'; diff --git a/workout-logger/lib/services/interfaces/ml_service_interface.dart b/workout-logger/lib/services/interfaces/ml_service_interface.dart index 676850f..dcc9530 100644 --- a/workout-logger/lib/services/interfaces/ml_service_interface.dart +++ b/workout-logger/lib/services/interfaces/ml_service_interface.dart @@ -1,16 +1,8 @@ -// Abstract ML Service Interface (Dependency Inversion Principle) -// -// This interface defines the contract for machine learning operations. -// By depending on this abstraction, we can: -// - Swap ML algorithms without modifying consumers -// - Easily mock the ML service in tests -// - Follow the Open/Closed principle for new ML strategies - import '../../models/models.dart'; -/// Data point for ML training +/// Data point for ML training. class DataPoint { - final double x; // Session number or time + final double x; // Days since first session (time-based) final double y; // Volume or performance metric DataPoint({required this.x, required this.y}); @@ -28,30 +20,77 @@ class DataPoint { int get hashCode => Object.hash(x, y); } -/// Abstract interface for ML operations -/// -/// Implements Dependency Inversion Principle by allowing high-level modules -/// to depend on this abstraction rather than concrete ML implementations. +/// Per-muscle recovery state estimated by the exponential decay model. +class MuscleRecoveryStatus { + final String muscleGroupId; + + /// 0.0 = just trained (fully fatigued), 1.0 = fully recovered. + final double recoveryFraction; + + final Duration timeSinceLastTrained; + + /// How long until the muscle reaches ~95 % recovery (null = already there). + final Duration? estimatedTimeToFullRecovery; + + const MuscleRecoveryStatus({ + required this.muscleGroupId, + required this.recoveryFraction, + required this.timeSinceLastTrained, + this.estimatedTimeToFullRecovery, + }); + + /// ≥ 90 % — safe to train hard. + bool get isRecovered => recoveryFraction >= 0.90; + + /// < 70 % — still meaningfully fatigued; back off load. + bool get isUnderRecovered => recoveryFraction < 0.70; + + int get recoveryPercent => (recoveryFraction * 100).round(); +} + +/// Abstract interface for ML operations. abstract class IMLService { - /// Train a growth model using data points + /// Train a growth model using data points. GrowthModel trainGrowthModel(List dataPoints); - /// Extract data points from workout history for a specific exercise + /// Extract per-exercise data points (x = days since first session, y = volume). List extractExerciseDataPoints( String exerciseId, List sessions, ); - /// Get recommended sets based on last session and growth model + /// Extract per-muscle aggregate data points for the growth model. + /// y = sum of exercise volumes weighted by [MuscleActivation.activationPercentage]. + List extractMuscleDataPoints( + String muscleGroupId, + List sessions, + Map exerciseMap, + ); + + /// Compute recovery status for every muscle group that appears in [sessions]. + /// Uses an exponential decay model: recovery = 1 − exp(−t / τ). + Map computeMuscleRecoveryScores( + List sessions, + Map exerciseMap, { + DateTime? asOf, + }); + + /// Get recommended sets based on last session and growth model. + /// [minReps]/[maxReps] define the double-progression rep range. + /// Pass [recoveryScores] + [primaryMuscleIds] for recovery-aware advice. List recommendSets({ required List lastSession, GrowthModel? growthModel, + int minReps = 6, + int maxReps = 12, + Map? recoveryScores, + List? primaryMuscleIds, }); - /// Get default recommendations when no history exists + /// Get default recommendations when no history exists. List getDefaultRecommendations(int setCount); - /// Predict when a target will be completed based on growth model + /// Predict when a target will be completed based on growth model. DateTime? predictTargetCompletion({ required double currentValue, required double targetValue, diff --git a/workout-logger/lib/services/interfaces/readiness_manager_interface.dart b/workout-logger/lib/services/interfaces/readiness_manager_interface.dart new file mode 100644 index 0000000..7ea4741 --- /dev/null +++ b/workout-logger/lib/services/interfaces/readiness_manager_interface.dart @@ -0,0 +1,25 @@ +// Readiness Manager Interface (Dependency Inversion Principle) +// +// Abstracts daily readiness computation from Health Connect sleep/heart data. +// UI widgets depend on this abstraction so the data source and scoring can be +// swapped or mocked in tests. + +import '../../models/models.dart'; + +enum ReadinessStatus { idle, loading, ready, noData } + +/// Contract for computing and caching the user's daily readiness score. +abstract class IReadinessManager { + ReadinessStatus get status; + + /// Today's readiness, or null when nothing has been computed yet. + ReadinessSnapshot? get snapshot; + + /// Recomputes today's readiness from Health Connect. + /// + /// - No-op when the readiness setting is disabled. + /// - Serves a same-day cached snapshot (within a freshness TTL) unless + /// [force] is true. + /// - Never throws: any failure results in [ReadinessStatus.noData]. + Future refresh({bool force = false}); +} diff --git a/workout-logger/lib/services/interfaces/storage_service_interface.dart b/workout-logger/lib/services/interfaces/storage_service_interface.dart index 7ad6e1e..34132f6 100644 --- a/workout-logger/lib/services/interfaces/storage_service_interface.dart +++ b/workout-logger/lib/services/interfaces/storage_service_interface.dart @@ -67,6 +67,19 @@ abstract class IStorageService { Future getTrainingProgram(String id); Future deleteTrainingProgram(String id); + // ==================== PERSONAL RECORDS ==================== + + Future savePersonalRecord(PersonalRecord record); + Future getPersonalRecord(String exerciseId); + Future> getAllPersonalRecords(); + + // ==================== AI CONVERSATIONS ==================== + + Future saveConversation(Conversation conversation); + Future> getAllConversations(); + Future getConversation(String id); + Future deleteConversation(String id); + // ==================== EXPORT / IMPORT ==================== Future exportAllData(); diff --git a/workout-logger/lib/services/managers/conversation_manager.dart b/workout-logger/lib/services/managers/conversation_manager.dart new file mode 100644 index 0000000..fb784d8 --- /dev/null +++ b/workout-logger/lib/services/managers/conversation_manager.dart @@ -0,0 +1,126 @@ +// Conversation Manager (Single Responsibility Principle) +// +// Single source of truth for persisted AI coach conversations. Owns the +// in-memory list + the currently active conversation, and mirrors every +// mutation to storage. Does NOT talk to the AI backend — that's the +// AiCoachViewModel's job. + +import 'package:flutter/foundation.dart'; +import '../../models/models.dart'; +import '../interfaces/storage_service_interface.dart'; + +/// Manages the lifecycle of AI coach [Conversation]s (load, create, append, +/// rename, delete) backed by [IStorageService]. +/// +/// [kind] scopes this manager to a specific conversation category +/// (e.g. `'coach'` or `'optimizer'`). Only conversations with a matching +/// [Conversation.kind] are loaded or created by this instance. +class ConversationManager extends ChangeNotifier { + final IStorageService _storage; + final String kind; + + List _conversations = []; + Conversation? _active; + + ConversationManager(this._storage, {this.kind = 'coach'}); + + /// All conversations, most-recently-updated first. + List get conversations => List.unmodifiable(_conversations); + + /// The conversation currently shown in the coach screen, or null for a + /// fresh (unsaved) chat. + Conversation? get active => _active; + + /// Messages of the active conversation (empty for a fresh chat). + List get activeMessages => _active?.messages ?? const []; + + /// Load all conversations from storage. Does not change the active one. + /// Only conversations whose [Conversation.kind] matches [kind] are loaded. + Future loadConversations() async { + final all = await _storage.getAllConversations(); + _conversations = all.where((c) => c.kind == kind).toList(); + notifyListeners(); + } + + /// Begin a fresh conversation. Nothing is persisted until the first message + /// is appended (avoids littering storage with empty chats). + void startNewConversation() { + _active = null; + notifyListeners(); + } + + /// Make [id] the active conversation, if it exists. + void selectConversation(String id) { + final idx = _conversations.indexWhere((c) => c.id == id); + if (idx < 0) return; + _active = _conversations[idx]; + notifyListeners(); + } + + /// Append [message] to the active conversation, creating one if needed, + /// then persist. The conversation title is derived from the first user + /// message. Bumps `updatedAt` and re-sorts the list newest-first. + Future appendMessage(ChatMessage message) async { + final current = _active; + final Conversation updated; + + if (current == null) { + updated = Conversation( + title: _deriveTitle(message), + kind: kind, + messages: [message], + ); + } else { + final title = current.title.isEmpty && message.role == 'user' + ? _deriveTitle(message) + : current.title; + updated = current.copyWith( + title: title, + updatedAt: DateTime.now(), + messages: [...current.messages, message], + ); + } + + _active = updated; + _upsert(updated); + await _storage.saveConversation(updated); + notifyListeners(); + } + + /// Rename a conversation. + Future renameConversation(String id, String title) async { + final idx = _conversations.indexWhere((c) => c.id == id); + if (idx < 0) return; + final updated = _conversations[idx].copyWith( + title: title.trim(), + updatedAt: DateTime.now(), + ); + if (_active?.id == id) _active = updated; + _upsert(updated); + await _storage.saveConversation(updated); + notifyListeners(); + } + + /// Delete a conversation. Clears the active one if it was deleted. + Future deleteConversation(String id) async { + _conversations = _conversations.where((c) => c.id != id).toList(); + if (_active?.id == id) _active = null; + await _storage.deleteConversation(id); + notifyListeners(); + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + void _upsert(Conversation conversation) { + final next = _conversations.where((c) => c.id != conversation.id).toList() + ..add(conversation) + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + _conversations = next; + } + + String _deriveTitle(ChatMessage message) { + final text = message.text.trim().replaceAll(RegExp(r'\s+'), ' '); + if (text.isEmpty) return 'New chat'; + return text.length <= 40 ? text : '${text.substring(0, 40).trim()}…'; + } +} diff --git a/workout-logger/lib/services/managers/health_history_manager.dart b/workout-logger/lib/services/managers/health_history_manager.dart new file mode 100644 index 0000000..deb661e --- /dev/null +++ b/workout-logger/lib/services/managers/health_history_manager.dart @@ -0,0 +1,296 @@ +// Health History Manager +// +// Serves arbitrary-range sleep & heart-rate data for the detail screens. +// Stateless w.r.t. UI (not a ChangeNotifier) — screens drive it via +// FutureBuilder. The Health Connect service already reads any date range; +// this manager owns the windowing, bucketing and light caching on top. +// +// Performance: Day/Week use full HR samples (heavy, cached per immutable past +// day). Month/Year use restingHeartRate records (one/day, light) so a year +// never fans out into 365 sample queries. + +import 'dart:convert'; + +import '../../models/models.dart'; +import '../../models/sleep_hr_models.dart'; +import '../../models/workout_hr_models.dart'; +import '../interfaces/health_connect_service_interface.dart'; +import '../interfaces/storage_service_interface.dart'; +import '../utils/sleep_hr_builder.dart'; +import '../utils/workout_hr_builder.dart'; + +class HealthHistoryManager { + final IHealthConnectService _hc; + final IStorageService _storage; + + HealthHistoryManager(this._hc, this._storage); + + // ── Date helpers ──────────────────────────────────────────────────────────── + + static String dateKey(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; + + static DateTime _midnight(DateTime d) => DateTime(d.year, d.month, d.day); + + /// The [start, end) window covered by [g] anchored at [anchor]. + /// Day → that day. Week → 7 days ending on anchor. Month/Year → calendar unit. + static ({DateTime start, DateTime end}) rangeFor( + DateTime anchor, + HealthGranularity g, + ) { + final day = _midnight(anchor); + switch (g) { + case HealthGranularity.day: + return (start: day, end: day.add(const Duration(days: 1))); + case HealthGranularity.week: + final start = day.subtract(const Duration(days: 6)); + return (start: start, end: day.add(const Duration(days: 1))); + case HealthGranularity.month: + final start = DateTime(day.year, day.month, 1); + final end = DateTime(day.year, day.month + 1, 1); + return (start: start, end: end); + case HealthGranularity.year: + return (start: DateTime(day.year, 1, 1), end: DateTime(day.year + 1, 1, 1)); + } + } + + /// Steps the anchor by one unit of [g] in [dir] (+1 forward, -1 back). + static DateTime stepBy(DateTime anchor, HealthGranularity g, int dir) { + final day = _midnight(anchor); + switch (g) { + case HealthGranularity.day: + return day.add(Duration(days: dir)); + case HealthGranularity.week: + return day.add(Duration(days: 7 * dir)); + case HealthGranularity.month: + return DateTime(day.year, day.month + dir, day.day); + case HealthGranularity.year: + return DateTime(day.year + dir, day.month, day.day); + } + } + + Future> _granted() => _hc.grantedReadTypes(); + + /// HR breakdown for one recorded workout: curve, peak/avg/min, exercise + /// sections, and per-rest HR recovery. Null when no HR data covers the window. + Future workoutHr(WorkoutSession session) async { + final granted = await _granted(); + return buildWorkoutHrAnalysis(_hc, session, granted); + } + + // ── Day detail ────────────────────────────────────────────────────────────── + + /// Overnight HR snapshot for the night ending the morning of [morning]. + Future sleepNight(DateTime morning) async { + final granted = await _granted(); + return buildSleepHrSnapshot(_hc, morning, granted); + } + + /// All-day HR snapshot for [day]. Immutable past days are cached permanently; + /// today is always rebuilt (data is still accumulating). + Future hrDay(DateTime day) async { + final d = _midnight(day); + final isPast = d.isBefore(_midnight(DateTime.now())); + final cacheKey = 'hr.day.${dateKey(d)}'; + + if (isPast) { + final cached = await _readCachedHrDay(cacheKey); + if (cached != null) return cached; + } + + final granted = await _granted(); + final snap = await buildHrDaySnapshot(_hc, d, granted); + if (snap != null && isPast) { + try { + await _storage.saveSetting(cacheKey, jsonEncode(snap.toJson())); + } catch (_) {/* cache best-effort */} + } + return snap; + } + + Future _readCachedHrDay(String key) async { + try { + final raw = await _storage.getSetting(key); + if (raw == null) return null; + return HrDaySnapshot.fromJson(jsonDecode(raw) as Map); + } catch (_) { + return null; + } + } + + // ── Sleep aggregation ───────────────────────────────────────────────────────── + + /// Aggregated sleep-duration bars for [g] anchored at [anchor]. + /// Day/Week/Month → one bar per night; Year → 12 monthly averages. + /// Bars are emitted for every calendar slot in range (zero-filled) so the + /// chart axis stays stable. + Future> sleepBars( + DateTime anchor, + HealthGranularity g, + ) async { + final granted = await _granted(); + if (!granted.contains(HealthReadType.sleep)) return const []; + + final r = rangeFor(anchor, g); + // Pad the end so sleep ending the morning after the last day is captured. + final periods = + await _hc.readSleepSessions(r.start, r.end.add(const Duration(hours: 12))); + + // Group nightly totals by the day the session ENDS on (handles fragmented + // Pixel-Watch records — sum, don't max). + final byNight = {}; + for (final p in periods) { + final key = dateKey(p.end); + final t = byNight.putIfAbsent(key, () => _StageTally()); + t.add(p); + } + + if (g == HealthGranularity.year) { + // Average each month's nightly totals. + final byMonth = >{}; + byNight.forEach((key, tally) { + final d = DateTime.parse(key); + byMonth.putIfAbsent(d.month, () => []).add(tally); + }); + return List.generate(12, (i) { + final month = i + 1; + final tallies = byMonth[month] ?? const []; + final date = DateTime(_midnight(anchor).year, month, 1); + if (tallies.isEmpty) { + return SleepDayBar( + date: date, totalMinutes: 0, deepMin: 0, remMin: 0, lightMin: 0, awakeMin: 0); + } + final n = tallies.length; + return SleepDayBar( + date: date, + totalMinutes: tallies.fold(0, (s, t) => s + t.total) ~/ n, + deepMin: tallies.fold(0, (s, t) => s + t.deep) ~/ n, + remMin: tallies.fold(0, (s, t) => s + t.rem) ~/ n, + lightMin: tallies.fold(0, (s, t) => s + t.light) ~/ n, + awakeMin: tallies.fold(0, (s, t) => s + t.awake) ~/ n, + ); + }); + } + + // Per-night bars for each day in the range. + final bars = []; + for (var d = r.start; d.isBefore(r.end); d = d.add(const Duration(days: 1))) { + final t = byNight[dateKey(d)]; + bars.add(SleepDayBar( + date: d, + totalMinutes: t?.total ?? 0, + deepMin: t?.deep ?? 0, + remMin: t?.rem ?? 0, + lightMin: t?.light ?? 0, + awakeMin: t?.awake ?? 0, + )); + } + return bars; + } + + // ── HR aggregation ──────────────────────────────────────────────────────────── + + /// Aggregated HR range bars for [g] anchored at [anchor]. + /// Week → per-day min/max from full samples (cached). Month/Year → daily / + /// monthly min–max of resting-HR records (light query path). + Future> hrBars( + DateTime anchor, + HealthGranularity g, + ) async { + final granted = await _granted(); + if (!granted.contains(HealthReadType.heartRate) && + !granted.contains(HealthReadType.restingHeartRate)) { + return const []; + } + final r = rangeFor(anchor, g); + + if (g == HealthGranularity.week) { + final bars = []; + for (var d = r.start; d.isBefore(r.end); d = d.add(const Duration(days: 1))) { + final snap = await hrDay(d); + bars.add(HrRangeBar( + date: d, + label: _weekdayLabel(d), + minBpm: snap?.minBpm ?? 0, + maxBpm: snap?.maxBpm ?? 0, + avgBpm: snap?.avgBpm ?? 0, + restingBpm: snap?.restingBpm, + )); + } + return bars; + } + + // Month / Year → resting-HR records only. + final rhr = granted.contains(HealthReadType.restingHeartRate) + ? await _hc.readRestingHeartRate(r.start, r.end) + : []; + + final byDay = >{}; + for (final s in rhr) { + byDay.putIfAbsent(dateKey(s.time), () => []).add(s.value); + } + + if (g == HealthGranularity.month) { + final bars = []; + for (var d = r.start; d.isBefore(r.end); d = d.add(const Duration(days: 1))) { + final vals = byDay[dateKey(d)] ?? const []; + bars.add(_rangeBar(d, '${d.day}', vals)); + } + return bars; + } + + // Year → 12 monthly bars. + final byMonth = >{}; + byDay.forEach((key, vals) { + final m = DateTime.parse(key).month; + byMonth.putIfAbsent(m, () => []).addAll(vals); + }); + return List.generate(12, (i) { + final month = i + 1; + final date = DateTime(_midnight(anchor).year, month, 1); + return _rangeBar(date, _monthLabel(month), byMonth[month] ?? const []); + }); + } + + HrRangeBar _rangeBar(DateTime date, String label, List vals) { + if (vals.isEmpty) { + return HrRangeBar( + date: date, label: label, minBpm: 0, maxBpm: 0, avgBpm: 0, restingBpm: null); + } + final mn = vals.reduce((a, b) => a < b ? a : b); + final mx = vals.reduce((a, b) => a > b ? a : b); + final avg = vals.reduce((a, b) => a + b) / vals.length; + return HrRangeBar( + date: date, + label: label, + minBpm: mn.round(), + maxBpm: mx.round(), + avgBpm: avg, + restingBpm: avg.round(), + ); + } + + static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + static const _months = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; + String _weekdayLabel(DateTime d) => _weekdays[d.weekday - 1]; + String _monthLabel(int month) => _months[month - 1]; +} + +/// Accumulates stage minutes for one night across fragmented records. +class _StageTally { + int total = 0; + int deep = 0; + int rem = 0; + int light = 0; + int awake = 0; + + void add(SleepPeriod p) { + total += p.minutes; + deep += p.deepMinutes ?? 0; + rem += p.remMinutes ?? 0; + light += p.lightMinutes ?? 0; + awake += p.awakeMinutes ?? 0; + } +} diff --git a/workout-logger/lib/services/managers/managers.dart b/workout-logger/lib/services/managers/managers.dart index 8a6aed8..621badc 100644 --- a/workout-logger/lib/services/managers/managers.dart +++ b/workout-logger/lib/services/managers/managers.dart @@ -17,3 +17,5 @@ export 'target_manager.dart'; export 'analytics_manager.dart'; export 'program_manager.dart'; export 'health_sync_manager.dart'; +export 'pr_manager.dart'; +export 'readiness_manager.dart'; diff --git a/workout-logger/lib/services/managers/pr_manager.dart b/workout-logger/lib/services/managers/pr_manager.dart new file mode 100644 index 0000000..073c38b --- /dev/null +++ b/workout-logger/lib/services/managers/pr_manager.dart @@ -0,0 +1,105 @@ +// PRManager — detects and persists personal records per exercise. +// +// After each workout session is saved, call checkAndUpdatePRs() to compare +// every logged set against the stored PR for that exercise. Returns a list +// of NewPRResult describing which record types were broken so the UI can +// display badges on the summary screen. + +import 'package:flutter/foundation.dart'; +import '../../models/models.dart'; +import '../interfaces/storage_service_interface.dart'; + +class NewPRResult { + final String exerciseId; + final Set types; // 'weight' | 'reps' | 'volume' + + const NewPRResult({required this.exerciseId, required this.types}); +} + +class PRManager extends ChangeNotifier { + final IStorageService _storage; + + // exerciseId → best known record + final Map _cache = {}; + + PRManager(this._storage); + + Future load() async { + final records = await _storage.getAllPersonalRecords(); + _cache.clear(); + for (final r in records) { + _cache[r.exerciseId] = r; + } + } + + List get allRecords => List.unmodifiable(_cache.values.toList()); + + /// Seed PRs from historical sessions when no stored records exist yet. + /// + /// Sessions must be sorted oldest → newest so later sessions win on ties. + Future backfillFromSessions(List sessions) async { + final sorted = [...sessions]..sort((a, b) => a.date.compareTo(b.date)); + for (final s in sorted) { + await checkAndUpdatePRs(s); + } + } + + PersonalRecord? getRecord(String exerciseId) => _cache[exerciseId]; + + /// Compare each exercise log in [session] against stored PRs. + /// + /// Updates storage + in-memory cache for any broken records. + /// Returns only entries where at least one record was broken. + Future> checkAndUpdatePRs(WorkoutSession session) async { + final results = []; + + for (final log in session.exercises) { + if (log.sets.isEmpty) continue; + + final broken = await _checkExercise(log, session.date); + if (broken.isNotEmpty) { + results.add(NewPRResult(exerciseId: log.exerciseId, types: broken)); + } + } + + if (results.isNotEmpty) notifyListeners(); + return results; + } + + Future> _checkExercise(ExerciseLog log, DateTime date) async { + final existing = _cache[log.exerciseId]; + + double newBestWeight = existing?.bestWeight ?? 0; + int newBestReps = existing?.bestReps ?? 0; + double newBestVolume = existing?.bestVolume ?? 0; + + for (final set in log.sets) { + if (set.weight > newBestWeight) newBestWeight = set.weight; + if (set.reps > newBestReps) newBestReps = set.reps; + if (set.volume > newBestVolume) newBestVolume = set.volume; + } + + final broken = {}; + if (existing == null) { + broken.addAll(['weight', 'reps', 'volume']); + } else { + if (newBestWeight > existing.bestWeight) broken.add('weight'); + if (newBestReps > existing.bestReps) broken.add('reps'); + if (newBestVolume > existing.bestVolume) broken.add('volume'); + } + + if (broken.isEmpty) return broken; + + final updated = PersonalRecord( + exerciseId: log.exerciseId, + bestWeight: newBestWeight, + bestReps: newBestReps, + bestVolume: newBestVolume, + achievedAt: date, + ); + _cache[log.exerciseId] = updated; + await _storage.savePersonalRecord(updated); + + return broken; + } +} diff --git a/workout-logger/lib/services/managers/readiness_manager.dart b/workout-logger/lib/services/managers/readiness_manager.dart new file mode 100644 index 0000000..7b8f9f1 --- /dev/null +++ b/workout-logger/lib/services/managers/readiness_manager.dart @@ -0,0 +1,395 @@ +// Readiness Manager (Single Responsibility Principle) +// +// Owns the daily readiness slice of state: reads sleep/heart data from +// Health Connect, maintains a rolling 14-day personal baseline (recomputed +// at most once per day), scores today via ReadinessCalculator, and caches +// the result in settings storage so the home screen renders instantly. +// +// Failure policy: this feature is strictly additive — every error path +// degrades to ReadinessStatus.noData and never throws or blocks app init. + +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import '../../models/models.dart'; +import '../../models/sleep_hr_models.dart'; +import '../interfaces/health_connect_service_interface.dart'; +import '../interfaces/readiness_manager_interface.dart'; +import '../interfaces/storage_service_interface.dart'; +import '../settings_provider.dart'; +import '../utils/readiness_calculator.dart'; +import '../utils/sleep_hr_builder.dart'; + +class ReadinessManager extends ChangeNotifier implements IReadinessManager { + final IHealthConnectService _hc; + final IStorageService _storage; + final SettingsProvider _settings; + final ReadinessCalculator _calculator; + + static const _snapshotKey = 'readiness.snapshot'; + static const _baselineKey = 'readiness.baseline'; + static const _snapshotTtl = Duration(minutes: 30); + static const _baselineDays = 14; + + ReadinessStatus _status = ReadinessStatus.idle; + ReadinessSnapshot? _snapshot; + SleepHrSnapshot? _sleepHrSnapshot; + HrDaySnapshot? _hrDaySnapshot; + + SleepHrSnapshot? get sleepHrSnapshot => _sleepHrSnapshot; + + /// Today's all-day HR snapshot — backs the dashboard Heart-rate card. + /// Built best-effort during [refresh]; null when no HR data/permission. + HrDaySnapshot? get hrDaySnapshot => _hrDaySnapshot; + + // Debug-only: human-readable trace of the last refresh() execution. + // Empty until refresh() runs for the first time. + String _debugTrace = ''; + String get debugTrace => _debugTrace; + + ReadinessManager( + this._hc, + this._storage, + this._settings, { + ReadinessCalculator calculator = const ReadinessCalculator(), + }) : _calculator = calculator; + + @override + ReadinessStatus get status => _status; + + @override + ReadinessSnapshot? get snapshot => _snapshot; + + @override + Future refresh({bool force = false}) async { + if (!_settings.readinessEnabled) { + _debugTrace = 'readinessEnabled=false — refresh skipped'; + return; + } + + try { + final now = DateTime.now(); + final todayKey = ReadinessCalculator.dateKey(now); + debugPrint('[Readiness] refresh: todayKey=$todayKey force=$force'); + + // Fetch permissions first — needed on both the cached and live paths. + final granted = await _hc.grantedReadTypes(); + debugPrint('[Readiness] refresh: granted=$granted'); + if (granted.isEmpty) { + debugPrint('[Readiness] refresh: no permissions → noData'); + _debugTrace = 'NO PERMISSIONS granted\n' + 'Open Health Connect → App permissions → RepForge\n' + 'and allow Sleep and Heart rate.'; + _setNoData(); + return; + } + + final cached = await _loadSnapshot(); + if (cached != null && cached.dateKey == todayKey) { + // Same-day cache renders immediately; skip the re-fetch inside TTL. + _snapshot = cached; + _status = ReadinessStatus.ready; + notifyListeners(); + debugPrint('[Readiness] refresh: serving cached snapshot score=${cached.score}'); + if (!force && now.difference(cached.computedAt) < _snapshotTtl) { + _debugTrace = 'Serving cached snapshot (within ${_snapshotTtl.inMinutes}min TTL)\n' + 'score=${cached.score} band=${cached.band}\n' + 'computedAt=${cached.computedAt.toLocal()}'; + // Still build the HR snapshots if we don't have them yet. + if (_sleepHrSnapshot == null || _hrDaySnapshot == null) { + _sleepHrSnapshot ??= await _buildSleepHrSnapshot(now, granted); + _hrDaySnapshot ??= await _buildHrDaySnapshot(now, granted); + notifyListeners(); + } + return; + } + } + + final sleepMinutes = await _lastNightSleepMinutes(now, granted); + final restingHr = await _todayRestingHr(now, granted); + final hrv = await _todayHrv(now, granted); + + // Build HR snapshots (best-effort; failure must not affect score). + try { + _sleepHrSnapshot = await _buildSleepHrSnapshot(now, granted); + } catch (e) { + debugPrint('[Readiness] _buildSleepHrSnapshot failed (non-fatal): $e'); + _sleepHrSnapshot = null; + } + try { + _hrDaySnapshot = await _buildHrDaySnapshot(now, granted); + } catch (e) { + debugPrint('[Readiness] _buildHrDaySnapshot failed (non-fatal): $e'); + _hrDaySnapshot = null; + } + debugPrint('[Readiness] refresh: today → sleepMinutes=$sleepMinutes restingHr=$restingHr hrv=$hrv'); + + final baseline = await _baselineFor(todayKey, now, granted); + debugPrint('[Readiness] refresh: baseline → ' + 'avgSleep=${baseline.avgSleepMinutes?.toStringAsFixed(0)} (${baseline.sleepNights} nights) ' + 'avgRhr=${baseline.avgRestingHr?.toStringAsFixed(1)} (${baseline.rhrDays} days) ' + 'avgHrv=${baseline.avgHrvMs?.toStringAsFixed(1)} (${baseline.hrvDays} days)'); + + final snapshot = _calculator.compute( + today: now, + baseline: baseline, + lastNightSleepMinutes: sleepMinutes, + todayRestingHr: restingHr, + todayHrvMs: hrv, + ); + debugPrint('[Readiness] refresh: snapshot score=${snapshot.score} band=${snapshot.band} ' + 'sleepScore=${snapshot.sleepScore} rhrScore=${snapshot.rhrScore} hrvScore=${snapshot.hrvScore}'); + + // Build human-readable trace for the in-app debug panel. + final need = ReadinessCalculator.minBaselineSamples; + final buf = StringBuffer(); + buf.writeln('Granted: ${granted.map((e) => e.name).join(', ')}'); + buf.writeln(''); + buf.writeln('TODAY:'); + buf.writeln(' sleep : ${sleepMinutes != null ? "${sleepMinutes}min" : "— (no data)"}' + '${!granted.contains(HealthReadType.sleep) ? " [no perm]" : ""}'); + buf.writeln(' RHR : ${restingHr != null ? "${restingHr.toStringAsFixed(1)} bpm" : "— (no data)"}' + '${!granted.contains(HealthReadType.restingHeartRate) ? " [no perm]" : ""}'); + buf.writeln(' HRV : ${hrv != null ? "${hrv.toStringAsFixed(1)} ms" : "— (no data)"}' + '${!granted.contains(HealthReadType.hrv) ? " [no perm]" : ""}'); + buf.writeln(''); + buf.writeln('BASELINE (14d, need ≥$need samples):'); + buf.writeln(' sleep : ${baseline.avgSleepMinutes?.toStringAsFixed(0) ?? "—"}min' + ' · ${baseline.sleepNights} nights' + ' ${baseline.sleepNights >= need ? "✓" : "⚠ need $need"}'); + buf.writeln(' RHR : ${baseline.avgRestingHr?.toStringAsFixed(1) ?? "—"} bpm' + ' · ${baseline.rhrDays} days' + ' ${baseline.rhrDays >= need ? "✓" : "⚠ need $need"}'); + buf.writeln(' HRV : ${baseline.avgHrvMs?.toStringAsFixed(1) ?? "—"} ms' + ' · ${baseline.hrvDays} days' + ' ${baseline.hrvDays >= need ? "✓" : "⚠ need $need"}'); + buf.writeln(''); + buf.writeln('SLEEP HR:'); + if (_sleepHrSnapshot != null) { + final sh = _sleepHrSnapshot!; + buf.writeln(' segments=${sh.segments.length} p95=${sh.p95Bpm}bpm' + ' stages=${sh.stageStats.map((s) => s.stage).join(",")}'); + } else { + buf.writeln(' — no snapshot (need heartRate perm + sleep data)'); + } + buf.writeln(''); + buf.writeln('SCORES:'); + buf.writeln(' sleep=${snapshot.sleepScore ?? "—"} rhr=${snapshot.rhrScore ?? "—"} hrv=${snapshot.hrvScore ?? "—"}'); + buf.writeln(' overall=${snapshot.score ?? "null"} band=${snapshot.band?.name ?? "—"}'); + if (snapshot.score == null) { + buf.writeln(''); + buf.writeln('⚠ Score null: a component needs both today\'s data'); + buf.writeln(' AND ≥$need baseline days to contribute.'); + } + _debugTrace = buf.toString().trimRight(); + + if (snapshot.score == null) { + debugPrint('[Readiness] refresh: score null → noData ' + '(need ${ReadinessCalculator.minBaselineSamples}+ baseline days; ' + 'have sleep=${baseline.sleepNights} rhr=${baseline.rhrDays} hrv=${baseline.hrvDays})'); + _setNoData(); + return; + } + + _snapshot = snapshot; + _status = ReadinessStatus.ready; + await _storage.saveSetting(_snapshotKey, jsonEncode(snapshot.toJson())); + notifyListeners(); + } catch (e) { + debugPrint('[Readiness] refresh failed: $e'); + _debugTrace = 'refresh() threw: $e'; + _setNoData(); + } + } + + /// Builds last night's overnight HR snapshot for the Sleep HR chart, + /// falling back to the night before when the watch hasn't synced yet. + Future _buildSleepHrSnapshot( + DateTime now, + Set granted, + ) => + buildSleepHrSnapshot(_hc, now, granted, fallbackToPriorNight: true); + + /// Builds today's all-day HR snapshot for the Heart-rate card. + Future _buildHrDaySnapshot( + DateTime now, + Set granted, + ) => + buildHrDaySnapshot(_hc, now, granted); + + void _setNoData() { + _snapshot = null; + _status = ReadinessStatus.noData; + notifyListeners(); + } + + Future _loadSnapshot() async { + try { + final raw = await _storage.getSetting(_snapshotKey); + if (raw == null) return null; + return ReadinessSnapshot.fromJson( + jsonDecode(raw) as Map, + ); + } catch (_) { + return null; + } + } + + /// Returns the cached baseline when it was already computed today, + /// otherwise rebuilds it from the trailing [_baselineDays] window + /// (excluding last night / today, which are what we score). + Future _baselineFor( + String todayKey, + DateTime now, + Set granted, + ) async { + try { + final raw = await _storage.getSetting(_baselineKey); + if (raw != null) { + final cached = + ReadinessBaseline.fromJson(jsonDecode(raw) as Map); + if (cached.dateKey == todayKey) return cached; + } + } catch (_) { + // Corrupt cache — fall through to recompute. + } + + final day = DateTime(now.year, now.month, now.day); + final windowStart = day.subtract(const Duration(days: _baselineDays)); + + double? avgSleep; + var sleepNights = 0; + if (granted.contains(HealthReadType.sleep)) { + // End the window at yesterday 18:00 so last night isn't in its own baseline. + final periods = await _hc.readSleepSessions( + windowStart, + day.subtract(const Duration(hours: 6)), + ); + final nightly = _nightlySleepMinutes(periods); + sleepNights = nightly.length; + if (sleepNights > 0) { + avgSleep = nightly.reduce((a, b) => a + b) / sleepNights; + } + } + + double? avgRhr; + var rhrDays = 0; + if (granted.contains(HealthReadType.restingHeartRate)) { + final samples = await _hc.readRestingHeartRate(windowStart, day); + final daily = _dailyAverages(samples); + rhrDays = daily.length; + if (rhrDays > 0) avgRhr = daily.reduce((a, b) => a + b) / rhrDays; + } + + double? avgHrv; + var hrvDays = 0; + if (granted.contains(HealthReadType.hrv)) { + final samples = await _hc.readHrvRmssd(windowStart, day); + final daily = _dailyAverages(samples); + hrvDays = daily.length; + if (hrvDays > 0) avgHrv = daily.reduce((a, b) => a + b) / hrvDays; + } + + final baseline = ReadinessBaseline( + dateKey: todayKey, + avgSleepMinutes: avgSleep, + sleepNights: sleepNights, + avgRestingHr: avgRhr, + rhrDays: rhrDays, + avgHrvMs: avgHrv, + hrvDays: hrvDays, + ); + await _storage.saveSetting(_baselineKey, jsonEncode(baseline.toJson())); + return baseline; + } + + /// Total minutes per night, bucketed by the day the session ENDS on. + /// + /// Health Connect (Pixel Watch, etc.) writes multiple records per night — + /// one per sleep stage or one per awakening gap. Summing gives the real + /// nightly total; taking max severely under-counts fragmented recordings. + List _nightlySleepMinutes(List periods) { + final byNight = {}; + for (final p in periods) { + final key = ReadinessCalculator.dateKey(p.end); + byNight[key] = (byNight[key] ?? 0) + p.minutes; + } + return byNight.values.map((m) => m.toDouble()).toList(); + } + + /// One average per calendar day a sample exists on. + List _dailyAverages(List samples) { + final sums = {}; + final counts = {}; + for (final s in samples) { + final key = ReadinessCalculator.dateKey(s.time); + sums[key] = (sums[key] ?? 0) + s.value; + counts[key] = (counts[key] ?? 0) + 1; + } + return sums.entries.map((e) => e.value / counts[e.key]!).toList(); + } + + Future _lastNightSleepMinutes( + DateTime now, + Set granted, + ) async { + if (!granted.contains(HealthReadType.sleep)) return null; + final day = DateTime(now.year, now.month, now.day); + final periods = await _hc.readSleepSessions( + day.subtract(const Duration(hours: 6)), + day.add(const Duration(hours: 12)), + ); + for (final p in periods) { + final stageInfo = p.hasStages + ? 'L=${p.lightMinutes} D=${p.deepMinutes} R=${p.remMinutes} A=${p.awakeMinutes}' + : 'no stages'; + debugPrint('[Readiness] period ${p.start.toLocal().hour}:${p.start.toLocal().minute.toString().padLeft(2, '0')}' + '→${p.end.toLocal().hour}:${p.end.toLocal().minute.toString().padLeft(2, '0')}' + ' actual=${p.minutes}min ($stageInfo)'); + } + return _calculator.lastNightSleepMinutes(now, periods); + } + + /// Latest resting-HR record in the past 24h; falls back to the minimum + /// raw heart-rate sample between 02:00–10:00 today. The fallback is the + /// only minute-level query and only runs when no RHR record exists. + Future _todayRestingHr( + DateTime now, + Set granted, + ) async { + if (granted.contains(HealthReadType.restingHeartRate)) { + final samples = await _hc.readRestingHeartRate( + now.subtract(const Duration(hours: 24)), + now, + ); + if (samples.isNotEmpty) { + samples.sort((a, b) => a.time.compareTo(b.time)); + return samples.last.value; + } + } + if (granted.contains(HealthReadType.heartRate)) { + final day = DateTime(now.year, now.month, now.day); + final samples = await _hc.readHeartRateSamples( + day.add(const Duration(hours: 2)), + day.add(const Duration(hours: 10)), + ); + if (samples.isNotEmpty) { + return samples.map((s) => s.value).reduce((a, b) => a < b ? a : b); + } + } + return null; + } + + Future _todayHrv(DateTime now, Set granted) async { + if (!granted.contains(HealthReadType.hrv)) return null; + final samples = await _hc.readHrvRmssd( + now.subtract(const Duration(hours: 48)), + now, + ); + debugPrint('[Readiness] HRV samples (48h): ${samples.length}'); + if (samples.isEmpty) return null; + samples.sort((a, b) => a.time.compareTo(b.time)); + return samples.last.value; + } +} diff --git a/workout-logger/lib/services/ml_service.dart b/workout-logger/lib/services/ml_service.dart index 47f8997..ffae2a4 100644 --- a/workout-logger/lib/services/ml_service.dart +++ b/workout-logger/lib/services/ml_service.dart @@ -1,256 +1,485 @@ -// ML Service - Linear Regression for Growth Rate Prediction -// and Progressive Overload Recommendations -// -// This is a concrete implementation of IMLService. -// Following Dependency Inversion Principle: high-level modules depend on -// the IMLService abstraction, not this concrete class. -// Following Open/Closed Principle: new ML algorithms can be added by -// creating new implementations of IMLService. - import 'dart:math'; import '../models/models.dart'; import 'interfaces/ml_service_interface.dart'; -// Re-export DataPoint from interface for backward compatibility -export 'interfaces/ml_service_interface.dart' show DataPoint; +export 'interfaces/ml_service_interface.dart' show DataPoint, MuscleRecoveryStatus; -/// Linear regression based implementation of the ML service. +/// Growth modelling + double-progression recommendations + per-muscle +/// recovery scoring. /// -/// This class implements IMLService, allowing it to be swapped -/// for other ML algorithms without modifying the consuming code. +/// 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. class MLService implements IMLService { - // ==================== LINEAR REGRESSION ==================== + // 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; + + // ==================== GROWTH MODEL ==================== - /// Train a growth model using simple linear regression - /// x = session number (0, 1, 2, ...) - /// y = volume or performance metric @override GrowthModel trainGrowthModel(List dataPoints) { return MLService.trainGrowthModelStatic(dataPoints); } - /// Static version for backward compatibility + /// 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(), - ); + 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; - double sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; + 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, + ); + } - for (var point in dataPoints) { - sumX += point.x; - sumY += point.y; - sumXY += point.x * point.y; - sumX2 += point.x * point.x; + /// 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; + } - // Calculate slope and intercept using least squares - final denominator = n * sumX2 - sumX * sumX; - if (denominator == 0) { - return GrowthModel( + 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: sumY / n, + intercept: mean, r2: 0, - lastTrained: DateTime.now(), + rss: double.infinity, + stdError: 0, + degenerate: true, ); } - final slope = (n * sumXY - sumX * sumY) / denominator; - final intercept = (sumY - slope * sumX) / n; - - // Calculate R² (coefficient of determination) - final yMean = sumY / n; - double ssTotal = 0, ssResidual = 0; - - for (var point in dataPoints) { - final predicted = slope * point.x + intercept; - ssTotal += pow(point.y - yMean, 2); - ssResidual += pow(point.y - predicted, 2); + 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; } - final double r2Value = ssTotal > 0 - ? (1 - (ssResidual / ssTotal)).toDouble() - : 0.0; + // 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 GrowthModel( + return _Fit( slope: slope, intercept: intercept, - r2: r2Value.clamp(0.0, 1.0), - lastTrained: DateTime.now(), + r2: ssTotal > 0 ? (1 - ssResidual / ssTotal).toDouble() : 0.0, + rss: ssResidual, + stdError: stdError, + degenerate: false, ); } - /// Extract data points from workout history for a specific exercise + 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; + } + + // ==================== DATA EXTRACTION ==================== + + /// x = days since first session for this exercise, y = total volume. @override List extractExerciseDataPoints( String exerciseId, List sessions, ) { final dataPoints = []; - int sessionIndex = 0; - - // Sort sessions by date final sorted = List.from(sessions) ..sort((a, b) => a.date.compareTo(b.date)); - for (var session in sorted) { - for (var exerciseLog in session.exercises) { - if (exerciseLog.exerciseId == exerciseId) { - dataPoints.add( - DataPoint(x: sessionIndex.toDouble(), y: exerciseLog.totalVolume), - ); - sessionIndex++; + DateTime? firstDate; + for (final session in sorted) { + for (final log in session.exercises) { + if (log.exerciseId == exerciseId) { + firstDate ??= session.date; + final days = session.date.difference(firstDate).inDays.toDouble(); + dataPoints.add(DataPoint(x: days, y: log.totalVolume)); break; } } } + return dataPoints; + } + /// x = days since first session training this muscle, + /// y = effective volume = sum(exerciseVolume × activationPercentage / 100). + @override + List extractMuscleDataPoints( + String muscleGroupId, + List sessions, + Map exerciseMap, + ) { + final sorted = List.from(sessions) + ..sort((a, b) => a.date.compareTo(b.date)); + + final dataPoints = []; + DateTime? firstDate; + + for (final session in sorted) { + final volumes = _muscleVolumes(session, exerciseMap); + final vol = volumes[muscleGroupId]; + if (vol == null || vol == 0) continue; + firstDate ??= session.date; + final days = session.date.difference(firstDate).inDays.toDouble(); + dataPoints.add(DataPoint(x: days, y: vol)); + } return dataPoints; } - // ==================== RECOMMENDATIONS ==================== + // ==================== RECOVERY ==================== - /// Generate set recommendations based on previous performance + /// 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 - List recommendSets({ - required List lastSession, - GrowthModel? growthModel, - double targetProgressPercent = 5.0, // Default 5% increase + Map computeMuscleRecoveryScores( + List sessions, + Map exerciseMap, { + DateTime? asOf, }) { - if (lastSession.isEmpty) { - return []; - } - - // Calculate target volume increase - final lastVolume = lastSession.fold( - 0, - (sum, set) => sum + set.volume, - ); + final now = asOf ?? DateTime.now(); + final sorted = List.from(sessions) + ..sort((a, b) => a.date.compareTo(b.date)); - // Use growth model slope if available, otherwise use default percentage - double targetVolumeIncrease; - if (growthModel != null && growthModel.r2 > 0.3) { - // Use learned growth rate - targetVolumeIncrease = growthModel.slope; - } else { - // Default: aim for 5% increase - targetVolumeIncrease = lastVolume * (targetProgressPercent / 100); + // 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 recommendations = []; - final volumeIncreasePerSet = targetVolumeIncrease / lastSession.length; - - for (var set in lastSession) { - final targetVolume = set.volume + volumeIncreasePerSet; - final recommendation = _calculateOptimalSet( - currentWeight: set.weight, - currentReps: set.reps, - targetVolume: targetVolume, + 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, ); - recommendations.add(recommendation); } + return result; + } - return recommendations; + /// Effective volume per muscle group for one session. + static 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; } - /// Calculate optimal weight/reps to achieve target volume - static SetRecommendation _calculateOptimalSet({ - required double currentWeight, - required int currentReps, - required double targetVolume, + // ==================== RECOMMENDATIONS ==================== + + // Weekly relative growth thresholds (% of current volume per week). + // Below _plateauWeeklyPct the curve is effectively flat; below + // _declineWeeklyPct volume is genuinely regressing and a deload pays off. + static const _plateauWeeklyPct = 0.5; + static const _declineWeeklyPct = -2.0; + static const _minR2ForTrendSignal = 0.2; + + /// Double-progression with trend- and recovery-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. + /// + /// 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. + @override + List recommendSets({ + required List lastSession, + GrowthModel? growthModel, + int minReps = 6, + int maxReps = 12, + Map? recoveryScores, + List? primaryMuscleIds, }) { - // Strategy 1: Try adding reps first (safer progression) - if (currentReps < 12) { - final newReps = currentReps + 1; - final newVolume = currentWeight * newReps; - - if (newVolume >= targetVolume * 0.95) { - return SetRecommendation( - weight: currentWeight, - reps: newReps, - confidence: 'high', - reasoning: 'Add 1 rep for progressive overload', - ); - } + if (lastSession.isEmpty) return []; + + final trendIsTrustworthy = + growthModel != null && growthModel.r2 > _minR2ForTrendSignal; + final weeklyPct = trendIsTrustworthy ? growthModel.weeklyGrowthPercent : null; + final isDeclining = weeklyPct != null && weeklyPct < _declineWeeklyPct; + final isPlateau = + weeklyPct != null && !isDeclining && weeklyPct < _plateauWeeklyPct; + + final isUnderRecovered = primaryMuscleIds != null && + recoveryScores != null && + primaryMuscleIds.any((m) => recoveryScores[m]?.isUnderRecovered ?? false); + + final worstRecovery = isUnderRecovered + ? primaryMuscleIds + .map((m) => recoveryScores[m]) + .whereType() + .map((s) => s.recoveryPercent) + .fold(100, (a, b) => a < b ? a : b) + : null; + + return lastSession + .map((set) => _doubleProgression( + set: set, + minReps: minReps, + maxReps: maxReps, + isPlateau: isPlateau, + isDeclining: isDeclining, + isUnderRecovered: isUnderRecovered, + recoveryPercent: worstRecovery, + )) + .toList(); + } - // Try adding 2 reps - if (currentReps < 11) { - final twoMoreReps = currentReps + 2; - final volumeWith2Reps = currentWeight * twoMoreReps; - - if (volumeWith2Reps >= targetVolume * 0.95) { - return SetRecommendation( - weight: currentWeight, - reps: twoMoreReps, - confidence: 'high', - reasoning: 'Add 2 reps to match target volume', - ); - } - } + static SetRecommendation _doubleProgression({ + required WorkoutSet set, + required int minReps, + required int maxReps, + required bool isPlateau, + required bool isDeclining, + required bool isUnderRecovered, + int? recoveryPercent, + }) { + if (isUnderRecovered) { + return SetRecommendation( + weight: set.weight, + reps: set.reps, + confidence: 'low', + reasoning: + 'Muscle only $recoveryPercent% recovered — maintain load, skip progression', + ); } - // Strategy 2: Increase weight - final weightIncrement = currentWeight < 40 ? 2.5 : 5.0; - final newWeight = currentWeight + weightIncrement; - - // When increasing weight, maintain or slightly reduce reps - int newReps = currentReps; - if (currentReps >= 10) { - newReps = currentReps - 2; // Reset rep range when weight goes up + 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', + ); } - newReps = newReps.clamp(6, 15); - final newVolume = newWeight * newReps; + if (isPlateau) { + return SetRecommendation( + weight: set.weight, + reps: set.reps, + confidence: 'medium', + reasoning: 'Plateau detected — maintain load and focus on form quality', + ); + } - String confidence; - if (newVolume >= targetVolume * 0.9 && newVolume <= targetVolume * 1.1) { - confidence = 'high'; - } else if (newVolume >= targetVolume * 0.8) { - confidence = 'medium'; - } else { - confidence = 'low'; + 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: newWeight, - reps: newReps, - confidence: confidence, - reasoning: 'Increase weight by ${weightIncrement}kg, adjust reps', + weight: set.weight, + reps: set.reps + 1, + confidence: 'high', + reasoning: 'Add 1 rep (${set.reps + 1}/$maxReps) — progressive overload', ); } - /// Fill in default recommendations for a new exercise + /// Fill in default recommendations when no history exists. @override List getDefaultRecommendations(int setCount) { return List.generate( setCount, - (index) => SetRecommendation( + (_) => SetRecommendation( weight: 0, reps: 10, confidence: 'low', - reasoning: 'No previous data - adjust based on feel', + reasoning: 'No previous data — adjust based on feel', ), ); } // ==================== TARGET PREDICTIONS ==================== - /// Predict when a target will be achieved + // 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, @@ -258,45 +487,59 @@ class MLService implements IMLService { required GrowthModel growthModel, double sessionsPerWeek = 3.0, }) { - if (currentValue >= targetValue) { - return DateTime.now(); // Already achieved - } - - if (growthModel.slope <= 0) { - return null; // No growth or declining - can't predict + 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; } - final gapToTarget = targetValue - currentValue; - final sessionsNeeded = gapToTarget / growthModel.slope; - final weeksNeeded = sessionsNeeded / sessionsPerWeek; - final daysNeeded = (weeksNeeded * 7).ceil(); - - return DateTime.now().add(Duration(days: daysNeeded)); + if (daysFromNow <= 0) return DateTime.now(); + if (!daysFromNow.isFinite || daysFromNow > _maxPredictionDays) return null; + return DateTime.now().add(Duration(days: daysFromNow.ceil())); } - /// Calculate confidence interval for prediction + /// 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({ + predictTargetWithConfidence({ required double currentValue, required double targetValue, required GrowthModel growthModel, double sessionsPerWeek = 3.0, }) { - // Create instance to call the non-static method - final mlService = MLService(); - final expected = mlService.predictTargetCompletion( + final expected = MLService().predictTargetCompletion( currentValue: currentValue, targetValue: targetValue, growthModel: growthModel, sessionsPerWeek: sessionsPerWeek, ); - if (expected == null) return null; - // Adjust based on model quality (R²) final daysToTarget = expected.difference(DateTime.now()).inDays; - final uncertainty = ((1 - growthModel.r2) * daysToTarget * 0.5).ceil(); - + 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, @@ -304,3 +547,22 @@ class MLService implements IMLService { ); } } + +/// 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/settings_provider.dart b/workout-logger/lib/services/settings_provider.dart index 00d5cc1..164df92 100644 --- a/workout-logger/lib/services/settings_provider.dart +++ b/workout-logger/lib/services/settings_provider.dart @@ -1,6 +1,7 @@ -// Settings Provider - User preferences (weight unit, increments) +// Settings Provider - User preferences (weight unit, increments, user profile) import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'interfaces/storage_service_interface.dart'; enum WeightUnit { kg, lbs } @@ -11,11 +12,27 @@ class SettingsProvider extends ChangeNotifier { WeightUnit _weightUnit = WeightUnit.kg; double _weightIncrement = 2.5; bool _healthConnectEnabled = false; + bool _readinessEnabled = false; + String? _userName; + String? _lastSeenVersion; + String _geminiApiKey = ''; + String _geminiModel = 'gemini-2.5-flash'; + String _weeklyInsights = ''; + DateTime? _weeklyInsightsDate; + bool _showAdvancedMetrics = false; WeightUnit get weightUnit => _weightUnit; double get weightIncrement => _weightIncrement; String get unitLabel => _weightUnit == WeightUnit.kg ? 'kg' : 'lbs'; bool get healthConnectEnabled => _healthConnectEnabled; + bool get readinessEnabled => _readinessEnabled; + String? get userName => _userName; + String? get lastSeenVersion => _lastSeenVersion; + String get geminiApiKey => _geminiApiKey; + String get geminiModel => _geminiModel; + String get weeklyInsights => _weeklyInsights; + DateTime? get weeklyInsightsDate => _weeklyInsightsDate; + bool get showAdvancedMetrics => _showAdvancedMetrics; SettingsProvider(this._storage); @@ -30,6 +47,41 @@ class SettingsProvider extends ChangeNotifier { final hcEnabled = await _storage.getSetting('healthConnectEnabled'); _healthConnectEnabled = hcEnabled == 'true'; + + final readiness = await _storage.getSetting('readinessEnabled'); + _readinessEnabled = readiness == 'true'; + + _userName = await _storage.getSetting('userName'); + _lastSeenVersion = await _storage.getSetting('lastSeenVersion'); + _geminiApiKey = await _storage.getSetting('geminiApiKey') ?? ''; + _geminiModel = await _storage.getSetting('geminiModel') ?? 'gemini-2.5-flash'; + _weeklyInsights = await _storage.getSetting('weeklyInsights') ?? ''; + final dateStr = await _storage.getSetting('weeklyInsightsDate'); + _weeklyInsightsDate = dateStr != null ? DateTime.tryParse(dateStr) : null; + final advMetrics = await _storage.getSetting('showAdvancedMetrics'); + _showAdvancedMetrics = advMetrics == 'true'; + } + + Future setUserName(String name) async { + _userName = name.trim(); + await _storage.saveSetting('userName', _userName!); + notifyListeners(); + } + + Future markVersionSeen(String version) async { + _lastSeenVersion = version; + await _storage.saveSetting('lastSeenVersion', version); + notifyListeners(); + } + + /// Returns the current app version string (e.g. "1.0.19"). + Future getCurrentVersion() async { + try { + final info = await PackageInfo.fromPlatform(); + return info.version; + } catch (_) { + return 'unknown'; + } } double get _defaultIncrement => _weightUnit == WeightUnit.kg ? 2.5 : 5.0; @@ -54,6 +106,41 @@ class SettingsProvider extends ChangeNotifier { notifyListeners(); } + Future setReadinessEnabled(bool enabled) async { + _readinessEnabled = enabled; + await _storage.saveSetting('readinessEnabled', enabled.toString()); + notifyListeners(); + } + + Future setGeminiModel(String model) async { + _geminiModel = model; + await _storage.saveSetting('geminiModel', model); + notifyListeners(); + } + + Future setGeminiApiKey(String key) async { + _geminiApiKey = key.trim(); + await _storage.saveSetting('geminiApiKey', _geminiApiKey); + notifyListeners(); + } + + Future setShowAdvancedMetrics(bool value) async { + _showAdvancedMetrics = value; + await _storage.saveSetting('showAdvancedMetrics', value.toString()); + notifyListeners(); + } + + Future saveWeeklyInsights(String insights) async { + _weeklyInsights = insights; + _weeklyInsightsDate = DateTime.now(); + await _storage.saveSetting('weeklyInsights', insights); + await _storage.saveSetting( + 'weeklyInsightsDate', + _weeklyInsightsDate!.toIso8601String(), + ); + notifyListeners(); + } + /// Convert from internal kg storage to display unit. double toDisplay(double kg) { if (_weightUnit == WeightUnit.lbs) return kg * 2.20462; diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index f06c0e2..873de63 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -24,6 +24,8 @@ class StorageService implements IStorageService { static const String _customExercisesBox = 'custom_exercises'; static const String _settingsBox = 'settings'; static const String _trainingProgramsBox = 'training_programs'; + static const String _personalRecordsBox = 'personal_records'; + static const String _aiConversationsBox = 'ai_conversations'; late Box _sessionsBox; late Box _routinesBoxInstance; @@ -32,6 +34,8 @@ class StorageService implements IStorageService { late Box _customExercisesBoxInstance; late Box _settingsBoxInstance; late Box _trainingProgramsBoxInstance; + late Box _personalRecordsBoxInstance; + late Box _aiConversationsBoxInstance; String _appVersion = const String.fromEnvironment( 'APP_VERSION', @@ -68,6 +72,12 @@ class StorageService implements IStorageService { _trainingProgramsBoxInstance = await Hive.openBox( _trainingProgramsBox, ); + _personalRecordsBoxInstance = await Hive.openBox( + _personalRecordsBox, + ); + _aiConversationsBoxInstance = await Hive.openBox( + _aiConversationsBox, + ); // Initialize default muscle groups if empty if (_muscleGroupsBoxInstance.isEmpty) { @@ -359,6 +369,9 @@ class StorageService implements IStorageService { 'customExercises': _customExercisesBoxInstance.values .map(_normalizeExportValue) .toList(growable: false), + 'conversations': _aiConversationsBoxInstance.values + .map(_normalizeExportValue) + .toList(growable: false), 'settings': settingsMap, 'exportDate': DateTime.now().toIso8601String(), 'appVersion': _appVersion, @@ -450,6 +463,20 @@ class StorageService implements IStorageService { } } } + + // Import AI conversations (merge: skip if id already exists) + final conversations = data['conversations']; + if (conversations is List) { + for (var item in conversations) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final conversation = Conversation.fromJson(map); + final existing = await getConversation(conversation.id); + if (existing == null) { + await saveConversation(conversation); + } + } + } } // ==================== TRAINING PROGRAMS ==================== @@ -484,6 +511,65 @@ class StorageService implements IStorageService { await _trainingProgramsBoxInstance.delete(id); } + // ==================== PERSONAL RECORDS ==================== + + @override + Future savePersonalRecord(PersonalRecord record) async { + await _personalRecordsBoxInstance.put( + record.exerciseId, + jsonEncode(record.toJson()), + ); + } + + @override + Future getPersonalRecord(String exerciseId) async { + final json = _personalRecordsBoxInstance.get(exerciseId); + if (json == null) return null; + return PersonalRecord.fromJson(jsonDecode(json)); + } + + @override + Future> getAllPersonalRecords() async { + final records = []; + for (final json in _personalRecordsBoxInstance.values) { + records.add(PersonalRecord.fromJson(jsonDecode(json))); + } + return records; + } + + // ==================== AI CONVERSATIONS ==================== + + @override + Future saveConversation(Conversation conversation) async { + await _aiConversationsBoxInstance.put( + conversation.id, + jsonEncode(conversation.toJson()), + ); + } + + @override + Future> getAllConversations() async { + final conversations = []; + for (final json in _aiConversationsBoxInstance.values) { + conversations.add(Conversation.fromJson(jsonDecode(json))); + } + // Most recently updated first. + conversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + return conversations; + } + + @override + Future getConversation(String id) async { + final json = _aiConversationsBoxInstance.get(id); + if (json == null) return null; + return Conversation.fromJson(jsonDecode(json)); + } + + @override + Future deleteConversation(String id) async { + await _aiConversationsBoxInstance.delete(id); + } + // ==================== STATS ==================== @override diff --git a/workout-logger/lib/services/utils/readiness_calculator.dart b/workout-logger/lib/services/utils/readiness_calculator.dart new file mode 100644 index 0000000..74a1259 --- /dev/null +++ b/workout-logger/lib/services/utils/readiness_calculator.dart @@ -0,0 +1,147 @@ +// Readiness Calculator (pure, no I/O) +// +// Scores today's training readiness against the user's own rolling baseline. +// Each component (sleep, resting HR, HRV) is scored 0–100 independently and +// only penalizes adverse deviation — being at or better than baseline is 100. +// The overall score is a weighted average renormalized over the components +// that are actually available, so sleep-only users get a first-class score. + +import '../../models/models.dart'; + +class ReadinessCalculator { + const ReadinessCalculator(); + + /// Minimum baseline samples before a component participates in scoring. + static const int minBaselineSamples = 5; + + /// Component weights, renormalized over available components. + static const double sleepWeight = 0.5; + static const double rhrWeight = 0.3; + static const double hrvWeight = 0.2; + + /// Sleep under this many minutes is capped at [shortSleepMaxScore] + /// regardless of the user's baseline (guards chronically short baselines). + static const int shortSleepMinutes = 300; + static const int shortSleepMaxScore = 40; + + static const int highBandThreshold = 75; + static const int moderateBandThreshold = 50; + + /// Returns total minutes of sleep in the window yesterday 18:00 → today 12:00. + /// + /// Health Connect (especially Pixel Watch) writes sleep as multiple records + /// per night — one per stage or one per awakening gap. Summing gives the true + /// sleep total; taking the longest single record under-counts badly. + int? lastNightSleepMinutes(DateTime today, List periods) { + final day = DateTime(today.year, today.month, today.day); + final windowStart = day.subtract(const Duration(hours: 6)); // 18:00 prev day + final windowEnd = day.add(const Duration(hours: 12)); + + var totalMinutes = 0; + for (final p in periods) { + if (!p.end.isAfter(windowStart) || !p.start.isBefore(windowEnd)) continue; + totalMinutes += p.minutes; + } + return totalMinutes > 0 ? totalMinutes : null; + } + + // Keep backward-compatible name used in tests; delegates to the new method. + SleepPeriod? lastNightSleep(DateTime today, List periods) { + final minutes = lastNightSleepMinutes(today, periods); + if (minutes == null) return null; + // Return a synthetic period whose .minutes equals the summed total. + final now = DateTime(today.year, today.month, today.day); + return SleepPeriod(start: now, end: now.add(Duration(minutes: minutes))); + } + + ReadinessSnapshot compute({ + required DateTime today, + required ReadinessBaseline baseline, + int? lastNightSleepMinutes, + double? todayRestingHr, + double? todayHrvMs, + }) { + final sleepBaseline = + baseline.sleepNights >= minBaselineSamples ? baseline.avgSleepMinutes : null; + final rhrBaseline = + baseline.rhrDays >= minBaselineSamples ? baseline.avgRestingHr : null; + final hrvBaseline = + baseline.hrvDays >= minBaselineSamples ? baseline.avgHrvMs : null; + + final sleepScore = _sleepScore(lastNightSleepMinutes, sleepBaseline); + final rhrScore = _rhrScore(todayRestingHr, rhrBaseline); + final hrvScore = _hrvScore(todayHrvMs, hrvBaseline); + + int? score; + ReadinessBand? band; + var weighted = 0.0; + var totalWeight = 0.0; + if (sleepScore != null) { + weighted += sleepScore * sleepWeight; + totalWeight += sleepWeight; + } + if (rhrScore != null) { + weighted += rhrScore * rhrWeight; + totalWeight += rhrWeight; + } + if (hrvScore != null) { + weighted += hrvScore * hrvWeight; + totalWeight += hrvWeight; + } + if (totalWeight > 0) { + score = (weighted / totalWeight).round().clamp(0, 100); + band = score >= highBandThreshold + ? ReadinessBand.high + : score >= moderateBandThreshold + ? ReadinessBand.moderate + : ReadinessBand.low; + } + + return ReadinessSnapshot( + dateKey: dateKey(today), + score: score, + band: band, + sleepMinutes: sleepScore != null ? lastNightSleepMinutes : null, + sleepBaselineMinutes: sleepScore != null ? sleepBaseline : null, + sleepScore: sleepScore, + restingHr: rhrScore != null ? todayRestingHr : null, + rhrBaseline: rhrScore != null ? rhrBaseline : null, + rhrScore: rhrScore, + hrvMs: hrvScore != null ? todayHrvMs : null, + hrvBaseline: hrvScore != null ? hrvBaseline : null, + hrvScore: hrvScore, + ); + } + + // Every 10% of sleep below the personal average costs 20 points. + int? _sleepScore(int? minutes, double? avgMinutes) { + if (minutes == null || avgMinutes == null || avgMinutes <= 0) return null; + final ratio = minutes / avgMinutes; + var score = (100 - _adverse(1 - ratio) * 200).round().clamp(0, 100); + if (minutes < shortSleepMinutes && score > shortSleepMaxScore) { + score = shortSleepMaxScore; + } + return score; + } + + // Elevated resting HR is the penalty: +10% over baseline scores 50. + int? _rhrScore(double? rhr, double? avgRhr) { + if (rhr == null || avgRhr == null || avgRhr <= 0) return null; + final deviation = (rhr - avgRhr) / avgRhr; + return (100 - _adverse(deviation) * 500).round().clamp(0, 100); + } + + // Suppressed HRV is the penalty: −20% under baseline scores 50. + int? _hrvScore(double? hrv, double? avgHrv) { + if (hrv == null || avgHrv == null || avgHrv <= 0) return null; + final ratio = hrv / avgHrv; + return (100 - _adverse(1 - ratio) * 250).round().clamp(0, 100); + } + + double _adverse(double deviation) => deviation > 0 ? deviation : 0; + + static String dateKey(DateTime date) => + '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; +} diff --git a/workout-logger/lib/services/utils/sleep_hr_builder.dart b/workout-logger/lib/services/utils/sleep_hr_builder.dart new file mode 100644 index 0000000..02f6ffa --- /dev/null +++ b/workout-logger/lib/services/utils/sleep_hr_builder.dart @@ -0,0 +1,210 @@ +// Sleep-HR snapshot builder. +// +// Extracted from ReadinessManager so the overnight-HR snapshot can be built +// for ANY night, not just last night. ReadinessManager builds it for "today" +// (with a prior-night fallback for un-synced mornings); HealthHistoryManager +// builds it for arbitrary historical dates as the user navigates. +// +// Pure function over IHealthConnectService — no state, no caching here. + +import 'package:flutter/foundation.dart'; + +import '../../models/sleep_hr_models.dart'; +import '../interfaces/health_connect_service_interface.dart'; + +/// Builds an overnight HR snapshot for the night that ENDS on the morning of +/// [morning] (i.e. the local calendar day [morning]). +/// +/// Returns null when HR/sleep permission is missing or no samples exist. +/// When [fallbackToPriorNight] is true and the target night has no sleep data, +/// it retries the night before (covers mornings where the watch hasn't synced). +Future buildSleepHrSnapshot( + IHealthConnectService hc, + DateTime morning, + Set granted, { + bool fallbackToPriorNight = false, +}) async { + if (!granted.contains(HealthReadType.heartRate)) return null; + if (!granted.contains(HealthReadType.sleep)) return null; + + final day = DateTime(morning.year, morning.month, morning.day); + + var windowStart = day.subtract(const Duration(hours: 6)); + var windowEnd = day.add(const Duration(hours: 12)); + + var periods = await hc.readSleepSessions(windowStart, windowEnd); + if (periods.isEmpty && fallbackToPriorNight) { + windowStart = windowStart.subtract(const Duration(days: 1)); + windowEnd = windowEnd.subtract(const Duration(days: 1)); + periods = await hc.readSleepSessions(windowStart, windowEnd); + debugPrint('[SleepHr] no data for target night — fell back to night before'); + } + if (periods.isEmpty) return null; + + // Use the earliest start and latest end across all records. + final sleepStart = periods.map((p) => p.start).reduce((a, b) => a.isBefore(b) ? a : b); + final sleepEnd = periods.map((p) => p.end).reduce((a, b) => a.isAfter(b) ? a : b); + + // Read HR samples covering the full sleep window (+ 15 min buffer). + final samples = await hc.readHeartRateSamples( + sleepStart.subtract(const Duration(minutes: 15)), + sleepEnd.add(const Duration(minutes: 15)), + ); + if (samples.isEmpty) return null; + + // Flatten all stage intervals from all periods into one sorted list. + final allIntervals = periods + .expand((p) => p.stageTimeline) + .toList() + ..sort((a, b) => a.start.compareTo(b.start)); + + String stageAt(DateTime t) { + for (final iv in allIntervals) { + if (!t.isBefore(iv.start) && t.isBefore(iv.end)) return iv.stage; + } + return 'awake'; + } + + // Bucket samples into 10-minute windows aligned to sleepStart. + final segmentMap = >{}; + for (final s in samples) { + final offsetMin = s.time.difference(sleepStart).inMinutes; + if (offsetMin < 0) continue; + final bucket = (offsetMin ~/ 10) * 10; + segmentMap.putIfAbsent(bucket, () => []); + segmentMap[bucket]!.add((bpm: s.value.round(), stage: stageAt(s.time))); + } + + final segments = []; + final sortedBuckets = segmentMap.keys.toList()..sort(); + for (final bucket in sortedBuckets) { + final entries = segmentMap[bucket]!; + if (entries.length < 2) continue; + final bpms = entries.map((e) => e.bpm).toList()..sort(); + final stageCounts = {}; + for (final e in entries) { + stageCounts[e.stage] = (stageCounts[e.stage] ?? 0) + 1; + } + final dominantStage = stageCounts.entries + .reduce((a, b) => a.value >= b.value ? a : b) + .key; + segments.add(SleepHrSegment( + windowStart: sleepStart.add(Duration(minutes: bucket)), + minBpm: bpms.first, + maxBpm: bpms.last, + avgBpm: bpms.reduce((a, b) => a + b) / bpms.length, + stage: dominantStage, + )); + } + if (segments.isEmpty) return null; + + // P5 / P95 across all samples. + final allBpms = samples.map((s) => s.value.round()).toList()..sort(); + final p5Bpm = allBpms[(allBpms.length * 0.05).floor().clamp(0, allBpms.length - 1)]; + final p95Bpm = allBpms[(allBpms.length * 0.95).floor().clamp(0, allBpms.length - 1)]; + + // Per-stage stats (min 3 samples required). + final byStage = >{}; + for (final s in samples) { + final stage = stageAt(s.time); + byStage.putIfAbsent(stage, () => []); + byStage[stage]!.add(s.value.round()); + } + final stageStats = []; + for (final entry in byStage.entries) { + final bpms = entry.value..sort(); + if (bpms.length < 3) continue; + stageStats.add(SleepStageStats( + stage: entry.key, + minBpm: bpms.first, + p25Bpm: bpms[(bpms.length * 0.25).floor()], + avgBpm: bpms.reduce((a, b) => a + b) / bpms.length, + p75Bpm: bpms[(bpms.length * 0.75).floor()], + maxBpm: bpms.last, + sampleCount: bpms.length, + )); + } + + return SleepHrSnapshot( + sleepStart: sleepStart, + sleepEnd: sleepEnd, + p5Bpm: p5Bpm, + p95Bpm: p95Bpm, + segments: segments, + stageStats: stageStats, + ); +} + +/// Builds an all-day HR snapshot for the local calendar day [day]: ~30-minute +/// min/max/avg buckets plus a resting-HR figure. +/// +/// Returns null when HR permission is missing or no samples exist for the day. +/// Resting HR = latest restingHeartRate record that day, else the minimum +/// raw sample between 02:00–10:00 (same fallback ReadinessManager uses). +Future buildHrDaySnapshot( + IHealthConnectService hc, + DateTime day, + Set granted, { + Duration bucket = const Duration(minutes: 30), +}) async { + if (!granted.contains(HealthReadType.heartRate)) return null; + + final start = DateTime(day.year, day.month, day.day); + final end = start.add(const Duration(days: 1)); + + final samples = await hc.readHeartRateSamples(start, end); + if (samples.isEmpty) return null; + + final bucketMin = bucket.inMinutes; + final byBucket = >{}; + for (final s in samples) { + final offset = s.time.difference(start).inMinutes; + if (offset < 0 || offset >= 1440) continue; + final key = (offset ~/ bucketMin) * bucketMin; + byBucket.putIfAbsent(key, () => []).add(s.value.round()); + } + + final buckets = []; + for (final key in byBucket.keys.toList()..sort()) { + final bpms = byBucket[key]!; + buckets.add(HrBucket( + windowStart: start.add(Duration(minutes: key)), + minBpm: bpms.reduce((a, b) => a < b ? a : b), + maxBpm: bpms.reduce((a, b) => a > b ? a : b), + avgBpm: bpms.reduce((a, b) => a + b) / bpms.length, + )); + } + if (buckets.isEmpty) return null; + + final allBpms = samples.map((s) => s.value.round()).toList(); + final minBpm = allBpms.reduce((a, b) => a < b ? a : b); + final maxBpm = allBpms.reduce((a, b) => a > b ? a : b); + final avgBpm = allBpms.reduce((a, b) => a + b) / allBpms.length; + + // Resting HR. + int? restingBpm; + if (granted.contains(HealthReadType.restingHeartRate)) { + final rhr = await hc.readRestingHeartRate(start, end); + if (rhr.isNotEmpty) { + rhr.sort((a, b) => a.time.compareTo(b.time)); + restingBpm = rhr.last.value.round(); + } + } + restingBpm ??= () { + final morning = samples.where((s) { + final h = s.time.difference(start).inMinutes; + return h >= 120 && h <= 600; // 02:00–10:00 + }); + if (morning.isEmpty) return null; + return morning.map((s) => s.value).reduce((a, b) => a < b ? a : b).round(); + }(); + + return HrDaySnapshot( + day: start, + restingBpm: restingBpm, + minBpm: minBpm, + maxBpm: maxBpm, + avgBpm: avgBpm, + buckets: buckets, + ); +} diff --git a/workout-logger/lib/services/utils/workout_hr_builder.dart b/workout-logger/lib/services/utils/workout_hr_builder.dart new file mode 100644 index 0000000..23dba10 --- /dev/null +++ b/workout-logger/lib/services/utils/workout_hr_builder.dart @@ -0,0 +1,169 @@ +// Workout HR analysis builder. +// +// Pure function over IHealthConnectService: pulls HR samples for a recorded +// workout window, builds a downsampled curve, and measures HR recovery across +// each rest gap (reconstructed from set timestamps + timeTaken). + +import '../../models/models.dart'; +import '../../models/workout_hr_models.dart'; +import '../interfaces/health_connect_service_interface.dart'; + +/// Minimum HR drop (bpm) for a rest to count as "recovered". +const int kRestRecoveryThreshold = 5; + +/// Minimum HR samples in-window before an analysis is worthwhile. +const int _minSamples = 5; + +/// Builds the per-workout HR analysis, or null when HR permission is missing +/// or too few samples cover the workout window. +Future buildWorkoutHrAnalysis( + IHealthConnectService hc, + WorkoutSession session, + Set granted, +) async { + if (!granted.contains(HealthReadType.heartRate)) return null; + + final start = session.date; + final end = start.add(Duration(minutes: session.duration)); + + // Read with a small buffer so set-end peaks near the edges are covered. + final raw = await hc.readHeartRateSamples( + start.subtract(const Duration(minutes: 1)), + end.add(const Duration(minutes: 1)), + ); + final samples = raw.where((s) => !s.time.isBefore(start) && !s.time.isAfter(end)).toList() + ..sort((a, b) => a.time.compareTo(b.time)); + if (samples.length < _minSamples) return null; + + final bpms = samples.map((s) => s.value).toList(); + final avg = (bpms.reduce((a, b) => a + b) / bpms.length).round(); + final peak = bpms.reduce((a, b) => a > b ? a : b).round(); + final lo = bpms.reduce((a, b) => a < b ? a : b).round(); + + // Curve: 30-second bucket averages. + final curve = _buildCurve(samples, start); + + // Rest + exercise-section analysis from set timestamps (shared validity gate). + final valid = _timestampsValid(session, start, end); + final rests = valid ? _buildRests(session, samples) : const []; + final exercises = valid ? _buildExerciseSpans(session) : const []; + + return WorkoutHrAnalysis( + start: start, + end: end, + avgBpm: avg, + peakBpm: peak, + minBpm: lo, + curve: curve, + rests: rests, + exercises: exercises, + hasRestAnalysis: valid, + ); +} + +/// Set timestamps must actually span the session, otherwise they're +/// placeholders (old/imported sessions) and gaps/sections are meaningless. +bool _timestampsValid(WorkoutSession session, DateTime start, DateTime end) { + final sets = session.exercises.expand((e) => e.sets).toList(); + if (sets.length < 2) return false; + final ts = sets.map((s) => s.timestamp).toList()..sort(); + if (ts.last.difference(ts.first).inMinutes < 5) return false; + if (ts.first.isBefore(start.subtract(const Duration(minutes: 5))) || + ts.last.isAfter(end.add(const Duration(minutes: 5)))) { + return false; + } + return true; +} + +/// One section per exercise, spanning its first set's start to its last set. +List _buildExerciseSpans(WorkoutSession session) { + final spans = []; + for (final log in session.exercises) { + if (log.sets.isEmpty) continue; + final times = log.sets.map((s) => s.timestamp).toList()..sort(); + final firstSet = log.sets.reduce((a, b) => a.timestamp.isBefore(b.timestamp) ? a : b); + final start = firstSet.timestamp.subtract(Duration(seconds: firstSet.timeTaken ?? 0)); + spans.add(ExerciseHrSpan( + exerciseId: log.exerciseId, + start: start, + end: times.last, + setCount: log.sets.length, + )); + } + spans.sort((a, b) => a.start.compareTo(b.start)); + return spans; +} + +List _buildCurve(List samples, DateTime start) { + const bucketSec = 30; + final byBucket = >{}; + for (final s in samples) { + final off = s.time.difference(start).inSeconds; + if (off < 0) continue; + byBucket.putIfAbsent((off ~/ bucketSec) * bucketSec, () => []).add(s.value); + } + final points = []; + for (final key in byBucket.keys.toList()..sort()) { + final vals = byBucket[key]!; + points.add(HrCurvePoint( + time: start.add(Duration(seconds: key)), + bpm: vals.reduce((a, b) => a + b) / vals.length, + )); + } + return points; +} + +List _buildRests( + WorkoutSession session, + List samples, +) { + // Flatten all sets across exercises, ordered by timestamp. + final sets = session.exercises.expand((e) => e.sets).toList() + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + if (sets.length < 2) return const []; + + double? maxIn(DateTime a, DateTime b) { + final vs = samples + .where((s) => !s.time.isBefore(a) && !s.time.isAfter(b)) + .map((s) => s.value); + return vs.isEmpty ? null : vs.reduce((x, y) => x > y ? x : y); + } + + double? minIn(DateTime a, DateTime b) { + final vs = samples + .where((s) => !s.time.isBefore(a) && !s.time.isAfter(b)) + .map((s) => s.value); + return vs.isEmpty ? null : vs.reduce((x, y) => x < y ? x : y); + } + + final rests = []; + for (var i = 0; i < sets.length - 1; i++) { + final a = sets[i]; + final b = sets[i + 1]; + final restStart = a.timestamp; + // Next set begins after subtracting how long it took to perform. + final nextStart = b.timestamp.subtract(Duration(seconds: b.timeTaken ?? 0)); + final restEnd = nextStart.isAfter(restStart) ? nextStart : b.timestamp; + final durSec = restEnd.difference(restStart).inSeconds; + if (durSec < 5) continue; + + // Peak HR around the set's end; trough during the rest. + final peak = maxIn(restStart.subtract(const Duration(seconds: 20)), + restStart.add(const Duration(seconds: 20))) ?? + minIn(restStart, restEnd); + final trough = minIn(restStart, restEnd); + if (peak == null || trough == null) continue; + + final recovery = (peak - trough).round(); + rests.add(RestRecovery( + afterSet: i + 1, + restStart: restStart, + durationSec: durSec, + peakBpm: peak.round(), + troughBpm: trough.round(), + recoveryBpm: recovery, + recovered: recovery >= kRestRecoveryThreshold, + )); + } + return rests; +} diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 79ef6c8..5fd1b48 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -748,7 +748,7 @@ class WorkoutProvider extends ChangeNotifier { // ==================== ROUTINES ==================== - Future createRoutine(String name, List exerciseIds) async { + Future createRoutine(String name, List exerciseIds) async { final routine = Routine( id: _uuid.v4(), name: name, @@ -757,6 +757,7 @@ class WorkoutProvider extends ChangeNotifier { await _storage.saveRoutine(routine); _routines.add(routine); notifyListeners(); + return routine; } Future updateRoutine(Routine routine) async { @@ -962,6 +963,29 @@ class WorkoutProvider extends ChangeNotifier { return volumeByMuscle; } + /// Per-muscle recovery scores using exponential decay (recovery = 1 − e^(−t/τ)). + Map getMuscleRecoveryScores() { + final exerciseMap = {for (final e in _allExercises) e.id: e}; + return _mlService.computeMuscleRecoveryScores(_sessions, exerciseMap); + } + + /// Per-muscle growth models trained on aggregate weighted volume. + Map getMuscleGrowthModels() { + final exerciseMap = {for (final e in _allExercises) e.id: e}; + final muscleIds = { + for (final e in _allExercises) + for (final a in e.muscleActivations) a.muscleGroupId, + }; + final result = {}; + for (final id in muscleIds) { + final points = _mlService.extractMuscleDataPoints(id, _sessions, exerciseMap); + if (points.length >= 2) { + result[id] = _mlService.trainGrowthModel(points); + } + } + return result; + } + /// Get growth model for an exercise GrowthModel? getGrowthModel(String exerciseId) => _growthModels[exerciseId]; @@ -988,6 +1012,139 @@ class WorkoutProvider extends ChangeNotifier { return best; } + /// Per-exercise contribution to a muscle group's volume within a time window. + /// + /// [start]/[end] default to the last 7 days. Results are sorted by contributed + /// volume (desc). This is a pure, parameterized query intended to double as the + /// implementation surface for a future Coach agent tool. + List<({String exerciseId, String name, double volume, GrowthModel? growth})> + getMuscleExerciseBreakdown( + String muscleId, { + DateTime? start, + DateTime? end, + }) { + final now = DateTime.now(); + final from = start ?? now.subtract(const Duration(days: 7)); + final to = end ?? now; + final exerciseMap = { + for (final e in _allExercises) e.id: e, + }; + + final byExercise = {}; + for (final session in _sessions) { + if (session.date.isBefore(from) || session.date.isAfter(to)) continue; + for (final log in session.exercises) { + final exercise = exerciseMap[log.exerciseId]; + if (exercise == null) continue; + for (final activation in exercise.muscleActivations) { + if (activation.muscleGroupId != muscleId) continue; + byExercise[log.exerciseId] = (byExercise[log.exerciseId] ?? 0) + + log.totalVolume * (activation.activationPercentage / 100); + } + } + } + + final result = byExercise.entries + .map((e) => ( + exerciseId: e.key, + name: getExerciseName(e.key), + volume: e.value, + growth: _growthModels[e.key], + )) + .toList() + ..sort((a, b) => b.volume.compareTo(a.volume)); + return result; + } + + /// Per-session set-by-set progression for an exercise (oldest-first). + /// + /// Optionally bounded by [start]/[end]. Each entry holds the working sets + /// logged for the exercise in that session, so callers can chart weight/reps + /// per set. Pure & parameterized — also the surface for a future agent tool. + List<({DateTime date, List sets})> getSetProgression( + String exerciseId, { + DateTime? start, + DateTime? end, + }) { + final result = <({DateTime date, List sets})>[]; + // _sessions is maintained newest-first; reverse for oldest-first output. + for (final session in _sessions.reversed) { + if (start != null && session.date.isBefore(start)) continue; + if (end != null && session.date.isAfter(end)) continue; + for (final log in session.exercises) { + if (log.exerciseId == exerciseId && log.sets.isNotEmpty) { + result.add((date: session.date, sets: log.sets)); + break; + } + } + } + return result; + } + + /// Last [limit] sessions where [muscleId] was trained (newest-first). + List<({DateTime date, List exerciseNames, double volume})> + getRecentMuscleSessionSummaries(String muscleId, {int limit = 6}) { + final exerciseMap = { + for (final e in _allExercises) e.id: e, + }; + final result = <({DateTime date, List exerciseNames, double volume})>[]; + for (final session in _sessions) { + if (result.length >= limit) break; + final names = []; + double vol = 0; + for (final log in session.exercises) { + final exercise = exerciseMap[log.exerciseId]; + if (exercise == null) continue; + final activation = exercise.muscleActivations + .where((a) => a.muscleGroupId == muscleId) + .firstOrNull; + if (activation == null) continue; + names.add(exercise.name); + vol += log.totalVolume * (activation.activationPercentage / 100); + } + if (names.isNotEmpty) { + result.add((date: session.date, exerciseNames: names, volume: vol)); + } + } + return result; + } + + /// Weekly volume for [muscleId] over the last [weeks] weeks, oldest-first. + List<({DateTime weekStart, double volume})> getMuscleWeeklyVolumeSeries( + String muscleId, { + int weeks = 8, + }) { + final now = DateTime.now(); + final exerciseMap = { + for (final e in _allExercises) e.id: e, + }; + final buckets = {}; + final cutoff = now.subtract(Duration(days: weeks * 7)); + + for (final session in _sessions) { + if (session.date.isBefore(cutoff)) continue; + final weekIndex = now.difference(session.date).inDays ~/ 7; + if (weekIndex >= weeks) continue; + for (final log in session.exercises) { + final exercise = exerciseMap[log.exerciseId]; + if (exercise == null) continue; + for (final activation in exercise.muscleActivations) { + if (activation.muscleGroupId != muscleId) continue; + buckets[weekIndex] = (buckets[weekIndex] ?? 0) + + log.totalVolume * (activation.activationPercentage / 100); + } + } + } + + // weekIndex 0 = this week, weeks-1 = oldest; return oldest-first + return List.generate(weeks, (i) => weeks - 1 - i) + .map((wi) => ( + weekStart: now.subtract(Duration(days: (wi + 1) * 7)), + volume: buckets[wi] ?? 0, + )) + .toList(); + } + // ==================== QUICK STATS ==================== Future> getQuickStats() async { diff --git a/workout-logger/lib/theme/app_theme.dart b/workout-logger/lib/theme/app_theme.dart index e2f8694..5e2bcef 100644 --- a/workout-logger/lib/theme/app_theme.dart +++ b/workout-logger/lib/theme/app_theme.dart @@ -1,36 +1,63 @@ -// App Theme - Dark theme with modern styling - import 'package:flutter/material.dart'; -class AppTheme { - // Primary colors - static const Color primaryColor = Color(0xFF6C5CE7); - static const Color secondaryColor = Color(0xFF00D9FF); - static const Color accentColor = Color(0xFFFF6B6B); - - // Background colors - static const Color backgroundColor = Color(0xFF0D1117); - static const Color surfaceColor = Color(0xFF161B22); - static const Color cardColor = Color(0xFF21262D); - - // Text colors - static const Color textPrimary = Color(0xFFE6EDF3); - static const Color textSecondary = Color(0xFF8B949E); - static const Color textMuted = Color(0xFF484F58); - - // Status colors - static const Color success = Color(0xFF00D26A); - static const Color warning = Color(0xFFFFB800); - static const Color error = Color(0xFFFF4757); - - // Muscle group colors - static const Map muscleColors = { +// ── AppColors ────────────────────────────────────────────────────────────── +// Single source of truth for all colour tokens. Never use hex literals in +// widget files — always reference AppColors or AppTheme aliases below. +class AppColors { + const AppColors._(); + + // Backgrounds — soft-futurist dark + static const background = Color(0xFF07070A); // --bg + static const surface = Color(0xFF0C0C12); // --bg-1 + static const card = Color(0xFF11111A); // --bg-2 + static const cardHigh = Color(0xFF1A1A24); // slightly elevated + + // Glassmorphism surfaces + static const glass = Color(0x0AFFFFFF); // --surface 4% + static const glass2 = Color(0x0FFFFFFF); // --surface-2 6% + static const glass3 = Color(0x17FFFFFF); // --surface-3 9% + static const glassBorder = Color(0x12FFFFFF); // --border 7% + static const glassBorderStrong = Color(0x21FFFFFF); // --border-strong 13% + static const divider = Color(0x0FFFFFFF); // 6% white + + // Brand — electric violet primary, cyan data + static const primary = Color(0xFF7C3AED); // --accent oklch(0.68 0.18 285) + static const secondary = Color(0xFF00C2D4); // --data oklch(0.78 0.14 200) + static const accent = Color(0xFF7C3AED); // alias for primary + + // 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) + static const error = Color(0xFFE05040); // --danger oklch(0.68 0.20 25) + + // Text — opacity levels over the near-white base #F4F4F8 + static const textPrimary = Color(0xFFF4F4F8); // --fg + static const textSoft = Color(0xB8F4F4F8); // --fg-2 72% + static const textMuted = Color(0x7AF4F4F8); // --fg-3 48% + static const textFaint = Color(0x52F4F4F8); // --fg-4 32% + + // Glow helpers (use in BoxShadow) + static Color primaryGlow([double opacity = 0.35]) => + primary.withValues(alpha: opacity); + static Color secondaryGlow([double opacity = 0.35]) => + secondary.withValues(alpha: opacity); + static Color accentGlow([double opacity = 0.35]) => + accent.withValues(alpha: opacity); + static Color successGlow([double opacity = 0.35]) => + success.withValues(alpha: opacity); + static Color warningGlow([double opacity = 0.35]) => + warning.withValues(alpha: opacity); + + // Muscle group palette + static Color muscle(String id) => _muscleColors[id] ?? primary; + + static const Map _muscleColors = { 'chest': Color(0xFFFF6B6B), 'upper_chest': Color(0xFFFF8E8E), 'back': Color(0xFF4ECDC4), 'lats': Color(0xFF45B7AA), 'lower_back': Color(0xFF3D9D94), - 'shoulders': Color(0xFF6C5CE7), + 'shoulders': Color(0xFF7C3AED), 'front_delts': Color(0xFF8B7FE8), 'side_delts': Color(0xFF9D93EA), 'rear_delts': Color(0xFFAFA6EC), @@ -44,193 +71,214 @@ class AppTheme { 'core': Color(0xFFFD79A8), 'traps': Color(0xFFE17055), }; +} - static Color getMuscleColor(String muscleId) { - return muscleColors[muscleId] ?? primaryColor; - } +// ── AppTheme ─────────────────────────────────────────────────────────────── +class AppTheme { + const AppTheme._(); + + // Backward-compat aliases + static const Color primaryColor = AppColors.primary; + static const Color secondaryColor = AppColors.secondary; + static const Color accentColor = AppColors.accent; + static const Color backgroundColor = AppColors.background; + static const Color surfaceColor = AppColors.surface; + static const Color cardColor = AppColors.card; + static const Color textPrimary = AppColors.textPrimary; + static const Color textSecondary = AppColors.textSoft; + static const Color textMuted = AppColors.textMuted; + static const Color success = AppColors.success; + static const Color warning = AppColors.warning; + static const Color error = AppColors.error; + static const Map muscleColors = AppColors._muscleColors; + + static Color getMuscleColor(String id) => AppColors.muscle(id); static ThemeData get darkTheme { - return ThemeData( - useMaterial3: true, + final base = ThemeData.dark(useMaterial3: true); + return base.copyWith( brightness: Brightness.dark, - scaffoldBackgroundColor: backgroundColor, - + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), + }, + ), + scaffoldBackgroundColor: AppColors.background, colorScheme: const ColorScheme.dark( - primary: primaryColor, - secondary: secondaryColor, - surface: surfaceColor, - error: error, + primary: AppColors.primary, + secondary: AppColors.secondary, + surface: AppColors.surface, + error: AppColors.error, onPrimary: Colors.white, - onSecondary: Colors.black, - onSurface: textPrimary, + onSecondary: Colors.white, + onSurface: AppColors.textPrimary, onError: Colors.white, ), - - appBarTheme: const AppBarTheme( - backgroundColor: backgroundColor, - foregroundColor: textPrimary, + textTheme: _buildTextTheme(), + appBarTheme: AppBarTheme( + backgroundColor: AppColors.background, + foregroundColor: AppColors.textPrimary, elevation: 0, centerTitle: false, - titleTextStyle: TextStyle( - color: textPrimary, - fontSize: 24, - fontWeight: FontWeight.bold, + titleTextStyle: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.44, ), ), - cardTheme: CardThemeData( - color: cardColor, + color: AppColors.card, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(18), ), ), - elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - backgroundColor: primaryColor, + backgroundColor: AppColors.primary, foregroundColor: Colors.white, elevation: 0, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), - textStyle: const TextStyle( - fontSize: 16, + textStyle: TextStyle(fontFamily: 'Geist', + fontSize: 14, fontWeight: FontWeight.w600, + letterSpacing: 0.2, ), ), ), - outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( - foregroundColor: primaryColor, - side: const BorderSide(color: primaryColor), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), ), ), - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: primaryColor, - ), + style: TextButton.styleFrom(foregroundColor: AppColors.primary), ), - inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: surfaceColor, + fillColor: AppColors.glass, border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, + borderSide: const BorderSide(color: AppColors.glassBorder), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: primaryColor, width: 2), + borderSide: const BorderSide(color: AppColors.primary, width: 2), ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - hintStyle: const TextStyle(color: textMuted), - ), - - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - backgroundColor: surfaceColor, - selectedItemColor: primaryColor, - unselectedItemColor: textSecondary, - type: BottomNavigationBarType.fixed, - elevation: 0, - ), - - floatingActionButtonTheme: const FloatingActionButtonThemeData( - backgroundColor: primaryColor, - foregroundColor: Colors.white, - elevation: 4, + hintStyle: const TextStyle(color: AppColors.textFaint), ), - dividerTheme: const DividerThemeData( - color: cardColor, + color: AppColors.divider, thickness: 1, ), - chipTheme: ChipThemeData( - backgroundColor: cardColor, - selectedColor: primaryColor.withOpacity(0.3), - labelStyle: const TextStyle(color: textPrimary), - side: BorderSide.none, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + backgroundColor: AppColors.glass2, + selectedColor: Color(0x267C3AED), + labelStyle: const TextStyle(color: AppColors.textPrimary, fontSize: 11), + side: const BorderSide(color: AppColors.glassBorder), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(100)), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), ), - snackBarTheme: SnackBarThemeData( - backgroundColor: cardColor, - contentTextStyle: const TextStyle(color: textPrimary), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + backgroundColor: AppColors.cardHigh, + contentTextStyle: const TextStyle(color: AppColors.textPrimary), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), behavior: SnackBarBehavior.floating, ), - - textTheme: const TextTheme( - headlineLarge: TextStyle( - color: textPrimary, - fontSize: 32, - fontWeight: FontWeight.bold, - ), - headlineMedium: TextStyle( - color: textPrimary, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - headlineSmall: TextStyle( - color: textPrimary, - fontSize: 20, - fontWeight: FontWeight.w600, - ), - titleLarge: TextStyle( - color: textPrimary, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - titleMedium: TextStyle( - color: textPrimary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - titleSmall: TextStyle( - color: textSecondary, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - bodyLarge: TextStyle( - color: textPrimary, - fontSize: 16, - ), - bodyMedium: TextStyle( - color: textSecondary, - fontSize: 14, - ), - bodySmall: TextStyle( - color: textMuted, - fontSize: 12, - ), - labelLarge: TextStyle( - color: textPrimary, - fontSize: 14, - fontWeight: FontWeight.w600, - ), + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + ), + ); + } + + static TextTheme _buildTextTheme() { + return TextTheme( + headlineLarge: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 32, + fontWeight: FontWeight.w700, + letterSpacing: -1.28, + ), + headlineMedium: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 28, + fontWeight: FontWeight.w600, + letterSpacing: -1.12, + ), + headlineSmall: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w600, + letterSpacing: -0.88, + ), + titleLarge: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + titleMedium: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + titleSmall: TextStyle(fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + bodyLarge: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 16, + ), + bodyMedium: TextStyle(fontFamily: 'Geist', + color: AppColors.textSoft, + fontSize: 14, + ), + bodySmall: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + ), + labelLarge: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.4, + ), + labelMedium: TextStyle(fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 10, + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + ), + labelSmall: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 9, + fontWeight: FontWeight.w500, + letterSpacing: 0.4, ), ); } } -// Common UI Constants +// ── Spacing & Radius ──────────────────────────────────────────────────────── class AppSpacing { + const AppSpacing._(); static const double xs = 4; static const double sm = 8; static const double md = 16; @@ -240,9 +288,35 @@ class AppSpacing { } class AppRadius { + const AppRadius._(); static const double sm = 8; static const double md = 12; static const double lg = 16; - static const double xl = 24; + static const double xl = 18; // glass card radius + static const double xxl = 22; // nav pill radius static const double full = 999; } + +class AppBreakpoints { + const AppBreakpoints._(); + + static const double narrow = 360; + static const double compact = 600; + static const double contentMaxWidth = 600; + + static double hPadding(double width) { + if (width < narrow) return 12; + if (width < compact) return 20; + return 32; + } + + static int gridColumns(double width) => width >= compact ? 4 : 2; + + static double chartHeight(double width) => width < narrow ? 140 : 180; + + static double timerRingSize(double width) => (width * 0.6).clamp(160, 220); + + /// Vertical clearance needed to lift a Scaffold FAB above the custom RFNavBar. + /// Wrap the FAB in `Padding(padding: EdgeInsets.only(bottom: navBarClearance))`. + static const double navBarClearance = 80.0; +} diff --git a/workout-logger/lib/viewmodels/ai_coach_view_model.dart b/workout-logger/lib/viewmodels/ai_coach_view_model.dart new file mode 100644 index 0000000..036154c --- /dev/null +++ b/workout-logger/lib/viewmodels/ai_coach_view_model.dart @@ -0,0 +1,143 @@ +// ai_coach_view_model.dart — orchestration for the AI coach screen. +// +// Owns all coach logic so the View stays dumb: builds the system prompt, +// drives the streaming tool-call loop via IAiService + CoachToolService, and +// persists each turn through ConversationManager. Exposes immutable state. + +import 'package:flutter/foundation.dart'; +import 'package:google_generative_ai/google_generative_ai.dart' show Content, TextPart; + +import '../models/models.dart'; +import '../services/interfaces/ai_service_interface.dart'; +import '../services/ai/coach_tool_service.dart'; +import '../services/managers/conversation_manager.dart'; +import '../services/settings_provider.dart'; +import '../services/gemini_context_builder.dart'; + +class AiCoachViewModel extends ChangeNotifier { + final IAiService _ai; + final CoachToolService _coachTools; + final ConversationManager _conversations; + final SettingsProvider _settings; + + bool _loading = false; + String _streamingText = ''; + + AiCoachViewModel({ + required IAiService ai, + required CoachToolService coachTools, + required ConversationManager conversations, + required SettingsProvider settings, + }) : _ai = ai, + _coachTools = coachTools, + _conversations = conversations, + _settings = settings { + // Forward conversation-store changes so the View only watches the VM. + _conversations.addListener(notifyListeners); + } + + @override + void dispose() { + _conversations.removeListener(notifyListeners); + super.dispose(); + } + + // ── Exposed state (immutable snapshots) ──────────────────────────────────── + + bool get isConfigured => _ai.isConfigured; + bool get isLoading => _loading; + String get streamingText => _streamingText; + List get messages => _conversations.activeMessages; + List get conversations => _conversations.conversations; + String? get activeConversationId => _conversations.active?.id; + + // ── Commands ─────────────────────────────────────────────────────────────── + + /// Load the persisted conversation list (call when the screen opens). + Future loadConversations() => _conversations.loadConversations(); + + /// Start a fresh, unsaved conversation. + void newConversation() { + if (_loading) return; + _conversations.startNewConversation(); + } + + /// Switch to an existing conversation. + void selectConversation(String id) { + if (_loading) return; + _conversations.selectConversation(id); + } + + /// Delete a conversation. + Future deleteConversation(String id) => + _conversations.deleteConversation(id); + + /// Send a user message and stream the coach's reply (running the tool-call + /// loop). Both the user message and the final reply are persisted. + Future sendMessage(String text) async { + final trimmed = text.trim(); + if (trimmed.isEmpty || _loading) return; + + _loading = true; + _streamingText = ''; + notifyListeners(); + + // Persist the user message first; history is derived from the store. + await _conversations.appendMessage( + ChatMessage(role: 'user', text: trimmed), + ); + + final systemPrompt = _buildSystemPrompt(); + final history = _buildHistory(); + + final buffer = StringBuffer(); + try { + await for (final chunk in _ai.streamCoachReply( + userMessage: trimmed, + systemPrompt: systemPrompt, + history: history, + tools: _coachTools.buildTools(), + onToolCall: _coachTools.handleCall, + )) { + buffer.write(chunk); + _streamingText = buffer.toString(); + notifyListeners(); + } + final reply = buffer.toString().trim(); + if (reply.isNotEmpty) { + await _conversations.appendMessage( + ChatMessage(role: 'model', text: reply), + ); + } + } catch (e) { + buffer.write('\n\n_Error: ${e}_'); + final errText = buffer.toString().trim(); + if (errText.isNotEmpty) { + await _conversations.appendMessage( + ChatMessage(role: 'model', text: errText), + ); + } + } finally { + _streamingText = ''; + _loading = false; + notifyListeners(); + } + } + + // ── Internals ────────────────────────────────────────────────────────────── + + // Static prompt — live data is fetched by the model via the coach tools, + // keeping the prefix stable for implicit prompt caching. + String _buildSystemPrompt() => GeminiContextBuilder.buildCoachSystemPrompt( + userName: _settings.userName, + unitLabel: _settings.unitLabel, + ); + + /// Prior turns (everything before the user message just appended). + List _buildHistory() { + final msgs = _conversations.activeMessages; + final prior = + msgs.length > 1 ? msgs.sublist(0, msgs.length - 1) : []; + return prior.map((m) => Content(m.role, [TextPart(m.text)])).toList(); + } +} diff --git a/workout-logger/lib/viewmodels/routine_optimizer_view_model.dart b/workout-logger/lib/viewmodels/routine_optimizer_view_model.dart new file mode 100644 index 0000000..0ec730b --- /dev/null +++ b/workout-logger/lib/viewmodels/routine_optimizer_view_model.dart @@ -0,0 +1,204 @@ +// routine_optimizer_view_model.dart — Conversational routine optimizer VM. +// +// Drives IAiService.streamCoachReply with an optimizer-focused system prompt. +// Intercepts ask_user_questions tool calls — sets pendingQuestions and returns +// a Completer.future, suspending the stream until submitAnswers() is called. + +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:google_generative_ai/google_generative_ai.dart' + show Content, TextPart, FunctionCall, Tool; + +import '../models/models.dart'; +import '../services/interfaces/ai_service_interface.dart'; +import '../services/ai/coach_tool_service.dart'; +import '../services/managers/conversation_manager.dart'; +import '../services/settings_provider.dart'; +import '../services/gemini_context_builder.dart'; + +class RoutineOptimizerViewModel extends ChangeNotifier { + final IAiService _ai; + final CoachToolService _coachTools; + final ConversationManager _conversations; + final SettingsProvider _settings; + + bool _loading = false; + bool _disposed = false; + String _streamingText = ''; + PendingQuestions? _pendingQuestions; + Completer>? _pendingCompleter; + + RoutineOptimizerViewModel({ + required IAiService ai, + required CoachToolService coachTools, + required ConversationManager conversations, + required SettingsProvider settings, + }) : _ai = ai, + _coachTools = coachTools, + _conversations = conversations, + _settings = settings { + _conversations.addListener(_notify); + } + + @override + void dispose() { + _disposed = true; + _pendingCompleter?.complete({'answers': [], 'aborted': true}); + _pendingCompleter = null; + _pendingQuestions = null; + _conversations.removeListener(_notify); + super.dispose(); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + // ── State ────────────────────────────────────────────────────────────────── + + bool get isConfigured => _ai.isConfigured; + bool get isLoading => _loading; + String get streamingText => _streamingText; + PendingQuestions? get pendingQuestions => _pendingQuestions; + List get messages => _conversations.activeMessages; + List get conversations => _conversations.conversations; + String? get activeConversationId => _conversations.active?.id; + + // ── Commands ─────────────────────────────────────────────────────────────── + + Future loadConversations() => _conversations.loadConversations(); + + void selectConversation(String id) { + if (_loading) return; + _conversations.selectConversation(id); + } + + Future deleteConversation(String id) => + _conversations.deleteConversation(id); + + /// Begin a fresh conversation and auto-send the optimization seed prompt. + Future startForRoutine(Routine routine) async { + _conversations.startNewConversation(); + final seed = + 'Optimize my "${routine.name}" routine based on my past performance.'; + await sendMessage(seed); + } + + /// Submit the user's answers to the pending ask_user_questions call. + Future submitAnswers(List answers) async { + _pendingQuestions = null; + + final text = answers + .map((a) { + final parts = [...a.selected]; + if (a.custom != null && a.custom!.isNotEmpty) parts.add(a.custom!); + return '${a.question}: ${parts.join(', ')}'; + }) + .join(' · '); + + if (text.isNotEmpty) { + await _conversations.appendMessage(ChatMessage(role: 'user', text: text)); + } + + _pendingCompleter?.complete({ + 'answers': [for (final a in answers) a.toJson()], + }); + _pendingCompleter = null; + _notify(); + } + + Future sendMessage(String text) async { + final trimmed = text.trim(); + if (trimmed.isEmpty || _loading) return; + + _loading = true; + _streamingText = ''; + _notify(); + + await _conversations.appendMessage(ChatMessage(role: 'user', text: trimmed)); + + final systemPrompt = GeminiContextBuilder.buildOptimizerSystemPrompt( + userName: _settings.userName, + unitLabel: _settings.unitLabel, + ); + final history = _buildHistory(); + final tools = [ + ..._coachTools.buildTools(), + Tool(functionDeclarations: [CoachToolService.askUserQuestionsDeclaration]), + ]; + + final buffer = StringBuffer(); + try { + await for (final chunk in _ai.streamCoachReply( + userMessage: trimmed, + systemPrompt: systemPrompt, + history: history, + tools: tools, + onToolCall: _routeToolCall, + )) { + buffer.write(chunk); + _streamingText = buffer.toString(); + _notify(); + } + final reply = buffer.toString().trim(); + if (reply.isNotEmpty) { + await _conversations.appendMessage( + ChatMessage(role: 'model', text: reply), + ); + } + } catch (e) { + // Swallow internal abort signals from dispose(). + if (e is! StateError || e.message != 'optimizer_aborted') { + await _conversations.appendMessage( + ChatMessage(role: 'model', text: 'Error: $e'), + ); + } + } finally { + _streamingText = ''; + _loading = false; + _pendingQuestions = null; + _notify(); + } + } + + Future> _routeToolCall(FunctionCall call) async { + if (call.name == 'ask_user_questions') { + return _handleAskUserQuestions(Map.from(call.args)); + } + return _coachTools.handleCall(call); + } + + Future> _handleAskUserQuestions( + Map args, + ) async { + final pending = PendingQuestions.fromJson(args); + + final preamble = pending.preamble; + if (preamble != null && preamble.isNotEmpty) { + await _conversations.appendMessage( + ChatMessage(role: 'model', text: preamble), + ); + } + + _pendingQuestions = pending; + final completer = Completer>(); + _pendingCompleter = completer; + _notify(); + + final result = await completer.future; + + // If the session was abandoned (e.g. dispose() was called), abort cleanly. + if (result['aborted'] == true) { + throw StateError('optimizer_aborted'); + } + + return result; + } + + List _buildHistory() { + final msgs = _conversations.activeMessages; + final prior = + msgs.length > 1 ? msgs.sublist(0, msgs.length - 1) : []; + return prior.map((m) => Content(m.role, [TextPart(m.text)])).toList(); + } +} diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 00ac1fe..74f1eba 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: 1.0.19+20 +version: 2.0.0+21 environment: sdk: ^3.11.4 @@ -44,7 +44,7 @@ dependencies: provider: ^6.1.1 # Charts for visualization - fl_chart: ^0.69.0 + fl_chart: ^1.2.0 # Utilities uuid: ^4.5.1 @@ -53,12 +53,16 @@ dependencies: package_info_plus: ^8.3.1 # Health Connect integration - health_connector: ^3.8.1 + health_connector: ^3.9.1 + + # AI — Gemini + google_generative_ai: ^0.4.3 # Backup export/import file_picker: ^10.3.10 path_provider: ^2.1.5 share_plus: ^12.0.1 + gpt_markdown: ^1.1.7 dev_dependencies: flutter_test: @@ -98,25 +102,13 @@ flutter: # For details regarding adding assets from package dependencies, see # https://flutter.dev/to/asset-from-package - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + fonts: + - family: Geist + fonts: + - asset: assets/fonts/Geist-Variable.ttf + - family: GeistMono + fonts: + - asset: assets/fonts/GeistMono-Variable.ttf flutter_launcher_icons: android: true diff --git a/workout-logger/test/add_custom_exercise_screen_test.dart b/workout-logger/test/add_custom_exercise_screen_test.dart index c4e260e..7399dd5 100644 --- a/workout-logger/test/add_custom_exercise_screen_test.dart +++ b/workout-logger/test/add_custom_exercise_screen_test.dart @@ -186,7 +186,7 @@ void main() { // Assert - Description should change expect( - find.text('Targets a single muscle group (e.g., bicep curls)'), + find.text('Single muscle group'), findsOneWidget, ); }); diff --git a/workout-logger/test/ai_coach_view_model_test.dart b/workout-logger/test/ai_coach_view_model_test.dart new file mode 100644 index 0000000..13bebbe --- /dev/null +++ b/workout-logger/test/ai_coach_view_model_test.dart @@ -0,0 +1,149 @@ +// Unit tests for AiCoachViewModel — verifies orchestration (send → stream → +// persist) using a fake IAiService, so the View has no logic left to test. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_generative_ai/google_generative_ai.dart' + show Content, Tool, FunctionCall; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/interfaces/ai_service_interface.dart'; +import 'package:repforge/services/ai/coach_tool_service.dart'; +import 'package:repforge/services/managers/conversation_manager.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/viewmodels/ai_coach_view_model.dart'; +import 'test_utils/mock_storage_service.dart'; + +/// Scripted IAiService: yields fixed chunks; optionally invokes a tool first. +class _FakeAiService implements IAiService { + _FakeAiService({this.chunks = const ['Hello ', 'world'], this.invokeTool = false}); + + final List chunks; + final bool invokeTool; + int toolCallsMade = 0; + + @override + bool get isConfigured => true; + + @override + String get currentModel => 'fake-model'; + + @override + Stream streamCoachReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) async* { + if (invokeTool && onToolCall != null) { + await onToolCall(FunctionCall('get_muscle_recovery', {})); + toolCallsMade++; + } + for (final c in chunks) { + yield c; + } + } + + @override + Future generateProgram({ + required String userPrompt, + required List allExercises, + }) => + throw UnimplementedError(); + + @override + Future generateWeeklyInsights(String contextText) async => ''; + + @override + Future generateInsight(String system, String context) async => ''; + +} + +void main() { + group('AiCoachViewModel', () { + late MockStorageService storage; + late WorkoutProvider provider; + late ConversationManager conversations; + late SettingsProvider settings; + late PRManager pr; + + Future buildVm(_FakeAiService ai) async { + provider = WorkoutProvider( + storage, + programManager: ProgramManager(storage), + ); + await provider.init(); + pr = PRManager(storage); + settings = SettingsProvider(storage); + conversations = ConversationManager(storage); + return AiCoachViewModel( + ai: ai, + coachTools: CoachToolService(provider, pr), + conversations: conversations, + settings: settings, + ); + } + + setUp(() { + storage = MockStorageService(); + }); + + test('sendMessage appends user + model messages and persists', () async { + final vm = await buildVm(_FakeAiService()); + + await vm.sendMessage('How am I doing?'); + + expect(vm.messages, hasLength(2)); + expect(vm.messages[0].role, 'user'); + expect(vm.messages[0].text, 'How am I doing?'); + expect(vm.messages[1].role, 'model'); + expect(vm.messages[1].text, 'Hello world'); + expect(vm.isLoading, isFalse); + expect(vm.streamingText, isEmpty); + + // Persisted. + final stored = await storage.getAllConversations(); + expect(stored, hasLength(1)); + expect(stored.first.messages, hasLength(2)); + }); + + test('blank or whitespace messages are ignored', () async { + final vm = await buildVm(_FakeAiService()); + await vm.sendMessage(' '); + expect(vm.messages, isEmpty); + }); + + test('runs the tool-call loop via CoachToolService', () async { + final ai = _FakeAiService(invokeTool: true, chunks: const ['done']); + final vm = await buildVm(ai); + + await vm.sendMessage('what can I train?'); + + expect(ai.toolCallsMade, 1); + expect(vm.messages.last.text, 'done'); + }); + + test('newConversation then selectConversation swaps active state', + () async { + final vm = await buildVm(_FakeAiService()); + + await vm.sendMessage('first chat'); + final firstId = vm.activeConversationId; + expect(firstId, isNotNull); + + vm.newConversation(); + expect(vm.messages, isEmpty); + + await vm.sendMessage('second chat'); + final secondId = vm.activeConversationId; + expect(secondId, isNot(firstId)); + expect(vm.conversations, hasLength(2)); + + vm.selectConversation(firstId!); + expect(vm.activeConversationId, firstId); + expect(vm.messages.first.text, 'first chat'); + }); + }); +} diff --git a/workout-logger/test/analytics_manager_test.dart b/workout-logger/test/analytics_manager_test.dart index a07368f..e954c94 100644 --- a/workout-logger/test/analytics_manager_test.dart +++ b/workout-logger/test/analytics_manager_test.dart @@ -392,4 +392,36 @@ void main() { expect(stats['totalWorkouts'], 1); }); }); + + group('AnalyticsManager - updateGrowthModelsForExercises', () { + test('trains model only for the specified exercise subset', () async { + final sessions = [ + _session(id: 's1', exerciseId: 'ex1', weight: 100, date: DateTime(2024, 1, 1)), + _session(id: 's2', exerciseId: 'ex1', weight: 110, date: DateTime(2024, 1, 8)), + _session(id: 's3', exerciseId: 'ex2', weight: 80, date: DateTime(2024, 1, 1)), + _session(id: 's4', exerciseId: 'ex2', weight: 90, date: DateTime(2024, 1, 8)), + ]; + + // Only request model update for ex1 + await manager.updateGrowthModelsForExercises({'ex1'}, sessions); + + expect(manager.growthModels.containsKey('ex1'), isTrue); + expect(manager.growthModels.containsKey('ex2'), isFalse); + }); + + test('evicts stale model when data drops below two points', () async { + final twoSessions = [ + _session(id: 's1', exerciseId: 'ex1', weight: 100, date: DateTime(2024, 1, 1)), + _session(id: 's2', exerciseId: 'ex1', weight: 110, date: DateTime(2024, 1, 8)), + ]; + // First: train model with two sessions + await manager.updateGrowthModelsForExercises({'ex1'}, twoSessions); + expect(manager.growthModels.containsKey('ex1'), isTrue); + + // Then: drop to one session — model must be removed + final oneSession = [twoSessions.first]; + await manager.updateGrowthModelsForExercises({'ex1'}, oneSession); + expect(manager.growthModels.containsKey('ex1'), isFalse); + }); + }); } diff --git a/workout-logger/test/analytics_queries_test.dart b/workout-logger/test/analytics_queries_test.dart new file mode 100644 index 0000000..a559528 --- /dev/null +++ b/workout-logger/test/analytics_queries_test.dart @@ -0,0 +1,373 @@ +// Unit tests for the two new parameterised analytics query methods on +// WorkoutProvider: getSetProgression() and getMuscleExerciseBreakdown(). +// +// These methods are designed as the future agent-tool surface, so the tests +// double as a contract: pure, side-effect-free, date-range-aware. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'test_utils/mock_storage_service.dart'; +import 'test_utils/mock_ml_service.dart'; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +WorkoutSession _session({ + required String id, + required DateTime date, + required List logs, +}) => + WorkoutSession(id: id, date: date, duration: 30, exercises: logs); + +ExerciseLog _log(String exerciseId, List sets) => + ExerciseLog(exerciseId: exerciseId, sets: sets); + +WorkoutSet _set({double weight = 80.0, int reps = 10}) => + WorkoutSet(weight: weight, reps: reps); + +Exercise _exercise(String id, String muscleId, {int activation = 100}) => + Exercise( + id: id, + name: 'Ex-$id', + category: 'compound', + muscleActivations: [ + MuscleActivation( + muscleGroupId: muscleId, activationPercentage: activation), + ], + ); + +Future _makeProvider( + MockStorageService storage, { + MockMLService? ml, +}) async { + final p = WorkoutProvider( + storage, + mlService: ml ?? MockMLService(), + programManager: ProgramManager(storage), + ); + await p.init(); + return p; +} + +// ── getSetProgression ───────────────────────────────────────────────────────── + +void main() { + group('WorkoutProvider.getSetProgression', () { + late MockStorageService storage; + + setUp(() => storage = MockStorageService()); + + test('returns empty list when no sessions exist', () async { + final p = await _makeProvider(storage); + expect(p.getSetProgression('bench'), isEmpty); + }); + + test('returns empty list when exercise was never performed', () async { + storage.addMockSession(_session( + id: 's1', + date: DateTime(2024, 1, 10), + logs: [_log('squat', [_set(weight: 100)])], + )); + final p = await _makeProvider(storage); + expect(p.getSetProgression('bench'), isEmpty); + }); + + test('returns sessions oldest-first', () async { + storage.addMockSession(_session( + id: 's1', + date: DateTime(2024, 1, 10), + logs: [_log('bench', [_set(weight: 80)])], + )); + storage.addMockSession(_session( + id: 's2', + date: DateTime(2024, 1, 5), + logs: [_log('bench', [_set(weight: 75)])], + )); + final p = await _makeProvider(storage); + + final result = p.getSetProgression('bench'); + + expect(result.length, 2); + expect(result[0].date, DateTime(2024, 1, 5)); // older first + expect(result[1].date, DateTime(2024, 1, 10)); + }); + + test('each entry carries the correct sets', () async { + final set1 = _set(weight: 80, reps: 8); + final set2 = _set(weight: 85, reps: 6); + storage.addMockSession(_session( + id: 's1', + date: DateTime(2024, 2, 1), + logs: [_log('bench', [set1, set2])], + )); + final p = await _makeProvider(storage); + + final result = p.getSetProgression('bench'); + expect(result.length, 1); + expect(result[0].sets.length, 2); + expect(result[0].sets[0].weight, 80.0); + expect(result[0].sets[1].weight, 85.0); + }); + + test('excludes sessions outside [start, end] range', () async { + storage.addMockSession(_session( + id: 's1', + date: DateTime(2024, 3, 1), + logs: [_log('bench', [_set(weight: 70)])], + )); + storage.addMockSession(_session( + id: 's2', + date: DateTime(2024, 3, 10), + logs: [_log('bench', [_set(weight: 80)])], + )); + storage.addMockSession(_session( + id: 's3', + date: DateTime(2024, 3, 20), + logs: [_log('bench', [_set(weight: 90)])], + )); + final p = await _makeProvider(storage); + + final result = p.getSetProgression( + 'bench', + start: DateTime(2024, 3, 5), + end: DateTime(2024, 3, 15), + ); + + // Only s2 (Mar 10) falls in [Mar 5, Mar 15]. + expect(result.length, 1); + expect(result[0].sets[0].weight, 80.0); + }); + + test('start-only filter excludes sessions before start', () async { + storage.addMockSession(_session( + id: 's1', + date: DateTime(2024, 1, 1), + logs: [_log('bench', [_set(weight: 60)])], + )); + storage.addMockSession(_session( + id: 's2', + date: DateTime(2024, 6, 1), + logs: [_log('bench', [_set(weight: 90)])], + )); + final p = await _makeProvider(storage); + + final result = p.getSetProgression( + 'bench', + start: DateTime(2024, 3, 1), + ); + + expect(result.length, 1); + expect(result[0].sets[0].weight, 90.0); + }); + + test('only includes the target exercise from mixed-exercise sessions', + () async { + storage.addMockSession(_session( + id: 's1', + date: DateTime(2024, 4, 1), + logs: [ + _log('bench', [_set(weight: 80)]), + _log('squat', [_set(weight: 120)]), + ], + )); + final p = await _makeProvider(storage); + + final bench = p.getSetProgression('bench'); + final squat = p.getSetProgression('squat'); + + expect(bench.length, 1); + expect(bench[0].sets[0].weight, 80.0); + expect(squat.length, 1); + expect(squat[0].sets[0].weight, 120.0); + }); + + test('sessions with no sets for the exercise are excluded', () async { + storage.addMockSession(_session( + id: 's1', + date: DateTime(2024, 5, 1), + logs: [_log('bench', [])], // empty sets + )); + storage.addMockSession(_session( + id: 's2', + date: DateTime(2024, 5, 10), + logs: [_log('bench', [_set(weight: 80)])], + )); + final p = await _makeProvider(storage); + + final result = p.getSetProgression('bench'); + // Session with empty log is excluded; only s2 appears. + expect(result.length, 1); + expect(result[0].date, DateTime(2024, 5, 10)); + }); + }); + + // ── getMuscleExerciseBreakdown ───────────────────────────────────────────── + + group('WorkoutProvider.getMuscleExerciseBreakdown', () { + late MockStorageService storage; + + setUp(() => storage = MockStorageService()); + + test('returns empty list when no sessions exist', () async { + storage.addMockCustomExercise(_exercise('bench', 'chest')); + final p = await _makeProvider(storage); + expect(p.getMuscleExerciseBreakdown('chest'), isEmpty); + }); + + test('returns empty list when no session is in the default 7-day window', + () async { + storage.addMockCustomExercise(_exercise('bench', 'chest')); + storage.addMockSession(_session( + id: 's1', + date: DateTime(2020, 1, 1), // long ago + logs: [_log('bench', [_set(weight: 80)])], + )); + final p = await _makeProvider(storage); + expect(p.getMuscleExerciseBreakdown('chest'), isEmpty); + }); + + test('returns exercises sorted by contributed volume descending', () async { + // Two exercises both hitting chest. + storage.addMockCustomExercise( + _exercise('bench', 'chest', activation: 70)); + storage.addMockCustomExercise( + _exercise('cable', 'chest')); + + final now = DateTime.now(); + // bench: 80kg × 10 reps × 70% = 560 volume + // cable: 30kg × 8 reps × 100% = 240 volume + storage.addMockSession(_session( + id: 's1', + date: now, + logs: [ + _log('bench', [_set(weight: 80, reps: 10)]), + _log('cable', [_set(weight: 30, reps: 8)]), + ], + )); + final p = await _makeProvider(storage); + + final result = p.getMuscleExerciseBreakdown('chest'); + + expect(result.length, 2); + expect(result[0].exerciseId, 'bench'); // higher volume first + expect(result[1].exerciseId, 'cable'); + }); + + test('volume is weight × reps × (activationPercentage / 100)', () async { + storage.addMockCustomExercise( + _exercise('bench', 'chest', activation: 70)); + + final now = DateTime.now(); + // 1 set: 100kg × 5 reps × 70% = 350 + storage.addMockSession(_session( + id: 's1', + date: now, + logs: [_log('bench', [_set(weight: 100, reps: 5)])], + )); + final p = await _makeProvider(storage); + + final result = p.getMuscleExerciseBreakdown('chest'); + expect(result.length, 1); + expect(result[0].volume, closeTo(350.0, 0.01)); + }); + + test('exercises that do not activate the muscle are excluded', () async { + storage.addMockCustomExercise(_exercise('bench', 'chest')); + storage.addMockCustomExercise(_exercise('curl', 'biceps')); + + final now = DateTime.now(); + storage.addMockSession(_session( + id: 's1', + date: now, + logs: [ + _log('bench', [_set(weight: 80)]), + _log('curl', [_set(weight: 20)]), + ], + )); + final p = await _makeProvider(storage); + + final chestResult = p.getMuscleExerciseBreakdown('chest'); + expect(chestResult.every((e) => e.exerciseId == 'bench'), isTrue); + + final bicepsResult = p.getMuscleExerciseBreakdown('biceps'); + expect(bicepsResult.every((e) => e.exerciseId == 'curl'), isTrue); + }); + + test('date range filter excludes sessions outside [start, end]', () async { + storage.addMockCustomExercise(_exercise('bench', 'chest')); + storage.addMockSession(_session( + id: 's_old', + date: DateTime(2024, 1, 1), + logs: [_log('bench', [_set(weight: 60)])], + )); + storage.addMockSession(_session( + id: 's_new', + date: DateTime(2024, 6, 15), + logs: [_log('bench', [_set(weight: 90)])], + )); + final p = await _makeProvider(storage); + + final result = p.getMuscleExerciseBreakdown( + 'chest', + start: DateTime(2024, 6, 1), + end: DateTime(2024, 6, 30), + ); + + expect(result.length, 1); + // 90kg × 10 reps × 100% + expect(result[0].volume, closeTo(900.0, 0.01)); + }); + + test('sums volume across multiple sessions for the same exercise', () async { + storage.addMockCustomExercise(_exercise('bench', 'chest')); + final start = DateTime(2024, 7, 1); + // Two sessions: 800 + 1000 = 1800 total volume (100% activation). + storage.addMockSession(_session( + id: 's1', + date: DateTime(2024, 7, 5), + logs: [_log('bench', [_set(weight: 80, reps: 10)])], + )); + storage.addMockSession(_session( + id: 's2', + date: DateTime(2024, 7, 10), + logs: [_log('bench', [_set(weight: 100, reps: 10)])], + )); + final p = await _makeProvider(storage); + + final result = p.getMuscleExerciseBreakdown( + 'chest', + start: start, + end: DateTime(2024, 7, 31), + ); + + expect(result.length, 1); + expect(result[0].volume, closeTo(1800.0, 0.01)); + }); + + test('returns exercise name via getExerciseName', () async { + storage.addMockCustomExercise( + Exercise( + id: 'bench_custom', + name: 'Bench Press Custom', + category: 'compound', + isCustom: true, + muscleActivations: [ + MuscleActivation(muscleGroupId: 'chest', activationPercentage: 100), + ], + ), + ); + final now = DateTime.now(); + storage.addMockSession(_session( + id: 's1', + date: now, + logs: [_log('bench_custom', [_set(weight: 80)])], + )); + final p = await _makeProvider(storage); + + final result = p.getMuscleExerciseBreakdown('chest'); + expect(result.length, 1); + expect(result[0].name, 'Bench Press Custom'); + }); + }); +} diff --git a/workout-logger/test/analytics_screen_test.dart b/workout-logger/test/analytics_screen_test.dart new file mode 100644 index 0000000..fbfb123 --- /dev/null +++ b/workout-logger/test/analytics_screen_test.dart @@ -0,0 +1,506 @@ +// Widget tests for the refactored AnalyticsScreen tabs: +// • Overview — volume trend range toggle (4W / 12W / All) +// • Targets — summary header counts, on-track / stalled status words +// • Records — summary header, All / This month / By exercise filter, +// Recent / Heaviest sort toggle + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/analytics_screen.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/ai/gemini_ai_service.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'package:repforge/services/interfaces/ml_service_interface.dart'; +import 'test_utils/mock_storage_service.dart'; +import 'test_utils/mock_ml_service.dart'; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +Widget _wrap({ + required WorkoutProvider workoutProvider, + required PRManager prManager, + SettingsProvider? settings, +}) { + final sp = settings ?? SettingsProvider(MockStorageService()); + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: workoutProvider), + ChangeNotifierProvider.value(value: sp), + ChangeNotifierProvider.value(value: prManager), + ChangeNotifierProvider.value(value: GeminiAiService()), + Provider.value(value: MockMLService()), + ], + child: const MaterialApp(home: AnalyticsScreen()), + ); +} + +Future _makeProvider(MockStorageService storage) async { + final p = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); + await p.init(); + return p; +} + +Future _makePRManager(MockStorageService storage) async { + final m = PRManager(storage); + await m.load(); + return m; +} + +WorkoutSession _session({ + required String id, + required DateTime date, + String exerciseId = 'bench_press', + double weight = 80.0, + int reps = 10, +}) => + WorkoutSession( + id: id, + date: date, + duration: 30, + exercises: [ + ExerciseLog( + exerciseId: exerciseId, + sets: [WorkoutSet(weight: weight, reps: reps)], + ), + ], + ); + +/// Switch to tab [index] (0=Overview, 1=Exercises, 2=Targets, 3=Records). +Future _switchTab(WidgetTester tester, String label) async { + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); +} + +// ── Overview tab ────────────────────────────────────────────────────────────── + +void main() { + group('AnalyticsScreen – Overview tab', () { + late MockStorageService storage; + late WorkoutProvider provider; + late PRManager prManager; + + setUp(() async { + storage = MockStorageService(); + provider = await _makeProvider(storage); + prManager = await _makePRManager(storage); + }); + + testWidgets('shows empty chart when no sessions', (tester) async { + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + + // Overview is the first visible tab. + expect(find.text('Volume Trend'), findsOneWidget); + // Both Volume Trend and Muscle Focus cards show "No data yet" when empty. + expect(find.text('No data yet'), findsWidgets); + }); + + testWidgets('range toggle buttons 4W, 12W and All are visible', + (tester) async { + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + + expect(find.text('4W'), findsOneWidget); + expect(find.text('12W'), findsOneWidget); + expect(find.text('All'), findsOneWidget); + }); + + testWidgets('tapping 4W does not throw and keeps 4W visible', + (tester) async { + storage.addMockSession(_session(id: 's1', date: DateTime.now())); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('4W')); + await tester.pumpAndSettle(); + expect(find.text('4W'), findsOneWidget); + }); + + testWidgets('tapping All does not throw', (tester) async { + storage.addMockSession(_session(id: 's1', date: DateTime.now())); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('All')); + await tester.pumpAndSettle(); + expect(find.text('All'), findsOneWidget); + }); + + testWidgets('Muscle Focus card is shown on the Overview tab', + (tester) async { + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + + expect(find.text('Muscle Focus'), findsOneWidget); + }); + + testWidgets('Workout Frequency grid uses "This wk" label', (tester) async { + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + + expect(find.text('This wk'), findsOneWidget); + }); + }); + + // ── Targets tab ───────────────────────────────────────────────────────────── + + group('AnalyticsScreen – Targets tab', () { + late MockStorageService storage; + late WorkoutProvider provider; + late PRManager prManager; + + setUp(() async { + storage = MockStorageService(); + provider = await _makeProvider(storage); + prManager = await _makePRManager(storage); + }); + + testWidgets('shows empty state when no targets', (tester) async { + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Targets'); + + expect(find.text('No Targets Set'), findsOneWidget); + }); + + testWidgets('summary header shows "N active" count', (tester) async { + storage.addMockTarget(Target( + id: 't1', + exerciseId: 'bench_press', + targetType: 'weight', + targetValue: 100.0, + currentValue: 80.0, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Targets'); + + expect(find.text('1 active'), findsOneWidget); + }); + + testWidgets('shows "stalled" chip when no ETA is set', (tester) async { + // A target with no estimatedCompletionDate is stalled. + storage.addMockTarget(Target( + id: 't1', + exerciseId: 'bench_press', + targetType: 'weight', + targetValue: 100.0, + currentValue: 60.0, + // estimatedCompletionDate left null → stalled + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Targets'); + + expect(find.text('Stalled'), findsOneWidget); + }); + + testWidgets('shows "On track" chip when ETA is in the future', (tester) async { + storage.addMockTarget(Target( + id: 't1', + exerciseId: 'bench_press', + targetType: 'weight', + targetValue: 100.0, + currentValue: 80.0, + estimatedCompletionDate: DateTime.now().add(const Duration(days: 30)), + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Targets'); + + expect(find.text('On track'), findsOneWidget); + }); + + testWidgets('shows "stalled" chip when ETA is in the past', (tester) async { + storage.addMockTarget(Target( + id: 't1', + exerciseId: 'bench_press', + targetType: 'weight', + targetValue: 100.0, + currentValue: 60.0, + estimatedCompletionDate: + DateTime.now().subtract(const Duration(days: 1)), + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Targets'); + + expect(find.text('Stalled'), findsOneWidget); + }); + + testWidgets('completed targets show in summary', (tester) async { + storage.addMockTarget(Target( + id: 't1', + exerciseId: 'bench_press', + targetType: 'weight', + targetValue: 100.0, + currentValue: 100.0, + isCompleted: true, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Targets'); + + // 1 completed → "1 done" chip; active count is 0 which shows "0 active" + expect(find.text('1 done'), findsOneWidget); + }); + }); + + // ── Records tab ────────────────────────────────────────────────────────────── + + group('AnalyticsScreen – Records tab', () { + late MockStorageService storage; + late WorkoutProvider provider; + late PRManager prManager; + + setUp(() async { + storage = MockStorageService(); + provider = await _makeProvider(storage); + prManager = await _makePRManager(storage); + }); + + testWidgets('shows empty state when no PRs exist', (tester) async { + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + expect(find.text('No records yet'), findsOneWidget); + expect( + find.text('Finish a workout to set your first PRs'), findsOneWidget); + }); + + testWidgets('summary shows total PR count after seeding records', + (tester) async { + final session = _session(id: 's1', date: DateTime.now(), weight: 100.0); + await prManager.checkAndUpdatePRs(session); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + expect(find.text('1 PRs'), findsOneWidget); + }); + + testWidgets('newest PR hero card is shown', (tester) async { + final session = _session(id: 's1', date: DateTime.now(), weight: 100.0); + await prManager.checkAndUpdatePRs(session); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + expect(find.text('Latest PR'), findsOneWidget); + }); + + testWidgets('filter chips All, This month, By exercise are visible', + (tester) async { + final session = _session(id: 's1', date: DateTime.now()); + await prManager.checkAndUpdatePRs(session); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + expect(find.text('All'), findsOneWidget); + expect(find.text('This month'), findsOneWidget); + expect(find.text('By exercise'), findsOneWidget); + }); + + testWidgets('tapping "This month" filter does not throw', (tester) async { + final session = _session(id: 's1', date: DateTime.now()); + await prManager.checkAndUpdatePRs(session); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + await tester.tap(find.text('This month')); + await tester.pumpAndSettle(); + expect(find.text('This month'), findsOneWidget); + }); + + testWidgets('tapping "By exercise" filter does not throw', (tester) async { + final session = _session(id: 's1', date: DateTime.now()); + await prManager.checkAndUpdatePRs(session); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + await tester.tap(find.text('By exercise')); + await tester.pumpAndSettle(); + expect(find.text('By exercise'), findsOneWidget); + }); + + testWidgets('sort toggle shows Recent and Heaviest labels', (tester) async { + final session = _session(id: 's1', date: DateTime.now()); + await prManager.checkAndUpdatePRs(session); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + // Default sort label + expect(find.text('Recent'), findsOneWidget); + }); + + testWidgets('tapping sort toggle switches label to Heaviest', (tester) async { + final session = _session(id: 's1', date: DateTime.now()); + await prManager.checkAndUpdatePRs(session); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + await tester.tap(find.text('Recent')); + await tester.pumpAndSettle(); + expect(find.text('Heaviest'), findsOneWidget); + }); + + testWidgets('tapping sort toggle twice returns to Recent', (tester) async { + final session = _session(id: 's1', date: DateTime.now()); + await prManager.checkAndUpdatePRs(session); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + await tester.tap(find.text('Recent')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Heaviest')); + await tester.pumpAndSettle(); + expect(find.text('Recent'), findsOneWidget); + }); + + testWidgets('"This month" filter hides old PRs', (tester) async { + // One PR from last year, one from today. + final old = _session( + id: 's_old', + date: DateTime(2020, 1, 1), + exerciseId: 'bench_press', + weight: 50.0, + ); + final recent = _session( + id: 's_new', + date: DateTime.now(), + exerciseId: 'squat', + weight: 120.0, + ); + + // Add a custom exercise for squat so it resolves properly. + storage.addMockCustomExercise(Exercise( + id: 'squat', + name: 'Squat Custom', + category: 'compound', + isCustom: true, + muscleActivations: [ + MuscleActivation(muscleGroupId: 'quads', activationPercentage: 100), + ], + )); + + await prManager.checkAndUpdatePRs(old); + await prManager.checkAndUpdatePRs(recent); + + await tester.pumpWidget(_wrap( + workoutProvider: provider, + prManager: prManager, + )); + await tester.pumpAndSettle(); + await _switchTab(tester, 'Records'); + + // Both PRs shown under "All". + expect(find.text('2 PRs'), findsOneWidget); + + // Filter to This month — only the recent one remains. + await tester.tap(find.text('This month')); + await tester.pumpAndSettle(); + + expect(find.text('1 this month'), findsOneWidget); + }); + }); +} diff --git a/workout-logger/test/coach_tool_service_test.dart b/workout-logger/test/coach_tool_service_test.dart new file mode 100644 index 0000000..4cecf55 --- /dev/null +++ b/workout-logger/test/coach_tool_service_test.dart @@ -0,0 +1,170 @@ +// Unit tests for CoachToolService — each tool returns expected JSON shapes, +// backed by a seeded WorkoutProvider + PRManager. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_generative_ai/google_generative_ai.dart' show FunctionCall; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'package:repforge/services/ai/coach_tool_service.dart'; +import 'test_utils/mock_storage_service.dart'; + +void main() { + group('CoachToolService', () { + late MockStorageService storage; + late WorkoutProvider provider; + late PRManager pr; + late CoachToolService tools; + + WorkoutSession benchSession(DateTime date, double weight, {String? routineId}) { + return WorkoutSession( + id: 'sess-${date.millisecondsSinceEpoch}', + date: date, + routineId: routineId, + duration: 45, + exercises: [ + ExerciseLog( + exerciseId: 'bench_press', + sets: [ + WorkoutSet(weight: weight, reps: 8), + WorkoutSet(weight: weight, reps: 8), + ], + ), + ], + ); + } + + setUp(() async { + storage = MockStorageService(); + + // Two bench sessions on different days → enough for a growth model. + final now = DateTime.now(); + storage.addMockRoutine( + Routine(id: 'r1', name: 'Push Day', exerciseIds: ['bench_press']), + ); + storage.addMockSession( + benchSession(now.subtract(const Duration(days: 10)), 60, routineId: 'r1'), + ); + storage.addMockSession( + benchSession(now.subtract(const Duration(days: 3)), 65, routineId: 'r1'), + ); + + provider = WorkoutProvider( + storage, + programManager: ProgramManager(storage), + ); + await provider.init(); + + pr = PRManager(storage); + await pr.backfillFromSessions(provider.sessions); + + tools = CoachToolService(provider, pr); + }); + + test('exposes the expected tool declarations', () { + final declared = tools + .buildTools() + .expand((t) => t.functionDeclarations ?? []) + .map((f) => f.name) + .toSet(); + expect( + declared, + containsAll([ + 'get_exercise_performance', + 'get_workouts_in_range', + 'get_routine_performance', + 'get_personal_records', + 'get_goal_progress', + 'get_muscle_recovery', + ]), + ); + }); + + test('get_exercise_performance returns trend + PR for a known exercise', + () async { + final result = await tools.handleCall( + FunctionCall('get_exercise_performance', {'exercise_name': 'Bench Press'}), + ); + + expect(result['exercise'], 'Bench Press'); + expect(result['session_count'], 2); + expect(result['volume_trend'], isA>()); + expect((result['volume_trend'] as List), isNotEmpty); + expect(result['personal_record'], isNotNull); + }); + + test('get_exercise_performance returns an error for an unknown exercise', + () async { + final result = await tools.handleCall( + FunctionCall('get_exercise_performance', {'exercise_name': 'Nonexistent'}), + ); + expect(result['error'], isNotNull); + expect(result['available_examples'], isA>()); + }); + + test('get_workouts_in_range summarizes sessions in the window', () async { + final result = await tools.handleCall( + FunctionCall('get_workouts_in_range', {'days': 30}), + ); + expect(result['session_count'], 2); + expect(result['total_volume'], isA()); + expect((result['total_volume'] as num) > 0, isTrue); + }); + + test('get_routine_performance returns sessions logged against the routine', + () async { + final result = await tools.handleCall( + FunctionCall('get_routine_performance', {'routine_name': 'Push Day'}), + ); + expect(result['routine'], 'Push Day'); + expect(result['session_count'], 2); + expect(result['exercises'], contains('Bench Press')); + }); + + test('get_routine_performance errors for an unknown routine', () async { + final result = await tools.handleCall( + FunctionCall('get_routine_performance', {'routine_name': 'Leg Day'}), + ); + expect(result['error'], isNotNull); + expect(result['available_routines'], contains('Push Day')); + }); + + test('get_personal_records returns all records when unfiltered', () async { + final result = await tools.handleCall( + FunctionCall('get_personal_records', {}), + ); + final records = result['records'] as List; + expect(records, isNotEmpty); + expect((records.first as Map)['exercise'], 'Bench Press'); + }); + + test('get_goal_progress reflects active targets', () async { + await provider.createTarget( + exerciseId: 'bench_press', + type: 'weight', + targetValue: 100, + ); + + final result = await tools.handleCall( + FunctionCall('get_goal_progress', {'exercise_name': 'Bench Press'}), + ); + final goals = result['goals'] as List; + expect(goals, hasLength(1)); + expect((goals.first as Map)['type'], 'weight'); + expect((goals.first as Map)['target_value'], 100); + }); + + test('get_muscle_recovery returns per-muscle status', () async { + final result = await tools.handleCall( + FunctionCall('get_muscle_recovery', {}), + ); + final muscles = result['muscles'] as List; + expect(muscles, isNotEmpty); + final first = muscles.first as Map; + expect(first['muscle'], isA()); + expect(first['recovery_percent'], isA()); + expect(first['status'], isA()); + }); + }); +} diff --git a/workout-logger/test/conversation_manager_test.dart b/workout-logger/test/conversation_manager_test.dart new file mode 100644 index 0000000..f5fb58b --- /dev/null +++ b/workout-logger/test/conversation_manager_test.dart @@ -0,0 +1,148 @@ +// Unit tests for ConversationManager — persistence + active-conversation logic. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/managers/conversation_manager.dart'; +import 'test_utils/mock_storage_service.dart'; + +void main() { + group('ConversationManager', () { + late MockStorageService storage; + late ConversationManager manager; + + setUp(() { + storage = MockStorageService(); + manager = ConversationManager(storage); + }); + + test('appendMessage creates a conversation and persists it', () async { + await manager.appendMessage( + ChatMessage(role: 'user', text: 'How is my bench press?'), + ); + + expect(manager.active, isNotNull); + expect(manager.activeMessages, hasLength(1)); + + // Persisted to storage. + final stored = await storage.getAllConversations(); + expect(stored, hasLength(1)); + expect(stored.first.messages.first.text, 'How is my bench press?'); + }); + + test('title is derived from the first user message', () async { + await manager.appendMessage( + ChatMessage(role: 'user', text: 'Plan my next push day please'), + ); + expect(manager.active!.title, 'Plan my next push day please'); + }); + + test('long first message title is truncated', () async { + final long = 'a' * 80; + await manager.appendMessage(ChatMessage(role: 'user', text: long)); + expect(manager.active!.title.length, lessThanOrEqualTo(41)); + expect(manager.active!.title.endsWith('…'), isTrue); + }); + + test('multiple messages append to the same active conversation', () async { + await manager.appendMessage(ChatMessage(role: 'user', text: 'hi')); + await manager.appendMessage(ChatMessage(role: 'model', text: 'hello!')); + + expect(manager.activeMessages, hasLength(2)); + final stored = await storage.getAllConversations(); + expect(stored, hasLength(1)); + expect(stored.first.messages, hasLength(2)); + }); + + test('reload restores conversations from storage', () async { + await manager.appendMessage(ChatMessage(role: 'user', text: 'first')); + + final fresh = ConversationManager(storage); + await fresh.loadConversations(); + expect(fresh.conversations, hasLength(1)); + expect(fresh.conversations.first.messages.first.text, 'first'); + }); + + test('conversations are sorted most-recently-updated first', () async { + // Small delays keep updatedAt timestamps distinct (millisecond clock). + await manager.appendMessage(ChatMessage(role: 'user', text: 'older')); + final olderId = manager.active!.id; + + await Future.delayed(const Duration(milliseconds: 5)); + manager.startNewConversation(); + await manager.appendMessage(ChatMessage(role: 'user', text: 'newer')); + final newerId = manager.active!.id; + + expect(manager.conversations.first.id, newerId); + + // Touching the older one bumps it to the front. + await Future.delayed(const Duration(milliseconds: 5)); + manager.selectConversation(olderId); + await manager.appendMessage(ChatMessage(role: 'model', text: 'reply')); + expect(manager.conversations.first.id, olderId); + }); + + test('startNewConversation clears the active conversation', () async { + await manager.appendMessage(ChatMessage(role: 'user', text: 'hi')); + expect(manager.active, isNotNull); + + manager.startNewConversation(); + expect(manager.active, isNull); + expect(manager.activeMessages, isEmpty); + // The prior conversation is still saved. + expect(manager.conversations, hasLength(1)); + }); + + test('deleteConversation removes it and clears active when needed', () async { + await manager.appendMessage(ChatMessage(role: 'user', text: 'hi')); + final id = manager.active!.id; + + await manager.deleteConversation(id); + + expect(manager.active, isNull); + expect(manager.conversations, isEmpty); + expect(await storage.getAllConversations(), isEmpty); + }); + + test('renameConversation updates the title and persists', () async { + await manager.appendMessage(ChatMessage(role: 'user', text: 'hi')); + final id = manager.active!.id; + + await manager.renameConversation(id, 'My chat'); + + expect(manager.active!.title, 'My chat'); + final stored = await storage.getConversation(id); + expect(stored!.title, 'My chat'); + }); + + test('kind-scoped manager only loads matching conversations', () async { + // Seed two conversations directly into storage with different kinds. + final coachConv = Conversation(title: 'coach chat', kind: 'coach'); + final optimizerConv = + Conversation(title: 'optimizer chat', kind: 'optimizer'); + await storage.saveConversation(coachConv); + await storage.saveConversation(optimizerConv); + + final optimizerManager = ConversationManager(storage, kind: 'optimizer'); + await optimizerManager.loadConversations(); + + expect(optimizerManager.conversations, hasLength(1)); + expect(optimizerManager.conversations.first.title, 'optimizer chat'); + }); + + test('optimizer manager stamps kind on new conversations', () async { + final optimizerManager = ConversationManager(storage, kind: 'optimizer'); + await optimizerManager.appendMessage( + ChatMessage(role: 'user', text: 'Optimize Push Day'), + ); + expect(optimizerManager.active!.kind, 'optimizer'); + + final stored = await storage.getAllConversations(); + expect(stored.first.kind, 'optimizer'); + }); + + test('default manager uses coach kind', () async { + await manager.appendMessage(ChatMessage(role: 'user', text: 'hi')); + expect(manager.active!.kind, 'coach'); + }); + }); +} diff --git a/workout-logger/test/exercise_library_screen_test.dart b/workout-logger/test/exercise_library_screen_test.dart index 3eb9da7..16caadd 100644 --- a/workout-logger/test/exercise_library_screen_test.dart +++ b/workout-logger/test/exercise_library_screen_test.dart @@ -5,15 +5,21 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:repforge/screens/exercise_library_screen.dart'; import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; import 'package:repforge/services/managers/program_manager.dart'; import 'test_utils/mock_storage_service.dart'; Widget createTestWidget({ required Widget child, required WorkoutProvider provider, + SettingsProvider? settingsProvider, }) { - return ChangeNotifierProvider.value( - value: provider, + final settings = settingsProvider ?? SettingsProvider(MockStorageService()); + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: provider), + ChangeNotifierProvider.value(value: settings), + ], child: MaterialApp(home: child), ); } @@ -40,8 +46,8 @@ void main() { await tester.pumpAndSettle(); // Assert - expect(find.byIcon(Icons.search), findsOneWidget); - expect(find.text('Search exercises...'), findsOneWidget); + expect(find.byIcon(Icons.search_rounded), findsOneWidget); + expect(find.text('Search exercises…'), findsOneWidget); }); testWidgets('should display FAB to add custom exercise', (tester) async { @@ -56,7 +62,7 @@ void main() { // Assert expect(find.byType(FloatingActionButton), findsOneWidget); - expect(find.text('Add Exercise'), findsOneWidget); + expect(find.byIcon(Icons.add_rounded), findsOneWidget); }); testWidgets('should display custom exercises in the list', (tester) async { @@ -97,8 +103,8 @@ void main() { ); await tester.pumpAndSettle(); - // Assert - Should find the CUSTOM tag - expect(find.text('CUSTOM'), findsOneWidget); + // Assert - Should find the Custom tag + expect(find.text('Custom'), findsOneWidget); }); testWidgets('should display custom exercise count in header when present', ( @@ -184,7 +190,7 @@ void main() { await tester.pumpAndSettle(); // Assert - Should navigate to AddCustomExerciseScreen - expect(find.text('Add Custom Exercise'), findsOneWidget); + expect(find.text('New Exercise'), findsOneWidget); }); }); @@ -220,7 +226,7 @@ void main() { await tester.pumpAndSettle(); // Assert - Should show details sheet with delete option - expect(find.byIcon(Icons.delete_outline), findsOneWidget); + expect(find.byIcon(Icons.delete_outline_rounded), findsOneWidget); }); testWidgets('should show confirmation dialog when delete is tapped', ( @@ -240,11 +246,11 @@ void main() { await tester.pumpAndSettle(); // Tap delete button - await tester.tap(find.byIcon(Icons.delete_outline)); + await tester.tap(find.byIcon(Icons.delete_outline_rounded)); await tester.pumpAndSettle(); // Assert - Should show confirmation dialog - expect(find.text('Delete Custom Exercise?'), findsOneWidget); + expect(find.text('Delete Exercise?'), findsOneWidget); expect(find.text('Cancel'), findsOneWidget); expect(find.text('Delete'), findsWidgets); }); @@ -265,7 +271,7 @@ void main() { await tester.tap(find.text('Exercise To Delete')); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.delete_outline)); + await tester.tap(find.byIcon(Icons.delete_outline_rounded)); await tester.pumpAndSettle(); // Tap Delete in dialog @@ -292,7 +298,7 @@ void main() { // Act - Open details and tap delete await tester.tap(find.text('Exercise To Delete')); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.delete_outline)); + await tester.tap(find.byIcon(Icons.delete_outline_rounded)); await tester.pumpAndSettle(); // Tap Cancel diff --git a/workout-logger/test/exercise_progress_view_test.dart b/workout-logger/test/exercise_progress_view_test.dart new file mode 100644 index 0000000..f8f1ba4 --- /dev/null +++ b/workout-logger/test/exercise_progress_view_test.dart @@ -0,0 +1,510 @@ +// Widget tests for ExerciseProgressView (Analytics > Exercises tab). +// +// Covers: +// • exercise picker — trigger, bottom-sheet open, search filter, selection +// • chart-mode toggle — Volume ↔ Sets +// • set-progression chart — legend toggle (Weight / Reps hides bars), +// Recent / Weekly mode toggle +// +// fl_chart renders bars on a canvas, so bar-presence can't be verified with +// finders. Legend and axis-title visibility are tested through the text +// widgets that the State exposes, and state-transitions are verified by +// observing those text widgets before and after interactions. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/ai/gemini_ai_service.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/interfaces/ml_service_interface.dart'; +import 'package:repforge/screens/widgets/exercise_progress_view.dart'; +import 'test_utils/mock_storage_service.dart'; +import 'test_utils/mock_ml_service.dart'; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +const _kBenchId = 'bench_press'; // built-in exercise ID in ExerciseDatabase + +Widget _wrap({ + required Widget child, + required WorkoutProvider provider, + SettingsProvider? settings, +}) { + final sp = settings ?? SettingsProvider(MockStorageService()); + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: provider), + ChangeNotifierProvider.value(value: sp), + ChangeNotifierProvider.value(value: GeminiAiService()), + Provider.value(value: MockMLService()), + ], + child: MaterialApp(home: Scaffold(body: child)), + ); +} + +WorkoutSession _session({ + required String id, + required String exerciseId, + required DateTime date, + List? sets, +}) => + WorkoutSession( + id: id, + date: date, + duration: 30, + exercises: [ + ExerciseLog( + exerciseId: exerciseId, + sets: sets ?? + [ + WorkoutSet(weight: 80.0, reps: 8), + WorkoutSet(weight: 85.0, reps: 6), + ], + ), + ], + ); + +Future _makeProvider(MockStorageService storage) async { + final p = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); + await p.init(); + return p; +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +void main() { + late MockStorageService storage; + late WorkoutProvider provider; + + setUp(() async { + storage = MockStorageService(); + provider = await _makeProvider(storage); + }); + + // ── Empty state ──────────────────────────────────────────────────────────── + + group('empty state', () { + testWidgets('shows empty state when no sessions logged', (tester) async { + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + expect(find.text('No Exercise Data'), findsOneWidget); + expect(find.text('Complete workouts to track exercises'), findsOneWidget); + }); + }); + + // ── Exercise picker trigger ──────────────────────────────────────────────── + + group('exercise picker trigger', () { + testWidgets('shows "Pick an exercise" when no exercise is selected', + (tester) async { + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + expect(find.text('Pick an exercise…'), findsOneWidget); + // Chevron icon for the trigger + expect(find.byIcon(Icons.keyboard_arrow_down_rounded), findsOneWidget); + }); + + testWidgets('tapping trigger opens a bottom sheet', (tester) async { + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + + // Sheet title and search field + expect(find.text('Select Exercise'), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + }); + + testWidgets('sheet shows "N logged" count', (tester) async { + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + + expect(find.text('1 logged'), findsOneWidget); + }); + }); + + // ── Search filter ────────────────────────────────────────────────────────── + + group('exercise picker search', () { + testWidgets('typing filters the exercise list', (tester) async { + // Add two exercises to the performed set via sessions. + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + // Also add a custom exercise so we have a second item with a unique name. + storage.addMockCustomExercise(Exercise( + id: 'leg_press_custom', + name: 'Leg Press Custom', + category: 'compound', + isCustom: true, + muscleActivations: [ + MuscleActivation(muscleGroupId: 'quads', activationPercentage: 100), + ], + )); + storage.addMockSession(_session( + id: 's2', + date: DateTime.now(), + exerciseId: 'leg_press_custom', + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + + // Both exercises visible before filtering. + expect(find.text('Leg Press Custom'), findsOneWidget); + + // Type to filter — only the custom exercise should remain. + await tester.enterText(find.byType(TextField), 'Leg Press'); + await tester.pumpAndSettle(); + + expect(find.text('Leg Press Custom'), findsOneWidget); + }); + + testWidgets('shows "No exercises match" when search has no results', + (tester) async { + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'xyznonexistent'); + await tester.pumpAndSettle(); + + expect(find.text('No exercises match'), findsOneWidget); + }); + }); + + // ── Chart mode toggle ────────────────────────────────────────────────────── + + group('chart mode toggle', () { + testWidgets('Volume and Sets mode buttons appear after selecting exercise', + (tester) async { + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + // Open picker and select the exercise. + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + // The built-in Bench Press appears somewhere in the list; tap it. + await tester.tap(find.text('Bench Press').first); + await tester.pumpAndSettle(); + + // Chart mode toggle should now be visible. + expect(find.text('Volume'), findsOneWidget); + expect(find.text('Sets'), findsOneWidget); + }); + + testWidgets('tapping Sets shows Weight and Reps legend', (tester) async { + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bench Press').first); + await tester.pumpAndSettle(); + + // Switch to Sets mode. + await tester.tap(find.text('Sets')); + await tester.pumpAndSettle(); + + expect(find.text('Weight'), findsOneWidget); + expect(find.text('Reps'), findsOneWidget); + }); + + testWidgets('tapping Volume restores volume chart header', (tester) async { + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bench Press').first); + await tester.pumpAndSettle(); + + // Go to Sets then back to Volume. + await tester.tap(find.text('Sets')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Volume')); + await tester.pumpAndSettle(); + + // Volume chart header visible, legend gone. + expect(find.text('Volume Progression'), findsOneWidget); + expect(find.text('Weight'), findsNothing); + }); + }); + + // ── Set progression legend toggle ────────────────────────────────────────── + + group('set progression legend toggle', () { + Future openSetsChart(WidgetTester tester) async { + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bench Press').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sets')); + await tester.pumpAndSettle(); + } + + testWidgets('Weight and Reps legend items render', (tester) async { + await openSetsChart(tester); + expect(find.text('Weight'), findsOneWidget); + expect(find.text('Reps'), findsOneWidget); + }); + + testWidgets('tapping Weight legend does not throw and toggles opacity', + (tester) async { + await openSetsChart(tester); + + // Both legends start fully opaque (opacity = 1.0). + final weightOpacityBefore = tester + .widgetList(find.byType(AnimatedOpacity)) + .map((w) => w.opacity) + .toList(); + expect(weightOpacityBefore.every((o) => o == 1.0), isTrue); + + // Tap Weight to toggle it off. + await tester.tap(find.text('Weight')); + await tester.pumpAndSettle(); + + // One AnimatedOpacity should now be at 0.32 (the dimmed state). + final opacitiesAfter = tester + .widgetList(find.byType(AnimatedOpacity)) + .map((w) => w.opacity) + .toList(); + expect(opacitiesAfter.any((o) => o < 1.0), isTrue); + }); + + testWidgets('tapping Reps legend dims it', (tester) async { + await openSetsChart(tester); + + await tester.tap(find.text('Reps')); + await tester.pumpAndSettle(); + + final opacities = tester + .widgetList(find.byType(AnimatedOpacity)) + .map((w) => w.opacity) + .toList(); + expect(opacities.any((o) => o < 1.0), isTrue); + }); + + testWidgets('tapping legend twice restores full opacity', (tester) async { + await openSetsChart(tester); + + await tester.tap(find.text('Weight')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Weight')); + await tester.pumpAndSettle(); + + final opacities = tester + .widgetList(find.byType(AnimatedOpacity)) + .map((w) => w.opacity) + .toList(); + expect(opacities.every((o) => o == 1.0), isTrue); + }); + + testWidgets('left axis label (unit) is absent when Weight is toggled off', + (tester) async { + final sp = SettingsProvider(MockStorageService()); + storage.addMockSession(_session( + id: 's1', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + settings: sp, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bench Press').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sets')); + await tester.pumpAndSettle(); + + // Unit label appears as left axis name before toggle. + expect(find.text(sp.unitLabel), findsWidgets); + + // Toggle Weight off — the axis name widget is hidden via showTitles:false. + await tester.tap(find.text('Weight')); + await tester.pumpAndSettle(); + + // After toggle, the axis name widget for weight is suppressed. + // The SideTitles widgets generated by fl_chart are gone; only the legend + // text "Weight" (now dimmed) remains — still findable by text. + // What disappears is the fl_chart axis tick labels, verified indirectly + // by checking that showTitles propagates without throwing. + expect(find.text('Weight'), findsOneWidget); // legend still visible + }); + }); + + // ── Recent / Weekly mode toggle ──────────────────────────────────────────── + + group('set progression mode toggle', () { + Future openSetsMode(WidgetTester tester) async { + // Seed two sessions on different days. + storage.addMockSession(_session( + id: 's1', + date: DateTime.now().subtract(const Duration(days: 3)), + exerciseId: _kBenchId, + )); + storage.addMockSession(_session( + id: 's2', + date: DateTime.now(), + exerciseId: _kBenchId, + )); + provider = await _makeProvider(storage); + + await tester.pumpWidget(_wrap( + child: const ExerciseProgressView(), + provider: provider, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pick an exercise…')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bench Press').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sets')); + await tester.pumpAndSettle(); + } + + testWidgets('Recent and Weekly mode buttons are visible', (tester) async { + await openSetsMode(tester); + expect(find.text('Recent'), findsOneWidget); + expect(find.text('Weekly'), findsOneWidget); + }); + + testWidgets('tapping Weekly does not throw', (tester) async { + await openSetsMode(tester); + await tester.tap(find.text('Weekly')); + await tester.pumpAndSettle(); + // No exception → Weekly aggregation rendered without error. + expect(find.text('Weekly'), findsOneWidget); + }); + + testWidgets('tapping Weekly then Recent returns to recent view', + (tester) async { + await openSetsMode(tester); + + await tester.tap(find.text('Weekly')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Recent')); + await tester.pumpAndSettle(); + + expect(find.text('Recent'), findsOneWidget); + }); + }); +} diff --git a/workout-logger/test/gemini_ai_service_usage_test.dart b/workout-logger/test/gemini_ai_service_usage_test.dart new file mode 100644 index 0000000..046bdae --- /dev/null +++ b/workout-logger/test/gemini_ai_service_usage_test.dart @@ -0,0 +1,59 @@ +// Unit tests for GeminiAiService token-usage tracking + persistence. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/services/ai/gemini_ai_service.dart'; +import 'test_utils/mock_storage_service.dart'; + +void main() { + group('GeminiAiService token usage', () { + late MockStorageService storage; + late GeminiAiService service; + + setUp(() { + storage = MockStorageService(); + service = GeminiAiService(storage: storage); + }); + + test('starts at zero', () { + expect(service.totalTokensUsed, 0); + expect(service.promptTokensUsed, 0); + expect(service.responseTokensUsed, 0); + expect(service.aiRequestCount, 0); + }); + + test('recordUsage accumulates across calls', () { + service.recordUsage(prompt: 10, response: 5, total: 15); + service.recordUsage(prompt: 20, response: 10, total: 30); + + expect(service.promptTokensUsed, 30); + expect(service.responseTokensUsed, 15); + expect(service.totalTokensUsed, 45); + expect(service.aiRequestCount, 2); + }); + + test('usage is persisted and reloaded by a fresh instance', () async { + await service.recordUsage(prompt: 100, response: 40, total: 140); + + final reloaded = GeminiAiService(storage: storage); + await reloaded.loadUsage(); + + expect(reloaded.promptTokensUsed, 100); + expect(reloaded.responseTokensUsed, 40); + expect(reloaded.totalTokensUsed, 140); + expect(reloaded.aiRequestCount, 1); + }); + + test('resetUsage zeros counters and persists', () async { + await service.recordUsage(prompt: 100, response: 40, total: 140); + await service.resetUsage(); + + expect(service.totalTokensUsed, 0); + expect(service.aiRequestCount, 0); + + final reloaded = GeminiAiService(storage: storage); + await reloaded.loadUsage(); + expect(reloaded.totalTokensUsed, 0); + expect(reloaded.aiRequestCount, 0); + }); + }); +} diff --git a/workout-logger/test/health_history_manager_test.dart b/workout-logger/test/health_history_manager_test.dart new file mode 100644 index 0000000..293fb0d --- /dev/null +++ b/workout-logger/test/health_history_manager_test.dart @@ -0,0 +1,200 @@ +// Unit tests for HealthHistoryManager (windowing + aggregation). + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/models/sleep_hr_models.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; +import 'package:repforge/services/managers/health_history_manager.dart'; +import 'test_utils/mock_storage_service.dart'; + +class _StubHc implements IHealthConnectService { + Set granted; + List sleep; + List resting; + List heartRate; + + _StubHc({ + this.granted = const {}, + this.sleep = const [], + this.resting = const [], + this.heartRate = const [], + }); + + @override + Future> grantedReadTypes() async => granted; + + @override + Future> readSleepSessions(DateTime start, DateTime end) async => + sleep.where((p) => p.end.isAfter(start) && p.start.isBefore(end)).toList(); + + @override + Future> readRestingHeartRate(DateTime start, DateTime end) async => + resting.where((s) => !s.time.isBefore(start) && s.time.isBefore(end)).toList(); + + @override + Future> readHeartRateSamples(DateTime start, DateTime end) async => + heartRate.where((s) => !s.time.isBefore(start) && s.time.isBefore(end)).toList(); + + @override + Future> readHrvRmssd(DateTime start, DateTime end) async => const []; + + // Unused by these tests. + @override + Future isAvailable() async => true; + @override + Future requestPermissions() async => true; + @override + Future hasPermissions() async => true; + @override + Future requestReadPermissions() async => true; + @override + Future syncWorkoutSession(WorkoutSession session, {String? title}) async => true; +} + +SleepPeriod _night(DateTime end, {int deep = 0, int rem = 0, int light = 0, int awake = 0}) { + final total = deep + rem + light; + return SleepPeriod( + start: end.subtract(Duration(minutes: total + awake)), + end: end, + deepMinutes: deep, + remMinutes: rem, + lightMinutes: light, + awakeMinutes: awake, + ); +} + +void main() { + group('rangeFor / stepBy', () { + final anchor = DateTime(2026, 6, 14); // a Sunday + + test('day window is the single day', () { + final r = HealthHistoryManager.rangeFor(anchor, HealthGranularity.day); + expect(r.start, DateTime(2026, 6, 14)); + expect(r.end, DateTime(2026, 6, 15)); + }); + + test('week is the 7 days ending on the anchor', () { + final r = HealthHistoryManager.rangeFor(anchor, HealthGranularity.week); + expect(r.start, DateTime(2026, 6, 8)); + expect(r.end, DateTime(2026, 6, 15)); + }); + + test('month is the calendar month', () { + final r = HealthHistoryManager.rangeFor(anchor, HealthGranularity.month); + expect(r.start, DateTime(2026, 6, 1)); + expect(r.end, DateTime(2026, 7, 1)); + }); + + test('year is the calendar year', () { + final r = HealthHistoryManager.rangeFor(anchor, HealthGranularity.year); + expect(r.start, DateTime(2026, 1, 1)); + expect(r.end, DateTime(2027, 1, 1)); + }); + + test('stepBy moves by the active unit', () { + expect(HealthHistoryManager.stepBy(anchor, HealthGranularity.day, 1), + DateTime(2026, 6, 15)); + expect(HealthHistoryManager.stepBy(anchor, HealthGranularity.week, -1), + DateTime(2026, 6, 7)); + expect(HealthHistoryManager.stepBy(anchor, HealthGranularity.month, 1), + DateTime(2026, 7, 14)); + expect(HealthHistoryManager.stepBy(anchor, HealthGranularity.year, -1), + DateTime(2025, 6, 14)); + }); + }); + + group('sleepBars', () { + test('sums fragmented same-night records into one bar and zero-fills', () async { + // Two fragments ending the morning of Jun 14. + final hc = _StubHc( + granted: {HealthReadType.sleep}, + sleep: [ + _night(DateTime(2026, 6, 14, 3, 0), deep: 40, rem: 30, light: 60), + _night(DateTime(2026, 6, 14, 6, 30), deep: 20, rem: 50, light: 90), + ], + ); + final mgr = HealthHistoryManager(hc, MockStorageService()); + + final bars = await mgr.sleepBars(DateTime(2026, 6, 14), HealthGranularity.week); + expect(bars.length, 7); + + final night = bars.firstWhere((b) => b.date == DateTime(2026, 6, 14)); + expect(night.deepMin, 60); // 40 + 20 + expect(night.remMin, 80); // 30 + 50 + expect(night.lightMin, 150); // 60 + 90 + expect(night.totalMinutes, 290); + + // Other nights are zero-filled, keeping a stable 7-slot axis. + final empty = bars.firstWhere((b) => b.date == DateTime(2026, 6, 10)); + expect(empty.totalMinutes, 0); + }); + + test('year view returns 12 monthly average bars', () async { + final hc = _StubHc( + granted: {HealthReadType.sleep}, + sleep: [ + // Two nights in March averaging to 400 total min. + _night(DateTime(2026, 3, 10, 6), deep: 60, rem: 60, light: 180), // 300 + _night(DateTime(2026, 3, 20, 6), deep: 100, rem: 100, light: 300), // 500 + ], + ); + final mgr = HealthHistoryManager(hc, MockStorageService()); + + final bars = await mgr.sleepBars(DateTime(2026, 6, 14), HealthGranularity.year); + expect(bars.length, 12); + final march = bars[2]; + expect(march.date, DateTime(2026, 3, 1)); + expect(march.totalMinutes, 400); // (300 + 500) / 2 + expect(bars[0].totalMinutes, 0); // January empty + }); + }); + + group('hrBars (week, full-sample path)', () { + test('builds per-day min/max from HR samples and zero-fills', () async { + final hc = _StubHc( + granted: {HealthReadType.heartRate}, + heartRate: [ + HealthSample(time: DateTime(2026, 6, 13, 9), value: 70), + HealthSample(time: DateTime(2026, 6, 13, 14), value: 120), + HealthSample(time: DateTime(2026, 6, 13, 22), value: 60), + ], + ); + final mgr = HealthHistoryManager(hc, MockStorageService()); + + final bars = await mgr.hrBars(DateTime(2026, 6, 14), HealthGranularity.week); + expect(bars.length, 7); + + final d13 = bars.firstWhere((b) => b.date == DateTime(2026, 6, 13)); + expect(d13.minBpm, 60); + expect(d13.maxBpm, 120); + + final empty = bars.firstWhere((b) => b.date == DateTime(2026, 6, 9)); + expect(empty.maxBpm, 0); + }); + }); + + group('hrBars (month, resting-HR path)', () { + test('yields one range bar per day from resting records', () async { + final hc = _StubHc( + granted: {HealthReadType.restingHeartRate}, + resting: [ + HealthSample(time: DateTime(2026, 6, 5, 8), value: 56), + HealthSample(time: DateTime(2026, 6, 5, 9), value: 60), + HealthSample(time: DateTime(2026, 6, 12, 8), value: 52), + ], + ); + final mgr = HealthHistoryManager(hc, MockStorageService()); + + final bars = await mgr.hrBars(DateTime(2026, 6, 14), HealthGranularity.month); + expect(bars.length, 30); // June + + final d5 = bars[4]; + expect(d5.minBpm, 56); + expect(d5.maxBpm, 60); + expect(d5.restingBpm, 58); // mean of 56 & 60 + + final d1 = bars[0]; + expect(d1.maxBpm, 0); // no data → empty bar + }); + }); +} diff --git a/workout-logger/test/health_sync_manager_test.dart b/workout-logger/test/health_sync_manager_test.dart index 9597f8d..279432f 100644 --- a/workout-logger/test/health_sync_manager_test.dart +++ b/workout-logger/test/health_sync_manager_test.dart @@ -28,6 +28,28 @@ class _MockHcService implements IHealthConnectService { @override Future hasPermissions() async => true; + @override + Future requestReadPermissions() async => false; + + @override + Future> grantedReadTypes() async => const {}; + + @override + Future> readSleepSessions(DateTime start, DateTime end) async => + const []; + + @override + Future> readRestingHeartRate(DateTime start, DateTime end) async => + const []; + + @override + Future> readHrvRmssd(DateTime start, DateTime end) async => + const []; + + @override + Future> readHeartRateSamples(DateTime start, DateTime end) async => + const []; + @override Future syncWorkoutSession( WorkoutSession session, { diff --git a/workout-logger/test/history_manager_test.dart b/workout-logger/test/history_manager_test.dart index d8ed53b..98f3db4 100644 --- a/workout-logger/test/history_manager_test.dart +++ b/workout-logger/test/history_manager_test.dart @@ -290,4 +290,81 @@ void main() { expect(storage.sessions.first.hcSyncedAt, isNotNull); }); }); + + group('getSessionsInDateRange (inverted range tolerance)', () { + test('returns sessions even when start is after end', () async { + final s = _session(date: DateTime(2026, 3, 15)); + await manager.addSession(s); + + // Passing end before start — implementation normalises the range + final results = manager.getSessionsInDateRange( + DateTime(2026, 4, 1), + DateTime(2026, 3, 1), + ); + + expect(results, hasLength(1)); + expect(results.first.id, s.id); + }); + + test('returns empty when session falls outside the normalised range', + () async { + final s = _session(date: DateTime(2026, 1, 1)); + await manager.addSession(s); + + final results = manager.getSessionsInDateRange( + DateTime(2026, 5, 1), + DateTime(2026, 3, 1), // normalised: March→May; Jan is outside + ); + + expect(results, isEmpty); + }); + }); + + group('getRecentSessions', () { + test('returns only sessions within the last N days', () async { + final recent = _session( + id: 'recent', + date: DateTime.now().subtract(const Duration(days: 3)), + ); + final old = _session( + id: 'old', + date: DateTime.now().subtract(const Duration(days: 10)), + ); + await manager.addSession(recent); + await manager.addSession(old); + + final results = manager.getRecentSessions(7); + + expect(results.map((s) => s.id), contains('recent')); + expect(results.map((s) => s.id), isNot(contains('old'))); + }); + + test('returns empty when all sessions are older than N days', () async { + final s = _session( + date: DateTime.now().subtract(const Duration(days: 30)), + ); + await manager.addSession(s); + expect(manager.getRecentSessions(7), isEmpty); + }); + }); + + group('addSession - storage failure propagation', () { + test('propagates storage exception to the caller', () async { + final throwingStorage = _ThrowingStorageService(); + final m = HistoryManager(throwingStorage); + expect( + () => m.addSession(_session()), + throwsA(isA()), + ); + }); + }); +} + +// ── Throwing stub ───────────────────────────────────────────────────────────── + +class _ThrowingStorageService extends MockStorageService { + @override + Future saveWorkoutSession(WorkoutSession session) async { + throw StateError('Simulated storage failure'); + } } diff --git a/workout-logger/test/ml_service_test.dart b/workout-logger/test/ml_service_test.dart new file mode 100644 index 0000000..ff68406 --- /dev/null +++ b/workout-logger/test/ml_service_test.dart @@ -0,0 +1,511 @@ +import 'dart:math' show log; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/ml_service.dart'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +DataPoint dp(double x, double y) => DataPoint(x: x, y: y); + +WorkoutSet wset({double weight = 60.0, int reps = 10}) => + WorkoutSet(weight: weight, reps: reps); + +Exercise makeExercise(String id, String muscleId) => Exercise( + id: id, + name: id, + category: 'compound', + muscleActivations: [ + MuscleActivation(muscleGroupId: muscleId, activationPercentage: 100), + ], + ); + +WorkoutSession makeSession({ + required String id, + required DateTime date, + required String exerciseId, + required String muscleId, + double weight = 60.0, + int reps = 10, +}) { + return WorkoutSession( + id: id, + date: date, + exercises: [ + ExerciseLog( + exerciseId: exerciseId, + sets: [wset(weight: weight, reps: reps)], + ), + ], + duration: 45, + ); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +void main() { + final ml = MLService(); + + group('MLService - trainGrowthModel', () { + test('empty data returns zero-slope model', () { + final model = ml.trainGrowthModel([]); + expect(model.slope, 0.0); + expect(model.intercept, 0.0); + expect(model.r2, 0.0); + }); + + test('single data point returns zero-slope model with intercept = y', () { + final model = ml.trainGrowthModel([dp(0, 500)]); + expect(model.slope, closeTo(0.0, 0.001)); + expect(model.intercept, closeTo(500.0, 0.001)); + }); + + test('perfect linear data yields r2 close to 1.0', () { + // y = 10x + 100 → perfect linear + final points = List.generate(8, (i) => dp(i.toDouble(), 100 + 10.0 * i)); + final model = ml.trainGrowthModel(points); + expect(model.r2, closeTo(1.0, 0.01)); + }); + + test('constant data yields slope close to 0', () { + final points = List.generate(5, (i) => dp(i.toDouble(), 200.0)); + final model = ml.trainGrowthModel(points); + expect(model.slope.abs(), lessThan(0.001)); + }); + + test('positive-trending data produces positive slope', () { + final points = [dp(0, 100), dp(1, 110), dp(2, 120), dp(3, 130)]; + final model = ml.trainGrowthModel(points); + expect(model.slope, greaterThan(0)); + }); + + test('r2 is clamped between 0 and 1', () { + final points = [dp(0, 100), dp(1, 90), dp(2, 110), dp(3, 80)]; + final model = ml.trainGrowthModel(points); + expect(model.r2, greaterThanOrEqualTo(0.0)); + expect(model.r2, lessThanOrEqualTo(1.0)); + }); + + test('predict(n) = slope * n + intercept', () { + final points = List.generate(6, (i) => dp(i.toDouble(), 100 + 5.0 * i)); + final model = ml.trainGrowthModel(points); + // With near-perfect linear data predict should be close to the formula + final expected = model.slope * 3 + model.intercept; + expect(model.predict(3), closeTo(expected, 0.001)); + }); + + test('linear data over a long span still selects the linear curve', () { + // 10 sessions spread over 63 days — log candidate is eligible but + // must not beat a genuinely linear trend. + final points = List.generate(10, (i) => dp(i * 7.0, 100 + 8.0 * i)); + final model = ml.trainGrowthModel(points); + expect(model.curve, GrowthCurve.linear); + expect(model.r2, closeTo(1.0, 0.01)); + }); + + test('saturating data selects the logarithmic curve', () { + // y = 100 + 80·ln(1+x): fast early gains, then diminishing returns. + final points = List.generate(12, (i) { + final x = i * 5.0; + return dp(x, 100 + 80 * log(1 + x)); + }); + final model = ml.trainGrowthModel(points); + expect(model.curve, GrowthCurve.logarithmic); + expect(model.r2, greaterThan(0.95)); + // predict() reproduces the generating curve. + expect(model.predict(30), closeTo(100 + 80 * log(31), 5.0)); + // Instantaneous slope at the newest point is the tangent, far below + // the early-history rate a linear fit would average in. + expect(model.slope, closeTo(80 / (1 + 55), 0.5)); + }); + + test('log curve is not considered for short histories', () { + // Strongly saturating but only 5 points over 8 days. + final points = List.generate(5, (i) { + final x = i * 2.0; + return dp(x, 100 + 80 * log(1 + x)); + }); + final model = ml.trainGrowthModel(points); + expect(model.curve, GrowthCurve.linear); + }); + + test('a single deload outlier does not tilt the trend (robust pass)', () { + // Clean linear trend with one cut-short session at 40% volume. + final clean = List.generate(10, (i) => dp(i * 7.0, 200 + 5.0 * i * 7)); + final withOutlier = List.of(clean)..[5] = dp(35, (200 + 5.0 * 35) * 0.4); + + final robust = ml.trainGrowthModel(withOutlier); + final reference = ml.trainGrowthModel(clean); + // Slope recovered to within 10% of the outlier-free fit. + expect( + robust.slope, + closeTo(reference.slope, reference.slope.abs() * 0.10), + ); + }); + + test('model exposes lastX and a positive stdError on noisy data', () { + final points = [ + dp(0, 100), + dp(7, 130), + dp(14, 118), + dp(21, 150), + dp(28, 141), + dp(35, 168), + ]; + final model = ml.trainGrowthModel(points); + expect(model.lastX, 35); + expect(model.stdError, greaterThan(0)); + }); + }); + + group('GrowthModel - derived metrics', () { + test('weeklyGrowthPercent is growth relative to current level', () { + final model = GrowthModel( + slope: 2.0, // +2 volume/day + intercept: 600.0, + r2: 0.9, + lastTrained: DateTime.now(), + lastX: 50, + ); + // current = 600 + 2·50 = 700; weekly = 14/700 = 2% + expect(model.currentEstimate, closeTo(700, 0.001)); + expect(model.weeklyGrowthPercent, closeTo(2.0, 0.001)); + }); + + test('legacy four-field constructor stays linear and backward compatible', + () { + final model = GrowthModel( + slope: 5.0, + intercept: 100.0, + r2: 0.9, + lastTrained: DateTime.now(), + ); + expect(model.curve, GrowthCurve.linear); + expect(model.coefficient, 5.0); + expect(model.predict(3), closeTo(115.0, 0.001)); + }); + }); + + group('MLService - recommendSets', () { + test('empty lastSession returns empty list', () { + final recs = ml.recommendSets(lastSession: []); + expect(recs, isEmpty); + }); + + test('reps below maxReps → add one rep, keep weight', () { + final set = wset(weight: 60.0, reps: 10); + final recs = ml.recommendSets(lastSession: [set], maxReps: 12); + expect(recs.first.reps, 11); + expect(recs.first.weight, closeTo(60.0, 0.001)); + expect(recs.first.confidence, 'high'); + }); + + test('reps at maxReps → increase weight by 2.5 kg when weight < 40', () { + final set = wset(weight: 30.0, reps: 12); + final recs = ml.recommendSets(lastSession: [set], minReps: 6, maxReps: 12); + expect(recs.first.weight, closeTo(32.5, 0.001)); + expect(recs.first.reps, 6); + }); + + test('reps at maxReps → increase weight by 5 kg when weight >= 40', () { + final set = wset(weight: 80.0, reps: 12); + final recs = ml.recommendSets(lastSession: [set], minReps: 6, maxReps: 12); + expect(recs.first.weight, closeTo(85.0, 0.001)); + expect(recs.first.reps, 6); + }); + + test('plateau detected → holds weight and reps (medium confidence)', () { + final set = wset(weight: 60.0, reps: 10); + final plateauModel = GrowthModel( + slope: -0.5, + intercept: 600.0, + r2: 0.8, + lastTrained: DateTime.now(), + ); + final recs = ml.recommendSets( + lastSession: [set], + growthModel: plateauModel, + maxReps: 12, + ); + expect(recs.first.weight, closeTo(60.0, 0.001)); + expect(recs.first.reps, 10); + expect(recs.first.confidence, 'medium'); + }); + + test('declining trend → ~10% deload rounded to 2.5 kg', () { + final set = wset(weight: 100.0, reps: 8); + final decliningModel = GrowthModel( + slope: -3.0, // −21/week on ~600 volume ≈ −3.5%/week + intercept: 600.0, + r2: 0.8, + lastTrained: DateTime.now(), + ); + final recs = ml.recommendSets( + lastSession: [set], + growthModel: decliningModel, + maxReps: 12, + ); + expect(recs.first.weight, closeTo(90.0, 0.001)); + expect(recs.first.reps, 8); + expect(recs.first.confidence, 'medium'); + expect(recs.first.reasoning, contains('deload')); + }); + + test('untrustworthy fit (low r2) never triggers plateau or deload', () { + final set = wset(weight: 60.0, reps: 10); + final noisyModel = GrowthModel( + slope: -5.0, + intercept: 600.0, + r2: 0.1, // below the trust threshold + lastTrained: DateTime.now(), + ); + final recs = ml.recommendSets( + lastSession: [set], + growthModel: noisyModel, + maxReps: 12, + ); + // Falls through to normal double progression. + expect(recs.first.reps, 11); + expect(recs.first.weight, closeTo(60.0, 0.001)); + }); + + test('under-recovered muscle → maintenance recommendation (low confidence)', + () { + final set = wset(weight: 80.0, reps: 8); + final recovery = MuscleRecoveryStatus( + muscleGroupId: 'chest', + recoveryFraction: 0.4, // 40% recovered + timeSinceLastTrained: const Duration(hours: 24), + estimatedTimeToFullRecovery: const Duration(hours: 72), + ); + final recs = ml.recommendSets( + lastSession: [set], + recoveryScores: {'chest': recovery}, + primaryMuscleIds: ['chest'], + maxReps: 12, + ); + expect(recs.first.weight, closeTo(80.0, 0.001)); + expect(recs.first.reps, 8); + expect(recs.first.confidence, 'low'); + }); + + test('produces one recommendation per set in lastSession', () { + final sets = [wset(weight: 60.0, reps: 10), wset(weight: 60.0, reps: 9)]; + final recs = ml.recommendSets(lastSession: sets, maxReps: 12); + expect(recs.length, 2); + }); + }); + + group('MLService - getDefaultRecommendations', () { + test('returns the requested number of default recommendations', () { + final recs = ml.getDefaultRecommendations(3); + expect(recs.length, 3); + }); + + test('default recommendations have low confidence and zero weight', () { + final recs = ml.getDefaultRecommendations(2); + expect(recs.every((r) => r.confidence == 'low'), isTrue); + expect(recs.every((r) => r.weight == 0), isTrue); + }); + }); + + group('MLService - predictTargetCompletion', () { + test('returns null when slope is zero', () { + final model = GrowthModel( + slope: 0.0, + intercept: 100.0, + r2: 0.0, + lastTrained: DateTime.now(), + ); + final result = ml.predictTargetCompletion( + currentValue: 100.0, + targetValue: 200.0, + growthModel: model, + ); + expect(result, isNull); + }); + + test('returns null when slope is negative', () { + final model = GrowthModel( + slope: -1.0, + intercept: 200.0, + r2: 0.5, + lastTrained: DateTime.now(), + ); + final result = ml.predictTargetCompletion( + currentValue: 100.0, + targetValue: 200.0, + growthModel: model, + ); + expect(result, isNull); + }); + + test('returns a future date when slope > 0 and target > current', () { + final model = GrowthModel( + slope: 5.0, + intercept: 100.0, + r2: 0.9, + lastTrained: DateTime.now(), + ); + final result = ml.predictTargetCompletion( + currentValue: 100.0, + targetValue: 200.0, + growthModel: model, + ); + expect(result, isNotNull); + expect(result!.isAfter(DateTime.now()), isTrue); + }); + + test('returns now (not null) when current already meets target', () { + final model = GrowthModel( + slope: 5.0, + intercept: 100.0, + r2: 0.9, + lastTrained: DateTime.now(), + ); + final result = ml.predictTargetCompletion( + currentValue: 200.0, + targetValue: 200.0, + growthModel: model, + ); + expect(result, isNotNull); + }); + + test('logarithmic curve pushes the date out vs naive linear extrapolation', + () { + // Curve y = 100 + 80·ln(1+x), currently at x=55 (y ≈ 422). + final model = GrowthModel( + slope: 80 / 56, // tangent at x=55 + intercept: 100.0, + r2: 0.95, + lastTrained: DateTime.now(), + curve: GrowthCurve.logarithmic, + coefficient: 80.0, + lastX: 55, + ); + final current = model.currentEstimate; + final target = current + 50; + + final curveAware = ml.predictTargetCompletion( + currentValue: current, + targetValue: target, + growthModel: model, + )!; + // Exact inversion: Δx = (1+x)·(e^(50/80) − 1) ≈ 48.5 days, while the + // tangent rate promises 50/(80/56) = 35 days. + final days = curveAware.difference(DateTime.now()).inDays; + expect(days, greaterThan(40)); + expect(days, lessThan(55)); + }); + + test('returns null when the curve cannot reach the target within 2 years', + () { + final model = GrowthModel( + slope: 0.01, + intercept: 100.0, + r2: 0.9, + lastTrained: DateTime.now(), + ); + final result = ml.predictTargetCompletion( + currentValue: 100.0, + targetValue: 500.0, // 40,000 days away at 0.01/day + growthModel: model, + ); + expect(result, isNull); + }); + + test('confidence interval uses stdError when available', () { + final model = GrowthModel( + slope: 5.0, + intercept: 100.0, + r2: 0.9, + lastTrained: DateTime.now(), + stdError: 25.0, // → ±5 days at 5 volume/day + ); + final result = MLService.predictTargetWithConfidence( + currentValue: 100.0, + targetValue: 200.0, + growthModel: model, + )!; + expect(result.expected.difference(result.optimistic).inDays, 5); + expect(result.pessimistic.difference(result.expected).inDays, 5); + }); + }); + + group('MLService - computeMuscleRecoveryScores', () { + final chestExercise = makeExercise('bench_press', 'chest'); + final exerciseMap = {'bench_press': chestExercise}; + + test('returns empty map when sessions is empty', () { + final scores = ml.computeMuscleRecoveryScores([], exerciseMap); + expect(scores, isEmpty); + }); + + test('muscle trained just now has low recoveryFraction', () { + final now = DateTime.now(); + final session = makeSession( + id: 's1', + date: now, + exerciseId: 'bench_press', + muscleId: 'chest', + ); + final scores = ml.computeMuscleRecoveryScores( + [session], + exerciseMap, + asOf: now, + ); + expect(scores['chest'], isNotNull); + // At t=0, recovery = 1 - exp(0) = 0 + expect(scores['chest']!.recoveryFraction, closeTo(0.0, 0.05)); + }); + + test('muscle trained 7 days ago is near fully recovered', () { + final asOf = DateTime.now(); + final sevenDaysAgo = asOf.subtract(const Duration(days: 7)); + final session = makeSession( + id: 's1', + date: sevenDaysAgo, + exerciseId: 'bench_press', + muscleId: 'chest', + ); + final scores = ml.computeMuscleRecoveryScores( + [session], + exerciseMap, + asOf: asOf, + ); + // chest τ=48h; 168h elapsed → 1 - exp(-168/48) ≈ 0.97 + expect(scores['chest']!.recoveryFraction, greaterThan(0.9)); + }); + + test('exercises absent from exerciseMap produce no recovery entry', () { + final session = makeSession( + id: 's1', + date: DateTime.now().subtract(const Duration(hours: 12)), + exerciseId: 'unknown_exercise', + muscleId: 'chest', + ); + final scores = ml.computeMuscleRecoveryScores( + [session], + {}, // empty map — exercise not found + ); + expect(scores, isEmpty); + }); + + test('isRecovered is false for a muscle trained very recently', () { + final now = DateTime.now(); + final session = makeSession( + id: 's1', + date: now, + exerciseId: 'bench_press', + muscleId: 'chest', + ); + final scores = ml.computeMuscleRecoveryScores( + [session], + exerciseMap, + asOf: now, + ); + // recoveryFraction ≈ 0 → well below the 95% isRecovered threshold + expect(scores['chest']!.isRecovered, isFalse); + }); + }); +} diff --git a/workout-logger/test/model_serialization_test.dart b/workout-logger/test/model_serialization_test.dart new file mode 100644 index 0000000..13f922f --- /dev/null +++ b/workout-logger/test/model_serialization_test.dart @@ -0,0 +1,598 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; + +void main() { + // ── WorkoutSet ──────────────────────────────────────────────────────────── + + group('WorkoutSet', () { + final ts = DateTime(2026, 5, 1, 10, 30); + + test('toJson / fromJson round-trip preserves all fields', () { + final original = WorkoutSet( + weight: 80.0, + reps: 8, + isDropset: false, + timeTaken: 45, + timestamp: ts, + ); + final restored = WorkoutSet.fromJson(original.toJson()); + expect(restored.weight, original.weight); + expect(restored.reps, original.reps); + expect(restored.isDropset, original.isDropset); + expect(restored.timeTaken, original.timeTaken); + expect(restored.timestamp, original.timestamp); + }); + + test('volume = weight × reps for a plain set', () { + final s = WorkoutSet(weight: 100.0, reps: 5, timestamp: ts); + expect(s.volume, closeTo(500.0, 0.001)); + }); + + test('volume includes drop entries for a dropset', () { + final s = WorkoutSet( + weight: 60.0, + reps: 10, + isDropset: true, + drops: [DropsetEntry(weight: 40.0, reps: 8)], + timestamp: ts, + ); + // 60*10 + 40*8 = 600 + 320 = 920 + expect(s.volume, closeTo(920.0, 0.001)); + }); + + test('copyWith changes only the specified field', () { + final original = WorkoutSet(weight: 60.0, reps: 10, timestamp: ts); + final copy = original.copyWith(weight: 80.0); + expect(copy.weight, 80.0); + expect(copy.reps, original.reps); + expect(copy.timestamp, original.timestamp); + }); + }); + + // ── ExerciseLog ─────────────────────────────────────────────────────────── + + group('ExerciseLog', () { + final ts = DateTime(2026, 5, 1, 10, 30); + + test('toJson / fromJson round-trip preserves all fields', () { + final original = ExerciseLog( + exerciseId: 'bench_press', + sets: [ + WorkoutSet(weight: 80.0, reps: 8, timestamp: ts), + WorkoutSet(weight: 80.0, reps: 7, timestamp: ts), + ], + notes: 'felt strong', + ); + final restored = ExerciseLog.fromJson(original.toJson()); + expect(restored.exerciseId, original.exerciseId); + expect(restored.sets.length, original.sets.length); + expect(restored.notes, original.notes); + }); + + test('totalVolume sums all sets', () { + final log = ExerciseLog( + exerciseId: 'squat', + sets: [ + WorkoutSet(weight: 100.0, reps: 5, timestamp: ts), + WorkoutSet(weight: 100.0, reps: 5, timestamp: ts), + WorkoutSet(weight: 100.0, reps: 5, timestamp: ts), + ], + ); + expect(log.totalVolume, closeTo(1500.0, 0.001)); + }); + + test('totalVolume is 0 when sets is empty', () { + final log = ExerciseLog(exerciseId: 'squat', sets: []); + expect(log.totalVolume, 0.0); + }); + + test('copyWith changes exerciseId and preserves sets', () { + final ts2 = DateTime(2026, 5, 1, 10, 30); + final original = ExerciseLog( + exerciseId: 'squat', + sets: [WorkoutSet(weight: 60.0, reps: 10, timestamp: ts2)], + ); + final copy = original.copyWith(exerciseId: 'deadlift'); + expect(copy.exerciseId, 'deadlift'); + expect(copy.sets.length, 1); + }); + }); + + // ── WorkoutSession ──────────────────────────────────────────────────────── + + group('WorkoutSession', () { + final date = DateTime(2026, 5, 10, 8, 0); + final ts = DateTime(2026, 5, 10, 8, 5); + + test('toJson / fromJson round-trip preserves nested structure', () { + final original = WorkoutSession( + id: 'session-1', + date: date, + routineId: 'routine-a', + exercises: [ + ExerciseLog( + exerciseId: 'bench_press', + sets: [WorkoutSet(weight: 80.0, reps: 8, timestamp: ts)], + ), + ], + duration: 45, + notes: 'good session', + ); + final restored = WorkoutSession.fromJson(original.toJson()); + expect(restored.id, original.id); + expect(restored.date, original.date); + expect(restored.routineId, original.routineId); + expect(restored.exercises.length, 1); + expect(restored.exercises.first.exerciseId, 'bench_press'); + expect(restored.duration, original.duration); + expect(restored.notes, original.notes); + }); + + test('totalVolume aggregates across all exercise logs', () { + final session = WorkoutSession( + id: 'session-2', + date: date, + exercises: [ + ExerciseLog( + exerciseId: 'bench_press', + sets: [WorkoutSet(weight: 80.0, reps: 10, timestamp: ts)], // 800 + ), + ExerciseLog( + exerciseId: 'squat', + sets: [WorkoutSet(weight: 100.0, reps: 10, timestamp: ts)], // 1000 + ), + ], + duration: 60, + ); + expect(session.totalVolume, closeTo(1800.0, 0.001)); + }); + + test('copyWith changes date and preserves other fields', () { + final original = WorkoutSession( + id: 'session-3', + date: date, + exercises: [], + duration: 30, + ); + final newDate = DateTime(2026, 6, 1); + final copy = original.copyWith(date: newDate); + expect(copy.date, newDate); + expect(copy.id, original.id); + expect(copy.duration, original.duration); + }); + + test('hcSyncedAt round-trips correctly when set', () { + final syncTime = DateTime(2026, 5, 10, 9, 0); + final original = WorkoutSession( + id: 'session-4', + date: date, + exercises: [], + duration: 30, + hcSyncedAt: syncTime, + ); + final restored = WorkoutSession.fromJson(original.toJson()); + expect(restored.hcSyncedAt, syncTime); + }); + }); + + // ── Exercise ────────────────────────────────────────────────────────────── + + group('Exercise', () { + test('toJson / fromJson round-trip preserves all fields', () { + final original = Exercise( + id: 'cable_fly', + name: 'Cable Fly', + category: 'isolation', + isCustom: true, + muscleActivations: [ + MuscleActivation(muscleGroupId: 'chest', activationPercentage: 80), + MuscleActivation(muscleGroupId: 'triceps', activationPercentage: 20), + ], + ); + final restored = Exercise.fromJson(original.toJson()); + expect(restored.id, original.id); + expect(restored.name, original.name); + expect(restored.category, original.category); + expect(restored.isCustom, original.isCustom); + expect(restored.muscleActivations.length, 2); + }); + + test('primaryMuscle returns muscle with highest activationPercentage', () { + final exercise = Exercise( + id: 'ex1', + name: 'Compound Push', + category: 'compound', + muscleActivations: [ + MuscleActivation(muscleGroupId: 'chest', activationPercentage: 60), + MuscleActivation(muscleGroupId: 'shoulders', activationPercentage: 25), + MuscleActivation(muscleGroupId: 'triceps', activationPercentage: 15), + ], + ); + expect(exercise.primaryMuscle, 'chest'); + }); + + test('primaryMuscle returns "Unknown" when activations is empty', () { + final exercise = Exercise( + id: 'ex2', + name: 'Mystery', + category: 'compound', + muscleActivations: [], + ); + expect(exercise.primaryMuscle, 'Unknown'); + }); + }); + + // ── MuscleGroup ─────────────────────────────────────────────────────────── + + group('MuscleGroup', () { + test('toJson / fromJson round-trip preserves all fields', () { + final updated = DateTime(2026, 4, 1, 12, 0); + final original = MuscleGroup( + id: 'chest', + name: 'Chest', + growthRate: 0.15, + lastUpdated: updated, + ); + final restored = MuscleGroup.fromJson(original.toJson()); + expect(restored.id, original.id); + expect(restored.name, original.name); + expect(restored.growthRate, original.growthRate); + expect(restored.lastUpdated, original.lastUpdated); + }); + }); + + // ── Target ──────────────────────────────────────────────────────────────── + + group('Target', () { + final created = DateTime(2026, 3, 1); + + test('toJson / fromJson round-trip preserves all fields', () { + final original = Target( + id: 't1', + exerciseId: 'squat', + targetType: 'weight', + targetValue: 150.0, + currentValue: 100.0, + createdAt: created, + isCompleted: false, + ); + final restored = Target.fromJson(original.toJson()); + expect(restored.id, original.id); + expect(restored.exerciseId, original.exerciseId); + expect(restored.targetType, original.targetType); + expect(restored.targetValue, original.targetValue); + expect(restored.currentValue, original.currentValue); + expect(restored.createdAt, original.createdAt); + expect(restored.isCompleted, original.isCompleted); + }); + + test('progressPercentage = (current / target) × 100', () { + final t = Target( + id: 't2', + exerciseId: 'squat', + targetType: 'weight', + targetValue: 200.0, + currentValue: 50.0, + ); + expect(t.progressPercentage, closeTo(25.0, 0.001)); + }); + + test('progressPercentage clamps to 100 when current exceeds target', () { + final t = Target( + id: 't3', + exerciseId: 'squat', + targetType: 'weight', + targetValue: 100.0, + currentValue: 150.0, + ); + expect(t.progressPercentage, closeTo(100.0, 0.001)); + }); + }); + + // ── GrowthModel ─────────────────────────────────────────────────────────── + + group('GrowthModel', () { + test('predict(n) = slope * n + intercept', () { + final model = GrowthModel( + slope: 2.5, + intercept: 100.0, + r2: 0.9, + lastTrained: DateTime(2026, 1, 1), + ); + expect(model.predict(0), closeTo(100.0, 0.001)); + expect(model.predict(4), closeTo(110.0, 0.001)); + expect(model.predict(10), closeTo(125.0, 0.001)); + }); + + test('predict returns intercept when slope is zero', () { + final model = GrowthModel( + slope: 0.0, + intercept: 80.0, + r2: 0.0, + lastTrained: DateTime(2026, 1, 1), + ); + expect(model.predict(100), closeTo(80.0, 0.001)); + }); + }); + + // ── Routine ─────────────────────────────────────────────────────────────── + + group('Routine', () { + test('toJson / fromJson round-trip preserves all fields', () { + final created = DateTime(2026, 2, 15, 9, 0); + final original = Routine( + id: 'r1', + name: 'Push Day', + exerciseIds: ['bench_press', 'overhead_press', 'tricep_pushdown'], + createdAt: created, + ); + final restored = Routine.fromJson(original.toJson()); + expect(restored.id, original.id); + expect(restored.name, original.name); + expect(restored.exerciseIds, original.exerciseIds); + expect(restored.createdAt, original.createdAt); + }); + }); + + // ── PersonalRecord ──────────────────────────────────────────────────────── + + group('PersonalRecord', () { + final achieved = DateTime(2026, 4, 20); + + test('toJson / fromJson round-trip preserves all fields', () { + final original = PersonalRecord( + exerciseId: 'deadlift', + bestWeight: 180.0, + bestReps: 5, + bestVolume: 900.0, + achievedAt: achieved, + ); + final restored = PersonalRecord.fromJson(original.toJson()); + expect(restored.exerciseId, original.exerciseId); + expect(restored.bestWeight, original.bestWeight); + expect(restored.bestReps, original.bestReps); + expect(restored.bestVolume, original.bestVolume); + expect(restored.achievedAt, original.achievedAt); + }); + + test('copyWith changes bestWeight and preserves other fields', () { + final original = PersonalRecord( + exerciseId: 'deadlift', + bestWeight: 160.0, + bestReps: 5, + bestVolume: 800.0, + achievedAt: achieved, + ); + final updated = original.copyWith(bestWeight: 180.0); + expect(updated.bestWeight, 180.0); + expect(updated.bestReps, original.bestReps); + expect(updated.exerciseId, original.exerciseId); + expect(updated.achievedAt, original.achievedAt); + }); + }); + + // ── TrainingProgram ─────────────────────────────────────────────────────── + + group('TrainingProgram', () { + ProgramDay makeDay(String id) => ProgramDay( + id: id, + name: 'Day $id', + exercises: [ + ProgramExerciseSlot( + exerciseId: 'bench_press', + sets: 4, + minReps: 6, + maxReps: 10, + restSeconds: 120, + ), + ], + ); + + ProgramWeek makeWeek(int n, String? phaseId) => ProgramWeek( + weekNumber: n, + phaseId: phaseId, + days: [makeDay('d$n')], + ); + + TrainingPhase makePhase(String id, int start, int end) => TrainingPhase( + id: id, + name: 'Phase $id', + startWeek: start, + endWeek: end, + ); + + test('toJson / fromJson round-trip preserves nested structure', () { + final created = DateTime(2026, 1, 1, 0, 0); + final original = TrainingProgram( + id: 'prog-1', + name: '12-Week Block', + description: 'Hypertrophy focus', + totalWeeks: 4, + phases: [makePhase('foundation', 1, 2), makePhase('intensify', 3, 4)], + weeks: [makeWeek(1, 'foundation'), makeWeek(2, 'foundation'), makeWeek(3, 'intensify'), makeWeek(4, 'intensify')], + author: 'Coach', + isImported: false, + createdAt: created, + ); + final restored = TrainingProgram.fromJson(original.toJson()); + expect(restored.id, original.id); + expect(restored.name, original.name); + expect(restored.description, original.description); + expect(restored.totalWeeks, original.totalWeeks); + expect(restored.phases.length, 2); + expect(restored.weeks.length, 4); + expect(restored.author, original.author); + expect(restored.isImported, original.isImported); + expect(restored.createdAt, original.createdAt); + }); + + test('phaseForWeek returns the matching phase', () { + final program = TrainingProgram( + id: 'p', + name: 'Test', + totalWeeks: 4, + phases: [makePhase('foundation', 1, 2), makePhase('intensify', 3, 4)], + weeks: [], + ); + expect(program.phaseForWeek(1)!.id, 'foundation'); + expect(program.phaseForWeek(2)!.id, 'foundation'); + expect(program.phaseForWeek(3)!.id, 'intensify'); + expect(program.phaseForWeek(4)!.id, 'intensify'); + }); + + test('phaseForWeek returns null for out-of-range week', () { + final program = TrainingProgram( + id: 'p', + name: 'Test', + totalWeeks: 4, + phases: [makePhase('foundation', 1, 4)], + weeks: [], + ); + expect(program.phaseForWeek(5), isNull); + }); + + test('totalDays sums days across all weeks', () { + final program = TrainingProgram( + id: 'p', + name: 'Test', + totalWeeks: 3, + phases: [], + weeks: [ + ProgramWeek(weekNumber: 1, days: [makeDay('d1'), makeDay('d2'), makeDay('d3')]), + ProgramWeek(weekNumber: 2, days: [makeDay('d4'), makeDay('d5')]), + ProgramWeek(weekNumber: 3, days: [makeDay('d6'), makeDay('d7'), makeDay('d8'), makeDay('d9')]), + ], + ); + expect(program.totalDays, 9); + }); + + test('copyWith changes name and preserves other fields', () { + final original = TrainingProgram( + id: 'p', + name: 'Old Name', + totalWeeks: 4, + phases: [], + weeks: [], + ); + final copy = original.copyWith(name: 'New Name'); + expect(copy.name, 'New Name'); + expect(copy.id, original.id); + expect(copy.totalWeeks, original.totalWeeks); + }); + }); + + // ── ProgramExerciseSlot ─────────────────────────────────────────────────── + + group('ProgramExerciseSlot', () { + test('toJson / fromJson round-trip preserves all fields', () { + final original = ProgramExerciseSlot( + exerciseId: 'squat', + sets: 5, + minReps: 3, + maxReps: 5, + restSeconds: 180, + tempo: '3-1-1', + weightPercentage: 85.0, + notes: 'Stay braced', + supersetGroupId: 'ss-1', + ); + final restored = ProgramExerciseSlot.fromJson(original.toJson()); + expect(restored.exerciseId, original.exerciseId); + expect(restored.sets, original.sets); + expect(restored.minReps, original.minReps); + expect(restored.maxReps, original.maxReps); + expect(restored.restSeconds, original.restSeconds); + expect(restored.tempo, original.tempo); + expect(restored.weightPercentage, original.weightPercentage); + expect(restored.notes, original.notes); + expect(restored.supersetGroupId, original.supersetGroupId); + }); + + test('copyWith changes sets and preserves other fields', () { + final original = ProgramExerciseSlot( + exerciseId: 'squat', + sets: 4, + minReps: 6, + maxReps: 10, + restSeconds: 120, + ); + final copy = original.copyWith(sets: 5); + expect(copy.sets, 5); + expect(copy.exerciseId, original.exerciseId); + expect(copy.minReps, original.minReps); + }); + }); + + // ── Conversation.kind ───────────────────────────────────────────────────── + + group('Conversation.kind', () { + test('round-trips kind field', () { + final c = Conversation(title: 'test', kind: 'optimizer'); + final json = c.toJson(); + final restored = Conversation.fromJson(json); + expect(restored.kind, 'optimizer'); + }); + + test('missing kind in JSON defaults to coach', () { + final json = { + 'id': 'x', + 'title': 'legacy', + 'createdAt': DateTime.now().toIso8601String(), + 'messages': >[], + }; + final c = Conversation.fromJson(json); + expect(c.kind, 'coach'); + }); + }); + + // ── QuestionSpec / AnswerSpec / PendingQuestions ────────────────────────── + + group('QuestionSpec / AnswerSpec / PendingQuestions', () { + test('QuestionSpec round-trips from JSON with defaults', () { + final j = { + 'question': 'What is your goal?', + 'options': ['Strength', 'Hypertrophy'], + }; + final spec = QuestionSpec.fromJson(j); + expect(spec.question, 'What is your goal?'); + expect(spec.options, ['Strength', 'Hypertrophy']); + expect(spec.multiSelect, false); + expect(spec.allowCustom, true); + }); + + test('QuestionSpec reads multiSelect = true', () { + final j = { + 'question': 'Pick changes', + 'options': ['Reorder', 'Add exercise'], + 'multiSelect': true, + }; + expect(QuestionSpec.fromJson(j).multiSelect, true); + }); + + test('AnswerSpec.toJson omits null custom', () { + final a = AnswerSpec(question: 'q', selected: ['Strength']); + final json = a.toJson(); + expect(json.containsKey('custom'), false); + expect(json['selected'], ['Strength']); + }); + + test('AnswerSpec.toJson includes non-empty custom', () { + final a = AnswerSpec(question: 'q', selected: [], custom: 'Power lifting'); + final json = a.toJson(); + expect(json['custom'], 'Power lifting'); + }); + + test('PendingQuestions.fromJson parses preamble and questions', () { + final j = { + 'preamble': 'Let me understand your goals first.', + 'questions': [ + {'question': 'Goal?', 'options': ['Strength', 'Size']}, + ], + }; + final pq = PendingQuestions.fromJson(j); + expect(pq.preamble, 'Let me understand your goals first.'); + expect(pq.questions, hasLength(1)); + expect(pq.questions.first.question, 'Goal?'); + }); + }); +} diff --git a/workout-logger/test/pr_manager_test.dart b/workout-logger/test/pr_manager_test.dart new file mode 100644 index 0000000..5db0270 --- /dev/null +++ b/workout-logger/test/pr_manager_test.dart @@ -0,0 +1,239 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'test_utils/mock_storage_service.dart'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +WorkoutSession _session({ + String id = 's1', + DateTime? date, + List exercises = const [], +}) => WorkoutSession( + id: id, + date: date ?? DateTime(2026, 1, 1), + exercises: exercises, + duration: 30, + ); + +ExerciseLog _log(String exerciseId, {List sets = const []}) => + ExerciseLog(exerciseId: exerciseId, sets: sets); + +WorkoutSet _set({double weight = 60.0, int reps = 10}) => + WorkoutSet(weight: weight, reps: reps); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +void main() { + late MockStorageService storage; + late PRManager manager; + + setUp(() { + storage = MockStorageService(); + manager = PRManager(storage); + }); + + group('PRManager - load', () { + test('starts empty when storage has no records', () async { + await manager.load(); + expect(manager.allRecords, isEmpty); + }); + + test('populates allRecords from storage on load', () async { + await storage.savePersonalRecord(PersonalRecord( + exerciseId: 'bench', + bestWeight: 100.0, + bestReps: 5, + bestVolume: 500.0, + achievedAt: DateTime(2026, 1, 1), + )); + await manager.load(); + expect(manager.allRecords, hasLength(1)); + expect(manager.allRecords.first.exerciseId, 'bench'); + expect(manager.allRecords.first.bestWeight, 100.0); + }); + }); + + group('PRManager - backfillFromSessions', () { + test('creates record for exercise with no prior PR', () async { + await manager.backfillFromSessions([ + _session(exercises: [_log('bench', sets: [_set(weight: 80, reps: 5)])]), + ]); + final rec = manager.getRecord('bench'); + expect(rec, isNotNull); + expect(rec!.bestWeight, 80.0); + expect(rec.bestReps, 5); + }); + + test('updates record when later session contains new best weight', () async { + await manager.backfillFromSessions([ + _session( + id: 's1', + date: DateTime(2026, 1, 1), + exercises: [_log('bench', sets: [_set(weight: 80, reps: 5)])], + ), + _session( + id: 's2', + date: DateTime(2026, 1, 8), + exercises: [_log('bench', sets: [_set(weight: 100, reps: 5)])], + ), + ]); + expect(manager.getRecord('bench')!.bestWeight, 100.0); + }); + + test('updates record when later session contains new best reps', () async { + await manager.backfillFromSessions([ + _session( + id: 's1', + date: DateTime(2026, 1, 1), + exercises: [_log('bench', sets: [_set(weight: 60, reps: 8)])], + ), + _session( + id: 's2', + date: DateTime(2026, 1, 8), + exercises: [_log('bench', sets: [_set(weight: 60, reps: 12)])], + ), + ]); + expect(manager.getRecord('bench')!.bestReps, 12); + }); + + test('updates record when later session contains new best volume', () async { + await manager.backfillFromSessions([ + _session( + id: 's1', + date: DateTime(2026, 1, 1), + exercises: [_log('bench', sets: [_set(weight: 60, reps: 10)])], // 600 + ), + _session( + id: 's2', + date: DateTime(2026, 1, 8), + exercises: [_log('bench', sets: [_set(weight: 70, reps: 10)])], // 700 + ), + ]); + expect(manager.getRecord('bench')!.bestVolume, 700.0); + }); + + test('does not lower a PR when a weaker session is processed later', () async { + await manager.backfillFromSessions([ + _session( + id: 's1', + date: DateTime(2026, 1, 1), + exercises: [_log('bench', sets: [_set(weight: 100, reps: 10)])], + ), + _session( + id: 's2', + date: DateTime(2026, 1, 8), + exercises: [_log('bench', sets: [_set(weight: 60, reps: 5)])], + ), + ]); + expect(manager.getRecord('bench')!.bestWeight, 100.0); + }); + + test('handles multiple exercises in one session', () async { + await manager.backfillFromSessions([ + _session(exercises: [ + _log('bench', sets: [_set(weight: 80, reps: 8)]), + _log('squat', sets: [_set(weight: 120, reps: 5)]), + ]), + ]); + expect(manager.getRecord('bench'), isNotNull); + expect(manager.getRecord('squat'), isNotNull); + }); + }); + + group('PRManager - checkAndUpdatePRs', () { + test('first session for exercise always creates a PR with all three types', + () async { + final results = await manager.checkAndUpdatePRs( + _session(exercises: [_log('bench', sets: [_set(weight: 60, reps: 10)])]), + ); + expect(results, hasLength(1)); + expect(results.first.exerciseId, 'bench'); + expect(results.first.types, containsAll(['weight', 'reps', 'volume'])); + }); + + test('returns empty list when no PR is broken', () async { + await manager.checkAndUpdatePRs( + _session(exercises: [_log('bench', sets: [_set(weight: 100, reps: 10)])]), + ); + final results = await manager.checkAndUpdatePRs( + _session( + id: 's2', + exercises: [_log('bench', sets: [_set(weight: 60, reps: 5)])], + ), + ); + expect(results, isEmpty); + }); + + test('returns NewPRResult when weight PR is broken', () async { + await manager.checkAndUpdatePRs( + _session(exercises: [_log('bench', sets: [_set(weight: 80, reps: 5)])]), + ); + final results = await manager.checkAndUpdatePRs( + _session( + id: 's2', + exercises: [_log('bench', sets: [_set(weight: 100, reps: 5)])], + ), + ); + expect(results, hasLength(1)); + expect(results.first.types, contains('weight')); + }); + + test('returns NewPRResult when reps PR is broken', () async { + await manager.checkAndUpdatePRs( + _session(exercises: [_log('bench', sets: [_set(weight: 60, reps: 8)])]), + ); + final results = await manager.checkAndUpdatePRs( + _session( + id: 's2', + exercises: [_log('bench', sets: [_set(weight: 60, reps: 12)])], + ), + ); + expect(results.first.types, contains('reps')); + }); + + test('returns NewPRResult when both weight and reps are broken simultaneously', + () async { + await manager.checkAndUpdatePRs( + _session(exercises: [_log('bench', sets: [_set(weight: 60, reps: 8)])]), + ); + final results = await manager.checkAndUpdatePRs( + _session( + id: 's2', + exercises: [_log('bench', sets: [_set(weight: 80, reps: 10)])], + ), + ); + expect(results.first.types, containsAll(['weight', 'reps'])); + }); + + test('persists updated record to storage', () async { + await manager.checkAndUpdatePRs( + _session(exercises: [_log('bench', sets: [_set(weight: 100, reps: 5)])]), + ); + final stored = await storage.getPersonalRecord('bench'); + expect(stored, isNotNull); + expect(stored!.bestWeight, 100.0); + }); + + test('skips exercise log with no sets', () async { + final results = await manager.checkAndUpdatePRs( + _session(exercises: [_log('bench', sets: [])]), + ); + expect(results, isEmpty); + expect(manager.getRecord('bench'), isNull); + }); + }); + + group('PRManager - getRecord', () { + test('returns record for known exercise', () async { + await manager.checkAndUpdatePRs( + _session(exercises: [_log('bench', sets: [_set(weight: 80, reps: 8)])]), + ); + expect(manager.getRecord('bench'), isNotNull); + }); + + test('returns null for unknown exercise', () { + expect(manager.getRecord('unknown_exercise'), isNull); + }); + }); +} diff --git a/workout-logger/test/readiness_calculator_test.dart b/workout-logger/test/readiness_calculator_test.dart new file mode 100644 index 0000000..5a93167 --- /dev/null +++ b/workout-logger/test/readiness_calculator_test.dart @@ -0,0 +1,221 @@ +// Unit tests for ReadinessCalculator (pure scoring logic) + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/utils/readiness_calculator.dart'; + +void main() { + const calc = ReadinessCalculator(); + final today = DateTime(2026, 6, 10, 8); // 08:00 local + + ReadinessBaseline baseline({ + double? sleep = 420, // 7h average + int sleepNights = 14, + double? rhr = 55, + int rhrDays = 14, + double? hrv = 60, + int hrvDays = 14, + }) => + ReadinessBaseline( + dateKey: '2026-06-10', + avgSleepMinutes: sleep, + sleepNights: sleepNights, + avgRestingHr: rhr, + rhrDays: rhrDays, + avgHrvMs: hrv, + hrvDays: hrvDays, + ); + + group('component formulas', () { + test('at-baseline values all score 100 and band is high', () { + final s = calc.compute( + today: today, + baseline: baseline(), + lastNightSleepMinutes: 420, + todayRestingHr: 55, + todayHrvMs: 60, + ); + expect(s.sleepScore, 100); + expect(s.rhrScore, 100); + expect(s.hrvScore, 100); + expect(s.score, 100); + expect(s.band, ReadinessBand.high); + }); + + test('better-than-baseline values are not rewarded above 100', () { + final s = calc.compute( + today: today, + baseline: baseline(), + lastNightSleepMinutes: 540, // way over average + todayRestingHr: 48, // lower (better) than baseline + todayHrvMs: 90, // higher (better) than baseline + ); + expect(s.score, 100); + }); + + test('sleep at 75% of average scores 50', () { + final s = calc.compute( + today: today, + baseline: baseline(), + lastNightSleepMinutes: 315, // 420 * 0.75 + ); + expect(s.sleepScore, 50); + }); + + test('resting HR +10% over baseline scores 50', () { + final s = calc.compute( + today: today, + baseline: baseline(), + todayRestingHr: 60.5, // 55 * 1.10 + ); + expect(s.rhrScore, 50); + }); + + test('HRV −20% under baseline scores 50', () { + final s = calc.compute( + today: today, + baseline: baseline(), + todayHrvMs: 48, // 60 * 0.8 + ); + expect(s.hrvScore, 50); + }); + + test('extreme deviations clamp at 0', () { + final s = calc.compute( + today: today, + baseline: baseline(), + lastNightSleepMinutes: 60, + todayRestingHr: 90, + todayHrvMs: 10, + ); + expect(s.sleepScore, 0); + expect(s.rhrScore, 0); + expect(s.hrvScore, 0); + expect(s.score, 0); + expect(s.band, ReadinessBand.low); + }); + + test('short absolute sleep is capped even with a short baseline', () { + // 280 min sleep vs a 290 min average would naively score ~93. + final s = calc.compute( + today: today, + baseline: baseline(sleep: 290), + lastNightSleepMinutes: 280, + ); + expect(s.sleepScore, ReadinessCalculator.shortSleepMaxScore); + }); + }); + + group('weighting and partial data', () { + test('weights renormalize: sleep-only score equals sleep score', () { + final s = calc.compute( + today: today, + baseline: baseline(rhr: null, rhrDays: 0, hrv: null, hrvDays: 0), + lastNightSleepMinutes: 315, // sleep score 50 + ); + expect(s.score, 50); + expect(s.rhrScore, isNull); + expect(s.hrvScore, isNull); + }); + + test('sleep+RHR uses 0.5/0.3 weights renormalized', () { + final s = calc.compute( + today: today, + baseline: baseline(hrv: null, hrvDays: 0), + lastNightSleepMinutes: 315, // 50 + todayRestingHr: 55, // 100 + ); + // (50*0.5 + 100*0.3) / 0.8 = 68.75 → 69 + expect(s.score, 69); + expect(s.band, ReadinessBand.moderate); + }); + + test('component with fewer than 5 baseline samples is excluded', () { + final s = calc.compute( + today: today, + baseline: baseline(sleepNights: 4), + lastNightSleepMinutes: 100, // would tank the score if included + todayRestingHr: 55, + ); + expect(s.sleepScore, isNull); + expect(s.sleepMinutes, isNull); + expect(s.score, 100); // RHR only + }); + + test('no scorable components yields null score and band', () { + final s = calc.compute( + today: today, + baseline: const ReadinessBaseline(dateKey: '2026-06-10'), + lastNightSleepMinutes: 400, + ); + expect(s.score, isNull); + expect(s.band, isNull); + }); + }); + + group('bands', () { + test('75 is high and 74 is moderate', () { + // sleep ratio 0.875 → score 75 + final high = calc.compute( + today: today, + baseline: baseline(rhr: null, rhrDays: 0, hrv: null, hrvDays: 0), + lastNightSleepMinutes: (420 * 0.875).round(), + ); + expect(high.score, 75); + expect(high.band, ReadinessBand.high); + + final moderate = calc.compute( + today: today, + baseline: baseline(sleep: 400, rhr: null, rhrDays: 0, hrv: null, hrvDays: 0), + lastNightSleepMinutes: 348, // ratio 0.87 → 74 + ); + expect(moderate.score, 74); + expect(moderate.band, ReadinessBand.moderate); + }); + + test('49 is low', () { + final s = calc.compute( + today: today, + baseline: baseline(sleep: 480, rhr: null, rhrDays: 0, hrv: null, hrvDays: 0), + lastNightSleepMinutes: 358, // ratio ~0.746 → 49, above short-sleep cap + ); + expect(s.score, 49); + expect(s.band, ReadinessBand.low); + }); + }); + + group('lastNightSleep', () { + test('sums all periods in the night window (18:00 prev day → 12:00 today)', () { + final periods = [ + // 90-min nap yesterday afternoon — outside window (ends before 18:00) + SleepPeriod( + start: DateTime(2026, 6, 9, 14), + end: DateTime(2026, 6, 9, 15, 30), + ), + // Main sleep 23:00–06:30 = 450 min + SleepPeriod( + start: DateTime(2026, 6, 9, 23), + end: DateTime(2026, 6, 10, 6, 30), + ), + // Short morning doze 07:00–07:45 = 45 min (inside window) + SleepPeriod( + start: DateTime(2026, 6, 10, 7), + end: DateTime(2026, 6, 10, 7, 45), + ), + ]; + final picked = calc.lastNightSleep(today, periods); + expect(picked, isNotNull); + expect(picked!.minutes, 495); // 450 + 45 + }); + + test('returns null when nothing overlaps the window', () { + final periods = [ + SleepPeriod( + start: DateTime(2026, 6, 7, 23), + end: DateTime(2026, 6, 8, 7), + ), + ]; + expect(calc.lastNightSleep(today, periods), isNull); + }); + }); +} diff --git a/workout-logger/test/readiness_manager_test.dart b/workout-logger/test/readiness_manager_test.dart new file mode 100644 index 0000000..d469aa1 --- /dev/null +++ b/workout-logger/test/readiness_manager_test.dart @@ -0,0 +1,346 @@ +// Unit tests for ReadinessManager (orchestration, caching, degradation) + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; +import 'package:repforge/services/interfaces/readiness_manager_interface.dart'; +import 'package:repforge/services/managers/readiness_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/utils/readiness_calculator.dart'; +import 'test_utils/mock_storage_service.dart'; + +// ── Mocks ────────────────────────────────────────────────────────────────────── + +class _MockHcService implements IHealthConnectService { + Set granted; + List sleepPeriods; + List restingHr; + List hrv = const []; + List heartRate; + bool shouldThrow; + + int grantedCallCount = 0; + int sleepReadCount = 0; + int rhrReadCount = 0; + int hrvReadCount = 0; + int hrReadCount = 0; + + _MockHcService({ + this.granted = const {}, + this.sleepPeriods = const [], + this.restingHr = const [], + this.heartRate = const [], + this.shouldThrow = false, + }); + + void _maybeThrow() { + if (shouldThrow) throw Exception('mock HC error'); + } + + @override + Future isAvailable() async => true; + + @override + Future requestPermissions() async => true; + + @override + Future hasPermissions() async => true; + + @override + Future requestReadPermissions() async => granted.isNotEmpty; + + @override + Future> grantedReadTypes() async { + _maybeThrow(); + grantedCallCount++; + return granted; + } + + @override + Future> readSleepSessions(DateTime start, DateTime end) async { + _maybeThrow(); + sleepReadCount++; + return sleepPeriods + .where((p) => p.end.isAfter(start) && p.start.isBefore(end)) + .toList(); + } + + @override + Future> readRestingHeartRate(DateTime start, DateTime end) async { + _maybeThrow(); + rhrReadCount++; + return restingHr + .where((s) => !s.time.isBefore(start) && s.time.isBefore(end)) + .toList(); + } + + @override + Future> readHrvRmssd(DateTime start, DateTime end) async { + _maybeThrow(); + hrvReadCount++; + return hrv + .where((s) => !s.time.isBefore(start) && s.time.isBefore(end)) + .toList(); + } + + @override + Future> readHeartRateSamples(DateTime start, DateTime end) async { + _maybeThrow(); + hrReadCount++; + return heartRate + .where((s) => !s.time.isBefore(start) && s.time.isBefore(end)) + .toList(); + } + + @override + Future syncWorkoutSession(WorkoutSession session, {String? title}) async => + true; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/// 15 nights of 23:00–06:00 sleep (420 min each): 14 baseline nights plus +/// last night, which the manager scores against that baseline. +List _twoWeeksOfSleep(DateTime now) { + final day = DateTime(now.year, now.month, now.day); + return [ + for (var i = 0; i <= 14; i++) + SleepPeriod( + start: day.subtract(Duration(days: i)).subtract(const Duration(hours: 1)), + end: day.subtract(Duration(days: i)).add(const Duration(hours: 6)), + ), + ]; +} + +List _dailyRhr(DateTime now, double value, {double? todayValue}) { + final day = DateTime(now.year, now.month, now.day); + return [ + for (var i = 1; i <= 14; i++) + HealthSample(time: day.subtract(Duration(days: i, hours: -7)), value: value), + // "Today's" reading is stamped at test-setup time so it always falls + // inside the manager's trailing-24h query regardless of wall clock. + if (todayValue != null) HealthSample(time: now, value: todayValue), + ]; +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +void main() { + late MockStorageService storage; + late SettingsProvider settings; + + Future makeManager( + _MockHcService hc, { + bool enabled = true, + }) async { + storage = MockStorageService(); + settings = SettingsProvider(storage); + if (enabled) await storage.saveSetting('readinessEnabled', 'true'); + await settings.init(); + return ReadinessManager(hc, storage, settings); + } + + group('ReadinessManager.refresh', () { + test('is a no-op when the readiness setting is disabled', () async { + final hc = _MockHcService(granted: {HealthReadType.sleep}); + final manager = await makeManager(hc, enabled: false); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.idle); + expect(hc.grantedCallCount, 0); + }); + + test('goes to noData when no read permissions are granted', () async { + final hc = _MockHcService(); + final manager = await makeManager(hc); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.noData); + expect(manager.snapshot, isNull); + }); + + test('computes a sleep-only snapshot with partial permissions', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.ready); + final s = manager.snapshot!; + expect(s.sleepScore, isNotNull); + expect(s.rhrScore, isNull); + expect(s.hrvScore, isNull); + expect(s.score, isNotNull); + // Persisted for instant render next launch. + final cached = await storage.getSetting('readiness.snapshot'); + expect(cached, isNotNull); + }); + + test('serves the same-day cache inside the TTL without re-fetching', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + + await manager.refresh(); + final fetchesAfterFirst = hc.sleepReadCount; + await manager.refresh(); + + expect(hc.sleepReadCount, fetchesAfterFirst); + expect(manager.status, ReadinessStatus.ready); + }); + + test('force=true bypasses the snapshot cache', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + + await manager.refresh(); + final fetchesAfterFirst = hc.sleepReadCount; + await manager.refresh(force: true); + + expect(hc.sleepReadCount, greaterThan(fetchesAfterFirst)); + }); + + test('reuses the same-day baseline instead of recomputing', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + + await manager.refresh(); + // First refresh: 1 baseline read + 1 last-night read. + expect(hc.sleepReadCount, 2); + await manager.refresh(force: true); + // Forced refresh re-reads last night only — baseline is cached for today. + expect(hc.sleepReadCount, 3); + }); + + test('uses latest resting HR record and skips the minute-level fallback', + () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: { + HealthReadType.restingHeartRate, + HealthReadType.heartRate, + }, + restingHr: _dailyRhr(now, 55, todayValue: 60.5), + ); + final manager = await makeManager(hc); + + await manager.refresh(); + + expect(manager.snapshot!.restingHr, 60.5); + expect(manager.snapshot!.rhrScore, 50); + // The one HR-sample read is the all-day Heart-rate-card snapshot; the + // scoring path still uses the RHR record and skips its minute-level + // fallback (verified by restingHr above coming from the RHR record). + expect(hc.hrReadCount, 1); + }); + + test('falls back to minimum morning heart rate when no RHR record today', + () async { + final now = DateTime.now(); + final day = DateTime(now.year, now.month, now.day); + final hc = _MockHcService( + granted: { + HealthReadType.restingHeartRate, + HealthReadType.heartRate, + }, + // Baseline records exist on past days but none in the last 24h. + restingHr: _dailyRhr(day.subtract(const Duration(days: 2)), 55), + heartRate: [ + HealthSample(time: day.add(const Duration(hours: 3)), value: 62), + HealthSample(time: day.add(const Duration(hours: 4)), value: 55), + HealthSample(time: day.add(const Duration(hours: 5)), value: 58), + ], + ); + final manager = await makeManager(hc); + + await manager.refresh(); + + // Two HR-sample reads now: the all-day HR snapshot (for the Heart-rate + // card) plus the morning-RHR fallback used for scoring. + expect(hc.hrReadCount, 2); + expect(manager.snapshot?.restingHr, 55); + }); + + test('goes to noData when permissions exist but no data is scorable', + () async { + final hc = _MockHcService(granted: {HealthReadType.sleep}); + final manager = await makeManager(hc); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.noData); + expect(manager.snapshot, isNull); + }); + + test('never throws: HC errors degrade to noData', () async { + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + shouldThrow: true, + ); + final manager = await makeManager(hc); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.noData); + }); + + test('ignores a corrupt cached snapshot', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + await storage.saveSetting('readiness.snapshot', 'not json'); + + await manager.refresh(); + + expect(manager.status, ReadinessStatus.ready); + }); + + test('discards a stale snapshot from a previous day', () async { + final now = DateTime.now(); + final hc = _MockHcService( + granted: {HealthReadType.sleep}, + sleepPeriods: _twoWeeksOfSleep(now), + ); + final manager = await makeManager(hc); + final yesterday = now.subtract(const Duration(days: 1)); + await storage.saveSetting( + 'readiness.snapshot', + jsonEncode( + ReadinessSnapshot( + dateKey: ReadinessCalculator.dateKey(yesterday), + score: 12, + band: ReadinessBand.low, + computedAt: yesterday, + ).toJson(), + ), + ); + + await manager.refresh(); + + expect(manager.snapshot!.dateKey, ReadinessCalculator.dateKey(now)); + expect(manager.snapshot!.score, isNot(12)); + }); + }); +} diff --git a/workout-logger/test/rf_question_card_test.dart b/workout-logger/test/rf_question_card_test.dart new file mode 100644 index 0000000..e1db6a5 --- /dev/null +++ b/workout-logger/test/rf_question_card_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/widgets/rf_question_card.dart'; +import 'package:repforge/theme/app_theme.dart'; + +Widget _wrap(Widget child) => MaterialApp( + theme: AppTheme.darkTheme, + home: Scaffold(body: SingleChildScrollView(child: child)), +); + +void main() { + group('RFQuestionCard', () { + final singleSpec = QuestionSpec( + question: 'What is your goal?', + options: ['Strength', 'Hypertrophy', 'Fat loss'], + ); + + final multiSpec = QuestionSpec( + question: 'Which changes to apply?', + options: ['Reorder', 'Add exercise', 'Replace exercise'], + multiSelect: true, + ); + + testWidgets('renders question text and options', (tester) async { + await tester.pumpWidget(_wrap( + RFQuestionCard( + questions: [singleSpec], + onSubmit: (_) {}, + ), + )); + expect(find.text('What is your goal?'), findsOneWidget); + expect(find.text('Strength'), findsOneWidget); + expect(find.text('Hypertrophy'), findsOneWidget); + expect(find.text('Fat loss'), findsOneWidget); + }); + + testWidgets('single-select: tapping second chip deselects first', (tester) async { + List? submitted; + await tester.pumpWidget(_wrap( + RFQuestionCard( + questions: [singleSpec], + onSubmit: (a) => submitted = a, + ), + )); + + await tester.tap(find.text('Strength')); + await tester.pump(); + await tester.tap(find.text('Hypertrophy')); + await tester.pump(); + await tester.tap(find.text('Continue')); + await tester.pump(); + + expect(submitted, isNotNull); + expect(submitted!.first.selected, ['Hypertrophy']); + }); + + testWidgets('multi-select: multiple chips stay selected', (tester) async { + List? submitted; + await tester.pumpWidget(_wrap( + RFQuestionCard( + questions: [multiSpec], + onSubmit: (a) => submitted = a, + ), + )); + + await tester.tap(find.text('Reorder')); + await tester.pump(); + await tester.tap(find.text('Add exercise')); + await tester.pump(); + await tester.tap(find.text('Continue')); + await tester.pump(); + + expect(submitted!.first.selected, containsAll(['Reorder', 'Add exercise'])); + }); + + testWidgets('custom text is included in answer when typed', (tester) async { + List? submitted; + await tester.pumpWidget(_wrap( + RFQuestionCard( + questions: [singleSpec], + onSubmit: (a) => submitted = a, + ), + )); + + await tester.enterText(find.byType(TextField), 'Power lifting'); + await tester.tap(find.text('Continue')); + await tester.pump(); + + expect(submitted!.first.custom, 'Power lifting'); + }); + + testWidgets('multiple questions rendered and submitted together', (tester) async { + List? submitted; + await tester.pumpWidget(_wrap( + RFQuestionCard( + questions: [singleSpec, multiSpec], + onSubmit: (a) => submitted = a, + ), + )); + + expect(find.text('What is your goal?'), findsOneWidget); + expect(find.text('Which changes to apply?'), findsOneWidget); + + await tester.tap(find.text('Strength')); + await tester.pump(); + await tester.tap(find.text('Continue')); + await tester.pump(); + + expect(submitted, hasLength(2)); + expect(submitted![0].question, 'What is your goal?'); + expect(submitted![1].question, 'Which changes to apply?'); + }); + }); +} diff --git a/workout-logger/test/routine_optimizer_screen_test.dart b/workout-logger/test/routine_optimizer_screen_test.dart new file mode 100644 index 0000000..1be665d --- /dev/null +++ b/workout-logger/test/routine_optimizer_screen_test.dart @@ -0,0 +1,320 @@ +// Widget tests for RoutineOptimizerScreen. +// +// Tests the view layer by injecting a pre-built RoutineOptimizerViewModel +// via ChangeNotifierProvider.value, bypassing the real AI service setup. +// Uses RoutineOptimizerScreen.testBody() to render the inner view directly. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_generative_ai/google_generative_ai.dart' + show Content, Tool, FunctionCall; +import 'package:provider/provider.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/routine_optimizer_screen.dart'; +import 'package:repforge/screens/widgets/rf_question_card.dart'; +import 'package:repforge/services/ai/coach_tool_service.dart'; +import 'package:repforge/services/interfaces/ai_service_interface.dart'; +import 'package:repforge/services/managers/conversation_manager.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/theme/app_theme.dart'; +import 'package:repforge/viewmodels/routine_optimizer_view_model.dart'; +import 'test_utils/mock_storage_service.dart'; + +// ── Fake AI services ─────────────────────────────────────────────────────── + +/// AI that immediately yields a single reply chunk and completes. +class _ImmediateAi implements IAiService { + const _ImmediateAi({this.reply = 'All done!'}); + final String reply; + + @override + bool get isConfigured => true; + @override + String get currentModel => 'fake'; + + @override + Stream streamCoachReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) async* { + yield reply; + } + + @override + Future generateProgram({ + required String userPrompt, + required List allExercises, + }) => throw UnimplementedError(); + + @override + Future generateWeeklyInsights(String contextText) async => ''; + + @override + Future generateInsight(String system, String context) async => ''; +} + +/// AI that hangs indefinitely — keeps `isLoading` true for the entire test. +class _HangingAi implements IAiService { + final _done = Completer(); + + @override + bool get isConfigured => true; + @override + String get currentModel => 'fake'; + + void complete() => _done.complete(); + + @override + Stream streamCoachReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) async* { + await _done.future; + } + + @override + Future generateProgram({ + required String userPrompt, + required List allExercises, + }) => throw UnimplementedError(); + + @override + Future generateWeeklyInsights(String contextText) async => ''; + + @override + Future generateInsight(String system, String context) async => ''; +} + +/// AI that fires an `ask_user_questions` tool call before yielding a reply. +class _QuestionAi implements IAiService { + const _QuestionAi(); + + @override + bool get isConfigured => true; + @override + String get currentModel => 'fake'; + + @override + Stream streamCoachReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) async* { + if (onToolCall != null) { + await onToolCall(FunctionCall('ask_user_questions', { + 'preamble': 'Before I start, a quick question.', + 'questions': [ + { + 'question': 'What is your primary goal?', + 'options': ['Strength', 'Hypertrophy', 'Fat loss'], + }, + ], + })); + } + yield 'Done.'; + } + + @override + Future generateProgram({ + required String userPrompt, + required List allExercises, + }) => throw UnimplementedError(); + + @override + Future generateWeeklyInsights(String contextText) async => ''; + + @override + Future generateInsight(String system, String context) async => ''; +} + +// ── Test helpers ─────────────────────────────────────────────────────────── + +final _pushDay = Routine(id: 'r1', name: 'Push Day', exerciseIds: const []); + +RoutineOptimizerViewModel _buildVm(IAiService ai) { + final storage = MockStorageService(); + final wp = WorkoutProvider(storage, programManager: ProgramManager(storage)); + final pr = PRManager(storage); + final conversations = ConversationManager(storage, kind: 'optimizer'); + final settings = SettingsProvider(storage); + final coachTools = CoachToolService(wp, pr); + return RoutineOptimizerViewModel( + ai: ai, + coachTools: coachTools, + conversations: conversations, + settings: settings, + ); +} + +Widget _wrap(RoutineOptimizerViewModel vm) => MaterialApp( + theme: AppTheme.darkTheme, + home: ChangeNotifierProvider.value( + value: vm, + child: RoutineOptimizerScreen.testBody(_pushDay), + ), + ); + +// ── Tests ────────────────────────────────────────────────────────────────── + +void main() { + group('RoutineOptimizerScreen', () { + testWidgets('shows title and routine name in header', (tester) async { + final vm = _buildVm(const _ImmediateAi()); + await tester.pumpWidget(_wrap(vm)); + await tester.pump(); + + expect(find.text('Optimize Routine'), findsOneWidget); + expect(find.text('Push Day'), findsOneWidget); + }); + + testWidgets('shows loading indicator while AI is streaming', (tester) async { + final ai = _HangingAi(); + final vm = _buildVm(ai); + await tester.pumpWidget(_wrap(vm)); + + // Trigger streaming without awaiting — keeps isLoading = true. + unawaited(vm.startForRoutine(_pushDay)); + await tester.pump(); + + // Streaming bubble with loading dots should be visible. + expect(find.byType(CircularProgressIndicator).evaluate().isNotEmpty || + // RFLoadingDots is the animated dot indicator used in the bubble. + find.byWidgetPredicate( + (w) => w.runtimeType.toString() == 'RFLoadingDots', + ).evaluate().isNotEmpty || + find.byIcon(Icons.auto_fix_high_rounded).evaluate().isNotEmpty, + isTrue, + reason: 'A streaming/loading indicator should be visible'); + + // Verify we are in a loading state overall + expect(vm.isLoading, isTrue); + + ai.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('renders seed user message and AI reply', (tester) async { + final vm = _buildVm(const _ImmediateAi(reply: 'Great plan!')); + await tester.pumpWidget(_wrap(vm)); + + await vm.startForRoutine(_pushDay); + await tester.pump(); + + // Seed user message + expect( + find.textContaining('Push Day'), + findsWidgets, + reason: 'Routine name should appear in seed message or subtitle', + ); + + // AI reply + expect(find.textContaining('Great plan!'), findsOneWidget); + }); + + testWidgets('user messages align to the right', (tester) async { + final vm = _buildVm(const _ImmediateAi()); + await tester.pumpWidget(_wrap(vm)); + await vm.startForRoutine(_pushDay); + await tester.pump(); + + // There should be at least one message in the list. + expect(vm.messages.isNotEmpty, isTrue); + // User messages have role 'user' + expect(vm.messages.any((m) => m.role == 'user'), isTrue); + }); + + testWidgets('shows RFQuestionCard when AI asks questions', (tester) async { + final ai = _QuestionAi(); + final vm = _buildVm(ai); + await tester.pumpWidget(_wrap(vm)); + + // Start without awaiting so we can catch the pending state. + unawaited(vm.startForRoutine(_pushDay)); + + // Pump a few frames so the tool call fires and pendingQuestions is set. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + if (vm.pendingQuestions != null) { + await tester.pump(); + expect(find.byType(RFQuestionCard), findsOneWidget); + expect(find.text('What is your primary goal?'), findsOneWidget); + + // Submitting an answer unblocks the stream. + vm.submitAnswers([ + AnswerSpec(question: 'What is your primary goal?', selected: ['Strength']), + ]); + await tester.pumpAndSettle(); + expect(find.byType(RFQuestionCard), findsNothing); + } else { + // If the stream already completed (fast machine), just verify no crash. + await tester.pumpAndSettle(); + } + }); + + testWidgets('back button pops the route', (tester) async { + bool popped = false; + final vm = _buildVm(const _ImmediateAi()); + + await tester.pumpWidget(MaterialApp( + theme: AppTheme.darkTheme, + home: Builder(builder: (ctx) { + return Scaffold( + body: ElevatedButton( + onPressed: () => Navigator.push( + ctx, + MaterialPageRoute( + builder: (_) => ChangeNotifierProvider< + RoutineOptimizerViewModel>.value( + value: vm, + child: RoutineOptimizerScreen.testBody(_pushDay), + ), + ), + ).then((_) => popped = true), + child: const Text('Open'), + ), + ); + }), + )); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + // Now on the optimizer screen — tap back. + await tester.tap(find.byIcon(Icons.arrow_back_rounded)); + await tester.pumpAndSettle(); + + expect(popped, isTrue); + }); + + testWidgets('history sheet shows empty state when no conversations', + (tester) async { + final vm = _buildVm(const _ImmediateAi()); + await tester.pumpWidget(_wrap(vm)); + await tester.pump(); + + // Open the history sheet via the history button. + await tester.tap(find.byIcon(Icons.history_rounded)); + await tester.pumpAndSettle(); + + expect(find.text('Optimization History'), findsOneWidget); + expect( + find.text('No saved optimization sessions yet.'), + findsOneWidget, + ); + }); + }); +} diff --git a/workout-logger/test/routine_optimizer_view_model_test.dart b/workout-logger/test/routine_optimizer_view_model_test.dart new file mode 100644 index 0000000..4338cd0 --- /dev/null +++ b/workout-logger/test/routine_optimizer_view_model_test.dart @@ -0,0 +1,234 @@ +// Unit tests for RoutineOptimizerViewModel + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_generative_ai/google_generative_ai.dart' + show Content, Tool, FunctionCall; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/interfaces/ai_service_interface.dart'; +import 'package:repforge/services/ai/coach_tool_service.dart'; +import 'package:repforge/services/managers/conversation_manager.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/viewmodels/routine_optimizer_view_model.dart'; +import 'test_utils/mock_storage_service.dart'; + +// ── Fake IAiService ──────────────────────────────────────────────────────── + +class _SimpleAi implements IAiService { + _SimpleAi({this.chunks = const ['Done.'], this.toolCall}); + + final List chunks; + final FunctionCall? toolCall; + int calls = 0; + + @override + bool get isConfigured => true; + @override + String get currentModel => 'fake'; + + @override + Stream streamCoachReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) async* { + calls++; + final tc = toolCall; + if (tc != null && onToolCall != null) { + await onToolCall(tc); + } + for (final c in chunks) { + yield c; + } + } + + @override + Future generateProgram({ + required String userPrompt, + required List allExercises, + }) => + throw UnimplementedError(); + + @override + Future generateWeeklyInsights(String contextText) async => ''; + + @override + Future generateInsight(String system, String context) async => ''; +} + +class _ThrowingAi implements IAiService { + @override + bool get isConfigured => true; + @override + String get currentModel => 'fake'; + + @override + Stream streamCoachReply({ + required String userMessage, + required String systemPrompt, + required List history, + List? tools, + Future> Function(FunctionCall call)? onToolCall, + }) async* { + throw Exception('Network error'); + } + + @override + Future generateProgram({ + required String userPrompt, + required List allExercises, + }) => + throw UnimplementedError(); + + @override + Future generateWeeklyInsights(String contextText) => + throw UnimplementedError(); + + @override + Future generateInsight(String system, String context) => + throw UnimplementedError(); +} + +// ── Helper ──────────────────────────────────────────────────────────────── + +RoutineOptimizerViewModel _buildVm({ + required MockStorageService storage, + required IAiService ai, +}) { + final wp = WorkoutProvider(storage, programManager: ProgramManager(storage)); + final pr = PRManager(storage); + final conversations = ConversationManager(storage, kind: 'optimizer'); + final settings = SettingsProvider(storage); + final coachTools = CoachToolService(wp, pr); + return RoutineOptimizerViewModel( + ai: ai, + coachTools: coachTools, + conversations: conversations, + settings: settings, + ); +} + +final _routine = Routine(id: 'r1', name: 'Push Day', exerciseIds: const []); + +// ── Tests ────────────────────────────────────────────────────────────────── + +void main() { + late MockStorageService storage; + setUp(() => storage = MockStorageService()); + + group('RoutineOptimizerViewModel', () { + test('startForRoutine auto-sends seed message', () async { + final ai = _SimpleAi(); + final vm = _buildVm(storage: storage, ai: ai); + await vm.startForRoutine(_routine); + expect(ai.calls, 1); + expect(vm.messages.length, greaterThanOrEqualTo(2)); + expect(vm.messages.first.role, 'user'); + expect(vm.messages.first.text, contains('Push Day')); + }); + + test('isLoading is true during streaming and false after', () async { + final ai = _SimpleAi(chunks: ['chunk']); + final vm = _buildVm(storage: storage, ai: ai); + bool wasLoading = false; + vm.addListener(() { + if (vm.isLoading) wasLoading = true; + }); + await vm.startForRoutine(_routine); + expect(wasLoading, isTrue); + expect(vm.isLoading, isFalse); + }); + + test('ask_user_questions sets pendingQuestions mid-stream', () async { + final questionCall = FunctionCall('ask_user_questions', { + 'preamble': 'Quick question.', + 'questions': [ + { + 'question': 'Your goal?', + 'options': ['Strength', 'Hypertrophy'], + }, + ], + }); + + PendingQuestions? captured; + + final ai = _SimpleAi(chunks: ['Applied.'], toolCall: questionCall); + final vm = _buildVm(storage: storage, ai: ai); + + vm.addListener(() async { + if (vm.pendingQuestions != null && captured == null) { + captured = vm.pendingQuestions; + // Submit to unblock the stream + await vm.submitAnswers([ + AnswerSpec(question: 'Your goal?', selected: ['Strength']), + ]); + } + }); + + await vm.startForRoutine(_routine); + expect(captured?.questions.first.question, 'Your goal?'); + }); + + test('submitAnswers persists answers as a user message', () async { + bool questionsSeen = false; + final questionCall = FunctionCall('ask_user_questions', { + 'questions': [ + {'question': 'Goal?', 'options': ['Strength']}, + ], + }); + final ai = _SimpleAi(chunks: ['Done.'], toolCall: questionCall); + final vm = _buildVm(storage: storage, ai: ai); + + vm.addListener(() async { + if (vm.pendingQuestions != null && !questionsSeen) { + questionsSeen = true; + await vm.submitAnswers([ + AnswerSpec(question: 'Goal?', selected: ['Strength']), + ]); + } + }); + + await vm.startForRoutine(_routine); + + final userMessages = vm.messages.where((m) => m.role == 'user').toList(); + expect(userMessages.any((m) => m.text.contains('Strength')), isTrue); + }); + + test('stream error appends error message and clears loading', () async { + final vm = _buildVm(storage: storage, ai: _ThrowingAi()); + await vm.startForRoutine(_routine); + expect(vm.isLoading, isFalse); + expect(vm.pendingQuestions, isNull); + final modelMsgs = vm.messages.where((m) => m.role == 'model').toList(); + expect(modelMsgs.any((m) => m.text.contains('Error')), isTrue); + }); + + test('dispose completes pending Completer without leaking', () async { + final questionCall = FunctionCall('ask_user_questions', { + 'questions': [ + {'question': 'Goal?', 'options': ['Strength']}, + ], + }); + final ai = _SimpleAi(chunks: ['Done.'], toolCall: questionCall); + final vm = _buildVm(storage: storage, ai: ai); + + // Start but DON'T submit answers + // We need to ensure dispose() doesn't hang + final future = vm.startForRoutine(_routine); + await Future.delayed(Duration.zero); // let it start + + // If pendingQuestions is set, dispose should complete the completer + vm.dispose(); + + // The future should complete (not hang) after dispose + await future.timeout(const Duration(seconds: 2)); + expect(vm.pendingQuestions, isNull); + }); + }); +} diff --git a/workout-logger/test/test_utils/mock_ml_service.dart b/workout-logger/test/test_utils/mock_ml_service.dart index 998bbfd..100d089 100644 --- a/workout-logger/test/test_utils/mock_ml_service.dart +++ b/workout-logger/test/test_utils/mock_ml_service.dart @@ -66,10 +66,32 @@ class MockMLService implements IMLService { return dataPoints; } + @override + List extractMuscleDataPoints( + String muscleGroupId, + List sessions, + Map exerciseMap, + ) { + return []; + } + + @override + Map computeMuscleRecoveryScores( + List sessions, + Map exerciseMap, { + DateTime? asOf, + }) { + return {}; + } + @override List recommendSets({ required List lastSession, GrowthModel? growthModel, + int minReps = 6, + int maxReps = 12, + Map? recoveryScores, + List? primaryMuscleIds, }) { recommendSetsCallCount++; lastRecommendedLastSession = lastSession; diff --git a/workout-logger/test/test_utils/mock_storage_service.dart b/workout-logger/test/test_utils/mock_storage_service.dart index 6444969..2f0a9c3 100644 --- a/workout-logger/test/test_utils/mock_storage_service.dart +++ b/workout-logger/test/test_utils/mock_storage_service.dart @@ -20,6 +20,8 @@ class MockStorageService implements IStorageService { final List _muscleGroups = []; final Map _settings = {}; final List _trainingPrograms = []; + final Map _personalRecords = {}; + final Map _conversations = {}; bool saveCustomExerciseCalled = false; Exercise? lastSavedExercise; @@ -265,6 +267,39 @@ class MockStorageService implements IStorageService { _trainingPrograms.removeWhere((p) => p.id == id); } + @override + Future savePersonalRecord(PersonalRecord record) async { + _personalRecords[record.exerciseId] = record; + } + + @override + Future getPersonalRecord(String exerciseId) async => + _personalRecords[exerciseId]; + + @override + Future> getAllPersonalRecords() async => + List.from(_personalRecords.values); + + @override + Future saveConversation(Conversation conversation) async { + _conversations[conversation.id] = conversation; + } + + @override + Future> getAllConversations() async { + final list = _conversations.values.toList() + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + return list; + } + + @override + Future getConversation(String id) async => _conversations[id]; + + @override + Future deleteConversation(String id) async { + _conversations.remove(id); + } + @override Future exportAllData() async => '{}'; diff --git a/workout-logger/test/workout_hr_builder_test.dart b/workout-logger/test/workout_hr_builder_test.dart new file mode 100644 index 0000000..b0ce186 --- /dev/null +++ b/workout-logger/test/workout_hr_builder_test.dart @@ -0,0 +1,110 @@ +// Unit tests for the workout HR analysis builder (rest recovery + guards). + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; +import 'package:repforge/services/utils/workout_hr_builder.dart'; + +class _Hc implements IHealthConnectService { + final List hr; + _Hc(this.hr); + + @override + Future> readHeartRateSamples(DateTime start, DateTime end) async => + hr.where((s) => !s.time.isBefore(start) && !s.time.isAfter(end)).toList(); + + @override + Future> grantedReadTypes() async => {HealthReadType.heartRate}; + @override + Future> readSleepSessions(DateTime s, DateTime e) async => const []; + @override + Future> readRestingHeartRate(DateTime s, DateTime e) async => const []; + @override + Future> readHrvRmssd(DateTime s, DateTime e) async => const []; + @override + Future isAvailable() async => true; + @override + Future requestPermissions() async => true; + @override + Future hasPermissions() async => true; + @override + Future requestReadPermissions() async => true; + @override + Future syncWorkoutSession(WorkoutSession session, {String? title}) async => true; +} + +DateTime _t(int h, int m, [int s = 0]) => DateTime(2026, 6, 9, h, m, s); + +WorkoutSet _set(DateTime ts, {int timeTaken = 30}) => + WorkoutSet(weight: 100, reps: 8, timestamp: ts, timeTaken: timeTaken); + +void main() { + final session = WorkoutSession( + id: 'w1', + date: _t(18, 0), + duration: 20, // ends 18:20 + exercises: [ + ExerciseLog(exerciseId: 'bench', sets: [_set(_t(18, 2)), _set(_t(18, 5))]), + ExerciseLog(exerciseId: 'row', sets: [_set(_t(18, 10)), _set(_t(18, 14))]), + ], + ); + + // Crafted HR: drops during the first two rests, stays high in the third. + final samples = [ + HealthSample(time: _t(18, 2), value: 160), // set A1 end (peak) + HealthSample(time: _t(18, 3), value: 140), // rest 1 trough + HealthSample(time: _t(18, 4), value: 142), + HealthSample(time: _t(18, 5), value: 158), // set A2 end + HealthSample(time: _t(18, 7), value: 130), // rest 2 trough + HealthSample(time: _t(18, 10), value: 162), // set B1 end + HealthSample(time: _t(18, 12), value: 159), // rest 3 stays high + HealthSample(time: _t(18, 14), value: 150), // set B2 end + ]; + + test('computes per-rest recovery and flags short rests', () async { + final a = await buildWorkoutHrAnalysis(_Hc(samples), session, {HealthReadType.heartRate}); + expect(a, isNotNull); + expect(a!.peakBpm, 162); + expect(a.minBpm, 130); + expect(a.hasRestAnalysis, true); + + expect(a.restCount, 3); + expect(a.restsRecovered, 2); + expect(a.rests[0].recoveryBpm, 20); // 160 → 140 + expect(a.rests[0].recovered, true); + expect(a.rests[2].recoveryBpm, 3); // 162 → 159 + expect(a.rests[2].recovered, false); + expect(a.avgRecoveryBpm, 24); // (20 + 28) / 2 + + expect(a.exercises.length, 2); + expect(a.exercises.first.setCount, 2); + }); + + test('guards against placeholder timestamps (no per-set timing)', () async { + final flat = WorkoutSession( + id: 'w2', + date: _t(18, 0), + duration: 20, + exercises: [ + ExerciseLog(exerciseId: 'bench', sets: [_set(_t(18, 0)), _set(_t(18, 0))]), + ], + ); + final a = await buildWorkoutHrAnalysis(_Hc(samples), flat, {HealthReadType.heartRate}); + expect(a, isNotNull); + expect(a!.hasRestAnalysis, false); + expect(a.rests, isEmpty); + expect(a.exercises, isEmpty); + expect(a.curve, isNotEmpty); // curve still renders + }); + + test('returns null without HR permission', () async { + final a = await buildWorkoutHrAnalysis(_Hc(samples), session, {HealthReadType.sleep}); + expect(a, isNull); + }); + + test('returns null when too few samples cover the window', () async { + final sparse = _Hc([HealthSample(time: _t(18, 5), value: 150)]); + final a = await buildWorkoutHrAnalysis(sparse, session, {HealthReadType.heartRate}); + expect(a, isNull); + }); +} diff --git a/workout-logger/test/workout_provider_test.dart b/workout-logger/test/workout_provider_test.dart index db01f66..e6088c2 100644 --- a/workout-logger/test/workout_provider_test.dart +++ b/workout-logger/test/workout_provider_test.dart @@ -572,5 +572,165 @@ void main() { expect(provider.hasActiveWorkout, isTrue); }); }); + + group('init / loadAllData', () { + test('allExercises contains built-in exercises after init', () { + expect(provider.allExercises, isNotEmpty); + }); + + test('sessions loaded from storage after init', () async { + mockStorage.addMockSession(WorkoutSession( + id: 's1', + date: DateTime(2026, 1, 1), + exercises: [], + duration: 30, + )); + final p2 = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + await p2.init(); + expect(p2.sessions, hasLength(1)); + }); + + test('routines loaded from storage after init', () async { + mockStorage.addMockRoutine( + Routine(id: 'r1', name: 'Push Day', exerciseIds: []), + ); + final p2 = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + await p2.init(); + expect(p2.routines, hasLength(1)); + }); + }); + + group('active workout flow', () { + test('hasActiveWorkout is false before startWorkout', () { + expect(provider.hasActiveWorkout, isFalse); + }); + + test('hasActiveWorkout is true after startWorkout', () { + provider.startWorkout(exerciseIds: const ['bench_press']); + expect(provider.hasActiveWorkout, isTrue); + }); + + test('addSet increases currentExerciseLog sets count', () { + provider.startWorkout(exerciseIds: const ['bench_press']); + provider.addSet(WorkoutSet(weight: 60, reps: 10)); + expect(provider.currentExerciseLog!.sets, hasLength(1)); + }); + + test('removeLastSet decreases sets count', () { + provider.startWorkout(exerciseIds: const ['bench_press']); + provider.addSet(WorkoutSet(weight: 60, reps: 10)); + provider.addSet(WorkoutSet(weight: 60, reps: 10)); + provider.removeLastSet(); + expect(provider.currentExerciseLog!.sets, hasLength(1)); + }); + + test('nextExercise advances currentExerciseIndex', () { + provider.startWorkout(exerciseIds: const ['bench_press', 'squat']); + final moved = provider.nextExercise(); + expect(moved, isTrue); + expect(provider.currentExerciseIndex, 1); + }); + + test('finishWorkout saves session and clears active state', () async { + provider.startWorkout(exerciseIds: const ['bench_press']); + provider.addSet(WorkoutSet(weight: 60, reps: 10)); + await provider.finishWorkout(); + expect(provider.hasActiveWorkout, isFalse); + expect(provider.sessions, hasLength(1)); + }); + + test('cancelWorkout clears state without saving a session', () async { + provider.startWorkout(exerciseIds: const ['bench_press']); + provider.addSet(WorkoutSet(weight: 60, reps: 10)); + await provider.cancelWorkout(); + expect(provider.hasActiveWorkout, isFalse); + expect(provider.sessions, isEmpty); + }); + }); + + group('startWorkoutSafely', () { + test('starts workout and returns true when no conflict', () async { + final started = await provider.startWorkoutSafely( + exerciseIds: const ['bench_press'], + onConflict: () async => StartWorkoutConflictAction.cancel, + ); + expect(started, isTrue); + expect(provider.hasActiveWorkout, isTrue); + }); + + test('calls onConflict callback when a workout is already active', + () async { + provider.startWorkout(exerciseIds: const ['bench_press']); + var conflictCalled = false; + await provider.startWorkoutSafely( + exerciseIds: const ['squat'], + onConflict: () async { + conflictCalled = true; + return StartWorkoutConflictAction.cancel; + }, + ); + expect(conflictCalled, isTrue); + }); + + test('cancels existing and starts new when discardAndStart chosen', + () async { + provider.startWorkout(exerciseIds: const ['bench_press']); + final started = await provider.startWorkoutSafely( + exerciseIds: const ['squat'], + onConflict: () async => StartWorkoutConflictAction.discardAndStart, + ); + expect(started, isTrue); + expect( + provider.currentExerciseLogs.first.exerciseId, + 'squat', + ); + }); + + test('returns false when conflict resolved with cancel', () async { + provider.startWorkout(exerciseIds: const ['bench_press']); + final started = await provider.startWorkoutSafely( + exerciseIds: const ['squat'], + onConflict: () async => StartWorkoutConflictAction.cancel, + ); + expect(started, isFalse); + }); + }); + + group('getExerciseName', () { + test('returns name for a known built-in exercise id', () { + final name = provider.getExerciseName('bench_press'); + expect(name, isNot('Unknown Exercise')); + expect(name, isNotEmpty); + }); + + test('returns fallback for unknown exercise id', () { + expect(provider.getExerciseName('no_such_exercise'), 'Unknown Exercise'); + }); + }); + + group('deleteCustomExercise - routine guard', () { + test('returns false when exercise is referenced in a routine', () async { + await provider.addCustomExercise( + name: 'Cable Fly', + category: 'isolation', + primaryMuscleGroupId: 'chest', + ); + final exerciseId = + provider.allExercises.firstWhere((e) => e.isCustom).id; + await provider.createRoutine('Test Routine', [exerciseId]); + final deleted = await provider.deleteCustomExercise(exerciseId); + expect(deleted, isFalse); + expect( + provider.allExercises.any((e) => e.id == exerciseId), + isTrue, + ); + }); + }); }); }