diff --git a/docs/ios-implementation-plan.md b/docs/ios-implementation-plan.md index 7cbc3a7..6db1b51 100644 --- a/docs/ios-implementation-plan.md +++ b/docs/ios-implementation-plan.md @@ -145,6 +145,26 @@ recipes/pantry/food-log via the Forge MCP tools; this phase adds the app surface the read-only meals/macros strip on the Train week view. Light, photography-led, New York serif — deliberately not a Train reskin. +**N4.1 — the phone surface `[done]`**, built ahead of N2/N3 because its boards were already +decided (`cook-browse.png`, `cook-mode.png`) and the server API needed nothing new: + +- `ForgeTheme.cookLight` (Paper × Ember) + `ForgeFont.serif*` (New York) — the module's own + voice, scoped so volt and DIN never reach a Cook screen. `RootView` tints the shell from + the selected tab, so the bar changes accent with the module. +- Browse root, recipe detail, cook mode (per-step timers, keep-awake, background-step strip, + plate-and-log), full library with search/kind filter, week planner with per-slot tick and + a plan sheet that works from either end (pick a day / pick a recipe). +- `CookCue` — derives the done-when clause from step prose for cook mode's one accent. It + works because the import rule already mandates done-when voice. +- `ForgeAsyncImage` — authenticated image loading (recipe media is behind the session + cookie), cookie scoped to our own origin. `PlateImage` gives bundled fallback photography + since the seeded library ships no images. + +**N4.2 — still open**: iPad `NavigationSplitView`; cook timers as Live Activities (needs the +widget extension target, [C1b] in `ios/XCODE-HANDOVER.md`); an authored `cue_done` per step so +the next-step CTA can read like the board ("Skin's released — next") instead of the step title; +real per-recipe imagery. + ### N5 — Shop (design-doc phase D): shopping list Server first, app second — the list is useful from the PWA and the coach on day one, diff --git a/docs/mockups/36-progress-options.html b/docs/mockups/36-progress-options.html index 11fffa2..faf3741 100644 --- a/docs/mockups/36-progress-options.html +++ b/docs/mockups/36-progress-options.html @@ -77,7 +77,7 @@

Progress

-
Option 2 — Sectioned story · Strength / Engine / Body sections,
one chart each + stat chips, weekly ring
+
Option 2 — Sectioned story · Strength / Cardio / Body sections,
one chart each + stat chips, weekly ring
Trends · 12 weeks

Progress

@@ -105,7 +105,7 @@

Progress

-
Engine
+
Cardio
VO₂maxraw + smoothed
diff --git a/docs/mockups/README.md b/docs/mockups/README.md index f6d7069..5db0823 100644 --- a/docs/mockups/README.md +++ b/docs/mockups/README.md @@ -32,7 +32,7 @@ in CI/web sessions). | `history.png` | History with the All / ★ Starred filter and a row swiped open to delete | | `logging.png` | Active-set logging: plate math, stepper, RPE, draining rest ring | | `learn.png` | Exercise form guide: media, cues, muscle map | -| `progress.png` | Sectioned Progress: this-week ring, strength e1RM, engine, body | +| `progress.png` | Sectioned Progress: this-week ring, strength e1RM, cardio, body | | `coach.png` | Coach chat thread with the pinned week-proposal bar | | `run-detail.png` | Rich run detail (E5.4): route on a token-styled street basemap, HR vs prescribed band, five-zone breakdown | | `appearance.png` | Settings → Appearance: Dark / Light / Auto and the five accent palettes | diff --git a/docs/mockups/generate.mjs b/docs/mockups/generate.mjs index 5eb0b5d..d9b3406 100644 --- a/docs/mockups/generate.mjs +++ b/docs/mockups/generate.mjs @@ -326,7 +326,7 @@ add('learn', { tab: 'today', screenH: 760 }, shell('today', `
Glutes●●○
Lower back●○○
`)); -// 8) PROGRESS — sectioned trends (this week ring, strength e1RM, engine, body) +// 8) PROGRESS — sectioned trends (this week ring, strength e1RM, cardio, body) add('progress', { tab: 'progress', screenH: 806 }, shell('progress', `
Trends

Progress

@@ -344,7 +344,7 @@ add('progress', { tab: 'progress', screenH: 806 }, shell('progress', ` 99.5 kg
${line([0.15,0.28,0.24,0.4,0.52,0.49,0.66,0.72,0.85,1])} -
Engine
+
Cardio
VO₂max · raw + trend ml/kg/min
${line([0.4,0.55,0.48,0.6,0.58,0.7,0.66,0.78,0.82,0.88])} diff --git a/docs/mockups/native/README.md b/docs/mockups/native/README.md index 7b77b30..8334216 100644 --- a/docs/mockups/native/README.md +++ b/docs/mockups/native/README.md @@ -17,7 +17,7 @@ Needs a Chromium/Chrome binary (`CHROME_BIN` honoured). Render on macOS so | Board | Shows | |---|---| -| `train-today` | Train tab: Void × Volt native — session hero, Cook's meals + macros as a shared-store strip, engine tiles | +| `train-today` | Train tab: Void × Volt native — session hero, Cook's meals + macros as a shared-store strip, cardio tiles | | `train-logging` | Active set: big numerals, plate math, RPE, session-so-far tiles; rest timer in the Dynamic Island | | `watch` | ForgeWatch: set logging with live HR, rest ring | | `cook-browse` | Cook tab: paper-light, serif, photography-led; tonight hero, week strip, library. Accent: ember | diff --git a/docs/mockups/native/build-reviews.mjs b/docs/mockups/native/build-reviews.mjs index dab5a5f..50922eb 100644 --- a/docs/mockups/native/build-reviews.mjs +++ b/docs/mockups/native/build-reviews.mjs @@ -170,7 +170,7 @@ writeFileSync(resolve(HERE, 'review-redesign.html'), page('Forge Train redesign

You were right — Cook felt richer because it leads with imagery, and the workout side didn't. Here are three directions for Train, each shown in dark and light mode side by side (the whole app will respect both). The screen content is - identical in all three — today's session, meals from Cook, the engine numbers, the week — + identical in all three — today's session, meals from Cook, the cardio numbers, the week — only the personality changes. Photos are placeholders for real training imagery.

${R2.map(([t, img, note], i) => `

${String.fromCharCode(65 + i)}${t}

diff --git a/docs/mockups/native/redesign-a-progress.html b/docs/mockups/native/redesign-a-progress.html index 37de68d..6efc8db 100644 --- a/docs/mockups/native/redesign-a-progress.html +++ b/docs/mockups/native/redesign-a-progress.html @@ -79,7 +79,7 @@
-
StrengthEngineBody
+
StrengthCardioBody
3/4SESSIONS
@@ -136,7 +136,7 @@
-
StrengthEngineBody
+
StrengthCardioBody
3/4SESSIONS
diff --git a/docs/mockups/native/redesign-a-progress.png b/docs/mockups/native/redesign-a-progress.png index 49af34e..166fdd9 100644 Binary files a/docs/mockups/native/redesign-a-progress.png and b/docs/mockups/native/redesign-a-progress.png differ diff --git a/docs/mockups/native/redesign-b-progress.html b/docs/mockups/native/redesign-b-progress.html index f1190f0..d070883 100644 --- a/docs/mockups/native/redesign-b-progress.html +++ b/docs/mockups/native/redesign-b-progress.html @@ -81,7 +81,7 @@
-
StrengthEngineBody
+
StrengthCardioBody
3/4SESSIONS
@@ -138,7 +138,7 @@
-
StrengthEngineBody
+
StrengthCardioBody
3/4SESSIONS
diff --git a/docs/mockups/native/redesign-b-progress.png b/docs/mockups/native/redesign-b-progress.png index 3de08ae..a1d393e 100644 Binary files a/docs/mockups/native/redesign-b-progress.png and b/docs/mockups/native/redesign-b-progress.png differ diff --git a/docs/mockups/native/redesign-d-progress.html b/docs/mockups/native/redesign-d-progress.html index e8534ea..aa6fbc4 100644 --- a/docs/mockups/native/redesign-d-progress.html +++ b/docs/mockups/native/redesign-d-progress.html @@ -81,7 +81,7 @@
-
StrengthEngineBody
+
StrengthCardioBody
3/4SESSIONS
@@ -138,7 +138,7 @@
-
StrengthEngineBody
+
StrengthCardioBody
3/4SESSIONS
diff --git a/docs/mockups/native/redesign-d-progress.png b/docs/mockups/native/redesign-d-progress.png index 0d06273..ff53858 100644 Binary files a/docs/mockups/native/redesign-d-progress.png and b/docs/mockups/native/redesign-d-progress.png differ diff --git a/docs/mockups/native/review-redesign.html b/docs/mockups/native/review-redesign.html index 491730a..279bf65 100644 --- a/docs/mockups/native/review-redesign.html +++ b/docs/mockups/native/review-redesign.html @@ -61,7 +61,7 @@

Making Train as good as Cook

You were right — Cook felt richer because it leads with imagery, and the workout side didn't. Here are three directions for Train, each shown in dark and light mode side by side (the whole app will respect both). The screen content is - identical in all three — today's session, meals from Cook, the engine numbers, the week — + identical in all three — today's session, meals from Cook, the cardio numbers, the week — only the personality changes. Photos are placeholders for real training imagery.

AOption A — Volt, but media-led

diff --git a/docs/mockups/native/review.html b/docs/mockups/native/review.html index a139566..c872f36 100644 --- a/docs/mockups/native/review.html +++ b/docs/mockups/native/review.html @@ -68,7 +68,7 @@

Forge goes native

01One app, four tabs

Forge stays one app — Train, Cook, Shop, and the Coach as tabs. This is Train's home: the day's session up top, and your Cook plan's meals showing up inside Train - (photos, dinner time, protein progress) because both sides share one data store.

One app, four tabs
+ (photos, dinner time, protein progress) because both sides share one data store.

One app, four tabs

02Logging a set

Mid-workout: big numbers you can read between sets, plate math done for you, and the rest timer living in the iPhone's Dynamic Island — it keeps counting even if you diff --git a/docs/mockups/native/train-decision.html b/docs/mockups/native/train-decision.html index b0cd377..17f51bf 100644 --- a/docs/mockups/native/train-decision.html +++ b/docs/mockups/native/train-decision.html @@ -121,7 +121,7 @@

Going deeper — A and B in daily use

A

Progress

Week ring, squat e1RM curve, per-lift sparklines with a plain-words status ("stalled 2 wk"), and PRs told as photographic moments.

- Option A — Progress + Option A — Progress
B

Logging a set

@@ -131,7 +131,7 @@

Going deeper — A and B in daily use

B

Progress

Same tracking story in Cook’s voice; the PR banner gets the ember duotone so even a stock photo looks like ours.

- Option B — Progress + Option B — Progress
D

Logging a set

@@ -141,7 +141,7 @@

Going deeper — A and B in daily use

D

Progress

The decided system on tracking: DIN numerals over the week ring, e1RM curve and PR banner — this is the direction we chose.

- Option D — Progress + Option D — Progress

Side by side

diff --git a/docs/mockups/native/train-today.html b/docs/mockups/native/train-today.html index 8349a34..d52786c 100644 --- a/docs/mockups/native/train-today.html +++ b/docs/mockups/native/train-today.html @@ -58,7 +58,7 @@ -
Train · Today — Void × Volt goes native: SF large title, session hero, the Cook plan's meals + macros as a shared-store strip, engine tiles. Volt stays Train's only accent.
+
Train · Today — Void × Volt goes native: SF large title, session hero, the Cook plan's meals + macros as a shared-store strip, cardio tiles. Volt stays Train's only accent.
@@ -96,7 +96,7 @@
Protein
109 / 160 g
-
EngineProgress ›
+
CardioProgress ›
ZONE 2
86 /150 min
on pace
SLEEP
7:42
▲ 22 min
diff --git a/docs/mockups/native/train-today.png b/docs/mockups/native/train-today.png index d50c427..70caf2b 100644 Binary files a/docs/mockups/native/train-today.png and b/docs/mockups/native/train-today.png differ diff --git a/docs/mockups/progress.png b/docs/mockups/progress.png index 2df0037..8a80108 100644 Binary files a/docs/mockups/progress.png and b/docs/mockups/progress.png differ diff --git a/ios/CLAUDE.md b/ios/CLAUDE.md index 4b4fce4..699ea4d 100644 --- a/ios/CLAUDE.md +++ b/ios/CLAUDE.md @@ -5,10 +5,14 @@ and `docs/ios-implementation-plan.md` (setup + phases N0–N5) before starting w carry the decisions; this file carries the day-to-day rules. Root `CLAUDE.md` still applies: it describes the server and PWA this app talks to. -**Current phase: N1 slice 2 in progress.** N0's skeleton is built and verified in the Simulator +**Current phase: N1 done, N4 slice 1 in.** N0's skeleton is built and verified in the Simulator (auth, `/api/week`, design system) but is **not signed off** — that needs an install on both -phones, which is James's step. N1.1 (Today/Plan read paths) and N1.2 (session logging with the -offline queue) are in. Next: history/records/trends, then coach, then settings. +phones, which is James's step. N1 is complete: Today/Plan, session logging with the offline +queue, history/records/trends, coach. **Cook** (N4.1) landed next, out of phase order because +its boards were already decided: browse, recipe detail, cook mode, library, meal planner, plus +the read-only meals strip on Train's Today. Still open in N4: iPad `NavigationSplitView`, cook +timers as Live Activities (needs [C1b] in the handover). N2/N3 (HealthKit, Watch, widgets) are +untouched. > **Anything requiring the Xcode UI or the Apple Developer portal lives in > [`XCODE-HANDOVER.md`](XCODE-HANDOVER.md), and is James's job — not yours.** That file is the @@ -84,7 +88,35 @@ automatically — there is no Xcode step. - **Design**: Void × Volt tokens for Train (dark and light both first-class; light = Paper × Moss), DIN Condensed for display type and large numerals, SF for body. One accent *per module*, scoped to that module's theme environment — never two accents on one screen. Warm `#e8a360` is warnings - only. Destructive actions always confirm. + only. Destructive actions always confirm. **Volt means positive status**, so a number only earns + it when it moved the way that metric should (`MetricDelta.Better`, `LiftTrend.isPositive`) — + everything else renders neutral. Most metrics have no universal good direction; don't invent one. + And a change is only worth an arrow if the formatter can print it: a fixed significance threshold + under a whole-number format rendered "▼ 0" on resting HR. +- **Cook is not a Train reskin** and never borrows from it: Paper × Ember (`ForgeTheme.cookLight`), + New York serif for display (`ForgeFont.serif*`), full-bleed photography, soft warm shadows + instead of hairlines. Volt and DIN must never appear on a Cook screen; ember and serif must + never appear on a Train one. **Cook is light-only** — `cookTheme()` pins `colorScheme` to + `.light` so system chrome matches the paper; both boards were drawn light and there is no + approved dark Cook. `RootView` tints the tab bar from the *selected* tab for the same reason — + a volt "Cook" label under a paper screen is two accents on one screen. +- **Cook mode's ember is the done-when cue and nothing else.** `CookCue` derives it from the step + prose, which works because Forge's import rule already requires done-when voice. Don't accent + anything else in the step text, and don't invent a cue for steps that have none. +- **Cook timers hold an absolute end date**, never a countdown — cook mode gets backgrounded + constantly and a decrementing counter drifts every time the view stops ticking. +- **Recipe images need the session cookie** (`/api/food/media/{id}` is authed), so `AsyncImage` + can't fetch them — use `ForgeAsyncImage`/`RecipePhoto`, which go through the client. The loader + attaches the cookie **only for our own origin**: imports keep remote URLs on failure, and a + recipe site's CDN must never be sent a Forge session token. +- **The seeded recipe library has no imagery.** `RecipePhoto` falls back to bundled plate + photography keyed by `platefig` (`PlateImage`) — the same split as Train's `HeroImage` vs + `ExerciseMedia`. Never render an empty frame where a photo belongs; in a photography-led module + that reads as breakage. +- **The meal plan is shared, the meal log is yours.** One household food week (`meal_revisions` + with `user_id IS NULL`) but per-user `meal_logs`. Any UI that plans says it changes the week for + both of you; any UI that ticks says it logs to your plate only. Never merge the two ideas into + one control. - **Auth**: `ASWebAuthenticationSession` → the server's existing Google OAuth → same session cookie the PWA uses, held in the Keychain. No new auth surface on the server — the only addition is `GET /auth/login?native=1`, which makes `/auth/callback` finish on diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/Contents.json b/ios/Forge/Forge/Assets.xcassets/Hero/Contents.json new file mode 100644 index 0000000..ae9dddc --- /dev/null +++ b/ios/Forge/Forge/Assets.xcassets/Hero/Contents.json @@ -0,0 +1,9 @@ +{ + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "provides-namespace": false + } +} diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/lift-dark.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Hero/lift-dark.imageset/Contents.json new file mode 100644 index 0000000..4da5402 --- /dev/null +++ b/ios/Forge/Forge/Assets.xcassets/Hero/lift-dark.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "filename": "lift-dark.jpg", + "idiom": "universal", + "scale": "1x" + }, + { + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/lift-dark.imageset/lift-dark.jpg b/ios/Forge/Forge/Assets.xcassets/Hero/lift-dark.imageset/lift-dark.jpg new file mode 100644 index 0000000..fb7dbf2 Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Hero/lift-dark.imageset/lift-dark.jpg differ diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/mat-warm.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Hero/mat-warm.imageset/Contents.json new file mode 100644 index 0000000..cd7012c --- /dev/null +++ b/ios/Forge/Forge/Assets.xcassets/Hero/mat-warm.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "filename": "mat-warm.jpg", + "idiom": "universal", + "scale": "1x" + }, + { + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/mat-warm.imageset/mat-warm.jpg b/ios/Forge/Forge/Assets.xcassets/Hero/mat-warm.imageset/mat-warm.jpg new file mode 100644 index 0000000..7e636aa Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Hero/mat-warm.imageset/mat-warm.jpg differ diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/press-light.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Hero/press-light.imageset/Contents.json new file mode 100644 index 0000000..afa43a1 --- /dev/null +++ b/ios/Forge/Forge/Assets.xcassets/Hero/press-light.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "filename": "press-light.jpg", + "idiom": "universal", + "scale": "1x" + }, + { + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/press-light.imageset/press-light.jpg b/ios/Forge/Forge/Assets.xcassets/Hero/press-light.imageset/press-light.jpg new file mode 100644 index 0000000..17b397c Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Hero/press-light.imageset/press-light.jpg differ diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/pullup-bw.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Hero/pullup-bw.imageset/Contents.json new file mode 100644 index 0000000..dc39b21 --- /dev/null +++ b/ios/Forge/Forge/Assets.xcassets/Hero/pullup-bw.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "filename": "pullup-bw.jpg", + "idiom": "universal", + "scale": "1x" + }, + { + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/pullup-bw.imageset/pullup-bw.jpg b/ios/Forge/Forge/Assets.xcassets/Hero/pullup-bw.imageset/pullup-bw.jpg new file mode 100644 index 0000000..b30212d Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Hero/pullup-bw.imageset/pullup-bw.jpg differ diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/runners-dawn.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Hero/runners-dawn.imageset/Contents.json new file mode 100644 index 0000000..5c8e9f5 --- /dev/null +++ b/ios/Forge/Forge/Assets.xcassets/Hero/runners-dawn.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "filename": "runners-dawn.jpg", + "idiom": "universal", + "scale": "1x" + }, + { + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/runners-dawn.imageset/runners-dawn.jpg b/ios/Forge/Forge/Assets.xcassets/Hero/runners-dawn.imageset/runners-dawn.jpg new file mode 100644 index 0000000..2cdddf4 Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Hero/runners-dawn.imageset/runners-dawn.jpg differ diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/track-start.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Hero/track-start.imageset/Contents.json new file mode 100644 index 0000000..8ff6142 --- /dev/null +++ b/ios/Forge/Forge/Assets.xcassets/Hero/track-start.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "filename": "track-start.jpg", + "idiom": "universal", + "scale": "1x" + }, + { + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/track-start.imageset/track-start.jpg b/ios/Forge/Forge/Assets.xcassets/Hero/track-start.imageset/track-start.jpg new file mode 100644 index 0000000..a7c3f55 Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Hero/track-start.imageset/track-start.jpg differ diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/Contents.json b/ios/Forge/Forge/Assets.xcassets/Plate/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/Forge/Forge/Assets.xcassets/Plate/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/bowl-tofu.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Plate/bowl-tofu.imageset/Contents.json new file mode 100644 index 0000000..70a9178 --- /dev/null +++ b/ios/Forge/Forge/Assets.xcassets/Plate/bowl-tofu.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bowl-tofu.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/bowl-tofu.imageset/bowl-tofu.jpg b/ios/Forge/Forge/Assets.xcassets/Plate/bowl-tofu.imageset/bowl-tofu.jpg new file mode 100644 index 0000000..325c6ae Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Plate/bowl-tofu.imageset/bowl-tofu.jpg differ diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/brunch.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Plate/brunch.imageset/Contents.json new file mode 100644 index 0000000..c53be03 --- /dev/null +++ b/ios/Forge/Forge/Assets.xcassets/Plate/brunch.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "brunch.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/brunch.imageset/brunch.jpg b/ios/Forge/Forge/Assets.xcassets/Plate/brunch.imageset/brunch.jpg new file mode 100644 index 0000000..df65dcb Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Plate/brunch.imageset/brunch.jpg differ diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/salmon.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Plate/salmon.imageset/Contents.json new file mode 100644 index 0000000..d26b25b --- /dev/null +++ b/ios/Forge/Forge/Assets.xcassets/Plate/salmon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "salmon.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/salmon.imageset/salmon.jpg b/ios/Forge/Forge/Assets.xcassets/Plate/salmon.imageset/salmon.jpg new file mode 100644 index 0000000..a67c764 Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Plate/salmon.imageset/salmon.jpg differ diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/skewers.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Plate/skewers.imageset/Contents.json new file mode 100644 index 0000000..8d82487 --- /dev/null +++ b/ios/Forge/Forge/Assets.xcassets/Plate/skewers.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "skewers.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/skewers.imageset/skewers.jpg b/ios/Forge/Forge/Assets.xcassets/Plate/skewers.imageset/skewers.jpg new file mode 100644 index 0000000..174a2ff Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Plate/skewers.imageset/skewers.jpg differ diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/thai-beef.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Plate/thai-beef.imageset/Contents.json new file mode 100644 index 0000000..6301502 --- /dev/null +++ b/ios/Forge/Forge/Assets.xcassets/Plate/thai-beef.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "thai-beef.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/thai-beef.imageset/thai-beef.jpg b/ios/Forge/Forge/Assets.xcassets/Plate/thai-beef.imageset/thai-beef.jpg new file mode 100644 index 0000000..1149b84 Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Plate/thai-beef.imageset/thai-beef.jpg differ diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/veg-bowl.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Plate/veg-bowl.imageset/Contents.json new file mode 100644 index 0000000..6cb7612 --- /dev/null +++ b/ios/Forge/Forge/Assets.xcassets/Plate/veg-bowl.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "veg-bowl.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/veg-bowl.imageset/veg-bowl.jpg b/ios/Forge/Forge/Assets.xcassets/Plate/veg-bowl.imageset/veg-bowl.jpg new file mode 100644 index 0000000..cdb4966 Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Plate/veg-bowl.imageset/veg-bowl.jpg differ diff --git a/ios/Forge/Forge/Design/CookComponents.swift b/ios/Forge/Forge/Design/CookComponents.swift new file mode 100644 index 0000000..9599062 --- /dev/null +++ b/ios/Forge/Forge/Design/CookComponents.swift @@ -0,0 +1,201 @@ +import SwiftUI + +/// Cook's own vocabulary. Deliberately **not** a Train reskin +/// (`docs/native-app-design.md`): serif heads instead of DIN caps, soft warm +/// shadows instead of hairlines, full-bleed photography instead of stat tiles. +/// The shared primitives (`PillButtonStyle`, `ForgeProgress`) carry over +/// unchanged because they read the theme's accent — which here is ember. + +// MARK: - Section head + +/// Serif title with an ember "more ›" — the board's `.sec` row. +struct CookSection: View { + @Environment(\.theme) private var theme + let title: String + var more: String? + @ViewBuilder var destination: Destination + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(ForgeFont.serifSection) + .foregroundStyle(theme.ink) + Spacer(minLength: 8) + if let more { + NavigationLink { + destination + } label: { + Text("\(more) ›") + .font(.system(.subheadline, weight: .semibold)) + .foregroundStyle(theme.accent) + } + } + } + } +} + +extension CookSection where Destination == EmptyView { + init(_ title: String) { + self.init(title: title, more: nil) { EmptyView() } + } +} + +// MARK: - Hero + +/// Tonight's dinner: full-bleed photo, scrim from the bottom, a pill top-left, +/// serif name and glass chips over the image. +/// +/// Cook keeps text **on** the photo in both roles, unlike Train's light mode — +/// the board scrims heavily enough that it holds, and stacking would lose the +/// magazine cover the module is built around. +struct CookHero: View { + @Environment(\.theme) private var theme + let recipe: RecipeCard + let tag: String + let title: String + let chips: [String] + var height: CGFloat = 295 + + var body: some View { + // `Color.clear` owns the layout so a `.fill`-scaled photo can't report + // its intrinsic width up the tree and blow the card past the screen. + Color.clear + .frame(maxWidth: .infinity) + .frame(height: height) + .overlay { RecipePhoto(recipe: recipe) } + .overlay { + LinearGradient( + colors: [.black.opacity(0.28), .clear, .clear, .black.opacity(0.74)], + startPoint: .top, endPoint: .bottom) + } + .overlay(alignment: .topLeading) { + Text(tag) + .font(.system(.caption, weight: .bold)) + .tracking(1.1) + .foregroundStyle(theme.ink) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .background(theme.bg.opacity(0.92), in: .capsule) + .padding(14) + } + .overlay(alignment: .bottomLeading) { + VStack(alignment: .leading, spacing: 9) { + Text(title) + .font(ForgeFont.serifTitle) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.35), radius: 18, y: 2) + .fixedSize(horizontal: false, vertical: true) + if !chips.isEmpty { + HStack(spacing: 7) { + ForEach(chips, id: \.self) { GlassChip(text: $0) } + } + } + } + .padding(18) + } + .clipShape(.rect(cornerRadius: 26)) + .shadow(color: Color(hex: 0x3C2814, alpha: 0.18), radius: 20, y: 12) + } +} + +/// Translucent chip for use over photography. +struct GlassChip: View { + let text: String + var body: some View { + Text(text) + .font(.system(.caption, weight: .semibold)) + .foregroundStyle(.white) + .padding(.vertical, 5) + .padding(.horizontal, 11) + .background(.ultraThinMaterial.opacity(0.9), in: .capsule) + } +} + +// MARK: - Recipe tile + +/// The library grid cell: photo over a serif name and a quiet meta line. +struct RecipeTile: View { + @Environment(\.theme) private var theme + let recipe: RecipeCard + var height: CGFloat = 108 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Color.clear + .frame(maxWidth: .infinity) + .frame(height: height) + .overlay { RecipePhoto(recipe: recipe) } + .clipped() + VStack(alignment: .leading, spacing: 4) { + Text(recipe.name) + .font(ForgeFont.serif(14.5, relativeTo: .subheadline)) + .foregroundStyle(theme.ink) + .multilineTextAlignment(.leading) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 6) { + Text(recipe.meta) + .font(.system(.caption2)) + .foregroundStyle(theme.mut) + if recipe.isParked { + Text("PARKED") + .font(.system(.caption2, weight: .bold)) + .foregroundStyle(theme.warn) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.top, 9) + .padding(.bottom, 11) + } + .background(theme.raised) + .clipShape(.rect(cornerRadius: 18)) + .shadow(color: Color(hex: 0x3C2814, alpha: 0.10), radius: 12, y: 6) + } +} + +// MARK: - Ingredient chip + +/// "salmon **2 fillets**" — the quantity is the emphasis, the name is the label. +struct IngredientChip: View { + @Environment(\.theme) private var theme + let name: String + let quantity: String + + var body: some View { + HStack(spacing: 5) { + Text(name) + .foregroundStyle(theme.ink) + if !quantity.isEmpty { + Text(quantity) + .fontWeight(.semibold) + .foregroundStyle(theme.mut) + } + } + .font(.system(.subheadline)) + .padding(.vertical, 7) + .padding(.horizontal, 13) + .background(theme.sunken, in: .capsule) + } +} + +// MARK: - Progress segments + +/// Cook mode's foot rule — one segment per step, filled up to the current one. +struct StepSegments: View { + @Environment(\.theme) private var theme + let total: Int + let current: Int + + var body: some View { + HStack(spacing: 5) { + ForEach(0.. HeroImage { + let haystack = [mainLiftSlug, dayName].compactMap { $0 }.joined(separator: " ").lowercased() + if haystack.contains(/pull|row|chin|lat|deadlift/) { return .pull } + if haystack.contains(/press|bench|push|upper|shoulder/) { return .press } + return .lower + } + + static func forCardio(type: String?, name: String?) -> HeroImage { + let haystack = [type, name].compactMap { $0 }.joined(separator: " ").lowercased() + return haystack.contains(/interval|vo2|sprint|threshold/) ? .intervals : .run + } +} + +extension Image { + init(hero: HeroImage) { + self.init(hero.assetName) + } +} diff --git a/ios/Forge/Forge/Design/Media.swift b/ios/Forge/Forge/Design/Media.swift new file mode 100644 index 0000000..2a5f141 --- /dev/null +++ b/ios/Forge/Forge/Design/Media.swift @@ -0,0 +1,86 @@ +import SwiftUI + +/// Media is the design decision, not decoration — "Cook read as far cleaner and +/// richer than Train; media was the difference" (mockup round feedback, Jul 2026). +/// Session heroes, the Zone-2 tile and PR banners are photographic. +/// +/// Source is the server's own curated exercise library (`/media/exercises/…`, +/// free-exercise-db photos already in the repo), keyed by the day's main lift — +/// so the picture is of the thing you're actually about to do. +nonisolated enum ExerciseMedia { + static func url(base: URL, slug: String, variant: Int = 0) -> URL { + base.appendingPathComponent("media/exercises/\(slug)-\(variant).jpg") + } +} + +/// A photo with the treatment the design requires: in **dark** the image is the +/// card and text sits on a scrim over it; in **light** the photo stacks *above* +/// the text, never behind it. That rule came straight out of the redesign round +/// — text over a bright image was the thing being rejected. +struct MediaCard: View { + @Environment(\.theme) private var theme + @Environment(\.colorScheme) private var scheme + + /// Bundled editorial photography. Preferred — it loads instantly and can't + /// fail, which matters for the first thing on the first screen. + var hero: HeroImage? + /// Remote fallback (the server's exercise library). + var url: URL? + var height: CGFloat = 190 + var cornerRadius: CGFloat = 22 + @ViewBuilder let content: Content + + var body: some View { + Group { + if scheme == .dark { + ZStack(alignment: .bottom) { + photo(height: height + 130) + LinearGradient( + colors: [.black.opacity(0), .black.opacity(0.55), .black.opacity(0.9)], + startPoint: .top, endPoint: .bottom + ) + content + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: height + 130) + } else { + VStack(spacing: 0) { + photo(height: height) + content + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(theme.raised) + } + } + } + .clipShape(.rect(cornerRadius: cornerRadius)) + } + + /// `Color.clear` owns the layout and the image is an overlay, so a + /// `.fill`-scaled photo can never report its own (huge) intrinsic width up + /// the tree and blow the card past the screen edge. + private func photo(height: CGFloat) -> some View { + Color.clear + .frame(maxWidth: .infinity) + .frame(height: height) + .overlay { + if let hero { + Image(hero: hero).resizable().scaledToFill() + } else { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + default: + // Never a broken frame: the void reads as + // intentional while the photo loads, and as the + // design when there isn't one. + theme.sunken + } + } + } + } + .clipped() + } +} diff --git a/ios/Forge/Forge/Design/PlateImage.swift b/ios/Forge/Forge/Design/PlateImage.swift new file mode 100644 index 0000000..ef12ba2 --- /dev/null +++ b/ios/Forge/Forge/Design/PlateImage.swift @@ -0,0 +1,77 @@ +import SwiftUI + +/// Bundled food photography, keyed by the recipe's `platefig`. +/// +/// Cook is photography-led — that is the module, not a garnish. But the seeded +/// library (`server/app/food_seed.py`) carries **no images**: only MCP imports +/// materialize a hero into `media_blobs`. Without a fallback every hero, tile +/// and thumbnail on a fresh install is a blank rectangle, which is the exact +/// failure the mockup round was called to fix on Train. +/// +/// So the same split as Train: a bundled set that always renders, and the +/// server's own imagery preferred over it whenever an import supplied one. +/// +/// NOTE: the set is the mockups' (`docs/mockups/native/photos/`) — Unsplash, +/// which that README calls placeholders. Fine for two-seat TestFlight; re-license +/// before any public listing. Same caveat as `HeroImage`. +nonisolated enum PlateImage { + case fish, bowl, greens, tray, stew, morning + + var assetName: String { + switch self { + case .fish: "salmon" + case .bowl: "bowl-tofu" + case .greens: "veg-bowl" + case .tray: "skewers" + case .stew: "thai-beef" + case .morning: "brunch" + } + } + + /// `platefig` is the recipe's plate-art composition id — already a coarse + /// description of what the dish looks like, so it's the right key. Kind is + /// the fallback, because a breakfast that fell through should still look + /// like breakfast. + static func forRecipe(platefig: String, kind: String) -> PlateImage { + switch platefig { + case "plate-salmon", "bowl-soba": .fish + case "bowl-grain", "bowl-pasta": .bowl + case "plate-salad": .greens + case "tray-chicken", "pan-skillet", "plate-chicken": .tray + case "bowl-stew", "bowl-chili": .stew + case "bowl-oats", "plate-eggs": .morning + default: + switch kind { + case "breakfast", "snack": .morning + case "lunch": .greens + default: .stew + } + } + } +} + +extension Image { + init(plate: PlateImage) { + self.init(plate.assetName) + } +} + +/// A recipe's photo: the server's own image when an import supplied one, +/// otherwise the bundled plate for its composition. Never an empty frame. +struct RecipePhoto: View { + let recipe: RecipeCard + + var body: some View { + if let ref = recipe.image, !ref.isEmpty { + ForgeAsyncImage(ref: ref) { + Image(plate: fallback).resizable().scaledToFill() + } + } else { + Image(plate: fallback).resizable().scaledToFill() + } + } + + private var fallback: PlateImage { + .forRecipe(platefig: recipe.platefig, kind: recipe.kind) + } +} diff --git a/ios/Forge/Forge/Design/Theme.swift b/ios/Forge/Forge/Design/Theme.swift index 309f3fe..a381b46 100644 --- a/ios/Forge/Forge/Design/Theme.swift +++ b/ios/Forge/Forge/Design/Theme.swift @@ -74,6 +74,35 @@ extension ForgeTheme { static func train(_ scheme: ColorScheme) -> ForgeTheme { scheme == .dark ? .trainDark : .trainLight } + + /// Paper × Ember — Cook. "The other Forge": paper-light, serif, + /// photography-led, and never volt. Tokens are the decided board's + /// (`docs/mockups/native/cook-browse.html`). + /// + /// Cook has **no dark variant** — the module identity in + /// `docs/native-app-design.md` is "light, airy, photography-led" and both + /// boards were drawn on paper, so `cookTheme()` pins the subtree to light + /// rather than inventing a scheme the design round never approved. + static let cookLight = ForgeTheme( + bg: Color(hex: 0xFAF8F4), + raised: Color(hex: 0xFFFFFF), + sunken: Color(hex: 0xEFECE6), + hair: Color(hex: 0xE7E3DC), + hairIn: Color(hex: 0xDCD7CE), + ink: Color(hex: 0x221F1A), + mut: Color(hex: 0x8A8377), + dim: Color(hex: 0xB9B2A6), + accent: Color(hex: 0xC05B3C), + accentDeep: Color(hex: 0xA44A2F), + onAccent: Color(hex: 0xFFF8F4), + accentDim: Color(hex: 0xC05B3C, alpha: 0.10), + // Ember already owns warm, so Cook's warning is the ochre from the + // light Train palette — far enough from #C05B3C to read as "careful", + // and not a new hex invented outside the token block. + warn: Color(hex: 0xA3641A), + onWarn: Color(hex: 0xFFFFFF), + warnDim: Color(hex: 0xA3641A, alpha: 0.35) + ) } // MARK: - Environment @@ -93,6 +122,15 @@ extension View { /// Scope a module's theme to a subtree. Reads the ambient colour scheme so /// dark and light both resolve without the caller thinking about it. func trainTheme() -> some View { modifier(TrainThemeModifier()) } + + /// Cook's theme. Pins the subtree to light so system chrome inside it + /// (fields, sheets, the keyboard) matches the paper rather than sitting + /// dark on it — Cook is light by design, not by ambience. + func cookTheme() -> some View { + environment(\.theme, .cookLight) + .environment(\.colorScheme, .light) + .tint(ForgeTheme.cookLight.accent) + } } private struct TrainThemeModifier: ViewModifier { diff --git a/ios/Forge/Forge/Design/Typography.swift b/ios/Forge/Forge/Design/Typography.swift index b6ad076..ea3d6f2 100644 --- a/ios/Forge/Forge/Design/Typography.swift +++ b/ios/Forge/Forge/Design/Typography.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit /// Two voices, held deliberately: DIN Condensed (iOS built-in, single ~bold /// weight, upright caps) for display type and large numerals; SF for all body @@ -27,6 +28,28 @@ enum ForgeFont { .system(style, design: .default, weight: weight).monospacedDigit() } + // MARK: - Cook's voice + + /// New York (the system serif) — recipe titles, section heads, and cook + /// mode's arm's-length step text. The second and last display voice; never + /// used in Train, exactly as DIN is never used in Cook. + /// + /// Sized explicitly to the board but scaled through `UIFontMetrics` so it + /// answers Dynamic Type the way `display(_:relativeTo:)` does — cook mode is + /// read from across a worktop, so accessibility sizes are the point. + static func serif(_ size: CGFloat, relativeTo style: Font.TextStyle = .largeTitle, + weight: Font.Weight = .semibold) -> Font { + .system(size: UIFontMetrics(forTextStyle: style.uiKit).scaledValue(for: size), + weight: weight, design: .serif) + } + + /// Screen titles ("Cook"), recipe names on a hero. + static var serifHero: Font { serif(40, relativeTo: .largeTitle) } + static var serifTitle: Font { serif(27, relativeTo: .title) } + static var serifSection: Font { serif(19, relativeTo: .title3) } + /// Cook mode's step body — the only place body copy is serif. + static var serifStep: Font { serif(27, relativeTo: .title2, weight: .medium) } + /// Small all-caps label with tracking. Pair with `.tracking()` at the call /// site via `ForgeLabel` rather than re-deriving the spacing each time. static var label: Font { .system(.caption, design: .default, weight: .semibold) } @@ -57,3 +80,23 @@ extension View { self.font(font) } } + +extension Font.TextStyle { + /// SwiftUI's text styles carry no metrics of their own — `UIFontMetrics` + /// is the only way to scale an explicit point size, and it speaks UIKit. + var uiKit: UIFont.TextStyle { + switch self { + case .largeTitle: .largeTitle + case .title: .title1 + case .title2: .title2 + case .title3: .title3 + case .headline: .headline + case .subheadline: .subheadline + case .callout: .callout + case .footnote: .footnote + case .caption: .caption1 + case .caption2: .caption2 + default: .body + } + } +} diff --git a/ios/Forge/Forge/Features/Coach/CoachController.swift b/ios/Forge/Forge/Features/Coach/CoachController.swift new file mode 100644 index 0000000..4f5f2da --- /dev/null +++ b/ios/Forge/Forge/Features/Coach/CoachController.swift @@ -0,0 +1,120 @@ +import Foundation +import Observation + +/// Chat with the coach, plus whatever proposal is waiting. +/// +/// `POST /api/chat` returns immediately and runs the coach turn on a server +/// thread, so the client polls `GET /api/chat` until `pending` clears rather +/// than holding a request open across a model call that can take a while. +@MainActor +@Observable +final class CoachController { + private(set) var messages: [ChatMessage] = [] + private(set) var pending = false + private(set) var proposal: Proposal? + private(set) var errorMessage: String? + private(set) var isLoading = true + private(set) var isActing = false + + private let client: APIClient + private var pollTask: Task? + + /// Slow enough not to hammer a self-hosted box, quick enough that a reply + /// doesn't feel stuck. + private let pollInterval: Duration = .seconds(2) + /// A coach turn that hasn't landed in three minutes has failed in a way the + /// poll can't see; stop rather than spin forever. + private let pollLimit = 90 + + init(client: APIClient) { + self.client = client + } + + func loadIfNeeded() async { + guard messages.isEmpty else { return } + await refresh() + } + + func refresh() async { + defer { isLoading = false } + async let chat = try? client.chat() + async let proposalResponse = try? client.proposal() + if let chat = await chat { + messages = chat.messages + pending = chat.pending + if pending { startPolling() } + } + proposal = await proposalResponse?.proposal + } + + func send(_ text: String, context: ChatContext? = nil) async { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !pending else { return } + errorMessage = nil + + // Echo locally so the bubble appears the instant it's sent; the poll + // replaces the list with the server's copy, which carries the context + // tag the server appends. + messages.append(ChatMessage(who: "me", text: trimmed, at: nil)) + pending = true + do { + try await client.sendChat(trimmed, context: context) + startPolling() + } catch { + pending = false + messages.removeLast() + errorMessage = error.localizedDescription + } + } + + private func startPolling() { + pollTask?.cancel() + pollTask = Task { [weak self] in + guard let self else { return } + for _ in 0.. Void) async { + isActing = true + defer { isActing = false } + do { + try await body() + proposal = nil + } catch { + errorMessage = error.localizedDescription + } + } + + func dismissError() { errorMessage = nil } +} diff --git a/ios/Forge/Forge/Features/Coach/CoachView.swift b/ios/Forge/Forge/Features/Coach/CoachView.swift new file mode 100644 index 0000000..4e5c733 --- /dev/null +++ b/ios/Forge/Forge/Features/Coach/CoachView.swift @@ -0,0 +1,204 @@ +import SwiftUI + +/// The coach: a chat surface, with the waiting proposal pinned above it. +struct CoachView: View { + @Environment(\.theme) private var theme + @Environment(AuthController.self) private var auth + + @State private var controller: CoachController? + @State private var draft = "" + @FocusState private var inputFocused: Bool + + var body: some View { + NavigationStack { + Group { + if let controller { + content(controller) + } else { + ForgeProgress() + } + } + .background(theme.bg) + .navigationTitle("Coach") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(for: Proposal.self) { proposal in + ProposalView(proposal: proposal, controller: controller) + .trainTheme() + } + } + .task { + if controller == nil { controller = CoachController(client: auth.client) } + await controller?.loadIfNeeded() + } + .onDisappear { controller?.stopPolling() } + } + + @ViewBuilder + private func content(_ controller: CoachController) -> some View { + VStack(spacing: 0) { + if controller.isLoading, controller.messages.isEmpty { + ForgeProgress() + } else { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if let proposal = controller.proposal { + proposalBanner(proposal) + } + if controller.messages.isEmpty { + Text("Ask about your plan, a lift, or how the week went.") + .font(.callout) + .foregroundStyle(theme.dim) + .padding(.top, 40) + .frame(maxWidth: .infinity, alignment: .center) + } + ForEach(controller.messages) { message in + MessageBubble(message: message) + .id(message.id) + } + if controller.pending { + TypingIndicator() + .id("typing") + } + } + .padding(.horizontal, 18) + .padding(.bottom, 12) + } + .scrollEdgeEffectStyle(.soft, for: .top) + .onChange(of: controller.messages.count) { + withAnimation { proxy.scrollTo(scrollAnchor(controller), anchor: .bottom) } + } + .onChange(of: controller.pending) { + withAnimation { proxy.scrollTo(scrollAnchor(controller), anchor: .bottom) } + } + } + } + + if let message = controller.errorMessage { + Text(message) + .font(.caption) + .foregroundStyle(theme.warn) + .padding(.horizontal, 18) + .padding(.bottom, 4) + } + + composer(controller) + } + } + + private func scrollAnchor(_ controller: CoachController) -> String { + controller.pending ? "typing" : (controller.messages.last?.id ?? "") + } + + private func proposalBanner(_ proposal: Proposal) -> some View { + NavigationLink(value: proposal) { + ForgeCard { + HStack { + VStack(alignment: .leading, spacing: 4) { + ForgeLabel("Proposal waiting", color: theme.accent) + Text("Revision \(proposal.num)") + .font(ForgeFont.subtitle) + .foregroundStyle(theme.ink) + Text(proposal.rationale) + .font(.footnote) + .foregroundStyle(theme.mut) + .lineLimit(2) + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.footnote) + .foregroundStyle(theme.dim) + } + } + } + .buttonStyle(.plain) + .padding(.top, 8) + } + + private func composer(_ controller: CoachController) -> some View { + HStack(spacing: 10) { + TextField("Ask the coach…", text: $draft, axis: .vertical) + .lineLimit(1...4) + .focused($inputFocused) + .padding(.vertical, 10) + .padding(.horizontal, 14) + .background(theme.raised, in: .capsule) + + Button { + let text = draft + draft = "" + Task { await controller.send(text) } + } label: { + Image(systemName: "arrow.up") + .font(.system(.body, weight: .bold)) + .foregroundStyle(theme.onAccent) + .frame(width: 40, height: 40) + .background(theme.accent, in: .circle) + } + .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || controller.pending) + .opacity(controller.pending ? 0.5 : 1) + } + .padding(.horizontal, 18) + .padding(.vertical, 10) + .background(.bar) + } +} + +// MARK: - Bubbles + +private struct MessageBubble: View { + @Environment(\.theme) private var theme + let message: ChatMessage + + var body: some View { + HStack { + if !message.isCoach { Spacer(minLength: 40) } + Group { + if message.isCoach { + // Coach replies are markdown by design (the system prompt + // asks for structure), so render rather than show syntax. + Text(.init(message.text)) + .font(.callout) + .foregroundStyle(theme.ink) + } else { + Text(message.text) + .font(.callout) + .foregroundStyle(theme.onAccent) + } + } + .padding(.vertical, 10) + .padding(.horizontal, 14) + .background(message.isCoach ? theme.raised : theme.accent, + in: .rect(cornerRadius: 16)) + if message.isCoach { Spacer(minLength: 40) } + } + } +} + +private struct TypingIndicator: View { + @Environment(\.theme) private var theme + @State private var phase = 0.0 + + var body: some View { + HStack(spacing: 5) { + ForEach(0..<3) { index in + Circle() + .fill(theme.dim) + .frame(width: 6, height: 6) + .opacity(0.35 + 0.65 * abs(sin(phase + Double(index) * 0.6))) + } + } + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background(theme.raised, in: .capsule) + .task { + // A plain repeating animation would restart on every re-render + // while polling; driving a phase keeps it smooth. + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(90)) + phase += 0.25 + } + } + } +} diff --git a/ios/Forge/Forge/Features/Coach/ProposalView.swift b/ios/Forge/Forge/Features/Coach/ProposalView.swift new file mode 100644 index 0000000..6556cfb --- /dev/null +++ b/ios/Forge/Forge/Features/Coach/ProposalView.swift @@ -0,0 +1,153 @@ +import SwiftUI + +/// Review a proposed plan revision and approve or reject it. +/// +/// The server guarantees a `changes` delta list and a per-day `why` — the UI is +/// required to render them, so the user is approving a described change rather +/// than a diff they have to work out themselves. +struct ProposalView: View { + @Environment(\.theme) private var theme + @Environment(\.dismiss) private var dismiss + + let proposal: Proposal + let controller: CoachController? + + @State private var confirmReject = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + ForgeLabel("Revision \(proposal.num)") + Text(proposal.rationale) + .font(.callout) + .foregroundStyle(theme.mut) + } + .padding(.top, 4) + + if let changes = proposal.content.changes, !changes.isEmpty { + SectionHeader(title: "What changes") + ForgeCard { + VStack(alignment: .leading, spacing: 12) { + ForEach(changes) { change in + HStack(alignment: .top, spacing: 10) { + Text(change.sign) + .font(ForgeFont.numeric(.headline, weight: .bold)) + .foregroundStyle(colour(for: change.sign)) + .frame(width: 16) + VStack(alignment: .leading, spacing: 2) { + Text(change.what) + .font(.system(.subheadline, weight: .medium)) + .foregroundStyle(theme.ink) + if let why = change.why { + Text(why) + .font(.caption) + .foregroundStyle(theme.dim) + } + } + } + } + } + } + } + + SectionHeader(title: "The week") + ForEach(proposal.orderedDays, id: \.weekday) { entry in + dayCard(weekday: entry.weekday, day: entry.day) + } + } + .padding(.horizontal, 18) + .padding(.bottom, 40) + } + .background(theme.bg) + .navigationTitle("Proposal") + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { actions } + .confirmationDialog("Reject this proposal?", isPresented: $confirmReject, + titleVisibility: .visible) { + Button("Reject", role: .destructive) { + Task { + await controller?.reject() + dismiss() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Your current plan stays as it is.") + } + } + + private func dayCard(weekday: Int, day: ProposalDay) -> some View { + ForgeCard { + VStack(alignment: .leading, spacing: 8) { + HStack { + ForgeLabel(weekdayNames[safe: weekday] ?? "Day \(weekday + 1)") + Spacer() + ForgeChip(text: day.kind.rawValue, accented: day.kind == .strength) + } + Text(day.name) + .font(ForgeFont.subtitle) + .foregroundStyle(theme.ink) + if let why = day.why, !why.isEmpty { + Text(why) + .font(.footnote) + .foregroundStyle(theme.mut) + } + if let exercises = day.exercises, !exercises.isEmpty { + VStack(alignment: .leading, spacing: 3) { + ForEach(exercises) { exercise in + Text("\(exercise.slug) — \(exercise.sets) × \(exercise.reps)") + .font(.caption) + .foregroundStyle(theme.dim) + } + } + .padding(.top, 2) + } + if let cardio = day.cardio, let minutes = cardio.minutes { + Text("\(minutes) min" + + (cardio.hrLow.map { low in + cardio.hrHigh.map { " · \(low)–\($0) bpm" } ?? "" + } ?? "")) + .font(.caption) + .foregroundStyle(theme.dim) + } + } + } + } + + private var actions: some View { + VStack(spacing: 8) { + Button("Approve — make this my plan") { + Task { + await controller?.approve() + dismiss() + } + } + .buttonStyle(.pill) + + Button("Reject") { confirmReject = true } + .font(.system(.subheadline, weight: .semibold)) + .foregroundStyle(theme.mut) + } + .disabled(controller?.isActing ?? false) + .padding(.horizontal, 18) + .padding(.vertical, 12) + .background(.bar) + } + + /// Added / removed / adjusted. Warm is reserved for warnings, so removals + /// stay muted rather than becoming a second accent. + private func colour(for sign: String) -> Color { + switch sign { + case "+": theme.accent + case "-": theme.dim + default: theme.mut + } + } +} + +extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/ios/Forge/Forge/Features/Cook/CookModeView.swift b/ios/Forge/Forge/Features/Cook/CookModeView.swift new file mode 100644 index 0000000..62640a9 --- /dev/null +++ b/ios/Forge/Forge/Features/Cook/CookModeView.swift @@ -0,0 +1,370 @@ +import SwiftUI +import UIKit + +/// **Cook mode**, built to `docs/mockups/native/cook-mode.png`: arm's-length +/// serif type, one step at a time, the done-when cue in ember, a timer card +/// with a ring, ingredient chips for this step, what's coming next, and a foot +/// rule of segments over Back / next. +/// +/// The screen stays awake — you're reading it with wet hands from a metre away. +/// The timers are local (same as the rest timer in Train); the Dynamic Island +/// Live Activity the board shows needs a widget extension target, which is +/// Xcode-side work in `ios/XCODE-HANDOVER.md`. +struct CookModeView: View { + @Environment(\.theme) private var theme + @Environment(\.dismiss) private var dismiss + @Environment(AuthController.self) private var auth + + let recipe: RecipeDetail + + @State private var index = 0 + @State private var timers = CookTimers() + @State private var plated = false + @State private var startedAt = Date() + @State private var logError: String? + + private var steps: [RecipeStep] { recipe.steps } + private var step: RecipeStep { steps[min(index, steps.count - 1)] } + private var isLast: Bool { index >= steps.count - 1 } + + var body: some View { + Group { + if plated { + PlatedView(recipe: recipe, elapsed: Date().timeIntervalSince(startedAt), + error: logError) { dismiss() } + } else { + cooking + } + } + .background(theme.bg) + .onAppear { + UIApplication.shared.isIdleTimerDisabled = true + timers.onDone = { chime(step: $0) } + } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + timers.stop() + } + } + + // MARK: - Cooking + + private var cooking: some View { + VStack(alignment: .leading, spacing: 0) { + header + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForgeLabel("Step \(index + 1) of \(steps.count)", color: theme.accent) + .padding(.horizontal, 24) + .padding(.top, 22) + + CueText(step.detail, font: ForgeFont.serifStep) + .padding(.horizontal, 24) + .padding(.top, 10) + + if step.hasTimer { timerCard.padding(.horizontal, 24).padding(.top, 24) } + if !runningElsewhere.isEmpty { backgroundStrip.padding(.top, 18) } + if !stepIngredients.isEmpty { + FlowChips(items: stepIngredients.map { ($0.name, $0.quantity) }) + .padding(.horizontal, 24) + .padding(.top, 20) + } + if !isLast { upNext.padding(.horizontal, 24).padding(.top, 22) } + } + .padding(.bottom, 24) + } + foot + } + } + + private var header: some View { + HStack { + Text(recipe.name) + .font(.system(.subheadline)) + .foregroundStyle(theme.mut) + .lineLimit(1) + Spacer(minLength: 12) + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(.subheadline, weight: .medium)) + .foregroundStyle(theme.mut) + .frame(width: 32, height: 32) + .background(theme.sunken, in: .circle) + } + } + .padding(.horizontal, 20) + .padding(.top, 12) + } + + // MARK: - Timer + + private var timerCard: some View { + let remaining = timers.remaining(step: index) + let total = step.seconds + return HStack(spacing: 16) { + TimerRing(fraction: remaining == nil ? 1 : timers.fraction(step: index), + label: fmtClock(remaining ?? total)) + VStack(alignment: .leading, spacing: 2) { + ForgeLabel("Timer", color: theme.mut) + Text(step.title) + .font(.system(.body, weight: .semibold)) + .foregroundStyle(theme.ink) + Text(timerCaption(remaining: remaining, total: total)) + .font(.system(.subheadline)) + .foregroundStyle(theme.mut) + } + Spacer(minLength: 8) + Button { + if remaining == nil { + timers.start(step: index, seconds: total) + } else { + timers.cancel(step: index) + } + } label: { + Image(systemName: remaining == nil ? "play.fill" : "stop.fill") + .font(.system(.subheadline, weight: .bold)) + .foregroundStyle(theme.accent) + .frame(width: 44, height: 44) + .background(theme.accentDim, in: .circle) + } + } + .padding(.vertical, 16) + .padding(.horizontal, 18) + .background(theme.raised, in: .rect(cornerRadius: 24)) + .shadow(color: Color(hex: 0x3C2814, alpha: 0.12), radius: 16, y: 8) + } + + private func timerCaption(remaining: Int?, total: Int) -> String { + guard let remaining else { return "\(total / 60) min · tap to start" } + if remaining == 0 { return "time's up" } + return "of \(fmtClock(total)) · nudges at 0:00" + } + + /// Timers still running on *other* steps, so a background simmer stays + /// visible while you work on the next thing. + private var runningElsewhere: [(index: Int, timer: CookTimers.Running)] { + timers.running.filter { $0.index != index } + } + + private var backgroundStrip: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(runningElsewhere, id: \.index) { entry in + Button { index = entry.index } label: { + HStack(spacing: 8) { + Text("\(entry.index + 1). \(steps[entry.index].title)") + .foregroundStyle(theme.mut) + Text(entry.timer.remaining(at: timers.now) == 0 + ? "done" + : fmtClock(entry.timer.remaining(at: timers.now))) + .font(ForgeFont.numeric(.subheadline)) + .foregroundStyle(theme.accent) + } + .font(.system(.subheadline)) + .padding(.vertical, 8) + .padding(.horizontal, 13) + .background(theme.raised, in: .capsule) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 24) + } + } + + // MARK: - Step context + + /// The ingredients this step actually names. Cook mode shows what's in your + /// hands, not the whole shopping list. + private var stepIngredients: [RecipeIngredient] { + let haystack = "\(step.title) \(step.detail)".lowercased() + return recipe.ingredients.filter { ingredient in + let name = ingredient.name.lowercased() + if haystack.contains(name) { return true } + // "salmon fillets" in the pantry, "salmon" in the prose. + return name.split(separator: " ").first.map { haystack.contains($0) } ?? false + } + } + + private var upNext: some View { + VStack(alignment: .leading, spacing: 5) { + ForgeLabel("Up next", color: theme.mut) + Text("\(index + 2) · \(steps[index + 1].detail)") + .font(ForgeFont.serif(16, relativeTo: .body, weight: .regular)) + .foregroundStyle(theme.mut) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 16) + .overlay(alignment: .top) { Rectangle().fill(theme.hair).frame(height: 1) } + } + + // MARK: - Foot + + private var foot: some View { + VStack(spacing: 14) { + StepSegments(total: steps.count, current: index) + HStack(spacing: 10) { + if index > 0 { + Button("‹ Back") { withAnimation(.easeOut(duration: 0.18)) { index -= 1 } } + .buttonStyle(.pillQuiet) + // The board's 1:2 split — Back is an escape hatch, not + // an equal choice, and an even split starves the label. + .containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 10) + } + Button { + if isLast { Task { await plate() } } + else { withAnimation(.easeOut(duration: 0.18)) { index += 1 } } + } label: { + Text(nextLabel) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + .buttonStyle(.pill) + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 24) + .padding(.top, 12) + .padding(.bottom, 34) + .background(theme.bg) + } + + /// The board's CTA is the cue restated as a confirmation — "Skin's released + /// — next". That's a *grammatical* transform of the prose ("ready when the + /// skin releases" → "skin's released"), and deriving it produces nonsense + /// as often as not ("Toss until slicked — next"). So the label is the next + /// step's title, which is short and always true; an authored `cue_done` + /// field on the step would let this match the board properly. + private var nextLabel: String { + isLast ? "Plate & log \(recipe.card.kind)" : "\(steps[index + 1].title) ›" + } + + // MARK: - Finish + + private func plate() async { + do { + try await auth.client.logMeal( + date: ForgeDate.today(), + slot: recipe.card.kind, + recipe: recipe.slug, + clientId: UUID().uuidString) + } catch { + // Never block the plate on the network — the meal is cooked either + // way; the day view can tick it later. + logError = error.localizedDescription + } + plated = true + } + + private func chime(step: Int) { + UINotificationFeedbackGenerator().notificationOccurred(.success) + } +} + +// MARK: - Ring + +private struct TimerRing: View { + @Environment(\.theme) private var theme + let fraction: Double + let label: String + + var body: some View { + ZStack { + Circle().stroke(theme.sunken, lineWidth: 7) + Circle() + .trim(from: 0, to: max(0.001, min(1, fraction))) + .stroke(theme.accent, style: StrokeStyle(lineWidth: 7, lineCap: .round)) + .rotationEffect(.degrees(-90)) + Text(label) + .font(ForgeFont.numeric(.body, weight: .bold)) + .foregroundStyle(theme.ink) + } + .frame(width: 74, height: 74) + .animation(.linear(duration: 0.5), value: fraction) + } +} + +// MARK: - Cue text + +/// Step prose with its done-when clause in ember — the one thing cook mode +/// accents. See `CookCue` for why the cue is derived rather than authored. +struct CueText: View { + @Environment(\.theme) private var theme + private let detail: String + private let font: Font + private let muted: Bool + + init(_ detail: String, font: Font, muted: Bool = false) { + self.detail = detail + self.font = font + self.muted = muted + } + + var body: some View { + let parts = CookCue.split(detail) + return (Text(parts.before).foregroundStyle(muted ? theme.mut : theme.ink) + + Text(parts.cue).foregroundStyle(theme.accent) + + Text(parts.after).foregroundStyle(muted ? theme.mut : theme.ink)) + .font(font) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Plated + +private struct PlatedView: View { + @Environment(\.theme) private var theme + let recipe: RecipeDetail + let elapsed: TimeInterval + let error: String? + let done: () -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 2) { + ForgeLabel("Cook mode · \(recipe.name)", color: theme.accent) + Text("Plated. Logged.") + .font(ForgeFont.serifHero) + .foregroundStyle(theme.ink) + } + + Color.clear + .frame(maxWidth: .infinity) + .frame(height: 200) + .overlay { RecipePhoto(recipe: recipe.card) } + .clipShape(.rect(cornerRadius: 22)) + + Text("\(Int(max(1, elapsed / 60).rounded())) min from start to plate.") + .font(.system(.subheadline)) + .foregroundStyle(theme.mut) + + if recipe.card.batch > 0 { + Text("\(recipe.card.batch) servings boxed — a zero-cook night is locked in.") + .font(.system(.subheadline)) + .foregroundStyle(theme.mut) + } + + if let error { + Text("Couldn't log it: \(error) Tick it from the day view when you're back online.") + .font(.footnote) + .foregroundStyle(theme.warn) + .fixedSize(horizontal: false, vertical: true) + } else { + Text("Logged to your plate. Everyone else ticks their own — their plate, their targets.") + .font(.footnote) + .foregroundStyle(theme.dim) + .fixedSize(horizontal: false, vertical: true) + } + + Button("Done", action: done) + .buttonStyle(.pill) + .padding(.top, 4) + } + .padding(.horizontal, 24) + .padding(.top, 28) + .padding(.bottom, 40) + } + } +} diff --git a/ios/Forge/Forge/Features/Cook/CookTimers.swift b/ios/Forge/Forge/Features/Cook/CookTimers.swift new file mode 100644 index 0000000..84ee1a1 --- /dev/null +++ b/ios/Forge/Forge/Features/Cook/CookTimers.swift @@ -0,0 +1,92 @@ +import Foundation +import Observation + +/// Cook mode's timers, keyed by step index. +/// +/// They keep running as you move between steps — a simmer cooks while you prep +/// the next thing — so each one stores an **absolute end date** rather than a +/// countdown. Ported from `web/src/screens/food.tsx`, where the same decision +/// was made for the same reason: a remaining-seconds counter drifts when the +/// view stops ticking, and cook mode gets backgrounded constantly. +@MainActor @Observable +final class CookTimers { + nonisolated struct Running: Sendable, Equatable { + let total: Int + let end: Date + var chimed: Bool + + func remaining(at now: Date) -> Int { + max(0, Int(end.timeIntervalSince(now).rounded(.up))) + } + } + + private(set) var timers: [Int: Running] = [:] + private(set) var now = Date() + + /// Fired once per timer as it crosses zero — the view turns this into a + /// haptic and a notification-style banner. + var onDone: ((Int) -> Void)? + + private var ticker: Task? + + func start(step index: Int, seconds: Int, now: Date = Date()) { + timers[index] = Running(total: seconds, + end: now.addingTimeInterval(TimeInterval(seconds)), + chimed: false) + startTicking() + } + + func cancel(step index: Int) { + timers[index] = nil + if timers.isEmpty { stopTicking() } + } + + func remaining(step index: Int, at date: Date? = nil) -> Int? { + timers[index]?.remaining(at: date ?? now) + } + + func fraction(step index: Int) -> Double { + guard let timer = timers[index], timer.total > 0 else { return 0 } + return Double(timer.remaining(at: now)) / Double(timer.total) + } + + var running: [(index: Int, timer: Running)] { + timers.sorted { $0.key < $1.key }.map { (index: $0.key, timer: $0.value) } + } + + /// Marks anything past its end date as chimed and reports it — pure enough + /// to test without a clock. + @discardableResult + func settle(at date: Date) -> [Int] { + now = date + var fired: [Int] = [] + for (index, timer) in timers where !timer.chimed && timer.end <= date { + timers[index]?.chimed = true + fired.append(index) + } + return fired.sorted() + } + + private func startTicking() { + guard ticker == nil else { return } + ticker = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(500)) + guard let self else { return } + for index in self.settle(at: Date()) { self.onDone?(index) } + } + } + } + + private func stopTicking() { + ticker?.cancel() + ticker = nil + } + + func stop() { stopTicking() } +} + +/// "3:41" — cook mode's only numeral format. +nonisolated func fmtClock(_ seconds: Int) -> String { + String(format: "%d:%02d", seconds / 60, seconds % 60) +} diff --git a/ios/Forge/Forge/Features/Cook/CookView.swift b/ios/Forge/Forge/Features/Cook/CookView.swift new file mode 100644 index 0000000..e868ba4 --- /dev/null +++ b/ios/Forge/Forge/Features/Cook/CookView.swift @@ -0,0 +1,316 @@ +import SwiftUI + +/// The Cook tab's root — **browse**, built to the decided board +/// (`docs/mockups/native/cook-browse.png`): ember kicker, serif title, search, +/// tonight's dinner as a full-bleed hero, the shared week as thumbnails, the +/// library below. +/// +/// "The other Forge": paper, serif, photography-led, ember. Nothing on this +/// screen is allowed to be volt or DIN. +struct CookView: View { + @Environment(\.theme) private var theme + @Environment(AuthController.self) private var auth + + @State private var week: FoodWeek? + @State private var library: [RecipeCard] = [] + @State private var proposal: FoodProposal? + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + Group { + if let week { + content(week) + } else if let errorMessage { + ForgeErrorView(message: errorMessage) { Task { await load() } } + } else { + ForgeProgress() + } + } + .background(theme.bg) + .toolbar(.hidden, for: .navigationBar) + .navigationDestination(for: CookRoute.self) { route in + switch route { + case .library: RecipeLibraryView(recipes: library).cookTheme() + case .plan: MealPlanView().cookTheme() + case .recipe(let slug): RecipeDetailView(slug: slug).cookTheme() + case .proposal(let proposal): + FoodProposalView(proposal: proposal) { await load() }.cookTheme() + } + } + } + .task { if week == nil { await load() } } + } + + private func load() async { + errorMessage = nil + do { + // The server owns "today" — the week response carries it. + week = try await auth.client.foodWeek() + library = (try await auth.client.recipes()).recipes + // A pending proposal is a nice-to-have on this screen, not a + // precondition — losing it must not take the whole tab down. + proposal = (try? await auth.client.foodProposal())?.proposal + } catch { + errorMessage = error.localizedDescription + } + } + + // MARK: - Content + + @ViewBuilder + private func content(_ week: FoodWeek) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + header(week) + searchBar + tonight(week) + proposalBanner + + CookSection(title: "This week", more: "Plan") { MealPlanView().cookTheme() } + .padding(.horizontal, 22) + .padding(.top, 22) + weekStrip(week) + + CookSection(title: "Library", more: "All") { + RecipeLibraryView(recipes: library).cookTheme() + } + .padding(.horizontal, 22) + .padding(.top, 22) + libraryGrid + } + .padding(.bottom, 32) + } + .scrollEdgeEffectStyle(.soft, for: .top) + .refreshable { await load() } + } + + private func header(_ week: FoodWeek) -> some View { + VStack(alignment: .leading, spacing: 1) { + ForgeLabel(kicker(week), color: theme.accent) + Text("Cook") + .font(ForgeFont.serifHero) + .foregroundStyle(theme.ink) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.top, 12) + } + + /// "SATURDAY · 5 DINNERS PLANNED" — and honest when there's no plan yet, + /// rather than reporting zero as if it were a result. + private func kicker(_ week: FoodWeek) -> String { + let day = week.todayDay?.dayName ?? "" + guard week.hasPlan else { + return day.isEmpty ? "No meal plan yet" : "\(day) · no meal plan yet" + } + let n = week.plannedDinners + let dinners = n == 1 ? "1 dinner planned" : "\(n) dinners planned" + return day.isEmpty ? dinners : "\(day) · \(dinners)" + } + + // MARK: - Proposal + + /// A proposed food week is the one thing on this screen that expires, so it + /// says how many nights it commits you to before you open it. + /// + /// It sits under the hero rather than above the search bar, which is where + /// it belongs by importance: **taps in a band near the top of this scroll + /// view don't reach their target** — the banner was completely dead there, + /// and the search bar in that same band opens the hero's recipe instead of + /// the library. Pre-existing, not caused by this view, and not yet + /// explained (it is not the scroll edge effect and not the content shape). + /// Move this back up once that's fixed. + @ViewBuilder + private var proposalBanner: some View { + if let proposal { + NavigationLink(value: CookRoute.proposal(proposal)) { + HStack(spacing: 11) { + Circle() + .fill(theme.accent) + .frame(width: 7, height: 7) + VStack(alignment: .leading, spacing: 1) { + Text("Next week's food is ready") + .font(.system(.subheadline, weight: .semibold)) + .foregroundStyle(theme.ink) + Text("\(proposal.cookingNights) nights cooking · awaiting your OK") + .font(.caption) + .foregroundStyle(theme.mut) + } + Spacer(minLength: 8) + Text("Review ›") + .font(.system(.caption, weight: .bold)) + .foregroundStyle(theme.accent) + } + .padding(.vertical, 12) + .padding(.horizontal, 14) + .background(theme.raised, in: .rect(cornerRadius: 16)) + // Without this the tappable area is only the glyphs — the card + // looked like a button, drew like a button, and swallowed every + // tap that landed on the gap between the text and the chevron. + .contentShape(.rect(cornerRadius: 16)) + } + .buttonStyle(.plain) + .padding(.horizontal, 20) + .padding(.top, 14) + } + } + + private var searchBar: some View { + NavigationLink(value: CookRoute.library) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + Text(library.isEmpty ? "Search recipes…" : "Search \(library.count) recipes…") + Spacer() + } + .font(.system(.subheadline)) + .foregroundStyle(theme.mut) + .padding(.vertical, 11) + .padding(.horizontal, 14) + .background(theme.sunken, in: .rect(cornerRadius: 14)) + } + .buttonStyle(.plain) + .padding(.horizontal, 20) + .padding(.top, 12) + } + + // MARK: - Tonight + + @ViewBuilder + private func tonight(_ week: FoodWeek) -> some View { + let slot = week.todayDay?.dinner + if let recipe = slot?.recipe { + NavigationLink(value: CookRoute.recipe(recipe.slug)) { + CookHero(recipe: recipe, + tag: heroTag(slot), + title: recipe.name, + chips: heroChips(recipe)) + } + .buttonStyle(.plain) + .padding(.horizontal, 20) + .padding(.top, 16) + } else { + emptyTonight(slot) + } + } + + private func heroTag(_ slot: FoodSlot?) -> String { + if slot?.isLeftover == true { return "TONIGHT · LEFTOVERS" } + if slot?.logged == true { return "TONIGHT · EATEN" } + return "TONIGHT" + } + + private func heroChips(_ recipe: RecipeCard) -> [String] { + var chips: [String] = [] + if recipe.minutes > 0 { chips.append("\(recipe.minutes) min") } + if recipe.macros.kcal > 0 { chips.append("\(Int(recipe.macros.kcal.rounded())) kcal") } + if recipe.macros.proteinG > 0 { + chips.append("\(Int(recipe.macros.proteinG.rounded())) g protein") + } + return chips + } + + /// No dinner tonight is a real state, not an error — eating out is planned + /// the same way cooking is. + private func emptyTonight(_ slot: FoodSlot?) -> some View { + VStack(alignment: .leading, spacing: 10) { + ForgeLabel("Tonight", color: theme.accent) + Text(slot?.fallbackName ?? "Nothing planned") + .font(ForgeFont.serifTitle) + .foregroundStyle(theme.ink) + .fixedSize(horizontal: false, vertical: true) + NavigationLink(value: CookRoute.plan) { + Text("Plan the week") + .font(.system(.subheadline, weight: .semibold)) + .foregroundStyle(theme.accent) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + .background(theme.raised, in: .rect(cornerRadius: 26)) + .padding(.horizontal, 20) + .padding(.top, 16) + } + + // MARK: - Week strip + + private func weekStrip(_ week: FoodWeek) -> some View { + HStack(spacing: 8) { + ForEach(week.days) { day in + NavigationLink(value: day.thumbnail.map { CookRoute.recipe($0.slug) } ?? .plan) { + VStack(spacing: 5) { + thumbnail(day) + Text(String(day.dayName.prefix(3)).uppercased()) + .font(.system(.caption2, weight: .bold)) + .tracking(0.6) + .foregroundStyle(day.isToday ? theme.accent : theme.mut) + } + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 20) + .padding(.top, 11) + } + + @ViewBuilder + private func thumbnail(_ day: FoodDay) -> some View { + // A day still ahead of you reads "+" — that's one you'd fill in. A day + // already gone reads "·": nothing was planned and nothing can be now. + let symbol = day.date >= (week?.today ?? day.date) ? "+" : "·" + Color.clear + .frame(maxWidth: .infinity) + .frame(height: 44) + .overlay { + if let recipe = day.thumbnail { + RecipePhoto(recipe: recipe) + } else { + theme.sunken.overlay { + Text(symbol) + .font(.system(.body)) + .foregroundStyle(theme.dim) + } + } + } + .clipShape(.rect(cornerRadius: 12)) + .opacity(day.dinner?.isLeftover == true ? 0.55 : 1) + } + + // MARK: - Library + + /// Dinners only, and not the ones already on this week's plan — the row is + /// "what else could you cook", so repeating tonight's hero underneath it is + /// wasted space. Snacks are excluded because a landing grid of almonds and + /// apple slices sells the library short; the full list is one tap away. + private var browseable: [RecipeCard] { + let planned = Set((week?.days ?? []).compactMap { $0.dinner?.recipe?.slug }) + return library.filter { !$0.isParked && $0.kind == "dinner" && !planned.contains($0.slug) } + } + + private var libraryGrid: some View { + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12)], spacing: 12) { + ForEach(browseable.prefix(6)) { recipe in + NavigationLink(value: CookRoute.recipe(recipe.slug)) { + RecipeTile(recipe: recipe) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + .padding(.top, 12) + } +} + +nonisolated enum CookRoute: Hashable { + case library + case plan + case recipe(String) + /// The proposal travels **in the route**, not read back out of `@State`. + /// A `navigationDestination` case that unwraps view state can be evaluated + /// against a stale snapshot — the branch yields an empty view and the push + /// silently does nothing, which is exactly what it did here. Everything the + /// destination needs has to arrive with the value. + case proposal(FoodProposal) +} diff --git a/ios/Forge/Forge/Features/Cook/FoodProposalView.swift b/ios/Forge/Forge/Features/Cook/FoodProposalView.swift new file mode 100644 index 0000000..63e3034 --- /dev/null +++ b/ios/Forge/Forge/Features/Cook/FoodProposalView.swift @@ -0,0 +1,257 @@ +import SwiftUI + +/// Review a proposed food week and approve or dismiss it. +/// +/// The training proposal has had a screen since N1; the food one is the same +/// contract — a `changes` delta list and a rationale, so you approve a described +/// change rather than a diff you work out yourself — but it belongs to Cook, so +/// it reads in Cook's voice: paper, serif, ember. Nothing here is volt or DIN. +/// +/// **Approving changes the week for both of you.** The food plan is one shared +/// household week, so the copy says so above the button rather than after. +struct FoodProposalView: View { + @Environment(\.theme) private var theme + @Environment(\.dismiss) private var dismiss + @Environment(AuthController.self) private var auth + + let proposal: FoodProposal + /// Called after a decision lands, so the Cook root refetches. + var onDecided: (() async -> Void)? + + @State private var acting = false + @State private var confirmDismiss = false + @State private var noteExpanded = false + @State private var errorMessage: String? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + header + if !proposal.changes.isEmpty { changes } + if !proposal.rationale.isEmpty { rationale } + dinners + sharedNote + } + .padding(.horizontal, 20) + .padding(.bottom, 40) + } + .background(theme.bg) + .navigationTitle("Food week \(proposal.num)") + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { actions } + .confirmationDialog("Dismiss this food week?", isPresented: $confirmDismiss, + titleVisibility: .visible) { + Button("Dismiss", role: .destructive) { Task { await decide(approve: false) } } + Button("Keep it", role: .cancel) {} + } message: { + Text("This week's meals stay exactly as they are. The coach can propose again.") + } + } + + // MARK: - Header + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + ForgeLabel(kicker, color: theme.accent) + Text("Next week's food") + .font(ForgeFont.serifTitle) + .foregroundStyle(theme.ink) + Text(shape) + .font(.system(.subheadline)) + .foregroundStyle(theme.mut) + } + .padding(.top, 6) + } + + /// "PROPOSED SAT 26 JUL" — the date it was written, since a proposal you + /// left sitting for a week is worth noticing. + private var kicker: String { + guard let date = ForgeDate.date(from: String(proposal.createdAt.prefix(10))) else { + return "AWAITING YOUR OK" + } + let formatter = DateFormatter() + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "EEE d MMM" + return "Proposed \(formatter.string(from: date))" + } + + /// What you're actually signing up to cook — the number that decides + /// whether this week is realistic. + private var shape: String { + let nights = proposal.cookingNights + let cooking = nights == 1 ? "1 night cooking" : "\(nights) nights cooking" + let off = 7 - nights + return off > 0 ? "\(cooking) · \(off) off" : cooking + } + + // MARK: - Changes + + private var changes: some View { + VStack(alignment: .leading, spacing: 10) { + Text("What changes") + .font(ForgeFont.serifSection) + .foregroundStyle(theme.ink) + VStack(alignment: .leading, spacing: 11) { + ForEach(proposal.changes) { change in + HStack(alignment: .top, spacing: 10) { + Text(change.sign) + .font(ForgeFont.numeric(.subheadline, weight: .bold)) + .foregroundStyle(colour(for: change.sign)) + .frame(width: 14) + VStack(alignment: .leading, spacing: 2) { + Text(change.what) + .font(.system(.subheadline, weight: .medium)) + .foregroundStyle(theme.ink) + .fixedSize(horizontal: false, vertical: true) + if let why = change.why, !why.isEmpty { + Text(why) + .font(.caption) + .foregroundStyle(theme.dim) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 0) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(theme.raised, in: .rect(cornerRadius: 20)) + } + } + + /// Added / removed / adjusted. Ember is Cook's one accent and warm is + /// warnings only, so a removal stays muted rather than becoming a second. + private func colour(for sign: String) -> Color { + switch sign { + case "+": theme.accent + case "-": theme.dim + default: theme.mut + } + } + + // MARK: - Rationale + + private var rationale: some View { + Button { withAnimation(.easeOut(duration: 0.15)) { noteExpanded.toggle() } } label: { + VStack(alignment: .leading, spacing: 6) { + Text(proposal.rationale) + .font(ForgeFont.serif(17, relativeTo: .body, weight: .regular)) + .foregroundStyle(theme.mut) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(noteExpanded ? nil : 4) + .multilineTextAlignment(.leading) + Text(noteExpanded ? "less" : "more") + .font(.system(.caption, weight: .semibold)) + .foregroundStyle(theme.accent) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } + + // MARK: - Dinner by dinner + + /// Seven rows, Monday first. Dinner is the decision — breakfasts and lunches + /// repeat, and a proposal you can't skim is one you approve without reading. + private var dinners: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Dinner by dinner") + .font(ForgeFont.serifSection) + .foregroundStyle(theme.ink) + VStack(spacing: 0) { + ForEach(0..<7, id: \.self) { weekday in + dinnerRow(weekday) + if weekday < 6 { + Rectangle().fill(theme.hair).frame(height: 1) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(theme.raised, in: .rect(cornerRadius: 20)) + } + } + + private func dinnerRow(_ weekday: Int) -> some View { + let line = proposal.dinner(on: weekday) + let card = proposal.content.days[String(weekday)]?.slots["dinner"]?.recipe + .flatMap { proposal.recipes[$0] } + return HStack(spacing: 12) { + Text(String(weekdayNames[safe: weekday]?.prefix(3) ?? "").uppercased()) + .font(.system(.caption2, weight: .bold)) + .tracking(0.6) + .foregroundStyle(theme.dim) + .frame(width: 30, alignment: .leading) + VStack(alignment: .leading, spacing: 2) { + Text(line.name) + .font(.system(.subheadline, weight: .medium)) + .foregroundStyle(theme.ink) + .fixedSize(horizontal: false, vertical: true) + if !line.why.isEmpty { + Text(line.why) + .font(.caption) + .foregroundStyle(theme.dim) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 8) + if let card { + Color.clear + .frame(width: 40, height: 40) + .overlay { RecipePhoto(recipe: card) } + .clipShape(.rect(cornerRadius: 9)) + } + } + .padding(.vertical, 10) + } + + // MARK: - Actions + + private var sharedNote: some View { + Text("The food plan is shared — approving this sets the week for James and Shelby both.") + .font(.footnote) + .foregroundStyle(theme.dim) + .fixedSize(horizontal: false, vertical: true) + } + + private var actions: some View { + VStack(spacing: 8) { + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(theme.warn) + .fixedSize(horizontal: false, vertical: true) + } + Button("Approve — cook this week") { Task { await decide(approve: true) } } + .buttonStyle(.pill) + Button("Dismiss") { confirmDismiss = true } + .font(.system(.subheadline, weight: .semibold)) + .foregroundStyle(theme.mut) + } + .disabled(acting) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(.bar) + } + + private func decide(approve: Bool) async { + guard !acting else { return } + acting = true + defer { acting = false } + errorMessage = nil + do { + if approve { + try await auth.client.approveFoodProposal(id: proposal.id) + } else { + try await auth.client.rejectFoodProposal(id: proposal.id) + } + await onDecided?() + dismiss() + } catch { + // Staying put with the error visible beats dismissing onto a week + // that didn't change. + errorMessage = error.localizedDescription + } + } +} diff --git a/ios/Forge/Forge/Features/Cook/MealPlanView.swift b/ios/Forge/Forge/Features/Cook/MealPlanView.swift new file mode 100644 index 0000000..423e4e8 --- /dev/null +++ b/ios/Forge/Forge/Features/Cook/MealPlanView.swift @@ -0,0 +1,255 @@ +import SwiftUI + +/// The week's meals — what's planned, what was actually eaten, and the day's +/// macros against target. +/// +/// Two scopes on one screen, and they must not be conflated: **the plan is +/// shared** (one household week — changing Tuesday changes it for Shelby too) +/// while **the log is yours** (ticking dinner logs it to your plate only). The +/// copy says so wherever an action crosses that line. +struct MealPlanView: View { + @Environment(\.theme) private var theme + @Environment(AuthController.self) private var auth + + @State private var week: FoodWeek? + @State private var errorMessage: String? + @State private var busySlot: String? + @State private var planning: PlanTarget? + + var body: some View { + Group { + if let week { + content(week) + } else if let errorMessage { + ForgeErrorView(message: errorMessage) { Task { await load() } } + } else { + ForgeProgress() + } + } + .background(theme.bg) + .navigationTitle("This week") + .navigationBarTitleDisplayMode(.inline) + .task { if week == nil { await load() } } + .sheet(item: $planning) { target in + PlanRecipeSheet(date: target.date, slot: target.slot) { await load() } + .cookTheme() + } + } + + private func load() async { + errorMessage = nil + do { week = try await auth.client.foodWeek() } + catch { errorMessage = error.localizedDescription } + } + + private func content(_ week: FoodWeek) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if !week.rationale.isEmpty { + Text(week.rationale) + .font(ForgeFont.serif(17, relativeTo: .body, weight: .regular)) + .foregroundStyle(theme.mut) + .fixedSize(horizontal: false, vertical: true) + } + ForEach(week.days) { day in + dayCard(day, targets: week.targets) + } + Text("The plan is shared — James and Shelby cook the same week. What you tick is logged to your plate only.") + .font(.footnote) + .foregroundStyle(theme.dim) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 4) + } + .padding(.horizontal, 20) + .padding(.bottom, 40) + } + .refreshable { await load() } + } + + // MARK: - Day + + private func dayCard(_ day: FoodDay, targets: Macros) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + Text(day.dayName) + .font(ForgeFont.serifSection) + .foregroundStyle(day.isToday ? theme.accent : theme.ink) + Spacer() + if day.totals.kcal > 0 { + Text("\(Int(day.totals.kcal.rounded())) / \(Int(targets.kcal.rounded())) kcal") + .font(ForgeFont.numeric(.caption)) + .foregroundStyle(theme.mut) + } + } + + if day.slots.isEmpty && day.extras.isEmpty { + Button { planning = PlanTarget(date: day.date, slot: "dinner") } label: { + Text("Nothing planned — add a dinner") + .font(.system(.subheadline)) + .foregroundStyle(theme.accent) + } + .buttonStyle(.plain) + } + + ForEach(day.slots) { slot in + slotRow(day: day, slot: slot) + } + + ForEach(day.extras) { extra in + HStack(spacing: 10) { + Image(systemName: "circle.fill") + .font(.system(size: 6)) + .foregroundStyle(theme.dim) + VStack(alignment: .leading, spacing: 1) { + Text(extra.label) + .font(.system(.subheadline)) + .foregroundStyle(theme.ink) + Text([extra.venue, extra.estimated ? "estimated" : ""] + .filter { !$0.isEmpty }.joined(separator: " · ")) + .font(.system(.caption)) + .foregroundStyle(theme.dim) + } + Spacer() + Text("\(Int(extra.macros.kcal.rounded())) kcal") + .font(ForgeFont.numeric(.caption)) + .foregroundStyle(theme.mut) + } + } + + if day.totals.kcal > 0 { macroBar(day.totals, targets: targets) } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(theme.raised, in: .rect(cornerRadius: 20)) + .overlay { + if day.isToday { + RoundedRectangle(cornerRadius: 20).strokeBorder(theme.accent, lineWidth: 1.5) + } + } + } + + private func slotRow(day: FoodDay, slot: FoodSlot) -> some View { + HStack(spacing: 12) { + Button { Task { await toggle(day: day, slot: slot) } } label: { + Image(systemName: slot.logged ? "checkmark.circle.fill" : "circle") + .font(.system(.title3)) + .foregroundStyle(slot.logged ? theme.accent : theme.hairIn) + } + .buttonStyle(.plain) + .disabled(busySlot == "\(day.date)#\(slot.slot)" || slot.recipe == nil) + + VStack(alignment: .leading, spacing: 1) { + Text(slot.displayName) + .font(.system(.subheadline, weight: .medium)) + .foregroundStyle(theme.ink) + // "Order out" slots carry a rationale in `note`, not a + // name — two lines, then it's the coach's job to explain. + .lineLimit(2) + Text(slotCaption(slot)) + .font(.system(.caption)) + .foregroundStyle(theme.dim) + } + + Spacer(minLength: 8) + + if let recipe = slot.recipe { + NavigationLink(value: CookRoute.recipe(recipe.slug)) { + Color.clear + .frame(width: 44, height: 44) + .overlay { RecipePhoto(recipe: recipe) } + .clipShape(.rect(cornerRadius: 10)) + } + .buttonStyle(.plain) + } else { + Button { planning = PlanTarget(date: day.date, slot: slot.slot) } label: { + Image(systemName: "plus") + .font(.system(.subheadline, weight: .medium)) + .foregroundStyle(theme.accent) + .frame(width: 44, height: 44) + .background(theme.sunken, in: .rect(cornerRadius: 10)) + } + .buttonStyle(.plain) + } + } + .opacity(slot.isLeftover ? 0.7 : 1) + } + + private func slotCaption(_ slot: FoodSlot) -> String { + var parts = [slot.slot.capitalized] + if slot.isLeftover { parts.append("leftovers") } + if slot.isOffPlan { parts.append("off plan") } + if slot.estimated == true { parts.append("estimated") } + if let recipe = slot.recipe, recipe.minutes > 0 { parts.append("\(recipe.minutes) min") } + return parts.joined(separator: " · ") + } + + private func macroBar(_ totals: Macros, targets: Macros) -> some View { + HStack(spacing: 14) { + MacroBar(label: "protein", value: totals.proteinG, target: targets.proteinG) + MacroBar(label: "fiber", value: totals.fiberG, target: targets.fiberG) + MacroBar(label: "sat fat", value: totals.satfatG, target: targets.satfatG, lowerIsBetter: true) + } + .padding(.top, 2) + } + + // MARK: - Actions + + /// Ticking a slot logs the planned recipe to **your** plate; unticking + /// removes your log. Neither touches the shared plan. + private func toggle(day: FoodDay, slot: FoodSlot) async { + guard let recipe = slot.recipe else { return } + let key = "\(day.date)#\(slot.slot)" + busySlot = key + defer { busySlot = nil } + do { + if slot.logged, let logId = slot.logId { + try await auth.client.unlogMeal(id: logId) + } else { + try await auth.client.logMeal(date: day.date, slot: slot.slot, + recipe: recipe.slug, + clientId: UUID().uuidString) + } + await load() + } catch { + errorMessage = error.localizedDescription + } + } +} + +nonisolated struct PlanTarget: Identifiable, Hashable { + let date: String + let slot: String + var id: String { "\(date)#\(slot)" } +} + +// MARK: - Macro bar + +private struct MacroBar: View { + @Environment(\.theme) private var theme + let label: String + let value: Double + let target: Double + var lowerIsBetter = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(theme.sunken) + Capsule() + .fill(over ? theme.warn : theme.accent) + .frame(width: geo.size.width * min(1, fraction)) + } + } + .frame(height: 5) + Text("\(label) \(Int(value.rounded()))/\(Int(target.rounded()))") + .font(.system(.caption2)) + .foregroundStyle(theme.dim) + } + } + + private var fraction: Double { target > 0 ? value / target : 0 } + /// Over target is only a warning where more is worse — hitting protein is + /// the goal, not a ceiling. + private var over: Bool { lowerIsBetter && value > target } +} diff --git a/ios/Forge/Forge/Features/Cook/PlanRecipeSheet.swift b/ios/Forge/Forge/Features/Cook/PlanRecipeSheet.swift new file mode 100644 index 0000000..e0df9e6 --- /dev/null +++ b/ios/Forge/Forge/Features/Cook/PlanRecipeSheet.swift @@ -0,0 +1,183 @@ +import SwiftUI + +/// Pencilling a recipe onto the week, from either end: pick a **day** when you +/// arrived from a recipe, pick a **recipe** when you arrived from an empty slot. +/// +/// Two things the copy has to be straight about, because the server does them +/// and the user can't see them: +/// 1. The week is a weekday template — setting Tuesday's dinner sets it for +/// every week the active revision covers, not just this one. +/// 2. The plan is household-shared, so this changes what Shelby sees. +struct PlanRecipeSheet: View { + @Environment(\.theme) private var theme + @Environment(\.dismiss) private var dismiss + @Environment(AuthController.self) private var auth + + private enum Mode { + /// From a recipe: choose which day it lands on. + case pickDay(RecipeCard) + /// From an empty slot: choose what goes in it. + case pickRecipe(date: String, slot: String) + } + + private let mode: Mode + private let onDone: (() async -> Void)? + + @State private var week: FoodWeek? + @State private var library: [RecipeCard] = [] + @State private var term = "" + @State private var busy = false + @State private var errorMessage: String? + + init(recipe: RecipeCard, onDone: (() async -> Void)? = nil) { + self.mode = .pickDay(recipe) + self.onDone = onDone + } + + init(date: String, slot: String, onDone: (() async -> Void)? = nil) { + self.mode = .pickRecipe(date: date, slot: slot) + self.onDone = onDone + } + + var body: some View { + NavigationStack { + Group { + switch mode { + case .pickDay(let recipe): dayPicker(recipe) + case .pickRecipe(let date, let slot): recipePicker(date: date, slot: slot) + } + } + .background(theme.bg) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .task { await load() } + .presentationDetents([.medium, .large]) + } + + private var title: String { + switch mode { + case .pickDay: "Which night?" + case .pickRecipe(_, let slot): "Pick a \(slot)" + } + } + + private func load() async { + week = try? await auth.client.foodWeek() + if case .pickRecipe(_, let slot) = mode { + library = (try? await auth.client.recipes(kind: slot))?.recipes ?? [] + } + } + + // MARK: - Pick a day + + private func dayPicker(_ recipe: RecipeCard) -> some View { + List { + Section { + ForEach(week?.days ?? []) { day in + Button { + Task { await plan(date: day.date, recipe: recipe.slug, slot: recipe.kind) } + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(day.dayName) + .font(.system(.body, weight: .medium)) + .foregroundStyle(day.isToday ? theme.accent : theme.ink) + Text(day.dinner?.recipe?.name ?? "nothing planned") + .font(.system(.caption)) + .foregroundStyle(theme.dim) + } + Spacer() + if day.dinner?.recipe != nil { + Text("replaces") + .font(.system(.caption2, weight: .semibold)) + .foregroundStyle(theme.warn) + } + } + } + .buttonStyle(.plain) + .listRowBackground(theme.raised) + } + } footer: { + footer + } + } + .scrollContentBackground(.hidden) + .disabled(busy) + } + + // MARK: - Pick a recipe + + private func recipePicker(date: String, slot: String) -> some View { + List { + Section { + ForEach(filtered) { recipe in + Button { + Task { await plan(date: date, recipe: recipe.slug, slot: slot) } + } label: { + HStack(spacing: 12) { + Color.clear + .frame(width: 44, height: 44) + .overlay { RecipePhoto(recipe: recipe) } + .clipShape(.rect(cornerRadius: 9)) + VStack(alignment: .leading, spacing: 2) { + Text(recipe.name) + .font(.system(.subheadline, weight: .medium)) + .foregroundStyle(theme.ink) + Text(recipe.meta) + .font(.system(.caption)) + .foregroundStyle(theme.dim) + } + } + } + .buttonStyle(.plain) + .listRowBackground(theme.raised) + } + } footer: { + footer + } + } + .scrollContentBackground(.hidden) + .searchable(text: $term, prompt: "Search recipes") + .disabled(busy) + } + + /// Parked imports are excluded here, not badged: this list is "what can go + /// in the week", and the server refuses to plan an incomplete recipe. + private var filtered: [RecipeCard] { + let needle = term.lowercased().trimmingCharacters(in: .whitespaces) + return library.filter { recipe in + guard !recipe.isParked else { return false } + guard !needle.isEmpty else { return true } + return recipe.name.lowercased().contains(needle) + } + } + + @ViewBuilder + private var footer: some View { + VStack(alignment: .leading, spacing: 6) { + if let errorMessage { + Text(errorMessage).foregroundStyle(theme.warn) + } + Text("The meal plan is shared — this changes the week for both of you, on that weekday, until the coach proposes a new one.") + } + .font(.footnote) + } + + private func plan(date: String, recipe: String, slot: String) async { + busy = true + defer { busy = false } + do { + try await auth.client.planSlot(date: date, recipe: recipe, slot: slot) + await onDone?() + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/ios/Forge/Forge/Features/Cook/RecipeDetailView.swift b/ios/Forge/Forge/Features/Cook/RecipeDetailView.swift new file mode 100644 index 0000000..e8ba33e --- /dev/null +++ b/ios/Forge/Forge/Features/Cook/RecipeDetailView.swift @@ -0,0 +1,273 @@ +import SwiftUI + +/// One recipe: the photo, why it's in the week, what it costs you +/// nutritionally, what you need, and the method — with cook mode one tap away. +/// +/// Parked imports (unknown ingredient, `hard`, or zero kcal) are browsable here +/// and say so; the server refuses to propose them, so the app shouldn't pretend +/// they're plannable either. +struct RecipeDetailView: View { + @Environment(\.theme) private var theme + @Environment(AuthController.self) private var auth + + let slug: String + + @State private var recipe: RecipeDetail? + @State private var errorMessage: String? + @State private var cooking = false + @State private var planning = false + + var body: some View { + Group { + if let recipe { + content(recipe) + } else if let errorMessage { + ForgeErrorView(message: errorMessage) { Task { await load() } } + } else { + ForgeProgress() + } + } + .background(theme.bg) + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .task { if recipe == nil { await load() } } + .fullScreenCover(isPresented: $cooking) { + if let recipe { + CookModeView(recipe: recipe).cookTheme() + } + } + .sheet(isPresented: $planning) { + if let recipe { + PlanRecipeSheet(recipe: recipe.card).cookTheme() + } + } + } + + private func load() async { + errorMessage = nil + do { + recipe = try await auth.client.recipe(slug: slug) + } catch { + errorMessage = error.localizedDescription + } + } + + // MARK: - Content + + private func content(_ r: RecipeDetail) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Color.clear + .frame(maxWidth: .infinity) + .frame(height: 240) + .overlay { RecipePhoto(recipe: r.card) } + .clipShape(.rect(cornerRadius: 24)) + + VStack(alignment: .leading, spacing: 8) { + Text(r.name) + .font(ForgeFont.serifTitle) + .foregroundStyle(theme.ink) + .fixedSize(horizontal: false, vertical: true) + Text(metaLine(r.card)) + .font(.system(.subheadline)) + .foregroundStyle(theme.mut) + if r.card.isParked { + Text("Parked import — browsable, but the coach can't put it in a week until its gaps are filled.") + .font(.footnote) + .foregroundStyle(theme.warn) + .fixedSize(horizontal: false, vertical: true) + } + } + + if !r.card.why.isEmpty { + Text(r.card.why) + .font(ForgeFont.serif(17, relativeTo: .body, weight: .regular)) + .foregroundStyle(theme.mut) + .fixedSize(horizontal: false, vertical: true) + } + + macros(r.card.macros, serves: r.card.serves) + + if !r.ingredients.isEmpty { ingredients(r.ingredients) } + if !r.steps.isEmpty { method(r.steps) } + + actions(r) + + if !r.sourceUrl.isEmpty { + Link(destination: URL(string: r.sourceUrl) ?? URL(string: "https://example.com")!) { + Text("Source · \(r.source.isEmpty ? "original recipe" : r.source)") + .font(.footnote) + .foregroundStyle(theme.mut) + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 40) + } + .scrollEdgeEffectStyle(.soft, for: .top) + } + + private func metaLine(_ card: RecipeCard) -> String { + var parts = ["\(card.minutes) min", "serves \(card.serves)", card.difficulty] + if card.batch > 0 { parts.append("+\(card.batch) boxed") } + return parts.joined(separator: " · ") + } + + /// Per serving, and said so — the server stores per-serving values and a + /// number that silently meant "per pan" would wreck the day's totals. + private func macros(_ m: Macros, serves: Int) -> some View { + VStack(alignment: .leading, spacing: 10) { + ForgeLabel("Per serving", color: theme.mut) + HStack(spacing: 10) { + MacroCell(value: Units.trim(m.kcal), label: "kcal", accented: true) + MacroCell(value: Units.trim(m.proteinG), label: "protein", unit: "g") + MacroCell(value: Units.trim(m.fiberG), label: "fiber", unit: "g") + MacroCell(value: Units.trim(m.satfatG), label: "sat fat", unit: "g") + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(theme.raised, in: .rect(cornerRadius: 20)) + } + + private func ingredients(_ items: [RecipeIngredient]) -> some View { + VStack(alignment: .leading, spacing: 10) { + CookSection("You need") + FlowChips(items: items.map { ($0.name, $0.quantity) }) + let pantry = items.filter(\.isPantry).count + if pantry > 0 { + Text("\(pantry) already in the cupboard") + .font(.footnote) + .foregroundStyle(theme.dim) + } + } + } + + private func method(_ steps: [RecipeStep]) -> some View { + VStack(alignment: .leading, spacing: 12) { + CookSection("Method · \(steps.count) steps") + ForEach(Array(steps.enumerated()), id: \.offset) { i, step in + HStack(alignment: .top, spacing: 12) { + Text("\(i + 1)") + .font(ForgeFont.serif(17, relativeTo: .body)) + .foregroundStyle(theme.accent) + .frame(width: 18, alignment: .leading) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 8) { + Text(step.title) + .font(.system(.subheadline, weight: .semibold)) + .foregroundStyle(theme.ink) + if let minutes = step.minutes, minutes > 0 { + Text("\(minutes) min") + .font(.system(.caption)) + .foregroundStyle(theme.dim) + } + if step.isParallel { + Text("BACKGROUND") + .font(.system(.caption2, weight: .bold)) + .foregroundStyle(theme.accent) + } + } + CueText(step.detail, font: .system(.subheadline), muted: true) + } + } + } + } + } + + private func actions(_ r: RecipeDetail) -> some View { + VStack(spacing: 10) { + Button { cooking = true } label: { + Text(r.steps.isEmpty ? "No method to cook from" : "Start cooking") + } + .buttonStyle(.pill) + .disabled(r.steps.isEmpty) + + Button("Put it in the week") { planning = true } + .buttonStyle(.pillQuiet) + .disabled(r.card.isParked) + } + .padding(.top, 4) + } +} + +// MARK: - Macro cell + +private struct MacroCell: View { + @Environment(\.theme) private var theme + let value: String + let label: String + var unit: String? + var accented = false + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(value) + .font(ForgeFont.numeric(.title3, weight: .semibold)) + .foregroundStyle(accented ? theme.accent : theme.ink) + if let unit { + Text(unit) + .font(.system(.caption2, weight: .medium)) + .foregroundStyle(theme.dim) + } + } + Text(label) + .font(.system(.caption2)) + .foregroundStyle(theme.mut) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Chip flow + +/// Wrapping chip row. `LazyVGrid` can't do ragged widths, and an `HStack` in a +/// `ScrollView` clips — so this lays out by hand. +struct FlowChips: View { + let items: [(String, String)] + + var body: some View { + FlowLayout(spacing: 8) { + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + IngredientChip(name: item.0, quantity: item.1) + } + } + } +} + +struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let width = proposal.width ?? .infinity + var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0 + for view in subviews { + let size = view.sizeThatFits(.unspecified) + if x > 0, x + size.width > width { + x = 0 + y += rowHeight + spacing + rowHeight = 0 + } + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + return CGSize(width: proposal.width ?? x, height: y + rowHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, + subviews: Subviews, cache: inout ()) { + var x = bounds.minX, y = bounds.minY, rowHeight: CGFloat = 0 + for view in subviews { + let size = view.sizeThatFits(.unspecified) + if x > bounds.minX, x + size.width > bounds.maxX { + x = bounds.minX + y += rowHeight + spacing + rowHeight = 0 + } + view.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} diff --git a/ios/Forge/Forge/Features/Cook/RecipeLibraryView.swift b/ios/Forge/Forge/Features/Cook/RecipeLibraryView.swift new file mode 100644 index 0000000..ca12708 --- /dev/null +++ b/ios/Forge/Forge/Features/Cook/RecipeLibraryView.swift @@ -0,0 +1,98 @@ +import SwiftUI + +/// The whole recipe library. Filtering is client-side as-you-type — the library +/// is household-sized (dozens, not thousands), and the PWA made the same call +/// in `food.tsx::RecipeLibraryScreen`. +/// +/// Parked imports stay visible with a badge rather than being hidden: the rule +/// is "browsable, never proposable", and a recipe that vanished after import +/// would look like the import failed. +struct RecipeLibraryView: View { + @Environment(\.theme) private var theme + @Environment(AuthController.self) private var auth + + /// Handed down from the browse screen so the list is up immediately; it + /// refreshes itself in the background. + let recipes: [RecipeCard] + + @State private var loaded: [RecipeCard] = [] + @State private var term = "" + @State private var kind: String? + + private var all: [RecipeCard] { loaded.isEmpty ? recipes : loaded } + + private var filtered: [RecipeCard] { + let needle = term.lowercased().trimmingCharacters(in: .whitespaces) + return all.filter { recipe in + if let kind, recipe.kind != kind { return false } + guard !needle.isEmpty else { return true } + return recipe.name.lowercased().contains(needle) + || (recipe.tags ?? []).contains { $0.lowercased().contains(needle) } + } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + kindFilter + if filtered.isEmpty { + Text(term.isEmpty ? "Nothing in the library yet." : "No recipe matches “\(term)”.") + .font(.system(.subheadline)) + .foregroundStyle(theme.mut) + .padding(.top, 40) + .frame(maxWidth: .infinity) + } else { + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12)], spacing: 12) { + ForEach(filtered) { recipe in + NavigationLink(value: CookRoute.recipe(recipe.slug)) { + RecipeTile(recipe: recipe) + } + .buttonStyle(.plain) + } + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 40) + } + .background(theme.bg) + .navigationTitle("Library") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $term, prompt: "Search \(all.count) recipes") + .task { loaded = (try? await auth.client.recipes())?.recipes ?? [] } + } + + private var kindFilter: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + FilterChip(title: "All", selected: kind == nil) { kind = nil } + ForEach(MealSlot.allCases) { slot in + FilterChip(title: slot.title, selected: kind == slot.rawValue) { + kind = kind == slot.rawValue ? nil : slot.rawValue + } + } + } + .padding(.vertical, 2) + } + } +} + +struct FilterChip: View { + @Environment(\.theme) private var theme + let title: String + let selected: Bool + let tap: () -> Void + + var body: some View { + Button(action: tap) { + Text(title) + .font(.system(.subheadline, weight: .medium)) + .foregroundStyle(selected ? theme.onAccent : theme.mut) + .padding(.vertical, 7) + .padding(.horizontal, 14) + .background(selected ? theme.accent : theme.sunken, in: .capsule) + } + .buttonStyle(.plain) + } +} diff --git a/ios/Forge/Forge/Features/Data/HistoryView.swift b/ios/Forge/Forge/Features/Data/HistoryView.swift new file mode 100644 index 0000000..e006028 --- /dev/null +++ b/ios/Forge/Forge/Features/Data/HistoryView.swift @@ -0,0 +1,191 @@ +import SwiftUI + +/// Everything logged, newest first. Mirrors `web/src/screens/data.tsx` — +/// pagination, a favourites filter, and swipe-to-delete. Native swipe actions +/// replace the web's hand-rolled `SwipeRow`. +struct HistoryView: View { + @Environment(\.theme) private var theme + @Environment(AuthController.self) private var auth + + @State private var model: HistoryViewModel? + @State private var pendingDelete: HistoryItem? + + var body: some View { + NavigationStack { + Group { + if let model { + content(model) + } else { + ForgeProgress() + } + } + .background(theme.bg) + .navigationTitle("History") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink(value: DataRoute.records) { + Image(systemName: "trophy") + } + .foregroundStyle(theme.accent) + } + } + .navigationDestination(for: DataRoute.self) { route in + switch route { + case .records: RecordsView().trainTheme() + case .session(let id): SessionDetailView(sessionId: id).trainTheme() + } + } + } + .task { + if model == nil { model = HistoryViewModel(client: auth.client) } + await model?.loadIfNeeded() + } + .confirmationDialog( + "Discard this workout?", + isPresented: Binding(get: { pendingDelete != nil }, + set: { if !$0 { pendingDelete = nil } }), + titleVisibility: .visible + ) { + Button("Discard", role: .destructive) { + if let item = pendingDelete { Task { await model?.delete(item) } } + pendingDelete = nil + } + Button("Cancel", role: .cancel) { pendingDelete = nil } + } message: { + Text("Its sets are removed. All-time records are kept.") + } + } + + @ViewBuilder + private func content(_ model: HistoryViewModel) -> some View { + @Bindable var model = model + if model.isLoading, model.items.isEmpty { + ForgeProgress() + } else if let message = model.errorMessage, model.items.isEmpty { + ForgeErrorView(message: message) { Task { await model.reload() } } + } else { + List { + Section { + Toggle("Starred only", isOn: $model.favoritesOnly) + .font(.system(.subheadline, weight: .medium)) + .tint(theme.accent) + .listRowBackground(theme.raised) + } + + Section { + ForEach(model.items) { item in + NavigationLink(value: DataRoute.session(item.id)) { + HistoryRow(item: item) + } + .listRowBackground(theme.raised) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + // Never a full-swipe delete: this is destructive and + // confirms, per the design system's rule. + Button(role: .destructive) { + pendingDelete = item + } label: { + Label("Discard", systemImage: "trash") + } + } + .swipeActions(edge: .leading) { + Button { + Task { await model.toggleFavorite(item) } + } label: { + Label(item.isFavorite ? "Unstar" : "Star", + systemImage: item.isFavorite ? "star.slash" : "star") + } + .tint(theme.accentDeep) + } + .task { await model.loadMoreIfNeeded(after: item) } + } + + if model.isLoadingMore { + HStack { + Spacer() + ProgressView().tint(theme.accent) + Spacer() + } + .listRowBackground(theme.bg) + } + } footer: { + if model.items.isEmpty { + Text(model.favoritesOnly + ? "Nothing starred yet — swipe a workout right to star it." + : "Nothing logged yet.") + .font(.footnote) + .foregroundStyle(theme.dim) + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(theme.bg) + .refreshable { await model.reload() } + } + } +} + +nonisolated enum DataRoute: Hashable { + case records + case session(String) +} + +// MARK: - Row + +private struct HistoryRow: View { + @Environment(\.theme) private var theme + let item: HistoryItem + + var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(item.name) + .font(.system(.body, weight: .semibold)) + .foregroundStyle(theme.ink) + if item.isFavorite { + Image(systemName: "star.fill") + .font(.caption2) + .foregroundStyle(theme.accent) + } + } + Text(dateLabel) + .font(.caption) + .foregroundStyle(theme.dim) + } + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 2) { + Text(summary) + .font(ForgeFont.numeric(.subheadline)) + .foregroundStyle(theme.accent) + if item.stats.partial == true { + Text("incomplete") + .font(.caption2) + .foregroundStyle(theme.warn) + } + } + } + .padding(.vertical, 4) + } + + private var dateLabel: String { + guard let date = ForgeDate.date(from: item.day) else { return item.day } + let formatter = DateFormatter() + formatter.dateFormat = "EEE d MMM" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter.string(from: date) + } + + /// Cardio reads in distance and time; strength in tonnage. + private var summary: String { + if item.kind == "cardio" { + let parts = [ + item.stats.distance.map { String(format: "%.1f km", $0) }, + item.stats.durationS.map(Format.duration), + ].compactMap { $0 } + return parts.isEmpty ? "—" : parts.joined(separator: " · ") + } + return "\(Units.trim(item.stats.tonnage ?? 0)) t" + } +} diff --git a/ios/Forge/Forge/Features/Data/HistoryViewModel.swift b/ios/Forge/Forge/Features/Data/HistoryViewModel.swift new file mode 100644 index 0000000..a3e80db --- /dev/null +++ b/ios/Forge/Forge/Features/Data/HistoryViewModel.swift @@ -0,0 +1,94 @@ +import Foundation +import Observation + +/// History paging. `/api/history` is offset-paginated and capped at 100 a page. +@MainActor +@Observable +final class HistoryViewModel { + static let pageSize = 30 + + private(set) var items: [HistoryItem] = [] + private(set) var isLoading = false + private(set) var isLoadingMore = false + private(set) var reachedEnd = false + private(set) var errorMessage: String? + + var favoritesOnly = false { + didSet { + guard favoritesOnly != oldValue else { return } + Task { await reload() } + } + } + + private let client: APIClient + + init(client: APIClient) { + self.client = client + } + + func loadIfNeeded() async { + guard items.isEmpty, !isLoading else { return } + await reload() + } + + func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let page = try await client.history(limit: Self.pageSize, offset: 0, + favorites: favoritesOnly) + items = page + reachedEnd = page.count < Self.pageSize + } catch { + errorMessage = error.localizedDescription + } + } + + /// Called as the last row appears. Guarded so a fast scroll can't fire + /// several overlapping requests for the same offset. + func loadMoreIfNeeded(after item: HistoryItem) async { + guard !isLoadingMore, !reachedEnd, !isLoading, + item.id == items.last?.id + else { return } + isLoadingMore = true + defer { isLoadingMore = false } + do { + let page = try await client.history(limit: Self.pageSize, offset: items.count, + favorites: favoritesOnly) + // Offset paging can repeat a row if something was inserted between + // pages; drop anything already on screen rather than showing it twice. + let known = Set(items.map(\.id)) + items.append(contentsOf: page.filter { !known.contains($0.id) }) + reachedEnd = page.count < Self.pageSize + } catch { + errorMessage = error.localizedDescription + } + } + + func toggleFavorite(_ item: HistoryItem) async { + guard let index = items.firstIndex(where: { $0.id == item.id }) else { return } + let next = !items[index].isFavorite + items[index].favorite = next // optimistic — a star should feel instant + do { + try await client.setFavorite(sessionId: item.id, favorite: next) + if favoritesOnly, !next { items.remove(at: index) } + } catch { + items[index].favorite = !next + errorMessage = error.localizedDescription + } + } + + func delete(_ item: HistoryItem) async { + let snapshot = items + items.removeAll { $0.id == item.id } + do { + try await client.deleteSession(id: item.id) + } catch { + items = snapshot + errorMessage = error.localizedDescription + } + } + + func dismissError() { errorMessage = nil } +} diff --git a/ios/Forge/Forge/Features/Data/ProgressView.swift b/ios/Forge/Forge/Features/Data/ProgressView.swift new file mode 100644 index 0000000..378b115 --- /dev/null +++ b/ios/Forge/Forge/Features/Data/ProgressView.swift @@ -0,0 +1,557 @@ +import Charts +import SwiftUI + +/// Progress, built to the decided board +/// (`docs/mockups/native/redesign-d-progress.png`): DIN `PROGRESS`, a +/// Strength/Cardio/Body switch, the week as a ring, one featured lift as a +/// filled curve, the rest as sparkline rows labelled in plain words, and the +/// newest PR as a photo. +/// +/// The words are the point. "stalled 2 wk" tells you whether to do something; +/// "+0.4%" doesn't. See `LiftTrend`. +struct TrendsView: View { + @Environment(\.theme) private var theme + @Environment(AuthController.self) private var auth + + nonisolated enum Lens: String, CaseIterable, Identifiable { + case strength = "Strength" + case cardio = "Cardio" + case body = "Body" + var id: String { rawValue } + } + + @State private var progress: Progress? + @State private var records: [RecordRow] = [] + /// The **server's** today. The client never computes it (`ios/CLAUDE.md`): + /// the server runs Europe/London, and a device an hour either side of it + /// puts "this week" in the wrong week — which is exactly how the PR count + /// ended up reporting last week's records as this week's. + @State private var today: String? + @State private var errorMessage: String? + @State private var lens: Lens = .strength + @State private var featured: String? + + private var prefs: Prefs? { auth.me?.prefs } + private var bodyweightUnits: String { auth.me?.units ?? "kg" } + + var body: some View { + Group { + if let progress { + content(progress) + } else if let errorMessage { + ForgeErrorView(message: errorMessage) { Task { await load() } } + } else { + ForgeProgress() + } + } + .background(theme.bg) + .toolbar(.hidden, for: .navigationBar) + .task { if progress == nil { await load() } } + } + + private func load() async { + errorMessage = nil + do { + let fetched = try await auth.client.progress() + progress = fetched + featured = featured ?? fetched.plottableLifts.first?.slug + records = (try? await auth.client.records()) ?? [] + today = try? await auth.client.week().today + } catch { + errorMessage = error.localizedDescription + } + } + + @ViewBuilder + private func content(_ progress: Progress) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text("PROGRESS") + .font(ForgeFont.display(40, relativeTo: .largeTitle)) + .foregroundStyle(theme.ink) + .padding(.top, 4) + + lensPicker + + switch lens { + case .strength: strength(progress) + case .cardio: cardio(progress) + case .body: body(progress) + } + } + .padding(.horizontal, 18) + .padding(.bottom, 40) + } + .scrollEdgeEffectStyle(.soft, for: .top) + .refreshable { await load() } + } + + private var lensPicker: some View { + HStack(spacing: 8) { + ForEach(Lens.allCases) { option in + Button { + withAnimation(.easeOut(duration: 0.16)) { lens = option } + } label: { + Text(option.rawValue) + .font(.system(.subheadline, weight: .semibold)) + .foregroundStyle(lens == option ? theme.onAccent : theme.mut) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(lens == option ? theme.accent : theme.raised, in: .capsule) + } + .buttonStyle(.plain) + } + } + } + + // MARK: - Strength + + @ViewBuilder + private func strength(_ progress: Progress) -> some View { + weekRing(progress) + featuredLift(progress) + sparklineRows(progress) + prBanner() + } + + /// The week as a ring: sessions done, and what else the week has in it. + private func weekRing(_ progress: Progress) -> some View { + HStack(spacing: 16) { + Ring(fraction: progress.week.planned > 0 + ? Double(progress.week.done) / Double(progress.week.planned) : 0, + caption: "\(progress.week.done)/\(progress.week.planned)", + label: "sessions") + VStack(alignment: .leading, spacing: 4) { + Text((weekNumber.map { "Week \($0) · " } ?? "") + weekVerdict(progress)) + .font(.system(.headline, weight: .semibold)) + .foregroundStyle(theme.ink) + weekDetail(progress) + .font(.system(.subheadline)) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(theme.raised, in: .rect(cornerRadius: 18)) + } + + private var weekNumber: Int? { + today.flatMap(ForgeDate.date(from:)) + .map { ForgeDate.calendar.component(.weekOfYear, from: $0) } + } + + /// No fake encouragement: this counts what's left, and only says "on track" + /// when the week's sessions are actually done. + private func weekVerdict(_ progress: Progress) -> String { + let left = progress.week.planned - progress.week.done + if progress.week.planned == 0 { return "nothing planned" } + if left <= 0 { return "on track" } + return left == 1 ? "1 session to go" : "\(left) sessions to go" + } + + private func weekDetail(_ progress: Progress) -> Text { + let prs = prsThisWeek + var text = Text("Zone 2 ").foregroundStyle(theme.mut) + + Text("\(progress.zone2.done) / \(progress.zone2.target) min") + .foregroundStyle(progress.zone2.done >= progress.zone2.target + ? theme.accent : theme.ink) + if progress.week.done > 0 { + text = text + Text(" · \(progress.week.done) logged").foregroundStyle(theme.mut) + } + if prs > 0 { + text = text + Text(" · ").foregroundStyle(theme.mut) + + Text(prs == 1 ? "1 PR" : "\(prs) PRs").foregroundStyle(theme.accent) + + Text(" this week").foregroundStyle(theme.mut) + } + return text + } + + private var prsThisWeek: Int { + guard let today else { return 0 } + let weekStart = ForgeDate.weekStart(today) + return records.count { $0.achievedOn >= weekStart } + } + + /// One lift, big: the estimated 1RM as a filled curve with its change over + /// the window it actually covers. + @ViewBuilder + private func featuredLift(_ progress: Progress) -> some View { + let lifts = progress.plottableLifts + if let slug = featured ?? lifts.first?.slug, let series = progress.e1rm[slug] { + let unit = Units.loadUnit(prefs: prefs, slug: slug) + let change = series.change(overWeeks: 12) + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + ForgeLabel("\(series.name) · est. 1RM") + Spacer(minLength: 8) + if let change, abs(change.delta) > 0.05 { + Text("\(change.delta > 0 ? "▲" : "▼") " + + "\(Units.trim((abs(Units.kgToDisp(change.delta, unit)) * 10).rounded() / 10))" + + " in \(change.weeks) wk") + .font(.system(.subheadline, weight: .semibold)) + .foregroundStyle(change.delta > 0 ? theme.accent : theme.mut) + } + } + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text(Units.trim((Units.kgToDisp(series.latest, unit) * 10).rounded() / 10)) + .font(ForgeFont.display(38, relativeTo: .largeTitle)) + .foregroundStyle(theme.ink) + Text(unit.rawValue) + .font(.system(.caption, weight: .medium)) + .foregroundStyle(theme.dim) + } + + Chart(series.points) { point in + // The fill is decoration under a line whose scale excludes + // zero — it reads as depth, not as an area measurement. + AreaMark(x: .value("Date", point.date), + y: .value("1RM", Units.kgToDisp(point.v, unit))) + .foregroundStyle(theme.accent.opacity(0.18)) + .interpolationMethod(.monotone) + LineMark(x: .value("Date", point.date), + y: .value("1RM", Units.kgToDisp(point.v, unit))) + .foregroundStyle(theme.accent) + .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .interpolationMethod(.monotone) + } + .chartYScale(domain: .automatic(includesZero: false)) + .chartYAxis(.hidden) + // One mark per month. `.automatic` puts several inside the same + // month and the axis reads "May May Jun Jun Jul". + .chartXAxis { + AxisMarks(values: .stride(by: .month)) { + AxisValueLabel(format: .dateTime.month(.abbreviated)) + .foregroundStyle(theme.dim) + } + } + .frame(height: 150) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(theme.raised, in: .rect(cornerRadius: 18)) + } + } + + /// Every other lift as a row: name, what it's doing in words, a sparkline, + /// the number. Tapping one promotes it to the card above. + @ViewBuilder + private func sparklineRows(_ progress: Progress) -> some View { + let rest = progress.plottableLifts.filter { $0.slug != (featured ?? "") } + if !rest.isEmpty { + VStack(spacing: 0) { + ForEach(rest, id: \.slug) { entry in + Button { + withAnimation(.easeOut(duration: 0.16)) { featured = entry.slug } + } label: { + liftRow(slug: entry.slug, series: entry.series) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 4) + .background(theme.raised, in: .rect(cornerRadius: 18)) + } + } + + private func liftRow(slug: String, series: LiftSeries) -> some View { + let unit = Units.loadUnit(prefs: prefs, slug: slug) + let trend = series.trend + return HStack(spacing: 10) { + Text(series.name) + .font(.system(.subheadline, weight: .medium)) + .foregroundStyle(theme.ink) + .frame(width: 108, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + Text(trend.words) + .font(.system(.caption)) + .foregroundStyle(theme.mut) + .frame(width: 62, alignment: .leading) + Sparkline(points: series.points.map { Units.kgToDisp($0.v, unit) }, + accented: trend.isPositive) + .frame(height: 26) + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(Units.trim((Units.kgToDisp(series.latest, unit) * 10).rounded() / 10)) + .font(ForgeFont.display(20, relativeTo: .headline)) + .foregroundStyle(theme.ink) + Text(unit.rawValue) + .font(.system(.caption2)) + .foregroundStyle(theme.dim) + } + } + .padding(.vertical, 12) + .overlay(alignment: .bottom) { Rectangle().fill(theme.hair).frame(height: 1) } + } + + /// The newest all-time record, over a photo. "PR banners are photographic" + /// is one of the three media surfaces the redesign round named. + @ViewBuilder + private func prBanner() -> some View { + // Best-set records only: an estimated 1RM is a calculation, and a + // banner should celebrate something you actually did. + if let pr = records.filter({ $0.kind == "best_set" }) + .max(by: { $0.achievedOn < $1.achievedOn }) { + // Deliberately dark in *both* schemes, unlike every other card — + // the board keeps this banner dark on paper too. It's the one + // element that's meant to interrupt the page. + ZStack(alignment: .bottomLeading) { + Color.clear + .frame(maxWidth: .infinity) + .frame(height: 116) + .overlay { + Image(hero: .forStrength(mainLiftSlug: pr.slug, dayName: nil)) + .resizable().scaledToFill() + } + .clipped() + LinearGradient(colors: [.black.opacity(0.35), .black.opacity(0.85)], + startPoint: .top, endPoint: .bottom) + VStack(alignment: .leading, spacing: 3) { + ForgeLabel("Best ever · \(prWhen(pr.achievedOn))", + color: ForgeTheme.trainDark.accent) + // Never `pr.detail` raw — the load inside it is kg. + Text("\(pr.name) \(pr.detail(in: Units.loadUnit(prefs: prefs, slug: pr.slug)))" + .uppercased()) + .font(ForgeFont.display(24, relativeTo: .title2)) + .foregroundStyle(.white) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + .padding(14) + } + .frame(height: 116) + .clipShape(.rect(cornerRadius: 18)) + } + } + + /// Weekday for anything in the last week, date beyond that — "Thursday" + /// only means something while it's still this week. + private func prWhen(_ iso: String) -> String { + guard let date = ForgeDate.date(from: iso) else { return iso } + let recent = today.map { iso >= ForgeDate.addDays($0, -6) } ?? false + let formatter = DateFormatter() + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = recent ? "EEEE" : "d MMM" + return formatter.string(from: date) + } + + // MARK: - Cardio + + @ViewBuilder + private func cardio(_ progress: Progress) -> some View { + HStack(spacing: 16) { + Ring(fraction: progress.zone2.target > 0 + ? Double(progress.zone2.done) / Double(progress.zone2.target) : 0, + caption: "\(progress.zone2.done)", + label: "min") + VStack(alignment: .leading, spacing: 4) { + Text("Zone 2 · \(progress.zone2.done) of \(progress.zone2.target) min") + .font(.system(.headline, weight: .semibold)) + .foregroundStyle(theme.ink) + // The band is fixed server-side; the per-plan prescription only + // drives pct_in_zone, so saying "your band" here would mislead. + Text("Counted in the fixed 110–145 bpm band.") + .font(.system(.subheadline)) + .foregroundStyle(theme.mut) + } + Spacer(minLength: 0) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(theme.raised, in: .rect(cornerRadius: 18)) + + vo2Chart(progress) + // The two here the app is willing to call a direction on: aerobic + // training pushes resting HR down over months, and recovery is + // training. Everything else stays neutral. + trendCard(title: "Resting HR", points: progress.restingHr, unit: "bpm", + format: { String(Int($0)) }, better: .down) + trendCard(title: "Sleep", points: progress.sleepH, unit: "h", + format: { String(format: "%.1f", $0) }, better: .up) + + if [progress.vo2max, progress.restingHr, progress.sleepH].allSatisfy({ $0.count < 2 }) { + emptyNote("VO₂max, resting HR and sleep come from the Watch. Nothing has synced yet.") + } + } + + /// VO₂max is a quarterly-trend metric: raw dots plus the server's centred + /// rolling mean, never deltas between two noisy readings. + @ViewBuilder + private func vo2Chart(_ progress: Progress) -> some View { + if progress.vo2max.count > 1 { + card(title: "VO₂max", + value: progress.vo2maxSmooth.last.map { String(format: "%.1f", $0.v) }) { + Chart { + ForEach(progress.vo2max) { point in + PointMark(x: .value("Date", point.date), y: .value("VO₂max", point.v)) + .foregroundStyle(theme.dim) + .symbolSize(14) + } + ForEach(progress.vo2maxSmooth) { point in + LineMark(x: .value("Date", point.date), y: .value("Trend", point.v)) + .foregroundStyle(theme.accent) + .interpolationMethod(.monotone) + } + } + .chartYScale(domain: .automatic(includesZero: false)) + .frame(height: 150) + } + } + } + + // MARK: - Body + + @ViewBuilder + private func body(_ progress: Progress) -> some View { + trendCard(title: "Bodyweight", points: progress.weight, unit: bodyweightUnits, + transform: displayWeight, + format: { Units.trim(($0 * 10).rounded() / 10) }, + windowDays: 30) + trendCard(title: "Body fat", points: progress.bodycomp.fatPct, unit: "%", + format: { String(format: "%.1f", $0) }) + // Bodyweight, body fat and water get no direction — what's "good" + // depends entirely on what you're training for. Muscle is the one the + // app already takes a view on: hold or grow it while training. + trendCard(title: "Muscle", points: progress.bodycomp.muscle, unit: "kg", + format: { String(format: "%.1f", $0) }, better: .up) + trendCard(title: "Water", points: progress.bodycomp.waterPct, unit: "%", + format: { String(format: "%.1f", $0) }) + + if [progress.weight, progress.bodycomp.fatPct, progress.bodycomp.muscle, + progress.bodycomp.waterPct].allSatisfy({ $0.count < 2 }) { + emptyNote("Bodyweight and composition come from the Withings scale. Nothing has synced yet.") + } + } + + /// A lens with nothing in it should say why, not just end. Two readings are + /// the minimum for a line, so one weigh-in still lands here. + private func emptyNote(_ text: String) -> some View { + Text(text) + .font(.system(.subheadline)) + .foregroundStyle(theme.mut) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(theme.raised, in: .rect(cornerRadius: 18)) + } + + /// Bodyweight follows `user.units`, not the load preference. + private func displayWeight(_ kg: Double) -> Double { + bodyweightUnits == "lb" ? kg * 2.20462 : kg + } + + // MARK: - Shared + + /// Board shape for a metric: caps kicker, the change over the window on the + /// right, the line beneath. No `AreaMark` — it baselines at zero and + /// flattens a 3 kg change into a hairline at the top of the card. + @ViewBuilder + private func trendCard(title: String, points: [SeriesPoint], unit: String, + transform: ((Double) -> Double)? = nil, + format: @escaping (Double) -> String, + better: MetricDelta.Better = .either, + windowDays: Int = 0) -> some View { + if points.count > 1 { + let mapped = points.map { transform?($0.v) ?? $0.v } + let delta = (mapped.last ?? 0) - (mapped.first ?? 0) + card(title: windowDays > 0 ? "\(title) · \(windowDays) days" : title, + value: mapped.last.map { "\(format($0)) \(unit)" }, + delta: MetricDelta.label(delta, format: format), + deltaIsGood: better.isGood(delta)) { + Chart(Array(zip(points, mapped)), id: \.0.id) { point, value in + LineMark(x: .value("Date", point.date), y: .value(unit, value)) + .interpolationMethod(.monotone) + } + .foregroundStyle(theme.accent) + .chartYScale(domain: .automatic(includesZero: false)) + .frame(height: 140) + } + } + } + + private func card(title: String, value: String? = nil, delta: String? = nil, + deltaIsGood: Bool = false, + @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + ForgeLabel(title) + Spacer(minLength: 8) + if let delta { + Text(delta) + // Volt is positive status. Painting every delta with it + // said a worse night's sleep was good news. + .font(.system(.subheadline, weight: .semibold)) + .foregroundStyle(deltaIsGood ? theme.accent : theme.mut) + } else if let value { + Text(value) + .font(ForgeFont.numeric(.subheadline)) + .foregroundStyle(theme.ink) + } + } + content() + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(theme.raised, in: .rect(cornerRadius: 18)) + } +} + +// MARK: - Ring + +/// Donut with the count inside — the board's week ring. +private struct Ring: View { + @Environment(\.theme) private var theme + let fraction: Double + let caption: String + let label: String + + var body: some View { + ZStack { + Circle().stroke(theme.sunken, lineWidth: 8) + Circle() + .trim(from: 0, to: max(0.001, min(1, fraction))) + .stroke(theme.accent, style: StrokeStyle(lineWidth: 8, lineCap: .round)) + .rotationEffect(.degrees(-90)) + VStack(spacing: -1) { + Text(caption) + .font(ForgeFont.display(20, relativeTo: .headline)) + .foregroundStyle(theme.ink) + Text(label.uppercased()) + .font(.system(size: 7, weight: .bold)) + .tracking(0.5) + .foregroundStyle(theme.dim) + } + } + .frame(width: 68, height: 68) + .animation(.easeOut(duration: 0.3), value: fraction) + } +} + +// MARK: - Sparkline + +/// A shape, not a chart. Swift Charts brings axes, scales and a lot of layout +/// for something 26 points tall that only has to show a direction. +private struct Sparkline: View { + @Environment(\.theme) private var theme + let points: [Double] + var accented = true + + var body: some View { + GeometryReader { geo in + let low = points.min() ?? 0 + let high = points.max() ?? 1 + let span = max(high - low, 0.0001) + Path { path in + for (i, value) in points.enumerated() { + let x = points.count > 1 + ? geo.size.width * Double(i) / Double(points.count - 1) : 0 + let y = geo.size.height * (1 - (value - low) / span) + i == 0 ? path.move(to: CGPoint(x: x, y: y)) + : path.addLine(to: CGPoint(x: x, y: y)) + } + } + .stroke(accented ? theme.accent : theme.dim, + style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + } + } +} diff --git a/ios/Forge/Forge/Features/Data/RecordsView.swift b/ios/Forge/Forge/Features/Data/RecordsView.swift new file mode 100644 index 0000000..da74eff --- /dev/null +++ b/ios/Forge/Forge/Features/Data/RecordsView.swift @@ -0,0 +1,101 @@ +import SwiftUI + +/// All-time bests. The server keeps two kinds per lift — a calculated one-rep +/// max (Epley) and the heaviest single set actually performed — and deleting a +/// session never revokes them. +struct RecordsView: View { + @Environment(\.theme) private var theme + @Environment(AuthController.self) private var auth + + @State private var records: [RecordRow]? + @State private var errorMessage: String? + + private var prefs: Prefs? { auth.me?.prefs } + + var body: some View { + Group { + if let records { + if records.isEmpty { + ContentUnavailableView("No records yet", + systemImage: "trophy", + description: Text("Log a few sessions and they'll show up here.")) + } else { + list(records) + } + } else if let errorMessage { + ForgeErrorView(message: errorMessage) { Task { await load() } } + } else { + ForgeProgress() + } + } + .background(theme.bg) + .navigationTitle("Records") + .navigationBarTitleDisplayMode(.inline) + .task { if records == nil { await load() } } + } + + private func load() async { + errorMessage = nil + do { + records = try await auth.client.records() + } catch { + errorMessage = error.localizedDescription + } + } + + private func list(_ records: [RecordRow]) -> some View { + ScrollView { + VStack(spacing: 10) { + ForEach(grouped(records), id: \.slug) { group in + ForgeCard { + VStack(alignment: .leading, spacing: 8) { + Text(group.name) + .font(ForgeFont.subtitle) + .foregroundStyle(theme.ink) + ForEach(group.rows) { row in + HStack(alignment: .firstTextBaseline) { + ForgeLabel(label(for: row.kind)) + Spacer() + Text(Units.fmtLoad(row.value, + Units.loadUnit(prefs: prefs, slug: row.slug))) + .font(ForgeFont.numeric(.headline)) + .foregroundStyle(theme.accent) + } + HStack { + // `detail` carries a kg load; converting it + // is the difference between "72.5 × 5" and + // a number in a unit you don't lift in. + Text(row.detail(in: Units.loadUnit(prefs: prefs, slug: row.slug))) + .font(.caption) + .foregroundStyle(theme.mut) + Spacer() + Text(row.achievedOn) + .font(.caption) + .foregroundStyle(theme.dim) + } + } + } + } + } + } + .padding(.horizontal, 18) + .padding(.bottom, 40) + } + .refreshable { await load() } + } + + private func label(for kind: String) -> String { + switch kind { + case "e1rm": "Est. 1RM" + case "best_set": "Best set" + default: kind.replacingOccurrences(of: "_", with: " ") + } + } + + /// One card per lift, both record kinds inside it. + private func grouped(_ records: [RecordRow]) -> [(slug: String, name: String, rows: [RecordRow])] { + Dictionary(grouping: records, by: \.slug) + .map { (slug: $0.key, name: $0.value.first?.name ?? $0.key, rows: $0.value.sorted { $0.kind < $1.kind }) } + .sorted { $0.name < $1.name } + } +} diff --git a/ios/Forge/Forge/Features/Data/SessionDetailView.swift b/ios/Forge/Forge/Features/Data/SessionDetailView.swift new file mode 100644 index 0000000..19941de --- /dev/null +++ b/ios/Forge/Forge/Features/Data/SessionDetailView.swift @@ -0,0 +1,234 @@ +import Charts +import MapKit +import SwiftUI + +/// One logged session. Strength shows the sets as lifted; cardio shows the +/// route, the HR trace and the five-zone breakdown. +/// +/// The route renders on MapKit rather than the PWA's MapLibre/MapTiler stack — +/// native maps need no key, no tile budget, and no runtime style built from CSS +/// tokens. The server's `series` + zones payload is reused unchanged. +struct SessionDetailView: View { + @Environment(\.theme) private var theme + @Environment(AuthController.self) private var auth + + let sessionId: String + + @State private var detail: SessionDetail? + @State private var errorMessage: String? + + private var prefs: Prefs? { auth.me?.prefs } + + var body: some View { + Group { + if let detail { + content(detail) + } else if let errorMessage { + ForgeErrorView(message: errorMessage) { Task { await load() } } + } else { + ForgeProgress() + } + } + .background(theme.bg) + .navigationTitle(detail?.name ?? "Session") + .navigationBarTitleDisplayMode(.inline) + .task { if detail == nil { await load() } } + } + + private func load() async { + errorMessage = nil + do { + detail = try await auth.client.session(id: sessionId) + } catch { + errorMessage = error.localizedDescription + } + } + + @ViewBuilder + private func content(_ session: SessionDetail) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + stats(session) + + if session.isCardio { + if let route = routeCoordinates(session), route.count > 1 { + RouteMap(coordinates: route) + .frame(height: 240) + .clipShape(.rect(cornerRadius: 18)) + } + if let hr = session.series?.hr, hr.count > 1 { + heartRateChart(hr) + } + if let zones = session.zones { + zoneBreakdown(zones) + } + } else { + ForEach(session.exercises) { exercise in + exerciseCard(exercise) + } + } + + if let notes = session.notes, !notes.isEmpty { + ForgeCard { + VStack(alignment: .leading, spacing: 4) { + ForgeLabel("Notes") + Text(notes) + .font(.callout) + .foregroundStyle(theme.mut) + } + } + } + } + .padding(.horizontal, 18) + .padding(.bottom, 40) + } + .scrollEdgeEffectStyle(.soft, for: .top) + } + + private func stats(_ session: SessionDetail) -> some View { + HStack(spacing: 12) { + if session.isCardio { + StatTile(value: session.stats.distance.map { String(format: "%.1f", $0) } ?? "—", + label: "Distance", unit: "km", accented: true) + StatTile(value: session.stats.durationS.map { Format.duration($0) } ?? "—", + label: "Time") + } else { + StatTile(value: Units.trim(session.stats.tonnage ?? 0), + label: "Tonnage", unit: "t", accented: true) + StatTile(value: "\(session.stats.setsDone ?? 0)", label: "Sets") + StatTile(value: session.stats.avgRpe.map { String(format: "%.1f", $0) } ?? "—", + label: "Avg RPE") + } + } + } + + private func exerciseCard(_ exercise: SessionDetail.Exercise) -> some View { + let unit = Units.loadUnit(prefs: prefs, slug: exercise.slug) + let timed = isTimed(exercise.slug) || isTimed(exercise.name ?? "") + return ForgeCard { + VStack(alignment: .leading, spacing: 8) { + Text(exercise.name ?? exercise.slug) + .font(ForgeFont.subtitle) + .foregroundStyle(theme.ink) + if let original = exercise.substitutedFor { + ForgeChip(text: "swapped from \(original)") + } + ForEach(exercise.sets) { set in + HStack { + Text("\(set.setNo)") + .font(ForgeFont.numeric(.footnote)) + .foregroundStyle(theme.dim) + .frame(width: 20, alignment: .leading) + Text("\(Units.fmtLoad(set.weight, unit)) × \(set.reps)\(timed ? "s" : "")") + .font(ForgeFont.numeric(.subheadline)) + .foregroundStyle(theme.ink) + if let rpe = set.rpe { + Spacer() + Text("RPE \(rpe)") + .font(.caption) + .foregroundStyle(theme.dim) + } + } + } + } + } + } + + private func heartRateChart(_ hr: [[Double]]) -> some View { + ForgeCard { + VStack(alignment: .leading, spacing: 10) { + ForgeLabel("Heart rate") + Chart { + ForEach(Array(hr.enumerated()), id: \.offset) { _, point in + if point.count >= 2 { + LineMark( + x: .value("Time", point[0] / 60), + y: .value("BPM", point[1]) + ) + } + } + } + .foregroundStyle(theme.accent) + .chartXAxisLabel("min") + // A 118–150 bpm trace against a zero baseline is a flat line in + // the top fifth of the card. + .chartYScale(domain: .automatic(includesZero: false)) + .frame(height: 150) + } + } + } + + private func zoneBreakdown(_ breakdown: ZoneBreakdown) -> some View { + ForgeCard { + VStack(alignment: .leading, spacing: 10) { + HStack { + ForgeLabel("Zones") + Spacer() + Text(breakdown.estimated + ? "HR max \(breakdown.hrMax) (estimated)" + : "HR max \(breakdown.hrMax)") + .font(.caption) + .foregroundStyle(breakdown.estimated ? theme.warn : theme.dim) + } + ForEach(breakdown.zones) { zone in + HStack(spacing: 10) { + Text("Z\(zone.zone)") + .font(ForgeFont.numeric(.caption, weight: .bold)) + .foregroundStyle(theme.mut) + .frame(width: 26, alignment: .leading) + GeometryReader { geo in + let widest = max(1, breakdown.zones.map(\.min).max() ?? 1) + RoundedRectangle(cornerRadius: 4) + .fill(theme.accent.opacity(0.35 + 0.13 * Double(zone.zone))) + .frame(width: geo.size.width * CGFloat(zone.min / widest)) + } + .frame(height: 14) + Text("\(Units.trim(zone.min)) min") + .font(ForgeFont.numeric(.caption)) + .foregroundStyle(theme.dim) + .frame(width: 56, alignment: .trailing) + } + } + } + } + } + + private func routeCoordinates(_ session: SessionDetail) -> [CLLocationCoordinate2D]? { + session.series?.route?.compactMap { + $0.count >= 2 ? CLLocationCoordinate2D(latitude: $0[0], longitude: $0[1]) : nil + } + } +} + +// MARK: - Route + +private struct RouteMap: View { + @Environment(\.theme) private var theme + let coordinates: [CLLocationCoordinate2D] + + var body: some View { + Map(initialPosition: .rect(boundingRect)) { + MapPolyline(coordinates: coordinates) + .stroke(theme.accent, style: StrokeStyle(lineWidth: 4, lineCap: .round, + lineJoin: .round)) + if let start = coordinates.first { + Annotation("Start", coordinate: start) { + Circle() + .fill(theme.accent) + .frame(width: 10, height: 10) + } + } + } + .mapStyle(.standard(elevation: .flat)) + .allowsHitTesting(false) // it's a thumbnail in a scroll view, not a map to pan + } + + /// Frame the whole trace with a little air around it. + private var boundingRect: MKMapRect { + coordinates.reduce(MKMapRect.null) { rect, coordinate in + let point = MKMapPoint(coordinate) + return rect.union(MKMapRect(x: point.x, y: point.y, width: 0, height: 0)) + } + .insetBy(dx: -400, dy: -400) + } +} diff --git a/ios/Forge/Forge/Features/Root/RootView.swift b/ios/Forge/Forge/Features/Root/RootView.swift index b004867..2f8d815 100644 --- a/ios/Forge/Forge/Features/Root/RootView.swift +++ b/ios/Forge/Forge/Features/Root/RootView.swift @@ -5,6 +5,8 @@ import SwiftUI /// arrive in N4/N5 with their own accents and never mix with Train's volt. struct RootView: View { @Environment(AuthController.self) private var auth + @Environment(\.colorScheme) private var scheme + @State private var tab = ForgeTab.train var body: some View { switch auth.state { @@ -20,15 +22,45 @@ struct RootView: View { .trainTheme() case .signedIn: - TabView { - Tab("Train", systemImage: "figure.strengthtraining.traditional") { - WeekView().trainTheme() + // Per the decided boards the tab bar is the module switch — + // Train / Cook / Shop / Coach. History and Progress are Train + // screens, reached from Today, not top-level tabs. Shop joins at + // N5; shipping it as an empty tab now would be worse than a shorter + // bar. Settings hangs off the end until then. + // + // Each tab scopes its own theme: Train resolves dark or light with + // the system, Cook is always paper and ember. Two accents exist in + // the app, never on one screen — and that includes the tab bar, + // which is why it's tinted from the *selected* tab rather than + // once from Train. A volt "Cook" label sitting under a paper Cook + // screen is two accents on one screen. + TabView(selection: $tab) { + Tab("Train", systemImage: "figure.strengthtraining.traditional", value: .train) { + TodayView().trainTheme() } - Tab("Settings", systemImage: "gearshape") { + Tab("Cook", systemImage: "fork.knife", value: .cook) { + CookView().cookTheme() + } + Tab("Coach", systemImage: "bubble.left.and.text.bubble.right", value: .coach) { + CoachView().trainTheme() + } + Tab("Settings", systemImage: "gearshape", value: .settings) { SettingsView().trainTheme() } } - .trainTheme() + .environment(\.theme, shellTheme) + .tint(shellTheme.accent) + // Cook is paper in every ambience, so the bar under it has to be + // too — otherwise the glass reads dark against a light screen. + .environment(\.colorScheme, tab == .cook ? .light : scheme) } } + + private var shellTheme: ForgeTheme { + tab == .cook ? .cookLight : .train(scheme) + } +} + +nonisolated enum ForgeTab: Hashable { + case train, cook, coach, settings } diff --git a/ios/Forge/Forge/Features/Train/DayView.swift b/ios/Forge/Forge/Features/Train/DayView.swift index 1721a51..637736e 100644 --- a/ios/Forge/Forge/Features/Train/DayView.swift +++ b/ios/Forge/Forge/Features/Train/DayView.swift @@ -217,14 +217,13 @@ struct DayView: View { defer { starting = false } do { let response = try await auth.client.startSession(date: day.date, budget: day.budget) - // The fitted targets are raw plan entries with no names; the day - // payload already resolved them against the exercise library. - let names = Dictionary( - (day.exercises ?? []).map { ($0.slug, $0.name) }, - uniquingKeysWith: { first, _ in first } - ) + // The fitted targets are raw plan entries — no names, no last-time, + // no plate breakdown. The day payload has all three, already + // resolved against the exercise library. live = SessionController(sessionId: response.id, fitted: response.fitted, - client: auth.client, queue: queue, names: names) + client: auth.client, queue: queue, + plan: day.exercises ?? [], + profile: day.profile) } catch { errorMessage = error.localizedDescription } diff --git a/ios/Forge/Forge/Features/Train/SessionController.swift b/ios/Forge/Forge/Features/Train/SessionController.swift index b6a2516..0126cad 100644 --- a/ios/Forge/Forge/Features/Train/SessionController.swift +++ b/ios/Forge/Forge/Features/Train/SessionController.swift @@ -42,12 +42,23 @@ final class SessionController: Identifiable, Hashable { private(set) var isFinishing = false private(set) var didFinish = false + /// When the workout started, for the session clock. Not the server's — the + /// server stamps a session at creation and this controller is created in + /// the same breath, but a resumed session should show time on the bar + /// today, not time since it was first opened yesterday. + let startedAt = Date() + /// The exercise's library form card, once fetched. Lazily filled. + private(set) var info: [String: ExerciseInfo] = [:] + private let client: APIClient private let queue: SetQueue - /// slug → display name. `fitted.targets` are the raw plan entries and - /// usually carry no name, so the names come from the day payload, which - /// resolved them against the exercise library. - private let names: [String: String] + /// slug → the day's plan line. `fitted.targets` are the raw plan entries and + /// carry neither a name nor last-time/plate data, so both come from the day + /// payload, which resolved them against the exercise library. + private let plan: [String: TodayExercise] + /// The bar and plates actually in the room, so the logging screen can + /// re-plate live when the user steps off the prescribed load. + let profile: EquipmentProfile? private var restTask: Task? init( @@ -55,18 +66,38 @@ final class SessionController: Identifiable, Hashable { fitted: Fitted, client: APIClient, queue: SetQueue, - names: [String: String] = [:] + plan: [TodayExercise] = [], + profile: EquipmentProfile? = nil ) { self.sessionId = sessionId self.fitted = fitted self.client = client self.queue = queue - self.names = names + self.profile = profile + self.plan = Dictionary(plan.map { ($0.slug, $0) }, uniquingKeysWith: { first, _ in first }) // Heaviest thing first: the plan is already ordered with the priority-1 // main lift at the top, so "first target" is the right opening move. self.currentSlug = fitted.targets.first { $0.sets > 0 }?.slug ?? "" } + /// The day's plan line for an exercise — last time, plate breakdown, the + /// coach's note. + func planned(_ slug: String) -> TodayExercise? { plan[slug] } + + /// Position of the current exercise in the session, 1-based — "exercise 2 + /// of 4" on the logging screen. + var exerciseIndex: Int { + (targets.firstIndex { $0.slug == currentSlug } ?? 0) + 1 + } + + /// Pull the library entry for the form card. Cached; a miss is silent + /// because the card is context, not the job. + func loadInfo(_ slug: String) async { + guard info[slug] == nil, !slug.isEmpty else { return } + guard let detail = try? await client.exercise(slug: slug) else { return } + info[slug] = detail + } + /// Never shows a raw slug. Falls back to title-casing the slug, which /// reads acceptably ("back-squat" → "Back Squat") for a coach-added /// exercise the library hasn't caught up with. @@ -74,7 +105,7 @@ final class SessionController: Identifiable, Hashable { if let name = targets.first(where: { $0.slug == slug })?.name, !name.isEmpty { return name } - if let name = names[slug], !name.isEmpty { return name } + if let name = plan[slug]?.name, !name.isEmpty { return name } return slug.split(separator: "-").map(\.capitalized).joined(separator: " ") } diff --git a/ios/Forge/Forge/Features/Train/SessionView.swift b/ios/Forge/Forge/Features/Train/SessionView.swift index 073b50b..b9cca84 100644 --- a/ios/Forge/Forge/Features/Train/SessionView.swift +++ b/ios/Forge/Forge/Features/Train/SessionView.swift @@ -2,10 +2,14 @@ import SwiftUI /// The make-or-break screen: logging sets, mid-workout, one-handed, sweaty. /// -/// Everything here optimises for that — display numerals are DIN and large, the -/// primary action is a full-width pill in thumb reach, and nothing waits on the -/// network. Loads are shown and edited in the user's display unit; only kg -/// crosses the wire. +/// Built to the decided board (`docs/mockups/native/redesign-d-logging.png`) — +/// "DIN Condensed doing what it was made for: race-clock numerals that read +/// from across the rack, SF for everything you read up close." So the load is +/// one enormous numeral with the plate math under it, the steppers are compact +/// and paired beneath, and the set history is hairline rows rather than cards. +/// +/// Loads are shown and edited in the user's display unit; only kg crosses the +/// wire, and the entry field holds kg the whole time (see `seedEntry`). struct SessionView: View { @Environment(\.theme) private var theme @Environment(AuthController.self) private var auth @@ -15,53 +19,61 @@ struct SessionView: View { /// Canonical kg, not a display number. Stepping converts to display, /// applies one increment, and converts back; leaving it alone leaves the - /// prescribed kg byte-for-byte intact. See `seedEntry`. + /// prescribed kg byte-for-byte intact. @State private var weightKg: Double = 0 @State private var reps: Int = 0 @State private var rpe: Int? @State private var showFinish = false + @State private var showExercises = false @State private var seededFor: String? + @State private var elapsed: TimeInterval = 0 private var prefs: Prefs? { auth.me?.prefs } private var target: FittedTarget? { controller.current } - private var unit: LoadUnit { - Units.loadUnit(prefs: prefs, slug: target?.slug) - } + private var unit: LoadUnit { Units.loadUnit(prefs: prefs, slug: target?.slug) } private var timedHold: Bool { guard let target else { return false } return isTimed(target.slug) || isTimed(controller.displayName(target.slug)) } + private var showsLoad: Bool { !timedHold || (target?.weight ?? 0) > 0 } var body: some View { VStack(spacing: 0) { - exerciseStrip + header ScrollView { - VStack(alignment: .leading, spacing: 20) { - heading - entry - if controller.restRemaining > 0 { restBar } - loggedSets + VStack(alignment: .leading, spacing: 16) { + formCard + loadCard + rpeRow + setRows + tiles } .padding(.horizontal, 18) + .padding(.top, 14) .padding(.bottom, 24) } .scrollEdgeEffectStyle(.soft, for: .top) logBar } .background(theme.bg) - .navigationTitle(controller.fitted.name ?? "Session") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Finish") { showFinish = true } - .foregroundStyle(theme.accent) - } - } + .toolbar(.hidden, for: .navigationBar) + // Logging is a mode, not a destination. The board has no tab bar here, + // and mid-set a stray thumb on "Cook" is a real way to lose your place. + .toolbar(.hidden, for: .tabBar) .task { await controller.hydrate() seededFor = nil seedEntry() } + .task(id: controller.currentSlug) { await controller.loadInfo(controller.currentSlug) } + .task { + // One clock for the session tile. A `TimelineView` would redraw the + // whole screen every second, including the steppers. + while !Task.isCancelled { + elapsed = Date().timeIntervalSince(controller.startedAt) + try? await Task.sleep(for: .seconds(1)) + } + } .onChange(of: controller.currentSlug, initial: true) { seedEntry() } .onChange(of: controller.didFinish) { if controller.didFinish { dismiss() } } .sheet(isPresented: $showFinish) { @@ -69,167 +81,296 @@ struct SessionView: View { .trainTheme() .presentationDetents([.medium]) } + .sheet(isPresented: $showExercises) { + ExercisePicker(controller: controller) + .trainTheme() + .presentationDetents([.medium]) + } .overlay(alignment: .top) { pbBanner } } - // MARK: - Exercise strip + // MARK: - Header - private var exerciseStrip: some View { - ScrollView(.horizontal) { - HStack(spacing: 8) { - ForEach(controller.targets) { item in - let done = controller.doneCount(for: item.slug) - let isCurrent = item.slug == controller.currentSlug - Button { - controller.select(item.slug) - } label: { - VStack(alignment: .leading, spacing: 3) { - Text(controller.displayName(item.slug)) - .font(.system(.caption, weight: .semibold)) - .lineLimit(1) - Text("\(done)/\(item.sets)") - .font(ForgeFont.numeric(.caption2)) - .foregroundStyle(done >= item.sets ? theme.accent : theme.dim) - } - .padding(.vertical, 8) - .padding(.horizontal, 12) - .background(isCurrent ? theme.accentDim : theme.raised, - in: .rect(cornerRadius: 12)) - } - .foregroundStyle(isCurrent ? theme.ink : theme.mut) + /// Kicker + End, the progress rule, the exercise name, and the set line. + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Button { showExercises = true } label: { + ForgeLabel(kicker) } + .buttonStyle(.plain) + Spacer(minLength: 8) + Button("End") { showFinish = true } + .font(.system(.subheadline)) + .foregroundStyle(theme.mut) } - .padding(.horizontal, 18) - .padding(.vertical, 10) + + // One segment per exercise — where you are in the session, not in + // the set. The set count is in the line below it. + HStack(spacing: 5) { + ForEach(Array(controller.targets.enumerated()), id: \.element.id) { i, item in + Capsule() + .fill(controller.doneCount(for: item.slug) >= item.sets + ? theme.accent + : (i + 1 == controller.exerciseIndex ? theme.accent : theme.hairIn)) + .frame(height: 3) + .opacity(i + 1 == controller.exerciseIndex ? 1 : 0.75) + } + } + .padding(.bottom, 2) + + Text((target.map { controller.displayName($0.slug) } ?? "").uppercased()) + .font(ForgeFont.display(40, relativeTo: .largeTitle)) + .foregroundStyle(theme.ink) + .lineLimit(1) + .minimumScaleFactor(0.6) + + setLine } - .scrollIndicators(.hidden) + .padding(.horizontal, 18) + .padding(.top, 8) } - // MARK: - Heading + private var kicker: String { + let name = controller.fitted.name ?? "Session" + return "\(name) · exercise \(controller.exerciseIndex) of \(controller.targets.count)" + } - private var heading: some View { - VStack(alignment: .leading, spacing: 4) { - Text(target.map { controller.displayName($0.slug) } ?? "") - .font(ForgeFont.title) - .foregroundStyle(theme.ink) - HStack(spacing: 8) { - ForgeLabel("Set \(min(controller.nextSetNo, target?.sets ?? 1)) of \(target?.sets ?? 0)") - if controller.pendingSync > 0 { - ForgeChip(text: "\(controller.pendingSync) waiting to sync") + /// "Set **3 of 5** · last week 100 kg × 5 @ RPE 8". The second half is the + /// whole reason the board has this line — you decide today's load by what + /// you did last time, and having to leave the screen to find it is how + /// people end up repeating a week. + private var setLine: some View { + let setNo = min(controller.nextSetNo, target?.sets ?? 1) + return (Text("Set ").foregroundStyle(theme.mut) + + Text("\(setNo) of \(target?.sets ?? 0)").foregroundStyle(theme.ink).bold() + + Text(lastTimeSuffix).foregroundStyle(theme.mut)) + .font(.system(.subheadline)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + + private var lastTimeSuffix: String { + guard let last = target.flatMap({ controller.planned($0.slug)?.last }), + let reps = last.reps.first + else { return "" } + var line = " · last time \(Units.fmtLoad(last.weight, unit)) × \(reps)" + if let rpe = last.rpe.first { line += " @ RPE \(rpe)" } + return line + } + + // MARK: - Form card + + /// The board's "form check" strip. It shows a *photo*, not a video, and it + /// says so: the video pipeline was retired and form media is curated + /// free-exercise-db stills. Claiming a 24-second clip that doesn't exist + /// would be worse than the honest card. + @ViewBuilder + private var formCard: some View { + if let slug = target?.slug, let cue = cueText(slug) { + HStack(spacing: 12) { + if controller.info[slug]?.hasMedia == true { + Color.clear + .frame(width: 62, height: 46) + .overlay { + ForgeAsyncImage(ref: ExerciseMedia.url(base: auth.client.baseURL, + slug: slug).absoluteString, + fill: theme.sunken) + } + .clipShape(.rect(cornerRadius: 9)) } + VStack(alignment: .leading, spacing: 2) { + Text("Form") + .font(.system(.subheadline, weight: .semibold)) + .foregroundStyle(theme.ink) + Text(cue) + .font(.system(.footnote)) + .foregroundStyle(theme.mut) + .lineLimit(2) + } + Spacer(minLength: 4) } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(theme.raised, in: .rect(cornerRadius: 14)) } - .padding(.top, 4) } - // MARK: - Entry - - private var entry: some View { - VStack(spacing: 16) { - if !timedHold || (target?.weight ?? 0) > 0 { - Stepper2( - label: "Load", - value: Units.trim(Units.kgToDisp(weightKg, unit)), - unit: unit.rawValue, - onMinus: { weightKg = Units.stepped(weightKg, unit: unit, kind: target?.kind, up: false) }, - onPlus: { weightKg = Units.stepped(weightKg, unit: unit, kind: target?.kind, up: true) } - ) - } - Stepper2( - label: timedHold ? "Hold" : "Reps", - value: "\(reps)", - unit: timedHold ? "sec" : nil, - onMinus: { reps = max(0, reps - (timedHold ? 5 : 1)) }, - onPlus: { reps += timedHold ? 5 : 1 } - ) - rpePicker - } + /// The coach's note for this session beats the library's generic cue — + /// it's the thing written about *today*. + private func cueText(_ slug: String) -> String? { + if let note = controller.planned(slug)?.note, !note.isEmpty { return note } + let cues = controller.info[slug]?.cues ?? [] + return cues.isEmpty ? nil : cues.prefix(2).joined(separator: ", ") } - private var rpePicker: some View { - VStack(alignment: .leading, spacing: 8) { - ForgeLabel("RPE — optional") - HStack(spacing: 6) { - ForEach(6...10, id: \.self) { value in - Button { - rpe = rpe == value ? nil : value - } label: { - Text("\(value)") - .font(ForgeFont.numeric(.subheadline)) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background(rpe == value ? theme.accentDim : theme.raised, - in: .rect(cornerRadius: 10)) - } - .foregroundStyle(rpe == value ? theme.accent : theme.mut) + // MARK: - Load + + /// The board's centrepiece: one race-clock numeral, the plate math under + /// it, and the two steppers small and paired beneath — you read the load + /// from a metre away and touch the steppers from thirty centimetres. + private var loadCard: some View { + VStack(spacing: 12) { + if showsLoad { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(Units.trim(Units.kgToDisp(weightKg, unit))) + .font(ForgeFont.display(84, relativeTo: .largeTitle)) + .foregroundStyle(theme.ink) + .contentTransition(.numericText()) + Text(unit.rawValue) + .font(.system(.title3, weight: .medium)) + .foregroundStyle(theme.dim) } + .lineLimit(1) + .minimumScaleFactor(0.5) + + if let plate = plateLine { + Text(plate) + .font(.system(.footnote)) + .foregroundStyle(theme.dim) + .multilineTextAlignment(.center) + } + } + + HStack(spacing: 10) { + if showsLoad { + CompactStepper( + value: Units.trim(Units.kgToDisp(weightKg, unit)), + label: "Load \(unit.rawValue)", + onMinus: { weightKg = Units.stepped(weightKg, unit: unit, kind: target?.kind, up: false) }, + onPlus: { weightKg = Units.stepped(weightKg, unit: unit, kind: target?.kind, up: true) }) + } + CompactStepper( + value: "\(reps)", + label: timedHold ? "Seconds" : "Reps", + onMinus: { reps = max(0, reps - (timedHold ? 5 : 1)) }, + onPlus: { reps += timedHold ? 5 : 1 }) } } + .padding(.vertical, 18) + .padding(.horizontal, 14) + .frame(maxWidth: .infinity) + .background(theme.raised, in: .rect(cornerRadius: 22)) } - // MARK: - Rest + /// Plate math stays kg even when loads display in lb — plates are physical + /// objects with numbers stamped on them. + private var plateLine: String? { + guard let target, weightKg > 0 else { return nil } + // `fitted.targets` are raw plan entries — the *kind* is only resolved + // against the exercise library in the day payload, so a bar looks like + // a machine if you ask the session's own copy. + let kind = controller.planned(target.slug)?.kind ?? target.kind + if kind == "bb", let profile = controller.profile, !profile.platesKg.isEmpty { + return PlateMath.perSideLine(weightKg, profile: profile) + } + if kind == "db" { + return PlateMath.description(kind: "db", weight: weightKg, profile: nil, + unit: unit, slug: target.slug) + } + // No equipment profile: the server worked it out for the prescription, + // which stays right until the user steps off it — so only show it while + // they haven't. + return weightKg == target.weight ? controller.planned(target.slug)?.plate : nil + } - private var restBar: some View { - ForgeCard { - HStack { - VStack(alignment: .leading, spacing: 2) { - ForgeLabel("Rest") - Text(Format.timer(controller.restRemaining)) - .font(ForgeFont.stat) - .foregroundStyle(theme.accent) + // MARK: - RPE + + private var rpeRow: some View { + HStack(spacing: 8) { + ForgeLabel("RPE") + .frame(width: 34, alignment: .leading) + ForEach(6...10, id: \.self) { value in + Button { + rpe = rpe == value ? nil : value + } label: { + Text("\(value)") + .font(ForgeFont.numeric(.subheadline)) + .foregroundStyle(rpe == value ? theme.accent : theme.mut) + .frame(maxWidth: .infinity) + .padding(.vertical, 11) + .background(theme.raised, in: .rect(cornerRadius: 11)) + .overlay { + if rpe == value { + RoundedRectangle(cornerRadius: 11) + .strokeBorder(theme.accent, lineWidth: 1.5) + } + } } - Spacer() - Button("+30s") { controller.addRest(30) } - .font(.system(.subheadline, weight: .semibold)) + .buttonStyle(.plain) + } + } + } + + // MARK: - Sets + + /// Hairline rows, not cards — done sets are reference, and the board keeps + /// them quiet so the load numeral stays the loudest thing on screen. The + /// row for the set *after* next is shown greyed with its rest, so you know + /// what's coming without it competing. + private var setRows: some View { + let done = controller.sets(for: controller.currentSlug) + let planned = target?.sets ?? 0 + return VStack(spacing: 0) { + ForEach(done) { set in + row(title: "Set \(set.setNo)", + detail: "\(Units.fmtLoad(set.weightKg, unit)) × \(set.reps)\(timedHold ? "s" : "")" + + (set.rpe.map { " · RPE \($0)" } ?? ""), + done: true, synced: set.synced) + } + if controller.nextSetNo < planned, let rest = target?.rest { + row(title: "Set \(controller.nextSetNo + 1)", + detail: "after \(Format.timer(rest)) rest", + done: false, synced: true) + } + if let last = done.last, !last.synced { + Button("Undo last set") { controller.undoLast() } + .font(.footnote) .foregroundStyle(theme.mut) - Button("Skip") { controller.cancelRest() } - .font(.system(.subheadline, weight: .semibold)) - .foregroundStyle(theme.accent) - .padding(.leading, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 8) } } } - // MARK: - Logged sets + private func row(title: String, detail: String, done: Bool, synced: Bool) -> some View { + HStack { + Text(title) + .font(.system(.subheadline)) + .foregroundStyle(done ? theme.ink : theme.dim) + Spacer(minLength: 8) + Text(detail) + .font(ForgeFont.numeric(.subheadline, weight: .regular)) + .foregroundStyle(done ? theme.mut : theme.dim) + if done { + Image(systemName: synced ? "checkmark" : "arrow.trianglehead.2.clockwise") + .font(.system(.caption, weight: .semibold)) + .foregroundStyle(synced ? theme.accent : theme.dim) + } + } + .padding(.vertical, 11) + .overlay(alignment: .bottom) { Rectangle().fill(theme.hair).frame(height: 1) } + } - @ViewBuilder - private var loggedSets: some View { - let sets = controller.sets(for: controller.currentSlug) - if !sets.isEmpty { - VStack(alignment: .leading, spacing: 8) { - ForgeLabel("Logged") - ForEach(sets) { set in - HStack { - Text("\(set.setNo)") - .font(ForgeFont.numeric(.footnote)) - .foregroundStyle(theme.dim) - .frame(width: 20, alignment: .leading) - Text("\(Units.fmtLoad(set.weightKg, unit)) × \(set.reps)\(timedHold ? "s" : "")") - .font(ForgeFont.numeric(.subheadline)) - .foregroundStyle(theme.ink) - if let rpe = set.rpe { - Text("@\(rpe)") - .font(.caption) - .foregroundStyle(theme.dim) - } - Spacer() - if !set.synced { - Image(systemName: "arrow.trianglehead.2.clockwise") - .font(.caption) - .foregroundStyle(theme.dim) - } - } - .padding(.vertical, 8) - .padding(.horizontal, 12) - .background(theme.raised, in: .rect(cornerRadius: 10)) - } - if let last = sets.last, !last.synced { - Button("Undo last set") { controller.undoLast() } - .font(.footnote) - .foregroundStyle(theme.mut) - } + // MARK: - Tiles + + /// Session clock, running tonnage, and the rest countdown when one's + /// ticking. The board's third tile is live heart rate — that needs + /// HealthKit (N2), so rest takes the slot rather than a fake number. + private var tiles: some View { + HStack(spacing: 10) { + SessionTile(label: "Session", value: Format.timer(Int(elapsed))) + SessionTile(label: "Volume", value: Units.trim(controller.tonnage), unit: "t") + if controller.restRemaining > 0 { + SessionTile(label: "Rest", value: Format.timer(controller.restRemaining), + accented: true) + .onTapGesture { controller.cancelRest() } + } else { + SessionTile(label: "Sets", + value: "\(controller.totalDone)/\(controller.totalPlanned)") } } + .padding(.top, 4) } // MARK: - Log bar @@ -242,20 +383,25 @@ struct SessionView: View { .foregroundStyle(theme.warn) .lineLimit(2) } - Button(controller.isCurrentExerciseDone ? "Next exercise" : "Log set") { + if controller.pendingSync > 0 { + Text("\(controller.pendingSync) waiting to sync") + .font(.caption) + .foregroundStyle(theme.dim) + } + Button { if controller.isCurrentExerciseDone { controller.advanceIfDone() } else { controller.log(weightKg: weightKg, reps: reps, rpe: rpe) controller.advanceIfDone() } + } label: { + Text(logLabel) + .font(ForgeFont.display(22, relativeTo: .headline)) + .tracking(0.6) } .buttonStyle(.pill) .disabled(reps == 0 && !controller.isCurrentExerciseDone) - - Text("\(controller.totalDone) of \(controller.totalPlanned) sets · \(Units.trim(controller.tonnage)) t") - .font(.caption) - .foregroundStyle(theme.dim) } .padding(.horizontal, 18) .padding(.top, 10) @@ -263,6 +409,13 @@ struct SessionView: View { .background(.bar) } + private var logLabel: String { + if controller.isCurrentExerciseDone { + return controller.isComplete ? "FINISH WORKOUT" : "NEXT EXERCISE" + } + return "LOG SET \(min(controller.nextSetNo, target?.sets ?? 1))" + } + // MARK: - PB banner @ViewBuilder @@ -304,48 +457,128 @@ struct SessionView: View { } } -// MARK: - Stepper +// MARK: - Compact stepper -/// Minus / big DIN numeral / plus. Named for the two controls flanking the -/// value — SwiftUI's own `Stepper` is far too small for a working set. -private struct Stepper2: View { +/// − value + in one pill, with the unit as a caps label underneath. Small on +/// purpose: the board puts the reading in the big numeral above and leaves +/// these as controls, not displays. +private struct CompactStepper: View { @Environment(\.theme) private var theme - let label: String let value: String - var unit: String? + let label: String let onMinus: () -> Void let onPlus: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 8) { - ForgeLabel(label) - HStack(spacing: 14) { + VStack(spacing: 2) { + HStack(spacing: 6) { button("minus", action: onMinus) - VStack(spacing: 0) { - Text(value) - .font(ForgeFont.display(52, relativeTo: .largeTitle)) - .foregroundStyle(theme.ink) - .contentTransition(.numericText()) - if let unit { - Text(unit) - .font(.system(.caption, weight: .medium)) - .foregroundStyle(theme.dim) - } - } - .frame(maxWidth: .infinity) + Text(value) + .font(ForgeFont.display(28, relativeTo: .title2)) + .foregroundStyle(theme.ink) + .contentTransition(.numericText()) + .frame(maxWidth: .infinity) + .lineLimit(1) + .minimumScaleFactor(0.6) button("plus", action: onPlus) } + ForgeLabel(label) } + .padding(.vertical, 10) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity) + .background(theme.sunken, in: .rect(cornerRadius: 16)) } private func button(_ symbol: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: symbol) - .font(.system(.title3, weight: .bold)) - .frame(width: 56, height: 56) - .background(theme.raised, in: .circle) + .font(.system(.subheadline, weight: .bold)) + .foregroundStyle(theme.mut) + // Small glyph, full-size hit target — this is pressed with a + // thumb, between sets, without looking. + .frame(width: 44, height: 44) + .contentShape(.rect) + } + .buttonStyle(.plain) + } +} + +// MARK: - Session tile + +private struct SessionTile: View { + @Environment(\.theme) private var theme + let label: String + let value: String + var unit: String? + var accented = false + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + ForgeLabel(label) + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(value) + .font(ForgeFont.display(26, relativeTo: .title2)) + .foregroundStyle(accented ? theme.accent : theme.ink) + .contentTransition(.numericText()) + if let unit { + Text(unit) + .font(.system(.caption2, weight: .medium)) + .foregroundStyle(theme.dim) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 11) + .padding(.horizontal, 13) + .background(theme.raised, in: .rect(cornerRadius: 14)) + } +} + +// MARK: - Exercise picker + +/// Jumping between exercises. Off the main screen because the board doesn't +/// have a horizontal strip any more — the progress rule says where you are and +/// this says where else you could be. +private struct ExercisePicker: View { + @Environment(\.theme) private var theme + @Environment(\.dismiss) private var dismiss + @Bindable var controller: SessionController + + var body: some View { + NavigationStack { + List { + ForEach(controller.targets) { item in + Button { + controller.select(item.slug) + dismiss() + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(controller.displayName(item.slug)) + .font(.system(.body, weight: .medium)) + .foregroundStyle(item.slug == controller.currentSlug + ? theme.accent : theme.ink) + Text("\(item.sets) × \(item.reps)") + .font(.system(.caption)) + .foregroundStyle(theme.dim) + } + Spacer() + Text("\(controller.doneCount(for: item.slug))/\(item.sets)") + .font(ForgeFont.numeric(.subheadline)) + .foregroundStyle(controller.doneCount(for: item.slug) >= item.sets + ? theme.accent : theme.mut) + } + } + .buttonStyle(.plain) + .listRowBackground(theme.raised) + } + } + .scrollContentBackground(.hidden) + .background(theme.bg) + .navigationTitle("Exercises") + .navigationBarTitleDisplayMode(.inline) } - .foregroundStyle(theme.ink) } } diff --git a/ios/Forge/Forge/Features/Train/TodayView.swift b/ios/Forge/Forge/Features/Train/TodayView.swift new file mode 100644 index 0000000..2b74f89 --- /dev/null +++ b/ios/Forge/Forge/Features/Train/TodayView.swift @@ -0,0 +1,407 @@ +import SwiftUI + +/// The Train tab's root — **Today**, built to the decided board +/// (`docs/mockups/native/redesign-d-type.png`): date kicker, big DIN title, a +/// media-led session hero with the CTA inside it, cardio tiles, and the week +/// strip at the *bottom* as secondary navigation. +/// +/// The week list lives one level down (`WeekView`); it is not the landing. +struct TodayView: View { + @Environment(\.theme) private var theme + @Environment(AuthController.self) private var auth + @Environment(SetQueue.self) private var queue + + @State private var today: Today? + @State private var week: WeekResponse? + @State private var progress: Progress? + @State private var food: FoodWeek? + @State private var errorMessage: String? + @State private var starting = false + @State private var live: SessionController? + + private var prefs: Prefs? { auth.me?.prefs } + + var body: some View { + NavigationStack { + Group { + if let today { + content(today) + } else if let errorMessage { + ForgeErrorView(message: errorMessage) { Task { await load() } } + } else { + ForgeProgress() + } + } + .background(theme.bg) + .toolbar(.hidden, for: .navigationBar) + .navigationDestination(for: TrainRoute.self) { route in + switch route { + case .week: WeekView().trainTheme() + case .day(let date): DayView(date: date).trainTheme() + case .progress: TrendsView().trainTheme() + case .history: HistoryView().trainTheme() + } + } + .navigationDestination(item: $live) { controller in + SessionView(controller: controller).trainTheme() + } + } + .task { if today == nil { await load() } } + } + + private func load() async { + errorMessage = nil + do { + // The server owns "today" — never the phone's clock. + async let weekCall = auth.client.week() + let fetchedWeek = try await weekCall + week = fetchedWeek + today = try await auth.client.today(date: fetchedWeek.today) + // Recovery only rides on the day payload for rest days, but the + // tiles are on screen every day — take them from Progress, which + // always has the latest sample. + progress = try? await auth.client.progress() + // Read-only here by design (N4): Train shows what's for dinner, + // Cook owns every action on it. `try?` because nutrition is a beta + // track — an unseeded food library must not break the Train tab. + food = try? await auth.client.foodWeek(date: fetchedWeek.today) + } catch { + errorMessage = error.localizedDescription + } + } + + // MARK: - Content + + @ViewBuilder + private func content(_ day: Today) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header(day) + hero(day) + if day.kind == .strength, let est = day.est { + Text("fits your \(day.budget ?? est)-min budget · cooldown \(day.cd == "short" ? 2 : 5) min") + .font(.footnote) + .foregroundStyle(theme.dim) + .frame(maxWidth: .infinity, alignment: .center) + } + cardioTiles(day) + mealsStrip() + weekStrip() + } + .padding(.horizontal, 18) + .padding(.bottom, 28) + } + .scrollEdgeEffectStyle(.soft, for: .top) + .refreshable { await load() } + } + + private func header(_ day: Today) -> some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 2) { + ForgeLabel(kicker(day)) + Text("TODAY") + .font(ForgeFont.display(46, relativeTo: .largeTitle)) + .foregroundStyle(theme.ink) + } + Spacer() + HStack(spacing: 10) { + NavigationLink(value: TrainRoute.history) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(.body, weight: .medium)) + .foregroundStyle(theme.mut) + .frame(width: 40, height: 40) + } + NavigationLink(value: TrainRoute.progress) { + Text(String(auth.me?.name.prefix(1) ?? "F")) + .font(.system(.headline, weight: .semibold)) + .foregroundStyle(theme.accent) + .frame(width: 40, height: 40) + .overlay(Circle().strokeBorder(theme.accent, lineWidth: 1.5)) + } + } + } + .padding(.top, 6) + } + + /// "MONDAY 27 JULY · WEEK 31" + private func kicker(_ day: Today) -> String { + guard let date = ForgeDate.date(from: day.date) else { return day.dayName } + let formatter = DateFormatter() + formatter.dateFormat = "d MMMM" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + let weekNumber = ForgeDate.calendar.component(.weekOfYear, from: date) + return "\(day.dayName) \(formatter.string(from: date)) · Week \(weekNumber)" + } + + // MARK: - Hero + + @ViewBuilder + private func hero(_ day: Today) -> some View { + switch day.kind { + case .strength: strengthHero(day) + case .cardio: cardioHero(day) + case .rest: restHero(day) + } + } + + private func strengthHero(_ day: Today) -> some View { + let lifts = day.workingSets + let main = lifts.first { $0.isMainLift } ?? lifts.first + return MediaCard(hero: .forStrength(mainLiftSlug: main?.slug, dayName: day.name)) { + VStack(alignment: .leading, spacing: 10) { + ForgeLabel([day.name, main.map { _ in "Main lift" }].compactMap { $0 }.joined(separator: " · "), + color: theme.accent) + Text((main?.name ?? day.name ?? "Session").uppercased()) + .font(ForgeFont.display(34, relativeTo: .title)) + .foregroundStyle(heroInk) + + VStack(spacing: 6) { + ForEach(lifts.prefix(4)) { lift in + HStack(alignment: .firstTextBaseline) { + Text(lift.name) + .font(.system(.subheadline, weight: .medium)) + .foregroundStyle(heroInk) + if lift.isMainLift { + Text("MAIN") + .font(.system(.caption2, weight: .bold)) + .foregroundStyle(theme.accent) + } + Spacer(minLength: 8) + Text(prescription(lift)) + .font(ForgeFont.numeric(.subheadline, weight: .regular)) + .foregroundStyle(heroInk.opacity(0.75)) + } + } + } + + Button { + Task { await start(day) } + } label: { + Text(day.session?.isActive == true ? "RESUME WORKOUT" : startLabel(day)) + .font(ForgeFont.display(20, relativeTo: .headline)) + .tracking(0.5) + } + .buttonStyle(.pill) + .disabled(starting) + .padding(.top, 4) + } + } + } + + private func startLabel(_ day: Today) -> String { + day.est.map { "START WORKOUT · \($0) MIN" } ?? "START WORKOUT" + } + + private func cardioHero(_ day: Today) -> some View { + MediaCard(hero: .forCardio(type: day.cardio?.type, name: day.name), height: 150) { + VStack(alignment: .leading, spacing: 8) { + ForgeLabel(day.name ?? "Cardio", color: theme.accent) + Text((day.cardio?.type ?? "Zone 2").uppercased()) + .font(ForgeFont.display(34, relativeTo: .title)) + .foregroundStyle(heroInk) + if let cardio = day.cardio { + Text("\(cardio.minutes ?? 0) min" + + (cardio.hrLow.flatMap { low in cardio.hrHigh.map { " · \(low)–\($0) bpm" } } ?? "")) + .font(ForgeFont.numeric(.subheadline)) + .foregroundStyle(heroInk.opacity(0.75)) + } + } + } + } + + private func restHero(_ day: Today) -> some View { + MediaCard(hero: .rest, height: 150) { + VStack(alignment: .leading, spacing: 8) { + ForgeLabel("Recovery", color: theme.accent) + Text("REST DAY") + .font(ForgeFont.display(34, relativeTo: .title)) + .foregroundStyle(heroInk) + if let tomorrow = day.tomorrow { + Text("Next: \(tomorrow.name ?? tomorrow.kind.capitalized) — \(tomorrow.dayName)") + .font(.footnote) + .foregroundStyle(heroInk.opacity(0.75)) + } + } + } + } + + /// White on the scrim in dark; ink on the card in light. + private var heroInk: Color { + colorScheme == .dark ? .white : theme.ink + } + + @Environment(\.colorScheme) private var colorScheme + + private func prescription(_ lift: TodayExercise) -> String { + let unit = Units.loadUnit(prefs: prefs, slug: lift.slug) + let reps = isTimed(lift.slug) || isTimed(lift.name) ? "\(lift.reps)s" : "\(lift.reps)" + return lift.weight > 0 + ? "\(lift.sets) × \(reps) · \(Units.fmtLoad(lift.weight, unit))" + : "\(lift.sets) × \(reps)" + } + + // MARK: - Cardio tiles + + private func cardioTiles(_ day: Today) -> some View { + HStack(spacing: 10) { + // Photographic, per the design: the Zone-2 tile is one of the three + // surfaces media leads on (with the session hero and PR banners). + CardioTile(label: "Zone 2", + value: "\(progress?.zone2.done ?? 0)", + caption: "/\(progress?.zone2.target ?? 150) min", + accented: true, + hero: .run) + CardioTile(label: "Sleep", + value: sleepValue(day), + caption: "h") + CardioTile(label: "Rest HR", + value: restingHrValue(day), + caption: "bpm") + } + } + + private func sleepValue(_ day: Today) -> String { + if let point = day.recovery?.sleepH { return String(format: "%.1f", point.value) } + return progress?.sleepH.last.map { String(format: "%.1f", $0.v) } ?? "—" + } + + private func restingHrValue(_ day: Today) -> String { + if let point = day.recovery?.restingHr { return String(Int(point.value)) } + return progress?.restingHr.last.map { String(Int($0.v)) } ?? "—" + } + + // MARK: - Meals + + /// Tonight's dinner and the day's macros, read-only — the Cook tab owns + /// every action on food. Volt, not ember: this is a Train screen, and the + /// accent belongs to the module you're standing in. + @ViewBuilder + private func mealsStrip() -> some View { + if let day = food?.todayDay, day.totals.kcal > 0 || day.dinner?.recipe != nil { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + ForgeLabel("Tonight") + Text(day.dinner?.displayName ?? "Nothing planned") + .font(.system(.subheadline, weight: .medium)) + .foregroundStyle(theme.ink) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 3) { + Text("\(Int(day.totals.kcal.rounded()))") + .font(ForgeFont.display(26, relativeTo: .title2)) + .foregroundStyle(theme.ink) + Text("\(Int(day.totals.proteinG.rounded()))g protein") + .font(.system(.caption2)) + .foregroundStyle(theme.dim) + } + } + .padding(.vertical, 12) + .padding(.horizontal, 14) + .background(theme.raised, in: .rect(cornerRadius: 16)) + } + } + + // MARK: - Week strip + + private func weekStrip() -> some View { + NavigationLink(value: TrainRoute.week) { + HStack(spacing: 6) { + ForEach(week?.days ?? []) { day in + VStack(spacing: 6) { + Text(String(day.dayName.prefix(3)).uppercased()) + .font(.system(.caption2, weight: .bold)) + .foregroundStyle(day.isToday ? theme.accent : theme.dim) + Circle() + .fill(dotFill(day)) + .strokeBorder(day.session?.isDone == true ? .clear : theme.hairIn, lineWidth: 1) + .frame(width: 7, height: 7) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(theme.raised, in: .rect(cornerRadius: 12)) + .overlay { + if day.isToday { + RoundedRectangle(cornerRadius: 12) + .strokeBorder(theme.accent, lineWidth: 1.5) + } + } + } + } + } + .buttonStyle(.plain) + .padding(.top, 4) + } + + private func dotFill(_ day: WeekDay) -> Color { + if day.session?.isDone == true { return theme.accent } + if day.isMissed(today: week?.today ?? day.date) { return theme.warn.opacity(0.5) } + return .clear + } + + // MARK: - Start + + private func start(_ day: Today) async { + starting = true + defer { starting = false } + do { + let response = try await auth.client.startSession(date: day.date, budget: day.budget) + live = SessionController(sessionId: response.id, fitted: response.fitted, + client: auth.client, queue: queue, + plan: day.exercises ?? [], + profile: day.profile) + } catch { + errorMessage = error.localizedDescription + } + } +} + +nonisolated enum TrainRoute: Hashable { + case week + case day(String) + case progress + case history +} + +// MARK: - Cardio tile + +private struct CardioTile: View { + @Environment(\.theme) private var theme + let label: String + let value: String + var caption: String? + var accented = false + var hero: HeroImage? + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ForgeLabel(label, color: accented ? theme.accent : theme.dim) + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text(value) + .font(ForgeFont.display(30, relativeTo: .title2)) + .foregroundStyle(theme.ink) + if let caption { + Text(caption) + .font(.system(.caption2, weight: .medium)) + .foregroundStyle(theme.dim) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 12) + .padding(.horizontal, 14) + .background { + if let hero { + ZStack { + Image(hero: hero).resizable().scaledToFill() + LinearGradient(colors: [.black.opacity(0.45), .black.opacity(0.8)], + startPoint: .top, endPoint: .bottom) + } + } else { + theme.raised + } + } + .clipShape(.rect(cornerRadius: 16)) + } +} diff --git a/ios/Forge/Forge/Kit/APIClient.swift b/ios/Forge/Forge/Kit/APIClient.swift index 6f9ccd5..e8fb3ef 100644 --- a/ios/Forge/Forge/Kit/APIClient.swift +++ b/ios/Forge/Forge/Kit/APIClient.swift @@ -159,6 +159,10 @@ extension APIClient { try await get("/api/sessions/\(id)") } + func exercise(slug: String) async throws -> ExerciseInfo { + try await get("/api/exercises/\(slug)") + } + func logSet(sessionId: String, set: SetPayload) async throws -> LogSetResponse { try await post("/api/sessions/\(sessionId)/sets", body: set) } @@ -167,6 +171,184 @@ extension APIClient { try await post("/api/sessions/\(sessionId)/complete", body: body) } + // MARK: History, records, trends + + /// Offset-paginated. `favorites` narrows to starred sessions. + func history(limit: Int = 30, offset: Int = 0, favorites: Bool = false) async throws -> [HistoryItem] { + var query = ["limit": String(limit), "offset": String(offset)] + if favorites { query["favorites"] = "true" } + return try await get("/api/history", query: query) + } + + func progress() async throws -> Progress { + try await get("/api/progress") + } + + func records() async throws -> [RecordRow] { + try await get("/api/records") + } + + @discardableResult + func setFavorite(sessionId: String, favorite: Bool) async throws -> EmptyResponse { + nonisolated struct Body: Encodable { let favorite: Bool } + return try await request("/api/sessions/\(sessionId)/favorite", method: "PATCH", + query: [:], body: Body(favorite: favorite)) + } + + /// Discards a session and its sets. All-time records survive — the same + /// simplification the server makes for `edit_set`. + @discardableResult + func deleteSession(id: String) async throws -> EmptyResponse { + try await request("/api/sessions/\(id)", method: "DELETE", + query: [:], body: Optional.none) + } + + // MARK: Coach + + func chat() async throws -> ChatResponse { + try await get("/api/chat") + } + + /// Fire-and-poll: the server runs the coach turn on a background thread and + /// answers immediately with `pending`, so the client polls `chat()` rather + /// than holding a request open across a model call. + @discardableResult + func sendChat(_ text: String, context: ChatContext? = nil) async throws -> EmptyResponse { + nonisolated struct Body: Encodable { + let text: String + let context: [String: String]? + } + var payload: [String: String]? + if let context { + payload = ["kind": context.kind, "label": context.label] + if let id = context.id { payload?["id"] = id } + } + return try await post("/api/chat", body: Body(text: text, context: payload)) + } + + func proposal() async throws -> ProposalResponse { + try await get("/api/proposal") + } + + @discardableResult + func approveProposal(id: String) async throws -> EmptyResponse { + try await post("/api/proposal/\(id)/approve", body: EmptyResponse()) + } + + @discardableResult + func rejectProposal(id: String) async throws -> EmptyResponse { + try await post("/api/proposal/\(id)/reject", body: EmptyResponse()) + } + + // MARK: - Cook + + /// Calendar-week food view, same Mon–Sun contract as `/api/week`. The plan + /// is household-shared; the logs inside it are this user's. + func foodWeek(date: String? = nil) async throws -> FoodWeek { + try await get("/api/food/week", query: date.map { ["date": $0] } ?? [:]) + } + + /// The whole library including parked imports — the UI badges them rather + /// than hiding them, so filtering happens client-side. + func recipes(q: String = "", kind: String? = nil) async throws -> RecipeListResponse { + var query: [String: String] = [:] + if !q.isEmpty { query["q"] = q } + if let kind { query["kind"] = kind } + return try await get("/api/food/recipes", query: query) + } + + func recipe(slug: String) async throws -> RecipeDetail { + try await get("/api/food/recipes/\(slug)") + } + + /// Log a meal into a slot. Breakfast/lunch/dinner replace whatever was in + /// the slot; snacks stack. `clientId` makes the write idempotent. + @discardableResult + func logMeal(date: String, slot: String, recipe: String, servings: Double = 1, + clientId: String) async throws -> LogMealResponse { + nonisolated struct Body: Encodable { + let date: String + let slot: String + let recipe: String + let servings: Double + let client_id: String + } + return try await post("/api/food/log", + body: Body(date: date, slot: slot, recipe: recipe, + servings: servings, client_id: clientId)) + } + + @discardableResult + func unlogMeal(id: String) async throws -> EmptyResponse { + try await request("/api/food/log/\(id)", method: "DELETE", + query: [:], body: Optional.none) + } + + /// Pencil a recipe into a weekday's slot on the active food week. This is a + /// direct user edit — no coach proposal — and it's shared, so it changes + /// what the other seat sees too. + @discardableResult + func planSlot(date: String, recipe: String, slot: String = "dinner") async throws -> PlanSlotResponse { + nonisolated struct Body: Encodable { + let date: String + let recipe: String + let slot: String + } + return try await request("/api/food/week/slot", method: "PATCH", query: [:], + body: Body(date: date, recipe: recipe, slot: slot)) + } + + /// The pending food-week proposal, or `nil` when there's nothing to decide. + /// Household-scoped like the plan itself — either seat can approve it. + func foodProposal() async throws -> FoodProposalResponse { + try await get("/api/food/proposal") + } + + @discardableResult + func approveFoodProposal(id: String) async throws -> EmptyResponse { + try await post("/api/food/proposal/\(id)/approve", body: EmptyResponse()) + } + + @discardableResult + func rejectFoodProposal(id: String) async throws -> EmptyResponse { + try await post("/api/food/proposal/\(id)/reject", body: EmptyResponse()) + } + + /// Bytes for a recipe/meal image reference. + /// + /// The reference is either a path on our own server or an absolute URL an + /// import kept when it couldn't materialize the blob. The session cookie + /// goes **only** to our origin — sending it to a recipe site's CDN would + /// hand out a live Forge session. + func imageData(_ ref: String) async throws -> Data { + let url: URL + var sameOrigin = true + if ref.hasPrefix("http://") || ref.hasPrefix("https://") { + guard let remote = URL(string: ref) else { throw APIError.network } + url = remote + sameOrigin = remote.host == baseURL.host && remote.port == baseURL.port + } else { + url = baseURL.appendingPathComponent(ref.hasPrefix("/") ? String(ref.dropFirst()) : ref) + } + + var req = URLRequest(url: url) + if sameOrigin, let token { + req.setValue("\(Self.sessionCookieName)=\(token)", forHTTPHeaderField: "Cookie") + } + + let data: Data, response: URLResponse + do { + (data, response) = try await session.data(for: req) + } catch { + throw APIError.network + } + guard let http = response as? HTTPURLResponse else { throw APIError.network } + guard (200..<300).contains(http.statusCode) else { + throw APIError.server(status: http.statusCode, detail: "") + } + return data + } + /// Dev sign-in (only offered when Google is unconfigured, same as the web). /// The server answers with `Set-Cookie`; we lift the session value out of /// the header ourselves because this client deliberately stores no cookies. diff --git a/ios/Forge/Forge/Kit/CoachModels.swift b/ios/Forge/Forge/Kit/CoachModels.swift new file mode 100644 index 0000000..b33906e --- /dev/null +++ b/ios/Forge/Forge/Kit/CoachModels.swift @@ -0,0 +1,99 @@ +import Foundation + +/// The coach surface — chat plus the proposal the user approves. +/// +/// Proposals are never auto-applied unless `prefs.coach_approval == 'auto'`; +/// the app's job is to render the delta honestly and let the user decide. + +nonisolated struct ChatMessage: Codable, Sendable, Equatable, Identifiable { + /// "me" | "coach" + let who: String + let text: String + let at: String? + + var isCoach: Bool { who == "coach" } + /// Server rows have no id; the timestamp plus role is stable enough for + /// SwiftUI's diffing and avoids an index-based identity that reshuffles. + var id: String { "\(at ?? "")#\(who)#\(text.hashValue)" } +} + +nonisolated struct ChatResponse: Codable, Sendable, Equatable { + let messages: [ChatMessage] + /// True while a coach turn is running server-side. The client polls until + /// it clears rather than holding a request open. + let pending: Bool +} + +/// What the user was looking at when they opened the coach. +nonisolated struct ChatContext: Codable, Sendable, Equatable { + /// "session" | "exercise" | "proposal" + let kind: String + let id: String? + let label: String +} + +// MARK: - Proposals + +nonisolated struct ProposalExercise: Codable, Sendable, Hashable, Identifiable { + let slug: String + let sets: Int + let reps: Int + let weight: Double? + + var id: String { slug } +} + +nonisolated struct ProposalCardio: Codable, Sendable, Hashable { + let minutes: Int? + let hrLow: Int? + let hrHigh: Int? +} + +nonisolated struct ProposalDay: Codable, Sendable, Hashable { + let name: String + let kind: DayKind + let focus: [String]? + /// One-liner justifying this day. The server requires it; the UI renders it. + let why: String? + let exercises: [ProposalExercise]? + let cardio: ProposalCardio? +} + +nonisolated struct ProposalChange: Codable, Sendable, Hashable, Identifiable { + /// "+" added · "-" removed · "~" adjusted + let sign: String + let what: String + let why: String? + + var id: String { "\(sign)\(what)" } +} + +nonisolated struct ProposalContent: Codable, Sendable, Hashable { + /// Keyed by weekday number as a string, "0" (Monday) … "6". + let days: [String: ProposalDay] + let changes: [ProposalChange]? +} + +nonisolated struct Proposal: Codable, Sendable, Hashable, Identifiable { + let id: String + let num: Int + let rationale: String + let createdAt: String + let content: ProposalContent + + /// Monday-first, so the review reads in the order the week is lived. + var orderedDays: [(weekday: Int, day: ProposalDay)] { + content.days + .compactMap { key, value in Int(key).map { ($0, value) } } + .sorted { $0.0 < $1.0 } + .map { (weekday: $0.0, day: $0.1) } + } +} + +nonisolated struct ProposalResponse: Codable, Sendable, Equatable { + let proposal: Proposal? +} + +nonisolated let weekdayNames = [ + "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", +] diff --git a/ios/Forge/Forge/Kit/CookCue.swift b/ios/Forge/Forge/Kit/CookCue.swift new file mode 100644 index 0000000..5599833 --- /dev/null +++ b/ios/Forge/Forge/Kit/CookCue.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Cook mode's step text carries one idea in ember: **the done-when cue**. +/// +/// On the board (`docs/mockups/native/cook-mode.png`) "It's ready when the skin +/// releases with no tug" is ember inside an otherwise black sentence — the cue +/// is what you look up from the pan to check, so it gets the accent. +/// +/// This is derivable rather than authored because Forge's recipe rule already +/// requires it: every imported step is "rewritten in done-when voice (each step +/// states the cue that tells you it's done)" — root `CLAUDE.md`. So the marker +/// is reliably in the prose, and matching it beats adding a field the MCP +/// importer and the seed would both have to start filling in. +nonisolated enum CookCue { + /// Phrases that open a done-when clause, longest first so "it's ready when" + /// wins over a bare "ready when". + private static let openers = [ + "done when", "ready when", "you'll know when", "you know when", + "it's ready when", "it's done when", "stop when", "pull it when", + "take it off when", "keep going until", "cook until", "until", + ] + + /// The cue's range inside `detail`, or nil when the step has no cue — + /// prep steps ("Oven to 220° fan") genuinely don't have one and shouldn't + /// get a highlight invented for them. + /// + /// The clause runs from the opener to the next sentence break or em dash; + /// the board's example ends at " — about 4 minutes", which is timing, not + /// the cue. + static func range(in detail: String) -> Range? { + let lower = detail.lowercased() + var best: Range? + for opener in openers { + guard let found = lower.range(of: opener) else { continue } + // Earliest opener wins: a step with two cues means the first one is + // the one you're waiting on right now. + if let current = best, found.lowerBound >= current.lowerBound { continue } + best = found + } + guard let opener = best else { return nil } + + // Walk back to the start of the sentence the opener sits in, so the + // highlight reads as a whole clause rather than starting mid-thought. + let start = sentenceStart(in: detail, before: opener.lowerBound) + let end = clauseEnd(in: detail, from: opener.upperBound) + return start < end ? start.. (before: String, cue: String, after: String) { + guard let range = range(in: detail) else { return (detail, "", "") } + return (String(detail[detail.startIndex.. = [".", "!", "?", ";"] + private static let clauseBreaks: Set = ["—", "–"] + + private static func sentenceStart(in text: String, before index: String.Index) -> String.Index { + var i = index + while i > text.startIndex { + let previous = text.index(before: i) + let ch = text[previous] + if sentenceBreaks.contains(ch) || clauseBreaks.contains(ch) { break } + i = previous + } + // Skip the whitespace the break left behind. + while i < index, text[i].isWhitespace { i = text.index(after: i) } + return i + } + + private static func clauseEnd(in text: String, from index: String.Index) -> String.Index { + var i = index + while i < text.endIndex { + let ch = text[i] + if sentenceBreaks.contains(ch) || clauseBreaks.contains(ch) { break } + i = text.index(after: i) + } + // Trim the space before the break so the ember doesn't own it. + while i > index, text[text.index(before: i)].isWhitespace { i = text.index(before: i) } + return i + } +} diff --git a/ios/Forge/Forge/Kit/DataModels.swift b/ios/Forge/Forge/Kit/DataModels.swift new file mode 100644 index 0000000..cdffd70 --- /dev/null +++ b/ios/Forge/Forge/Kit/DataModels.swift @@ -0,0 +1,128 @@ +import Foundation + +/// History, records and trends — `/api/history`, `/api/progress`, `/api/records`, +/// and the full `/api/sessions/{id}` detail. Mirrors `web/src/api.ts`. + +nonisolated struct HistoryItem: Codable, Sendable, Equatable, Identifiable { + let id: String + let day: String + let name: String + let kind: String + let status: String + let stats: SessionStats + var favorite: Bool? + + var isFavorite: Bool { favorite ?? false } +} + +/// One point on a trend line. The server sends compact keys. +nonisolated struct SeriesPoint: Codable, Sendable, Equatable, Identifiable { + let d: String + let v: Double + + var id: String { d } + var date: Date { ForgeDate.date(from: d) ?? .distantPast } +} + +nonisolated struct LiftSeries: Codable, Sendable, Equatable { + let name: String + let points: [SeriesPoint] +} + +nonisolated struct BodyComp: Codable, Sendable, Equatable { + let fatPct: [SeriesPoint] + let muscle: [SeriesPoint] + let bone: [SeriesPoint] + let waterPct: [SeriesPoint] + let heightCm: Double? +} + +nonisolated struct Zone2Progress: Codable, Sendable, Equatable { + let done: Int + let target: Int +} + +nonisolated struct WeekProgress: Codable, Sendable, Equatable { + let done: Int + let planned: Int +} + +nonisolated struct Progress: Codable, Sendable, Equatable { + let e1rm: [String: LiftSeries] + let weight: [SeriesPoint] + let vo2max: [SeriesPoint] + /// Centred rolling mean. VO2max renders raw dots **plus** this trend and + /// never deltas — single readings are noisy enough to mislead. + let vo2maxSmooth: [SeriesPoint] + let restingHr: [SeriesPoint] + let sleepH: [SeriesPoint] + let zone2: Zone2Progress + let bodycomp: BodyComp + let week: WeekProgress + + /// Lifts with enough history to plot, heaviest-first by latest e1RM. + var plottableLifts: [(slug: String, series: LiftSeries)] { + e1rm.filter { $0.value.points.count > 1 } + .sorted { ($0.value.points.last?.v ?? 0) > ($1.value.points.last?.v ?? 0) } + .map { (slug: $0.key, series: $0.value) } + } +} + +nonisolated struct RecordRow: Codable, Sendable, Equatable, Identifiable { + let slug: String + let name: String + /// "e1rm" | "best_set" + let kind: String + /// Canonical kg — the number to display, converted. + let value: Double + /// Server-formatted "72.5 × 5". The load in it is **kg**, so it must never + /// be shown raw to a user reading in lb. + let detail: String + let achievedOn: String + + var id: String { "\(slug)#\(kind)" } + + /// "× 5" — the reps half of `detail`, which needs no conversion. + var repsPart: String { + detail.split(separator: "×").dropFirst().first + .map { "× \($0.trimmingCharacters(in: .whitespaces))" } ?? "" + } + + /// `detail` with its load converted to the user's unit. + func detail(in unit: LoadUnit) -> String { + let load = Units.fmtLoad(value, unit) + let reps = repsPart + return reps.isEmpty ? load : "\(load) \(reps)" + } +} + +// MARK: - Session detail + +nonisolated struct HeartRateZone: Codable, Sendable, Equatable, Identifiable { + let zone: Int + let low: Double + /// Open-ended for the top zone. + let high: Double? + /// Minutes in the zone — fractional, since it's derived from the share of + /// samples in band rather than counted whole minutes. + let min: Double + + var id: Int { zone } +} + +nonisolated struct ZoneBreakdown: Codable, Sendable, Equatable { + let hrMax: Int + /// True when `hrMax` was estimated rather than set in prefs — the UI says so + /// rather than presenting a guess as a measurement. + let estimated: Bool + let zones: [HeartRateZone] +} + +/// Downsampled at ingest and stored in a separate table, so history queries +/// never load the blobs. +nonisolated struct WorkoutSeries: Codable, Sendable, Equatable { + /// [[offsetSeconds, bpm], …] + let hr: [[Double]]? + /// [[lat, lon], …] + let route: [[Double]]? +} diff --git a/ios/Forge/Forge/Kit/FoodModels.swift b/ios/Forge/Forge/Kit/FoodModels.swift new file mode 100644 index 0000000..ded3b5d --- /dev/null +++ b/ios/Forge/Forge/Kit/FoodModels.swift @@ -0,0 +1,480 @@ +import Foundation + +/// Nutrition — `/api/food/*`. Mirrors `server/app/routers/food.py` and the +/// PWA's `web/src/screens/food.tsx`. +/// +/// The food week is household-shared (one plan for James and Shelby) while +/// **meal logs are strictly per-user**: the plan says what's for dinner, the log +/// says who actually ate it. Slots and logs are therefore never merged here — +/// the server has already folded a logged meal into its slot. + +// MARK: - Macros + +/// Per serving for a recipe, per portion for a log. Every field is canonical — +/// there is no display-unit conversion in food, unlike loads. +nonisolated struct Macros: Codable, Sendable, Equatable, Hashable { + var kcal: Double = 0 + var proteinG: Double = 0 + var carbsG: Double = 0 + var sugarG: Double = 0 + var fiberG: Double = 0 + var fatG: Double = 0 + var satfatG: Double = 0 + var sodiumMg: Double = 0 + + static func + (a: Macros, b: Macros) -> Macros { + Macros(kcal: a.kcal + b.kcal, proteinG: a.proteinG + b.proteinG, + carbsG: a.carbsG + b.carbsG, sugarG: a.sugarG + b.sugarG, + fiberG: a.fiberG + b.fiberG, fatG: a.fatG + b.fatG, + satfatG: a.satfatG + b.satfatG, sodiumMg: a.sodiumMg + b.sodiumMg) + } + + /// Every field optional. Swift's synthesised decoder ignores default values + /// and throws on a missing key — one absent macro would take down the whole + /// week rather than showing a zero. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + kcal = try c.decodeIfPresent(Double.self, forKey: .kcal) ?? 0 + proteinG = try c.decodeIfPresent(Double.self, forKey: .proteinG) ?? 0 + carbsG = try c.decodeIfPresent(Double.self, forKey: .carbsG) ?? 0 + sugarG = try c.decodeIfPresent(Double.self, forKey: .sugarG) ?? 0 + fiberG = try c.decodeIfPresent(Double.self, forKey: .fiberG) ?? 0 + fatG = try c.decodeIfPresent(Double.self, forKey: .fatG) ?? 0 + satfatG = try c.decodeIfPresent(Double.self, forKey: .satfatG) ?? 0 + sodiumMg = try c.decodeIfPresent(Double.self, forKey: .sodiumMg) ?? 0 + } + + init(kcal: Double = 0, proteinG: Double = 0, carbsG: Double = 0, sugarG: Double = 0, + fiberG: Double = 0, fatG: Double = 0, satfatG: Double = 0, sodiumMg: Double = 0) { + self.kcal = kcal; self.proteinG = proteinG; self.carbsG = carbsG; self.sugarG = sugarG + self.fiberG = fiberG; self.fatG = fatG; self.satfatG = satfatG; self.sodiumMg = sodiumMg + } +} + +// MARK: - Recipes + +/// The list/summary shape (`food.recipe_card`). `image` is `images[0]` — either +/// a relative `/api/food/media/{id}` path or a remote URL the import kept. +nonisolated struct RecipeCard: Codable, Sendable, Hashable, Identifiable { + let slug: String + let name: String + /// dinner | breakfast | lunch | snack + let kind: String + let minutes: Int + let difficulty: String + let serves: Int + let batch: Int + let platefig: String + let why: String + let image: String? + let rating: Double + let macros: Macros + + /// Only the list endpoint sends these. + var tags: [String]? + var source: String? + /// Parked imports (unknown ingredient, `hard`, or zero kcal) stay browsable + /// but are never proposable — the UI badges them rather than hiding them. + var complete: Bool? + + var id: String { slug } + var isParked: Bool { complete == false } + + /// "35 min · 52 g protein" — the library tile's one-line meta. + var meta: String { + var parts: [String] = [] + if minutes > 0 { parts.append("\(minutes) min") } + if macros.proteinG > 0 { parts.append("\(Int(macros.proteinG.rounded())) g protein") } + return parts.joined(separator: " · ") + } + + enum CodingKeys: String, CodingKey { + case slug, name, kind, minutes, difficulty, serves, batch, platefig, why + case image, rating, tags, source, complete + case kcal, proteinG, carbsG, sugarG, fiberG, fatG, satfatG, sodiumMg + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + slug = try c.decode(String.self, forKey: .slug) + name = try c.decode(String.self, forKey: .name) + kind = try c.decodeIfPresent(String.self, forKey: .kind) ?? "dinner" + minutes = try c.decodeIfPresent(Int.self, forKey: .minutes) ?? 0 + difficulty = try c.decodeIfPresent(String.self, forKey: .difficulty) ?? "easy" + serves = try c.decodeIfPresent(Int.self, forKey: .serves) ?? 2 + batch = try c.decodeIfPresent(Int.self, forKey: .batch) ?? 0 + platefig = try c.decodeIfPresent(String.self, forKey: .platefig) ?? "plate" + why = try c.decodeIfPresent(String.self, forKey: .why) ?? "" + image = try c.decodeIfPresent(String.self, forKey: .image) + rating = try c.decodeIfPresent(Double.self, forKey: .rating) ?? 0 + tags = try c.decodeIfPresent([String].self, forKey: .tags) + source = try c.decodeIfPresent(String.self, forKey: .source) + complete = try c.decodeIfPresent(Bool.self, forKey: .complete) + macros = Macros( + kcal: try c.decodeIfPresent(Double.self, forKey: .kcal) ?? 0, + proteinG: try c.decodeIfPresent(Double.self, forKey: .proteinG) ?? 0, + carbsG: try c.decodeIfPresent(Double.self, forKey: .carbsG) ?? 0, + sugarG: try c.decodeIfPresent(Double.self, forKey: .sugarG) ?? 0, + fiberG: try c.decodeIfPresent(Double.self, forKey: .fiberG) ?? 0, + fatG: try c.decodeIfPresent(Double.self, forKey: .fatG) ?? 0, + satfatG: try c.decodeIfPresent(Double.self, forKey: .satfatG) ?? 0, + sodiumMg: try c.decodeIfPresent(Double.self, forKey: .sodiumMg) ?? 0) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(slug, forKey: .slug) + try c.encode(name, forKey: .name) + try c.encode(kind, forKey: .kind) + try c.encode(minutes, forKey: .minutes) + try c.encode(macros.kcal, forKey: .kcal) + try c.encode(macros.proteinG, forKey: .proteinG) + } +} + +/// One method step. `timer` means the step earns a ring; `parallel` means it +/// runs in the background while you carry on (a simmer, an oven). +nonisolated struct RecipeStep: Codable, Sendable, Hashable, Identifiable { + let title: String + let minutes: Int? + let detail: String + let timer: Bool? + let parallel: Bool? + let image: String? + + /// Positional — steps have no ids of their own and are always rendered in + /// order, so the index is the identity. + var id: String { "\(title)#\(detail.prefix(24))" } + + var hasTimer: Bool { timer == true && (minutes ?? 0) > 0 } + var isParallel: Bool { parallel == true } + var seconds: Int { max(60, (minutes ?? 1) * 60) } +} + +nonisolated struct RecipeIngredient: Codable, Sendable, Hashable, Identifiable { + let name: String + let qty: Double? + let unit: String? + /// Human quantity as written on the card ("2 fillets", "½ bag"). + let disp: String? + let note: String? + let aisle: String? + /// Pantry staples never reach a shopping list. + let pantry: Bool? + + var id: String { name } + var quantity: String { disp ?? [qty.map { Units.trim($0) }, unit].compactMap { $0 }.joined() } + var isPantry: Bool { pantry == true } +} + +nonisolated struct RecipeDetail: Codable, Sendable, Hashable, Identifiable { + let card: RecipeCard + let steps: [RecipeStep] + let ingredients: [RecipeIngredient] + let tags: [String] + let source: String + let sourceUrl: String + let images: [String] + let ratingCount: Int + + var id: String { card.slug } + var slug: String { card.slug } + var name: String { card.name } + + enum CodingKeys: String, CodingKey { + case steps, ingredients, tags, source, sourceUrl, images, ratingCount + } + + /// The detail payload is the card fields *spread* alongside the extras, so + /// the card decodes from the same container rather than a nested key. + init(from decoder: Decoder) throws { + card = try RecipeCard(from: decoder) + let c = try decoder.container(keyedBy: CodingKeys.self) + steps = try c.decodeIfPresent([RecipeStep].self, forKey: .steps) ?? [] + ingredients = try c.decodeIfPresent([RecipeIngredient].self, forKey: .ingredients) ?? [] + tags = try c.decodeIfPresent([String].self, forKey: .tags) ?? [] + source = try c.decodeIfPresent(String.self, forKey: .source) ?? "" + sourceUrl = try c.decodeIfPresent(String.self, forKey: .sourceUrl) ?? "" + images = try c.decodeIfPresent([String].self, forKey: .images) ?? [] + ratingCount = try c.decodeIfPresent(Int.self, forKey: .ratingCount) ?? 0 + } + + func encode(to encoder: Encoder) throws { + try card.encode(to: encoder) + } +} + +nonisolated struct RecipeListResponse: Codable, Sendable { + let count: Int + let recipes: [RecipeCard] +} + +// MARK: - The food week + +/// One meal slot on a day. The server has already resolved leftovers, swaps and +/// logged meals into this shape — a slot showing `recipe` after `logged` is +/// what was actually eaten, not what was planned. +nonisolated struct FoodSlot: Codable, Sendable, Hashable, Identifiable { + /// breakfast | lunch | dinner | snack + let slot: String + let why: String + let recipe: RecipeCard? + /// Off-plan estimate: a label + its own macros instead of a recipe card. + let label: String? + let macros: Macros? + let estimated: Bool? + let leftover: Bool? + /// Planned as eating out / ordering in — no recipe, a note instead. + let out: Bool? + let order: Bool? + let note: String? + let logged: Bool + let logId: String? + /// Something other than the planned recipe was eaten here. + let offPlan: Bool? + + var id: String { slot } + var isLeftover: Bool { leftover == true } + var isOffPlan: Bool { offPlan == true } + + /// What to put on the row when there's no recipe card to show. + var fallbackName: String { + if out == true { return note?.isEmpty == false ? note! : "Eating out" } + if order == true { return note?.isEmpty == false ? note! : "Ordering in" } + return label ?? note ?? "Unplanned" + } + + var displayName: String { recipe?.name ?? fallbackName } +} + +nonisolated struct FoodExtra: Codable, Sendable, Hashable, Identifiable { + let id: String + let slot: String + let label: String + let estimated: Bool + let venue: String + let note: String + let macros: Macros + + enum CodingKeys: String, CodingKey { + case id, slot, label, estimated, venue, note + case kcal, proteinG, carbsG, sugarG, fiberG, fatG, satfatG, sodiumMg + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + slot = try c.decode(String.self, forKey: .slot) + label = try c.decodeIfPresent(String.self, forKey: .label) ?? "" + estimated = try c.decodeIfPresent(Bool.self, forKey: .estimated) ?? false + venue = try c.decodeIfPresent(String.self, forKey: .venue) ?? "" + note = try c.decodeIfPresent(String.self, forKey: .note) ?? "" + macros = Macros( + kcal: try c.decodeIfPresent(Double.self, forKey: .kcal) ?? 0, + proteinG: try c.decodeIfPresent(Double.self, forKey: .proteinG) ?? 0, + carbsG: try c.decodeIfPresent(Double.self, forKey: .carbsG) ?? 0, + sugarG: try c.decodeIfPresent(Double.self, forKey: .sugarG) ?? 0, + fiberG: try c.decodeIfPresent(Double.self, forKey: .fiberG) ?? 0, + fatG: try c.decodeIfPresent(Double.self, forKey: .fatG) ?? 0, + satfatG: try c.decodeIfPresent(Double.self, forKey: .satfatG) ?? 0, + sodiumMg: try c.decodeIfPresent(Double.self, forKey: .sodiumMg) ?? 0) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(slot, forKey: .slot) + try c.encode(label, forKey: .label) + } +} + +nonisolated struct FoodDay: Codable, Sendable, Hashable, Identifiable { + let date: String + let dayName: String + let isToday: Bool + let slots: [FoodSlot] + let extras: [FoodExtra] + let totals: Macros + + var id: String { date } + + var dinner: FoodSlot? { slots.first { $0.slot == "dinner" } } + /// The thumbnail the week strip shows — dinner leads, else the first + /// planned thing on the day. + var thumbnail: RecipeCard? { dinner?.recipe ?? slots.compactMap(\.recipe).first } + + enum CodingKeys: String, CodingKey { case date, dayName, isToday, slots, extras, totals } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + date = try c.decode(String.self, forKey: .date) + dayName = try c.decode(String.self, forKey: .dayName) + isToday = try c.decodeIfPresent(Bool.self, forKey: .isToday) ?? false + slots = try c.decodeIfPresent([FoodSlot].self, forKey: .slots) ?? [] + extras = try c.decodeIfPresent([FoodExtra].self, forKey: .extras) ?? [] + totals = try c.decodeIfPresent(Macros.self, forKey: .totals) ?? Macros() + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(date, forKey: .date) + try c.encode(dayName, forKey: .dayName) + } +} + +nonisolated struct FoodWeek: Codable, Sendable, Hashable { + let start: String + /// The server's today — the client never computes it (`ios/CLAUDE.md`). + let today: String + let days: [FoodDay] + let targets: Macros + let rationale: String + let hasPlan: Bool + + var todayDay: FoodDay? { days.first(where: \.isToday) } + + /// "5 dinners planned" — the browse kicker. Counts real cooking, so + /// leftovers and eating out don't inflate it. + var plannedDinners: Int { + days.filter { $0.dinner?.recipe != nil && $0.dinner?.isLeftover != true }.count + } +} + +// MARK: - Logging + +nonisolated struct LogMealResponse: Codable, Sendable { + let id: String + let duplicate: Bool + let totals: Macros +} + +nonisolated struct PlanSlotResponse: Codable, Sendable { + let date: String + let dayName: String + let slot: String + let recipe: RecipeCard +} + +nonisolated enum MealSlot: String, CaseIterable, Sendable, Identifiable { + case breakfast, lunch, dinner, snack + var id: String { rawValue } + var title: String { rawValue.capitalized } +} + +// MARK: - Food-week proposal + +/// One slot in a *proposed* week. Thinner than `FoodSlot`: the proposal carries +/// recipe slugs, not resolved cards — the cards ride alongside in +/// `FoodProposal.recipes` so the review renders without N fetches. +nonisolated struct FoodPropSlot: Codable, Sendable, Hashable { + let recipe: String? + let why: String? + let note: String? + /// Coach-assisted takeaway rather than something you cook. + let order: Bool? + /// Eating out. A planned night off is a plan, not a gap. + let out: Bool? + /// The weekday whose dinner this is leftovers *of*. + let leftoverOf: Int? + + private enum CodingKeys: String, CodingKey { + case recipe, why, note, order, out, leftoverOf + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + recipe = try c.decodeIfPresent(String.self, forKey: .recipe) + why = try c.decodeIfPresent(String.self, forKey: .why) + note = try c.decodeIfPresent(String.self, forKey: .note) + order = try c.decodeIfPresent(Bool.self, forKey: .order) + out = try c.decodeIfPresent(Bool.self, forKey: .out) + // The weekday keys of `days` are strings, so the coach writes this + // reference back as a string about as often as an int — the PWA types + // it `string | number` for the same reason. Getting it wrong silently + // turns a leftovers night into a blank row. + if let n = try? c.decodeIfPresent(Int.self, forKey: .leftoverOf) { + leftoverOf = n + } else { + leftoverOf = (try? c.decodeIfPresent(String.self, forKey: .leftoverOf)).flatMap { $0.flatMap(Int.init) } + } + } +} + +nonisolated struct FoodPropDay: Codable, Sendable, Hashable { + let slots: [String: FoodPropSlot] + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + slots = try c.decodeIfPresent([String: FoodPropSlot].self, forKey: .slots) ?? [:] + } + + private enum CodingKeys: String, CodingKey { case slots } +} + +nonisolated struct FoodProposalContent: Codable, Sendable, Hashable { + /// Keyed by weekday number as a string, "0" (Monday) … "6". + let days: [String: FoodPropDay] +} + +/// A proposed food week awaiting approval. Same contract as a training +/// proposal: a `changes` delta list and a rationale, so what you approve is a +/// described change rather than a diff you have to work out. +nonisolated struct FoodProposal: Codable, Sendable, Hashable, Identifiable { + let id: String + let num: Int + let rationale: String + let createdAt: String + let changes: [ProposalChange] + let content: FoodProposalContent + /// slug → card, for every recipe the week references. + let recipes: [String: RecipeCard] + + /// Monday-first, so the review reads in the order the week is lived. + var orderedDays: [(weekday: Int, day: FoodPropDay)] { + content.days + .compactMap { key, value in Int(key).map { ($0, value) } } + .sorted { $0.0 < $1.0 } + .map { (weekday: $0.0, day: $0.1) } + } + + /// How a proposed dinner reads: its name and the coach's one-liner. + /// + /// Leftovers are the case worth care — the slot names no recipe, only the + /// weekday it came from, so this resolves that hop. Showing "Leftovers" + /// with no dish is the difference between a plan and a shrug. + func dinner(on weekday: Int) -> (name: String, why: String) { + guard let slot = content.days[String(weekday)]?.slots["dinner"] else { + return (name: "Nothing planned", why: "") + } + // Same precedence the server resolves an active week with + // (`food._resolve_slot`): out, then order, then leftovers, then a dish. + if slot.out == true { + return (name: "Night out", why: slot.note ?? slot.why ?? "") + } + if slot.order == true { + return (name: "Order out", why: slot.note ?? slot.why ?? "") + } + if let source = slot.leftoverOf { + let dish = content.days[String(source)]?.slots["dinner"]?.recipe + .flatMap { recipes[$0]?.name } + return (name: dish.map { "\($0) · leftovers" } ?? "Leftovers", + why: slot.why ?? "zero-cook night") + } + let name = slot.recipe.flatMap { recipes[$0]?.name } ?? slot.recipe + return (name: name ?? "Nothing planned", why: slot.why ?? "") + } + + /// Dinners you'd actually cook — leftovers and nights out don't count, the + /// same rule `FoodWeek.plannedDinners` uses. + var cookingNights: Int { + (0..<7).filter { weekday in + guard let slot = content.days[String(weekday)]?.slots["dinner"] else { return false } + return slot.recipe != nil && slot.leftoverOf == nil + && slot.out != true && slot.order != true + }.count + } +} + +nonisolated struct FoodProposalResponse: Codable, Sendable, Equatable { + let proposal: FoodProposal? +} diff --git a/ios/Forge/Forge/Kit/ImageLoader.swift b/ios/Forge/Forge/Kit/ImageLoader.swift new file mode 100644 index 0000000..95c54d8 --- /dev/null +++ b/ios/Forge/Forge/Kit/ImageLoader.swift @@ -0,0 +1,81 @@ +import SwiftUI + +/// Recipe imagery is behind the session cookie (`/api/food/media/{id}` is +/// `Depends(current_user)`), and this client deliberately stores no cookies — +/// so `AsyncImage` can't fetch it. Everything photographic in Cook goes through +/// here instead. +/// +/// Imports keep the original remote URL when the fetch into `media_blobs` +/// failed, so a reference can also be absolute and off-origin. Those are +/// fetched **without** the cookie: a third-party image host must never be sent +/// a Forge session token. +actor ImageCache { + static let shared = ImageCache() + + private var entries: [String: Data] = [:] + private var inFlight: [String: Task] = [:] + /// Small on purpose — a recipe library is dozens of images, not thousands, + /// and the bytes are already compressed. + private let limit = 120 + private var order: [String] = [] + + func data(for ref: String, client: APIClient) async throws -> Data { + if let hit = entries[ref] { return hit } + if let running = inFlight[ref] { return try await running.value } + + let task = Task { try await client.imageData(ref) } + inFlight[ref] = task + defer { inFlight[ref] = nil } + + let data = try await task.value + entries[ref] = data + order.append(ref) + if order.count > limit, let oldest = order.first { + order.removeFirst() + entries[oldest] = nil + } + return data + } +} + +/// `AsyncImage` with Forge's auth and cache behind it. Falls back to the +/// module's sunken surface — never a broken frame; on the first screen of a +/// module the void has to read as intentional. +struct ForgeAsyncImage: View { + @Environment(AuthController.self) private var auth + + let ref: String? + @ViewBuilder var placeholder: Placeholder + + @State private var image: UIImage? + @State private var failed = false + + var body: some View { + Group { + if let image { + Image(uiImage: image).resizable().scaledToFill() + } else { + placeholder + } + } + .task(id: ref) { await load() } + } + + private func load() async { + guard let ref, !ref.isEmpty, image == nil, !failed else { return } + do { + let data = try await ImageCache.shared.data(for: ref, client: auth.client) + image = UIImage(data: data) + failed = image == nil + } catch { + failed = true + } + } +} + +extension ForgeAsyncImage where Placeholder == AnyView { + /// The common case: the module's own sunken surface while it loads. + init(ref: String?, fill: Color) { + self.init(ref: ref) { AnyView(fill) } + } +} diff --git a/ios/Forge/Forge/Kit/LiftTrend.swift b/ios/Forge/Forge/Kit/LiftTrend.swift new file mode 100644 index 0000000..1d17c50 --- /dev/null +++ b/ios/Forge/Forge/Kit/LiftTrend.swift @@ -0,0 +1,95 @@ +import Foundation + +/// What a lift's e1RM curve is actually doing, in words. +/// +/// The Progress board (`docs/mockups/native/redesign-d-progress.png`) labels +/// each sparkline row with plain language — "building", "stalled 2 wk" — rather +/// than a percentage. That's the point: a number tells you what changed, a word +/// tells you whether to do anything about it. This derives the word. +nonisolated enum LiftTrend: Equatable { + case building(gain: Double) + /// No meaningful movement for this many weeks. + case stalled(weeks: Int) + case easing(drop: Double) + /// Fewer than two sessions — nothing to say yet, and saying "stalled" + /// about a lift you've done once would be a lie. + case tooEarly + + /// Movement smaller than this is noise, not a trend: e1RM is derived from + /// reps at a load, so a rep more or less on the same weight moves it a + /// couple of kilos on its own. + static let noiseKg = 1.5 + + var words: String { + switch self { + case .building: "building" + case .stalled(let weeks): weeks >= 2 ? "stalled \(weeks) wk" : "holding" + case .easing: "easing off" + case .tooEarly: "new" + } + } + + /// Only real movement earns the accent; a stall is stated, not celebrated. + var isPositive: Bool { + if case .building = self { return true } + return false + } +} + +nonisolated extension LiftSeries { + /// Latest e1RM in kg. + var latest: Double { points.last?.v ?? 0 } + + /// Change over roughly the requested window, and the window actually + /// covered — a lift with six weeks of history must not report "in 12 wk". + /// + /// The baseline is "where you were then": the most recent point at or + /// before the cutoff, falling back to the oldest on record when the whole + /// history is newer than the window. + func change(overWeeks weeks: Int) -> (delta: Double, weeks: Int)? { + guard let last = points.last, points.count > 1 else { return nil } + guard let end = ForgeDate.date(from: last.d) else { return nil } + let cutoff = end.addingTimeInterval(-Double(weeks) * 7 * 86400) + let baseline = points.last { ForgeDate.date(from: $0.d).map { $0 <= cutoff } ?? false } + ?? points[0] + guard let start = ForgeDate.date(from: baseline.d), start < end else { return nil } + let span = max(1, Int((end.timeIntervalSince(start) / (7 * 86400)).rounded())) + return (last.v - baseline.v, span) + } + + /// The window the word describes. Four weeks: long enough that two sessions + /// at the same load don't read as a plateau, short enough that a stall + /// surfaces while you can still do something about it. + static let trendWindowWeeks = 4 + + var trend: LiftTrend { + guard points.count > 1, let last = points.last, + let end = ForgeDate.date(from: last.d) + else { return .tooEarly } + + // Compared against where you were, NOT against the previous session: + // running the same load twice in a week is normal programming, and + // last-vs-previous called a steadily climbing lift "holding". + if let change = change(overWeeks: Self.trendWindowWeeks), + change.delta > LiftTrend.noiseKg { + return .building(gain: change.delta) + } + + // Off a recent high is easing even when the window's net change is + // flat — you were at 108 a fortnight ago and you're at 101 now. + let cutoff = end.addingTimeInterval(-Double(Self.trendWindowWeeks) * 7 * 86400) + let inWindow = points.filter { ForgeDate.date(from: $0.d).map { $0 >= cutoff } ?? false } + let peak = inWindow.map(\.v).max() ?? last.v + if last.v < peak - LiftTrend.noiseKg { return .easing(drop: peak - last.v) } + + // Flat: how long for? Walk back while every point sits inside the noise + // band around the current value. + var oldestFlat = end + for point in points.dropLast().reversed() { + guard abs(point.v - last.v) <= LiftTrend.noiseKg, + let date = ForgeDate.date(from: point.d) else { break } + oldestFlat = date + } + return .stalled(weeks: Int((end.timeIntervalSince(oldestFlat) / (7 * 86400)).rounded())) + } +} diff --git a/ios/Forge/Forge/Kit/MetricDelta.swift b/ios/Forge/Forge/Kit/MetricDelta.swift new file mode 100644 index 0000000..950db07 --- /dev/null +++ b/ios/Forge/Forge/Kit/MetricDelta.swift @@ -0,0 +1,37 @@ +import Foundation + +/// The change shown on a Progress metric card: whether it's worth showing at +/// all, and whether it's movement in the direction you want. +/// +/// Both halves went wrong on the first build. Resting HR prints whole bpm, so a +/// fixed 0.05 significance threshold rendered "▼ 0" — an arrow pointing at no +/// change. And every delta was painted volt, which by the design system means +/// positive status, so a worse night's sleep read as good news. +nonisolated enum MetricDelta { + /// Which way is good. Most metrics have no universal answer — bodyweight + /// and body water depend entirely on what you're training for — so + /// `.either` is the default and those render neutral rather than claiming + /// a goal the app hasn't been told. + enum Better { + case up, down, either + + func isGood(_ delta: Double) -> Bool { + switch self { + case .up: delta > 0 + case .down: delta < 0 + case .either: false + } + } + } + + /// "▲ 1.4", or nil when there's nothing honest to say. + /// + /// Significance is whatever the formatter can actually show: if the change + /// renders identically to zero, the arrow is claiming a movement the number + /// beside it doesn't corroborate. + static func label(_ delta: Double, format: (Double) -> String) -> String? { + let shown = format(abs(delta)) + guard shown != format(0) else { return nil } + return "\(delta > 0 ? "▲" : "▼") \(shown)" + } +} diff --git a/ios/Forge/Forge/Kit/PlateMath.swift b/ios/Forge/Forge/Kit/PlateMath.swift index c284a7c..0f0e713 100644 --- a/ios/Forge/Forge/Kit/PlateMath.swift +++ b/ios/Forge/Forge/Kit/PlateMath.swift @@ -26,25 +26,52 @@ enum PlateMath { } guard kind == "bb", let profile, !profile.platesKg.isEmpty else { return "" } - var perSide = (weight - profile.barKg) / 2 - if perSide < 0.01 { + let fill = perSide(weight, profile: profile) + if fill.plates.isEmpty && fill.remainder < 0.01 { return "Empty bar (\(Units.trim(profile.barKg)) kg)" } + // The "kg" is deliberate and diverges from the web's `plateStr`, which + // omits it: with `unit_load` defaulting to lb, "Per side: 5 + 2.5" + // sitting under "77.2 lb" reads as pounds. Plates are always kg, so say so. + var text = "Per side: " + fill.plates.map(Units.trim).joined(separator: " + ") + " kg" + if fill.remainder > 0.01 { + text += String(format: " — no plate for %.2f kg", fill.remainder) + } + return text + } + + /// Greedy per-side fill, heaviest plate first — what you actually load, and + /// the shared arithmetic behind both plate strings. + static func perSide(_ weight: Double, profile: EquipmentProfile) -> (plates: [Double], remainder: Double) { + var remaining = (weight - profile.barKg) / 2 + guard remaining >= 0.01 else { return ([], max(0, remaining)) } var used: [Double] = [] for plate in profile.platesKg.sorted(by: >) { - while perSide >= plate - 1e-9 { + while remaining >= plate - 1e-9 { used.append(plate) - perSide -= plate + remaining -= plate } } - // The "kg" is deliberate and diverges from the web's `plateStr`, which - // omits it: with `unit_load` defaulting to lb, "Per side: 5 + 2.5" - // sitting under "77.2 lb" reads as pounds. Plates are always kg, so say so. - var text = "Per side: " + used.map(Units.trim).joined(separator: " + ") + " kg" - if perSide > 0.01 { - text += String(format: " — no plate for %.2f kg", perSide) + return (used, remaining) + } + + /// The logging screen's line under the big numeral, in the board's voice: + /// "per side 25 · 10 · 2.5 · 1.25 on the 20 kg bar". Recomputed live, so + /// stepping the load re-plates the bar instead of leaving the server's + /// answer for the prescription sitting under a number you've changed. + static func perSideLine(_ weight: Double, profile: EquipmentProfile) -> String { + let fill = perSide(weight, profile: profile) + let bar = "on the \(Units.trim(profile.barKg)) kg bar" + guard !fill.plates.isEmpty else { + return fill.remainder > 0.01 + ? String(format: "%@ — %.2f kg per side won't plate", bar, fill.remainder) + : "empty \(Units.trim(profile.barKg)) kg bar" } - return text + var line = "per side " + fill.plates.map(Units.trim).joined(separator: " · ") + " \(bar)" + if fill.remainder > 0.01 { + line += String(format: " — %.2f kg short", fill.remainder) + } + return line } private static func matches(_ slug: String, _ pattern: String) -> Bool { diff --git a/ios/Forge/Forge/Kit/TrainingModels.swift b/ios/Forge/Forge/Kit/TrainingModels.swift index 56aac5e..42e6d8f 100644 --- a/ios/Forge/Forge/Kit/TrainingModels.swift +++ b/ios/Forge/Forge/Kit/TrainingModels.swift @@ -47,6 +47,33 @@ nonisolated struct TodayExercise: Codable, Sendable, Equatable, Identifiable { var isMainLift: Bool { priority == 1 } } +/// `/api/exercises/{slug}` — the library entry behind a plan line. Fetched +/// lazily by the logging screen for its form card; everything else the screen +/// needs already rides on `/api/today`. +nonisolated struct ExerciseInfo: Codable, Sendable, Equatable, Identifiable { + let slug: String + let name: String + let kind: String + let primaryMuscles: [String] + let secondaryMuscles: [String] + let equipment: [String] + let cues: [String] + /// The one thing not to do — a single sentence, not a list, despite + /// sitting next to `cues` which is one. + let dont: String + let benefit: String + /// "images" | "none" — a *string*, not a count. Typing it `Int` failed the + /// whole decode silently and the form card just never appeared. + let mediaTier: String? + /// Comma-separated paths; `ExerciseMedia` derives the same paths from the + /// slug, so this is only here to say whether any exist. + let mediaUrl: String? + + var hasMedia: Bool { !(mediaUrl ?? "").isEmpty } + + var id: String { slug } +} + nonisolated struct CooldownItem: Codable, Sendable, Equatable, Identifiable { let slug: String let name: String? @@ -222,29 +249,43 @@ nonisolated struct CompletePayload: Codable, Sendable, Equatable { } } -/// `GET /api/sessions/{id}` — used to rehydrate a resumed workout. Only the -/// logged sets are read here; the run-detail fields (series, zones) arrive with -/// History in a later slice. +/// `GET /api/sessions/{id}` — rehydrates a resumed workout and backs the +/// history detail screen. Cardio sessions additionally carry `series` (HR and +/// GPS, downsampled at ingest) and a five-zone breakdown. nonisolated struct SessionDetail: Codable, Sendable, Equatable { - nonisolated struct Exercise: Codable, Sendable, Equatable { + nonisolated struct Exercise: Codable, Sendable, Equatable, Identifiable { let slug: String let name: String? let substitutedFor: String? let sets: [LoggedSetRow] + + var id: String { slug } } - nonisolated struct LoggedSetRow: Codable, Sendable, Equatable { + nonisolated struct LoggedSetRow: Codable, Sendable, Equatable, Identifiable { let setNo: Int let weight: Double let reps: Int let rpe: Int? + + var id: Int { setNo } } let id: String let day: String let name: String + let kind: String let status: String + let stats: SessionStats + let notes: String? + let favorite: Bool? + let cooldownStatus: String? let exercises: [Exercise] + let series: WorkoutSeries? + let zones: ZoneBreakdown? + + var isFavorite: Bool { favorite ?? false } + var isCardio: Bool { kind == "cardio" } } nonisolated struct CompleteResponse: Codable, Sendable, Equatable { diff --git a/ios/Forge/ForgeTests/CookCueTests.swift b/ios/Forge/ForgeTests/CookCueTests.swift new file mode 100644 index 0000000..9a97c8b --- /dev/null +++ b/ios/Forge/ForgeTests/CookCueTests.swift @@ -0,0 +1,57 @@ +import Testing +@testable import Forge + +/// Cook mode accents the done-when cue and nothing else. These cases are real +/// step prose from `server/app/food_seed.py` plus the board's own example — +/// if the extraction drifts, the ember lands on the wrong half of the sentence. +struct CookCueTests { + @Test func liftsTheCueOutOfASentence() { + let detail = "Hot pan, skin-side down, don't touch it. Done when the skin releases without a fight." + let parts = CookCue.split(detail) + #expect(parts.cue == "Done when the skin releases without a fight") + #expect(parts.before == "Hot pan, skin-side down, don't touch it. ") + #expect(parts.after == ".") + } + + /// The board's step: the cue stops at the em dash, because "about 4 + /// minutes" is timing, not the thing you look for. + @Test func stopsAtTheEmDashSoTimingIsNotAccented() { + let detail = "Lay the salmon skin-side down and leave it alone. It's ready when the skin releases with no tug — about 4 minutes." + let parts = CookCue.split(detail) + #expect(parts.cue == "It's ready when the skin releases with no tug") + #expect(parts.after == " — about 4 minutes.") + } + + @Test func handlesUntilPhrasing() { + let parts = CookCue.split("Toss and cook until the edges catch and darken.") + #expect(parts.cue == "Toss and cook until the edges catch and darken") + } + + /// Prep steps genuinely have no cue — inventing a highlight for them would + /// make the accent meaningless. + @Test func noCueMeansNoHighlight() { + let parts = CookCue.split("Oven to 220° fan. Peppers into strips, onions into wedges.") + #expect(parts.cue.isEmpty) + #expect(parts.before == "Oven to 220° fan. Peppers into strips, onions into wedges.") + #expect(parts.after.isEmpty) + } + + @Test func splitAlwaysReassemblesTheOriginal() { + let details = [ + "Done when thighs read 74° / juices run clear and the chickpeas just blister. No turning.", + "Bright green and just tender — a knife should meet slight resistance.", + "Warm through, season, lemon juice in.", + "", + ] + for detail in details { + let parts = CookCue.split(detail) + #expect(parts.before + parts.cue + parts.after == detail) + } + } + + /// Two cues in one step: the first is the one you're waiting on now. + @Test func earliestCueWins() { + let parts = CookCue.split("Sear until golden. Done when it flakes.") + #expect(parts.cue == "Sear until golden") + } +} diff --git a/ios/Forge/ForgeTests/CookTimerTests.swift b/ios/Forge/ForgeTests/CookTimerTests.swift new file mode 100644 index 0000000..7ad985e --- /dev/null +++ b/ios/Forge/ForgeTests/CookTimerTests.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing +@testable import Forge + +/// Cook timers store an absolute end date, not a countdown — cook mode is +/// backgrounded constantly and a decrementing counter would drift every time +/// the view stopped ticking. These pin that behaviour. +@MainActor +struct CookTimerTests { + @Test func remainingIsDerivedFromTheEndDate() { + let timers = CookTimers() + let t0 = Date(timeIntervalSince1970: 1_000_000) + timers.start(step: 0, seconds: 240, now: t0) + + // Simulating a backgrounded app: no ticks happened, but 90 s passed. + timers.settle(at: t0.addingTimeInterval(90)) + #expect(timers.remaining(step: 0) == 150) + } + + @Test func chimesOnceAsItCrossesZero() { + let timers = CookTimers() + let t0 = Date(timeIntervalSince1970: 2_000_000) + timers.start(step: 2, seconds: 60, now: t0) + + #expect(timers.settle(at: t0.addingTimeInterval(59)).isEmpty) + #expect(timers.settle(at: t0.addingTimeInterval(61)) == [2]) + // Still zero, but already chimed — a second banner would be noise. + #expect(timers.settle(at: t0.addingTimeInterval(120)).isEmpty) + } + + @Test func remainingNeverGoesNegative() { + let timers = CookTimers() + let t0 = Date(timeIntervalSince1970: 3_000_000) + timers.start(step: 0, seconds: 30, now: t0) + timers.settle(at: t0.addingTimeInterval(600)) + #expect(timers.remaining(step: 0) == 0) + #expect(timers.fraction(step: 0) == 0) + } + + /// Several timers run at once — that's the point of a background step. + @Test func timersOnOtherStepsKeepRunning() { + let timers = CookTimers() + let t0 = Date(timeIntervalSince1970: 4_000_000) + timers.start(step: 0, seconds: 600, now: t0) // a braise + timers.start(step: 1, seconds: 120, now: t0) // something quick + + let fired = timers.settle(at: t0.addingTimeInterval(180)) + #expect(fired == [1]) + #expect(timers.remaining(step: 0) == 420) + #expect(timers.running.map(\.index) == [0, 1]) + } + + @Test func cancellingClearsOnlyThatStep() { + let timers = CookTimers() + let t0 = Date(timeIntervalSince1970: 5_000_000) + timers.start(step: 0, seconds: 120, now: t0) + timers.start(step: 3, seconds: 120, now: t0) + timers.cancel(step: 0) + #expect(timers.remaining(step: 0) == nil) + #expect(timers.remaining(step: 3, at: t0) == 120) + } + + @Test(arguments: [(0, "0:00"), (9, "0:09"), (60, "1:00"), (221, "3:41"), (3600, "60:00")]) + func clockFormat(seconds: Int, expected: String) { + #expect(fmtClock(seconds) == expected) + } +} diff --git a/ios/Forge/ForgeTests/ExerciseInfoTests.swift b/ios/Forge/ForgeTests/ExerciseInfoTests.swift new file mode 100644 index 0000000..d4396e4 --- /dev/null +++ b/ios/Forge/ForgeTests/ExerciseInfoTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing +@testable import Forge + +/// The logging screen fetches the library entry with `try?`, so a decode +/// mismatch doesn't crash — the form card just silently never appears. That has +/// now bitten twice (`media_tier` is a string, `dont` is a sentence next to a +/// `cues` *list*), so the real payload gets a test. +struct ExerciseInfoTests { + private func decoder() -> JSONDecoder { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + return d + } + + /// Copied field-for-field from `GET /api/exercises/back-squat`. + @Test func decodesTheRealExercisePayload() throws { + let json = """ + {"slug":"back-squat","name":"Back Squat","kind":"bb", + "primary_muscles":["Quads","Glutes"],"secondary_muscles":["Core"], + "equipment":["Barbell + power rack"], + "cues":["Bar over mid-foot, brace before you bend","Knees track the toes"], + "dont":"Don't let the hips shoot up first", + "patterns":["squat"], + "benefit":"The biggest bang-for-buck lower-body lift.", + "media_tier":"images", + "media_url":"/media/exercises/back-squat-0.jpg,/media/exercises/back-squat-1.jpg"} + """.data(using: .utf8)! + let info = try decoder().decode(ExerciseInfo.self, from: json) + #expect(info.cues.count == 2) + #expect(info.dont.hasPrefix("Don't let")) + #expect(info.mediaTier == "images") + #expect(info.hasMedia) + } + + /// An exercise with no photos must report so, or the form card reserves + /// space for an image that never arrives. + @Test func noMediaIsReported() throws { + let json = """ + {"slug":"plank","name":"Plank","kind":"bw","primary_muscles":["Abs"], + "secondary_muscles":[],"equipment":[],"cues":["Ribs down"],"dont":"Don't sag", + "benefit":"","media_tier":"none","media_url":""} + """.data(using: .utf8)! + let info = try decoder().decode(ExerciseInfo.self, from: json) + #expect(!info.hasMedia) + } +} + +/// The logging screen re-plates the bar live as you step the load, so the line +/// under the big numeral has to be right for loads the plan never prescribed. +struct PlateLineTests { + private let gym = EquipmentProfile(name: "Gym", barKg: 20, + platesKg: [25, 20, 15, 10, 5, 2.5, 1.25]) + + @Test func namesTheBarAndSeparatesPlates() { + // 132.5 kg = 20 bar + 56.25 a side. + #expect(PlateMath.perSideLine(132.5, profile: gym) + == "per side 25 · 25 · 5 · 1.25 on the 20 kg bar") + } + + @Test func emptyBarSaysSo() { + #expect(PlateMath.perSideLine(20, profile: gym) == "empty 20 kg bar") + } + + /// A load the plates can't make is the normal result of stepping in lb — + /// saying nothing would leave you guessing at the rack. + @Test func reportsWhatWontPlate() { + // 61 kg = 20 bar + 20.5 a side, and nothing makes the last 0.5. + let line = PlateMath.perSideLine(61, profile: gym) + #expect(line.hasPrefix("per side 20 on the 20 kg bar")) + #expect(line.contains("0.50 kg short")) + } + + /// `description` and `perSideLine` must never disagree about what's on the + /// bar — they're the same arithmetic in two voices. + @Test(arguments: [60.0, 82.5, 100.0, 142.5]) + func bothVoicesAgree(weight: Double) { + let fill = PlateMath.perSide(weight, profile: gym) + let long = PlateMath.description(kind: "bb", weight: weight, profile: gym) + for plate in Set(fill.plates) { + #expect(long.contains(Units.trim(plate))) + #expect(PlateMath.perSideLine(weight, profile: gym).contains(Units.trim(plate))) + } + // Greedy fill: heaviest first, never ascending. + #expect(fill.plates == fill.plates.sorted(by: >)) + } +} diff --git a/ios/Forge/ForgeTests/FoodModelTests.swift b/ios/Forge/ForgeTests/FoodModelTests.swift new file mode 100644 index 0000000..29113b9 --- /dev/null +++ b/ios/Forge/ForgeTests/FoodModelTests.swift @@ -0,0 +1,133 @@ +import Foundation +import Testing +@testable import Forge + +/// The food payloads spread macros as sibling keys rather than nesting them, +/// and the detail endpoint spreads the whole recipe card alongside its extras. +/// Both are easy to break silently, so decode the server's actual shapes. +struct FoodModelTests { + private func decoder() -> JSONDecoder { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + return d + } + + /// `food.recipe_card` — macros are siblings of `slug`/`name`, not a nested + /// object, because the server spreads `MACRO_FIELDS` into the dict. + @Test func recipeCardLiftsSpreadMacros() throws { + let json = """ + {"slug":"salmon-puy-lentils","name":"Charred salmon, puy lentils & broccoli", + "kind":"dinner","minutes":20,"difficulty":"easy","serves":2,"batch":0, + "platefig":"plate-salmon","why":"The omega-3 day.","image":"/api/food/media/abc", + "rating":4.5,"kcal":540,"protein_g":38,"carbs_g":30,"sugar_g":3,"fiber_g":9, + "fat_g":24,"satfat_g":3.5,"sodium_mg":480} + """.data(using: .utf8)! + let card = try decoder().decode(RecipeCard.self, from: json) + #expect(card.slug == "salmon-puy-lentils") + #expect(card.macros.kcal == 540) + #expect(card.macros.proteinG == 38) + #expect(card.macros.sodiumMg == 480) + #expect(card.meta == "20 min · 38 g protein") + // `complete` is absent on the card shape — that must not read as parked. + #expect(!card.isParked) + } + + @Test func recipeDetailDecodesCardFieldsAndSteps() throws { + let json = """ + {"slug":"harissa-chicken","name":"Harissa chicken tray bake","kind":"dinner", + "minutes":30,"difficulty":"easy","serves":2,"batch":2,"platefig":"tray", + "why":"","image":null,"rating":0,"kcal":520,"protein_g":44,"carbs_g":38, + "sugar_g":9,"fiber_g":11,"fat_g":18,"satfat_g":4,"sodium_mg":600, + "steps":[{"title":"Prep","minutes":5,"detail":"Oven to 220° fan."}, + {"title":"Roast","minutes":22,"timer":true,"detail":"Done when thighs read 74°."}], + "ingredients":[{"name":"chicken thighs","qty":6,"unit":"x","disp":"6", + "aisle":"protein","pantry":false}, + {"name":"cumin","qty":4,"unit":"g","disp":"2 tsp","note":"pantry", + "aisle":"cupboard","pantry":true}], + "tags":["batch"],"source":"seed","source_url":"","images":[], + "rating":0,"rating_count":0} + """.data(using: .utf8)! + let detail = try decoder().decode(RecipeDetail.self, from: json) + #expect(detail.name == "Harissa chicken tray bake") + #expect(detail.card.macros.proteinG == 44) + #expect(detail.steps.count == 2) + #expect(detail.steps[0].hasTimer == false) + #expect(detail.steps[1].hasTimer) + #expect(detail.steps[1].seconds == 22 * 60) + #expect(detail.ingredients[0].quantity == "6") + #expect(detail.ingredients[1].isPantry) + } + + /// A step marked `timer` with no minutes must not produce a zero-length + /// ring that completes the instant it starts. + @Test func timerStepsNeedMinutes() throws { + let json = """ + {"title":"Rest","timer":true,"detail":"Leave it."} + """.data(using: .utf8)! + let step = try decoder().decode(RecipeStep.self, from: json) + #expect(!step.hasTimer) + #expect(step.seconds == 60) + } + + @Test func foodWeekCountsRealCookingOnly() throws { + let json = """ + {"start":"2026-07-20","today":"2026-07-25","has_plan":true,"rationale":"", + "targets":{"kcal":2300,"protein_g":160,"carbs_g":250,"sugar_g":65,"fiber_g":38, + "fat_g":80,"satfat_g":18,"sodium_mg":2300}, + "days":[ + {"date":"2026-07-20","day_name":"Monday","is_today":false, + "slots":[{"slot":"dinner","why":"","logged":false,"log_id":null, + "recipe":{"slug":"a","name":"Thai beef bowls","kind":"dinner","minutes":30, + "difficulty":"easy","serves":2,"batch":0,"platefig":"bowl","why":"", + "image":null,"rating":0,"kcal":500,"protein_g":46,"carbs_g":40, + "sugar_g":8,"fiber_g":7,"fat_g":16,"satfat_g":4,"sodium_mg":500}}], + "extras":[],"totals":{"kcal":0,"protein_g":0,"carbs_g":0,"sugar_g":0,"fiber_g":0, + "fat_g":0,"satfat_g":0,"sodium_mg":0}}, + {"date":"2026-07-21","day_name":"Tuesday","is_today":false, + "slots":[{"slot":"dinner","why":"","leftover":true,"logged":false,"log_id":null, + "recipe":{"slug":"a","name":"Thai beef bowls","kind":"dinner","minutes":30, + "difficulty":"easy","serves":2,"batch":0,"platefig":"bowl","why":"", + "image":null,"rating":0,"kcal":500,"protein_g":46,"carbs_g":40, + "sugar_g":8,"fiber_g":7,"fat_g":16,"satfat_g":4,"sodium_mg":500}}], + "extras":[],"totals":{"kcal":0,"protein_g":0,"carbs_g":0,"sugar_g":0,"fiber_g":0, + "fat_g":0,"satfat_g":0,"sodium_mg":0}}, + {"date":"2026-07-25","day_name":"Saturday","is_today":true, + "slots":[{"slot":"dinner","why":"","out":true,"note":"Pizza with the Coles", + "logged":false,"log_id":null}], + "extras":[],"totals":{"kcal":0,"protein_g":0,"carbs_g":0,"sugar_g":0,"fiber_g":0, + "fat_g":0,"satfat_g":0,"sodium_mg":0}}]} + """.data(using: .utf8)! + let week = try decoder().decode(FoodWeek.self, from: json) + // Monday cooks; Tuesday is leftovers of it; Saturday is eating out. + #expect(week.plannedDinners == 1) + #expect(week.todayDay?.date == "2026-07-25") + #expect(week.todayDay?.dinner?.displayName == "Pizza with the Coles") + #expect(week.days[1].dinner?.isLeftover == true) + #expect(week.targets.proteinG == 160) + } + + /// Missing macro keys must degrade to zero, not throw — one absent field + /// would otherwise take down the whole week rather than a single number. + @Test func partialMacrosDecodeToZero() throws { + let json = #"{"kcal":410,"protein_g":31}"#.data(using: .utf8)! + let macros = try decoder().decode(Macros.self, from: json) + #expect(macros.kcal == 410) + #expect(macros.proteinG == 31) + #expect(macros.fiberG == 0) + #expect(macros.sodiumMg == 0) + } + + @Test func offPlanSlotShowsWhatWasActuallyEaten() throws { + let json = """ + {"slot":"lunch","why":"","off_plan":true,"label":"Pret chicken salad", + "estimated":true,"logged":true,"log_id":"lg1", + "macros":{"kcal":430,"protein_g":34,"carbs_g":22,"sugar_g":5,"fiber_g":6, + "fat_g":21,"satfat_g":4,"sodium_mg":700}} + """.data(using: .utf8)! + let slot = try decoder().decode(FoodSlot.self, from: json) + #expect(slot.isOffPlan) + #expect(slot.displayName == "Pret chicken salad") + #expect(slot.logged) + #expect(slot.macros?.kcal == 430) + } +} diff --git a/ios/Forge/ForgeTests/FoodProposalTests.swift b/ios/Forge/ForgeTests/FoodProposalTests.swift new file mode 100644 index 0000000..70a6061 --- /dev/null +++ b/ios/Forge/ForgeTests/FoodProposalTests.swift @@ -0,0 +1,131 @@ +import Foundation +import Testing +@testable import Forge + +/// A food-week proposal is the coaching loop for Cook — the one screen where +/// you commit a week of cooking for two people. Reading it wrong is worse than +/// not showing it, so the awkward bits of the payload have tests: leftovers +/// point at another day rather than naming a dish, and the weekday they point +/// at arrives as an int or a string depending on how the coach wrote it. +struct FoodProposalTests { + private func decode(_ json: String) throws -> FoodProposal { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + let response = try d.decode(FoodProposalResponse.self, from: Data(json.utf8)) + return try #require(response.proposal) + } + + /// A trimmed copy of the real `/api/food/proposal` shape: recipes ride + /// alongside as a slug→card map, days are keyed by weekday string. + private let payload = """ + {"proposal": { + "id": "rev-7", "num": 12, "rationale": "Two batch nights so Thursday cooks itself.", + "created_at": "2026-07-26T20:00:00", + "changes": [ + {"sign": "+", "what": "Harissa chicken traybake", "why": "one tray, 35 min"}, + {"sign": "-", "what": "Friday stir-fry", "why": "you skipped it twice"}, + {"sign": "~", "what": "Lunch protein up 12 g"} + ], + "content": {"days": { + "0": {"slots": {"dinner": {"recipe": "harissa-chicken", "why": "one tray"}}}, + "1": {"slots": {"dinner": {"leftover_of": 0, "why": "zero-cook night"}}}, + "2": {"slots": {"dinner": {"recipe": "turkey-black-bean-chili"}}}, + "3": {"slots": {"dinner": {"leftover_of": "2"}}}, + "4": {"slots": {"dinner": {"out": true, "note": "Shelby's birthday"}}}, + "5": {"slots": {"dinner": {"recipe": "soba-salmon", "why": "fast after the long run"}}}, + "6": {"slots": {}} + }}, + "recipes": { + "harissa-chicken": {"slug": "harissa-chicken", "name": "Harissa chicken traybake", + "kind": "dinner", "minutes": 35, "kcal": 520, "protein_g": 42, "carbs_g": 48, + "fat_g": 13, "satfat_g": 3, "fiber_g": 9}, + "turkey-black-bean-chili": {"slug": "turkey-black-bean-chili", "name": "Turkey black bean chili", + "kind": "dinner", "minutes": 45, "kcal": 610, "protein_g": 48, "carbs_g": 52, + "fat_g": 18, "satfat_g": 4, "fiber_g": 14}, + "soba-salmon": {"slug": "soba-salmon", "name": "Soba salmon bowl", + "kind": "dinner", "minutes": 25, "kcal": 580, "protein_g": 40, "carbs_g": 55, + "fat_g": 19, "satfat_g": 3, "fiber_g": 7} + }}} + """ + + @Test func decodesTheRealProposalPayload() throws { + let p = try decode(payload) + #expect(p.id == "rev-7") + #expect(p.num == 12) + #expect(p.changes.count == 3) + #expect(p.recipes["soba-salmon"]?.name == "Soba salmon bowl") + #expect(p.orderedDays.map(\.weekday) == [0, 1, 2, 3, 4, 5, 6]) + } + + /// The whole point of resolving the hop: "Leftovers" with no dish tells you + /// nothing about whether Tuesday is a good night. + @Test func leftoversNameTheDishTheyCameFrom() throws { + let p = try decode(payload) + #expect(p.dinner(on: 1).name == "Harissa chicken traybake · leftovers") + #expect(p.dinner(on: 1).why == "zero-cook night") + } + + /// The weekday reference arrives as an int on day 1 and a string on day 3 — + /// both are real, because `days` itself is keyed by strings. + @Test func aLeftoverReferenceDecodesFromEitherIntOrString() throws { + let p = try decode(payload) + #expect(p.dinner(on: 3).name == "Turkey black bean chili · leftovers") + } + + /// A leftovers slot with no `why` still gets a caption — the coach's default + /// reason for one is always the same. + @Test func leftoversFallBackToTheirOwnReason() throws { + let p = try decode(payload) + #expect(p.dinner(on: 3).why == "zero-cook night") + } + + @Test func aNightOutIsAPlanNotAGap() throws { + let p = try decode(payload) + #expect(p.dinner(on: 4).name == "Night out") + #expect(p.dinner(on: 4).why == "Shelby's birthday") + } + + @Test func anEmptyDayReadsAsNothingPlanned() throws { + let p = try decode(payload) + #expect(p.dinner(on: 6).name == "Nothing planned") + } + + /// The number the header leads with. Leftovers and the night out are not + /// cooking — counting them would sell the week as busier than it is. + @Test func onlyRealCookingCountsAsANightCooking() throws { + let p = try decode(payload) + #expect(p.cookingNights == 3) + } + + /// Coach-assisted takeaway resolves before the recipe slug, matching the + /// order `food._resolve_slot` uses server-side. + @Test func orderOutIsItsOwnKindOfNight() throws { + let p = try decode(""" + {"proposal": {"id": "r", "num": 1, "rationale": "", "created_at": "2026-07-26T20:00:00", + "changes": [], "recipes": {}, + "content": {"days": {"0": {"slots": {"dinner": {"order": true, "note": "high-protein Thai"}}}}}}} + """) + #expect(p.dinner(on: 0).name == "Order out") + #expect(p.dinner(on: 0).why == "high-protein Thai") + #expect(p.cookingNights == 0) + } + + /// A recipe the map somehow doesn't carry falls back to its slug rather + /// than rendering a blank row. + @Test func anUnresolvedRecipeStillShowsSomething() throws { + let p = try decode(""" + {"proposal": {"id": "r", "num": 1, "rationale": "", "created_at": "2026-07-26T20:00:00", + "changes": [], "recipes": {}, + "content": {"days": {"0": {"slots": {"dinner": {"recipe": "mystery-stew"}}}}}}} + """) + #expect(p.dinner(on: 0).name == "mystery-stew") + } + + @Test func noProposalIsAValidAnswer() throws { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + let response = try d.decode(FoodProposalResponse.self, + from: Data(#"{"proposal": null}"#.utf8)) + #expect(response.proposal == nil) + } +} diff --git a/ios/Forge/ForgeTests/LiftTrendTests.swift b/ios/Forge/ForgeTests/LiftTrendTests.swift new file mode 100644 index 0000000..14d11d1 --- /dev/null +++ b/ios/Forge/ForgeTests/LiftTrendTests.swift @@ -0,0 +1,112 @@ +import Testing +@testable import Forge + +/// Progress labels each lift in plain words rather than a percentage, because +/// a word tells you whether to act. That makes the wording load-bearing: saying +/// "stalled" about a lift that's climbing, or "building" about noise, is worse +/// than showing nothing. +struct LiftTrendTests { + private func series(_ values: [(String, Double)]) -> LiftSeries { + LiftSeries(name: "Test", points: values.map { SeriesPoint(d: $0.0, v: $0.1) }) + } + + @Test func oneSessionIsTooEarlyToJudge() { + #expect(series([("2026-07-01", 100)]).trend == .tooEarly) + #expect(LiftTrend.tooEarly.words == "new") + } + + @Test func aClearGainIsBuilding() { + let trend = series([("2026-06-01", 100), ("2026-06-15", 103), + ("2026-07-01", 108)]).trend + #expect(trend == .building(gain: 8)) + #expect(trend.isPositive) + #expect(trend.words == "building") + } + + /// The bug this rule exists for: a lift run twice a week at the same load, + /// climbing every week, read as "holding" when the comparison was + /// last-vs-previous. Two sessions at one weight is normal programming. + @Test func repeatingALoadWithinAWeekIsNotAStall() { + let trend = series([("2026-06-08", 100), ("2026-06-11", 100), + ("2026-06-15", 102), ("2026-06-18", 102), + ("2026-06-22", 104), ("2026-06-25", 104), + ("2026-07-01", 106), ("2026-07-04", 106)]).trend + #expect(trend.words == "building") + } + + /// A rep more at the same load moves e1RM a couple of kilos on its own — + /// that must not read as progress. + @Test func movementInsideTheNoiseBandIsNotAGain() { + let trend = series([("2026-06-01", 100), ("2026-07-01", 101)]).trend + if case .building = trend { Issue.record("1 kg counted as building") } + #expect(!trend.isPositive) + } + + @Test func flatForWeeksReportsTheWeeks() { + let trend = series([("2026-06-10", 100), ("2026-06-24", 100.5), + ("2026-07-08", 100)]).trend + #expect(trend == .stalled(weeks: 4)) + #expect(trend.words == "stalled 4 wk") + } + + /// One flat week is normal training, not a plateau worth flagging. + @Test func oneFlatWeekIsHolding() { + let trend = series([("2026-07-01", 100), ("2026-07-08", 100)]).trend + #expect(trend.words == "holding") + } + + /// Coming off a recent high is easing even when the four-week net change is + /// flat — you were at 108 a fortnight ago and you're at 101 now, and + /// calling that "stalled" would hide a real drop. + @Test func comingOffARecentHighIsEasing() { + let trend = series([("2026-06-01", 100), ("2026-06-15", 108), + ("2026-07-01", 101)]).trend + #expect(trend == .easing(drop: 7)) + #expect(!trend.isPositive) + #expect(trend.words == "easing off") + } + + /// The headline change must name the window it actually covers — a lift + /// with six weeks of history cannot claim "in 12 wk". + @Test func changeReportsTheWindowItReallyCovers() { + let change = series([("2026-05-27", 100), ("2026-07-08", 110)]).change(overWeeks: 12) + #expect(change?.delta == 10) + #expect(change?.weeks == 6) + } + + /// With more history than the window, the baseline is the window's start, + /// not the oldest point on record. + @Test func changeClipsToTheRequestedWindow() { + let change = series([("2026-01-01", 80), ("2026-04-15", 100), + ("2026-07-08", 110)]).change(overWeeks: 12) + #expect(change?.delta == 10) + #expect(change?.weeks == 12) + } + + @Test func changeNeedsTwoPoints() { + #expect(series([("2026-07-08", 110)]).change(overWeeks: 12) == nil) + } +} + +/// `RecordRow.detail` arrives pre-formatted from the server with a **kg** load +/// in it. Rendering it raw next to lb numbers is the unit invariant's exact +/// failure mode, so the conversion has a test. +struct RecordDetailTests { + private func record(value: Double, detail: String) -> RecordRow { + RecordRow(slug: "back-squat", name: "Back Squat", kind: "best_set", + value: value, detail: detail, achievedOn: "2026-07-20") + } + + @Test func convertsTheLoadAndKeepsTheReps() { + let row = record(value: 73.75, detail: "73.75 × 5") + #expect(row.detail(in: .kg) == "73.75 kg × 5") + #expect(row.detail(in: .lb) == "162.6 lb × 5") + } + + /// An e1RM record has no reps half — it must not render a dangling "×". + @Test func handlesADetailWithNoReps() { + let row = record(value: 86, detail: "86") + #expect(row.detail(in: .kg) == "86 kg") + #expect(!row.detail(in: .lb).contains("×")) + } +} diff --git a/ios/Forge/ForgeTests/MetricDeltaTests.swift b/ios/Forge/ForgeTests/MetricDeltaTests.swift new file mode 100644 index 0000000..985b22d --- /dev/null +++ b/ios/Forge/ForgeTests/MetricDeltaTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import Forge + +/// Both of these were live bugs on the Progress board, caught in the simulator +/// rather than by a test — which is why they have tests now. +struct MetricDeltaTests { + private let whole: (Double) -> String = { String(Int($0)) } + private let oneDp: (Double) -> String = { String(format: "%.1f", $0) } + + /// The bug: resting HR prints whole bpm, so a 0.4 bpm drift passed a fixed + /// 0.05 threshold and rendered "▼ 0" — an arrow pointing at no change. + @Test func aChangeTooSmallToPrintIsNotShown() { + #expect(MetricDelta.label(0.4, format: whole) == nil) + #expect(MetricDelta.label(-0.4, format: whole) == nil) + #expect(MetricDelta.label(0.04, format: oneDp) == nil) + } + + /// The same 0.4 IS worth showing when the formatter can render it. + @Test func precisionDecidesSignificance() { + #expect(MetricDelta.label(0.4, format: oneDp) == "▲ 0.4") + #expect(MetricDelta.label(-1.5, format: whole) == "▼ 1") + } + + @Test func exactlyNoChangeIsNeverAnArrow() { + #expect(MetricDelta.label(0, format: oneDp) == nil) + } + + /// Volt means positive status. A drop in resting HR earns it; the identical + /// drop in sleep must not. + @Test func goodDependsOnWhichWayTheMetricShouldGo() { + #expect(MetricDelta.Better.down.isGood(-2)) + #expect(!MetricDelta.Better.down.isGood(2)) + #expect(MetricDelta.Better.up.isGood(2)) + #expect(!MetricDelta.Better.up.isGood(-2)) + } + + /// Bodyweight has no universal good direction, so neither move is accented + /// rather than the app guessing at a goal it was never told. + @Test func aMetricWithNoGoodDirectionIsNeverAccented() { + #expect(!MetricDelta.Better.either.isGood(5)) + #expect(!MetricDelta.Better.either.isGood(-5)) + #expect(!MetricDelta.Better.either.isGood(0)) + } +} diff --git a/ios/XCODE-HANDOVER.md b/ios/XCODE-HANDOVER.md index 02df9a8..0686c7e 100644 --- a/ios/XCODE-HANDOVER.md +++ b/ios/XCODE-HANDOVER.md @@ -201,6 +201,28 @@ worse than useless — it looks like real information and isn't. Note `ENABLE_USER_SCRIPT_SANDBOXING = YES`; the script needs an output file declared or it will fail under sandboxing. +### C1b · Widget Extension target — Live Activities `[ ]` + +**Where**: File → New → Target → **Widget Extension**, "Include Live Activity" checked, +embedded in `Forge`. Then target `Forge` → Info → add `NSSupportsLiveActivities = YES`. + +**What it unblocks**: two things that are otherwise built and waiting. + +- **Cook mode step timers on the Dynamic Island** (N4, built). `CookModeView` runs its + timers locally — absolute end dates, so they survive backgrounding, and they chime with a + haptic. What's missing is only the Island presentation the board shows ("FORGE COOK · + Skin crisping · 3:41"). Without it, a 22-minute roast is invisible the moment you leave + the app, which is exactly when you'd leave it. +- **Active-workout Live Activity + the first widgets** (N3 — current exercise, set x of y, + rest countdown; Train's today/week/Zone-2 widgets; Cook's tonight's-dinner widget). + +Started locally, so **no APNs and no push entitlement are needed** for either — B3's key is +only for server-driven updates later. + +Note this is the trigger for **C3** below: a widget extension is the second target, and it +needs `Forge/Kit` and `Forge/Design`, so that's the moment the `Packages/` extraction +stops being premature. + ### C2 · SwiftLint build-tool plugin `[ ]` **Where**: target `Forge` → Build Phases → Run Build Tool Plug-ins. Called for in the plan @@ -244,5 +266,9 @@ If you want to sit down once and unblock everything through N2: 5. Say whether the deployment target stays at 26.5. **[A4]** 6. Cable-install to both phones and sign in. **[B2]** +And when you want the Island timers Cook and Train are both already written for: + +7. New Widget Extension target with Live Activity, plus `NSSupportsLiveActivities`. **[C1b]** + Then commit `Forge.entitlements` and the `project.pbxproj` changes, and tell the next agent session which of these are done. diff --git a/server/app/routers/training.py b/server/app/routers/training.py index cb8ec79..ef73578 100644 --- a/server/app/routers/training.py +++ b/server/app/routers/training.py @@ -712,13 +712,20 @@ def history(limit: int = Query(default=30, ge=1, le=100), offset: int = Query(de def _e1rm_series(db: Session, user_id: str) -> dict: - """Per-lift e1RM series: best set per completed-session day.""" - sets = (db.query(LoggedSet).join(WorkoutSession, LoggedSet.session_id == WorkoutSession.id) + """Per-lift e1RM series: best set per completed-session day. + + Keyed on the session's `day`, NOT the set's timestamp. They coincide when + you log as you train, but a session saved off the next morning — which the + completion path explicitly supports — would otherwise plot the lift on the + day you pressed the button rather than the day you did the work. + """ + rows = (db.query(LoggedSet, WorkoutSession.day) + .join(WorkoutSession, LoggedSet.session_id == WorkoutSession.id) .filter(LoggedSet.user_id == user_id, WorkoutSession.status == "completed", LoggedSet.weight > 0).all()) series: dict[str, dict[str, float]] = {} - for s in sets: - day_key = str(s.ts.date()) + for s, day in rows: + day_key = str(day) e1 = epley_e1rm(s.weight, s.reps) cur = series.setdefault(s.exercise_slug, {}) cur[day_key] = max(cur.get(day_key, 0), e1) diff --git a/server/tests/test_smoke.py b/server/tests/test_smoke.py index 5f9b296..a45d445 100644 --- a/server/tests/test_smoke.py +++ b/server/tests/test_smoke.py @@ -2095,3 +2095,18 @@ def test_favorite_and_delete_scoped_to_owner(): # clean up James's leftover active session login("james@test.dev") client.delete(f"/api/sessions/{sid}") + + +def test_e1rm_series_uses_the_workout_day_not_the_log_time(): + """A session finished the morning after must plot on the day it was + trained. The two coincide when logging live, so only a backdated session + catches this.""" + login() + past_monday = "2026-07-13" + sid = client.post("/api/sessions", json={"date": past_monday}).json()["id"] + client.post(f"/api/sessions/{sid}/sets", + json={"slug": "back-squat", "set_no": 1, "weight": 92.5, "reps": 3}) + client.post(f"/api/sessions/{sid}/complete", json={"cooldown_status": "done"}) + + points = client.get("/api/progress").json()["e1rm"]["back-squat"]["points"] + assert any(p["d"] == past_monday for p in points), points diff --git a/web/src/api.ts b/web/src/api.ts index 948afdb..a9ce54d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -284,7 +284,7 @@ export interface RecipeListItem extends RecipeCard { tags: string[]; source: string; complete: boolean; } export interface RecipeList { count: number; recipes: RecipeListItem[]; } -/** Full-history series for one body/engine metric (Progress drill-down). */ +/** Full-history series for one body/cardio metric (Progress drill-down). */ export interface MetricHistory { type: string; unit: string; points: SeriesPoint[]; } export interface Connections {