Skip to content

Commit 14585bd

Browse files
OneMuppetclaude
andcommitted
iOS Wave A: workout runner, progress (streak/PR/charts), shareable result cards
The train -> track -> share loop, all offline: - Workout RUNNER (full-screen): For Time stopwatch / AMRAP countdown with a round counter, movements resolved at your level, start/pause/finish, haptics. Finishing auto-saves a result to the log and offers a share card. "Start workout" button on every session. - LOG progress: day streak, total + this-week counts, an 8-week activity bar chart (Swift Charts), and automatic PR badges (beats your prior best per movement; time lower-is-better, loads/reps/rounds higher-is-better). Swipe to delete. - Shareable RESULT CARD: branded image (gradient, wordmark, result, level) rendered via ImageRenderer and shared through the system sheet. No backend. Builds clean; 0 em dashes; runner/log/share verified in the simulator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8a4b1e2 commit 14585bd

4 files changed

Lines changed: 347 additions & 19 deletions

File tree

Lines changed: 103 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import SwiftUI
22
import SwiftData
3+
import Charts
34

45
struct LogView: View {
56
@Environment(\.modelContext) private var ctx
@@ -8,16 +9,14 @@ struct LogView: View {
89
var body: some View {
910
NavigationStack {
1011
Group {
11-
if entries.isEmpty { empty } else { list }
12+
if entries.isEmpty { empty } else { content }
1213
}
1314
.background(Theme.bg.ignoresSafeArea())
1415
.navigationTitle("Log")
1516
.toolbar {
1617
if !entries.isEmpty {
1718
ToolbarItem(placement: .topBarTrailing) {
18-
ShareLink(item: CSV.file(for: entries)) {
19-
Image(systemName: "square.and.arrow.up")
20-
}.tint(Theme.primary)
19+
ShareLink(item: CSV.file(for: entries)) { Image(systemName: "square.and.arrow.up") }.tint(Theme.primary)
2120
}
2221
}
2322
}
@@ -28,36 +27,127 @@ struct LogView: View {
2827
VStack(spacing: 12) {
2928
Image(systemName: "checklist").font(.system(size: 44, weight: .light)).foregroundStyle(Theme.textFaint)
3029
Text("No results yet").font(.display(20)).foregroundStyle(Theme.text)
31-
Text("Log a workout or a benchmark and it shows up here. Export the whole log to a CSV anytime.")
30+
Text("Run a workout or log a benchmark and it shows up here, with PRs, a streak, and CSV export.")
3231
.font(.body(14)).foregroundStyle(Theme.textDim).multilineTextAlignment(.center).padding(.horizontal, 40)
3332
}.frame(maxWidth: .infinity, maxHeight: .infinity)
3433
}
3534

36-
private var list: some View {
35+
private var content: some View {
3736
List {
38-
ForEach(entries) { e in row(e) }
37+
Section {
38+
statsCard.listRowInsets(EdgeInsets(top: 8, leading: 18, bottom: 6, trailing: 18))
39+
.listRowBackground(Color.clear).listRowSeparator(.hidden)
40+
}
41+
Section {
42+
ForEach(entries) { e in
43+
row(e).listRowInsets(EdgeInsets(top: 0, leading: 18, bottom: 0, trailing: 18))
44+
.listRowBackground(Color.clear).listRowSeparator(.hidden)
45+
}
3946
.onDelete { idx in idx.map { entries[$0] }.forEach(ctx.delete) }
40-
.listRowBackground(Theme.surface)
41-
.listRowSeparatorTint(Theme.stroke)
47+
}
4248
}
4349
.listStyle(.plain)
4450
.scrollContentBackground(.hidden)
4551
}
4652

53+
// MARK: stats
54+
private var statsCard: some View {
55+
VStack(alignment: .leading, spacing: 14) {
56+
HStack {
57+
stat("\(streak)", streak == 1 ? "day streak" : "day streak")
58+
Divider().frame(height: 34).overlay(Theme.stroke)
59+
stat("\(entries.count)", "logged")
60+
Divider().frame(height: 34).overlay(Theme.stroke)
61+
stat("\(thisWeekCount)", "this week")
62+
}
63+
if weekCounts.contains(where: { $0.1 > 0 }) {
64+
Chart(weekCounts, id: \.0) { item in
65+
BarMark(x: .value("Week", item.0, unit: .weekOfYear), y: .value("Sessions", item.1))
66+
.foregroundStyle(Theme.primary).cornerRadius(3)
67+
}
68+
.frame(height: 70)
69+
.chartXAxis { AxisMarks(values: .stride(by: .weekOfYear)) { _ in } }
70+
.chartYAxis(.hidden)
71+
}
72+
}.frame(maxWidth: .infinity).card()
73+
}
74+
private func stat(_ v: String, _ l: String) -> some View {
75+
VStack(spacing: 2) {
76+
Text(v).font(.display(22, .bold)).foregroundStyle(Theme.text)
77+
Text(l.uppercased()).font(.system(size: 10, weight: .bold)).tracking(0.8).foregroundStyle(Theme.textFaint)
78+
}.frame(maxWidth: .infinity)
79+
}
80+
4781
private func row(_ e: LogEntry) -> some View {
4882
HStack(spacing: 12) {
4983
Circle().fill(Theme.levelColor(e.level)).frame(width: 8, height: 8)
5084
VStack(alignment: .leading, spacing: 2) {
51-
Text(e.name).font(.body(15, .semibold)).foregroundStyle(Theme.text)
85+
HStack(spacing: 6) {
86+
Text(e.name).font(.body(15, .semibold)).foregroundStyle(Theme.text)
87+
if prIDs.contains(e.persistentModelID) {
88+
Text("PR").font(.system(size: 9, weight: .black)).foregroundStyle(.black)
89+
.padding(.horizontal, 5).padding(.vertical, 2).background(Theme.primary, in: Capsule())
90+
}
91+
}
5292
Text("\(e.type) · \(prettyDate(e.date))\(e.notes.map { " · \($0)" } ?? "")")
5393
.font(.body(12)).foregroundStyle(Theme.textFaint).lineLimit(1)
5494
}
5595
Spacer()
5696
Text(e.result).font(.display(16, .bold)).foregroundStyle(Theme.primary)
57-
}.padding(.vertical, 4)
97+
}
98+
.padding(.vertical, 11)
99+
.overlay(alignment: .bottom) { Rectangle().fill(Theme.stroke).frame(height: 1) }
58100
}
59101

60-
private func prettyDate(_ d: Date) -> String {
61-
let f = DateFormatter(); f.dateFormat = "MMM d"; return f.string(from: d)
102+
// MARK: derived
103+
private var cal: Calendar { Calendar.current }
104+
private func day(_ d: Date) -> Date { cal.startOfDay(for: d) }
105+
private func week(_ d: Date) -> Date { cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: d)) ?? d }
106+
107+
private var streak: Int {
108+
let days = Set(entries.map { day($0.date) })
109+
guard !days.isEmpty else { return 0 }
110+
var cursor = day(Date())
111+
if !days.contains(cursor) { cursor = cal.date(byAdding: .day, value: -1, to: cursor)! } // grace for "not yet today"
112+
guard days.contains(cursor) else { return 0 }
113+
var n = 0
114+
while days.contains(cursor) { n += 1; cursor = cal.date(byAdding: .day, value: -1, to: cursor)! }
115+
return n
116+
}
117+
private var thisWeekCount: Int { entries.filter { week($0.date) == week(Date()) }.count }
118+
private var weekCounts: [(Date, Int)] {
119+
let now = week(Date())
120+
return (0..<8).reversed().map { off -> (Date, Int) in
121+
let w = cal.date(byAdding: .weekOfYear, value: -off, to: now) ?? now
122+
return (w, entries.filter { week($0.date) == w }.count)
123+
}
124+
}
125+
126+
/// IDs of the best entry per movement (only when there is a prior to beat).
127+
private var prIDs: Set<PersistentIdentifier> {
128+
var counts: [String: Int] = [:]
129+
for e in entries { counts[e.name, default: 0] += 1 }
130+
var best: [String: LogEntry] = [:]
131+
for e in entries where (counts[e.name] ?? 0) >= 2 {
132+
if let b = best[e.name] { if better(e, than: b) { best[e.name] = e } }
133+
else { best[e.name] = e }
134+
}
135+
return Set(best.values.map { $0.persistentModelID })
62136
}
137+
private func score(_ r: String) -> (Double, Bool) {
138+
if r.contains(":") {
139+
let parts = r.split(separator: ":")
140+
if parts.count == 2, let m = Double(parts[0]), let s = Double(parts[1].prefix(while: { $0.isNumber })) {
141+
return (m * 60 + s, true) // time, lower is better
142+
}
143+
}
144+
let num = Double(r.prefix(while: { $0.isNumber || $0 == "." })) ?? 0
145+
return (num, false) // load/reps/rounds, higher is better
146+
}
147+
private func better(_ a: LogEntry, than b: LogEntry) -> Bool {
148+
let (av, low) = score(a.result); let (bv, _) = score(b.result)
149+
return low ? av < bv : av > bv
150+
}
151+
152+
private func prettyDate(_ d: Date) -> String { let f = DateFormatter(); f.dateFormat = "MMM d"; return f.string(from: d) }
63153
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import SwiftUI
2+
import SwiftData
3+
4+
struct WorkoutRunnerView: View {
5+
@Environment(\.modelContext) private var ctx
6+
@Environment(\.dismiss) private var dismiss
7+
let metcon: Metcon
8+
let level: Int
9+
let gender: Gender
10+
11+
private enum Phase { case ready, running, paused, done }
12+
@State private var phase: Phase = .ready
13+
@State private var elapsed = 0 // seconds
14+
@State private var rounds = 0
15+
@State private var finalResult = ""
16+
@State private var saved: LogEntry?
17+
@State private var showShare = false
18+
19+
private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
20+
private var isAMRAP: Bool { metcon.type == "amrap" }
21+
private var cap: Int { metcon.timeCap * 60 }
22+
private var display: Int { isAMRAP ? max(0, cap - elapsed) : elapsed }
23+
24+
var body: some View {
25+
ZStack {
26+
Theme.bg.ignoresSafeArea()
27+
VStack(spacing: 0) {
28+
header
29+
Spacer()
30+
timer
31+
if isAMRAP && phase != .done { roundCounter }
32+
Spacer()
33+
if phase == .done { finishedSummary } else { controls }
34+
}
35+
.padding(20)
36+
}
37+
.onReceive(tick) { _ in
38+
guard phase == .running else { return }
39+
elapsed += 1
40+
if isAMRAP && elapsed >= cap { finish() }
41+
}
42+
.sheet(isPresented: $showShare) {
43+
if let s = saved {
44+
ShareResultView(card: ResultCard(name: metcon.name, result: s.result, subtitle: subtitle, levelNumber: level))
45+
}
46+
}
47+
}
48+
49+
private var header: some View {
50+
HStack {
51+
Button { dismiss() } label: { Image(systemName: "xmark").font(.system(size: 16, weight: .bold)).foregroundStyle(Theme.textDim).frame(width: 38, height: 38).background(Theme.surface, in: Circle()) }.buttonStyle(.plain)
52+
Spacer()
53+
VStack(spacing: 2) {
54+
Text(metcon.name).font(.display(17, .bold)).foregroundStyle(Theme.text)
55+
Text("\(typeLabel(metcon.type)) · Cap \(metcon.timeCap)").font(.body(12, .semibold)).foregroundStyle(Theme.textFaint)
56+
}
57+
Spacer()
58+
Color.clear.frame(width: 38, height: 38)
59+
}
60+
}
61+
62+
private var timer: some View {
63+
VStack(spacing: 6) {
64+
Text(isAMRAP && phase != .done ? "TIME REMAINING" : "ELAPSED")
65+
.font(.body(12, .bold)).tracking(1.5).foregroundStyle(Theme.textFaint)
66+
Text(fmt(display))
67+
.font(.system(size: 88, weight: .black, design: .rounded)).monospacedDigit()
68+
.foregroundStyle(phase == .running ? Theme.primary : Theme.text)
69+
.contentTransition(.numericText())
70+
// movement reminder
71+
VStack(spacing: 4) {
72+
ForEach(Array((metcon.movements ?? []).enumerated()), id: \.offset) { _, mv in
73+
let r = mv.resolved(levelNumber: level, gender: gender)
74+
Text("\(repsText(r.reps, unit: r.unit)) \(r.name)\(r.load.map { " · \(Int($0)) kg" } ?? "")")
75+
.font(.body(14, .medium)).foregroundStyle(Theme.textDim)
76+
}
77+
}.padding(.top, 14)
78+
}
79+
}
80+
81+
private var roundCounter: some View {
82+
VStack(spacing: 10) {
83+
Text("ROUNDS").font(.body(12, .bold)).tracking(1.5).foregroundStyle(Theme.textFaint)
84+
HStack(spacing: 24) {
85+
Button { if rounds > 0 { rounds -= 1; Haptics.tap() } } label: { stepIcon("minus") }.buttonStyle(.plain)
86+
Text("\(rounds)").font(.system(size: 48, weight: .black, design: .rounded)).foregroundStyle(Theme.text).frame(minWidth: 70)
87+
Button { rounds += 1; Haptics.select() } label: { stepIcon("plus") }.buttonStyle(.plain)
88+
}
89+
}.padding(.top, 24)
90+
}
91+
private func stepIcon(_ n: String) -> some View {
92+
Image(systemName: n).font(.system(size: 20, weight: .bold)).foregroundStyle(Theme.text).frame(width: 54, height: 54).background(Theme.surface, in: Circle()).overlay(Circle().strokeBorder(Theme.stroke))
93+
}
94+
95+
private var controls: some View {
96+
HStack(spacing: 12) {
97+
if phase == .ready {
98+
bigButton("Start", "play.fill", Theme.primary) { phase = .running; Haptics.success() }
99+
} else if phase == .running {
100+
bigButton("Pause", "pause.fill", Theme.surface2, fg: Theme.text) { phase = .paused; Haptics.tap() }
101+
bigButton("Finish", "flag.checkered", Theme.primary) { finish() }
102+
} else if phase == .paused {
103+
bigButton("Resume", "play.fill", Theme.primary) { phase = .running; Haptics.tap() }
104+
bigButton("Finish", "flag.checkered", Theme.surface2, fg: Theme.text) { finish() }
105+
}
106+
}
107+
}
108+
109+
private var finishedSummary: some View {
110+
VStack(spacing: 14) {
111+
VStack(spacing: 4) {
112+
Text("RESULT").font(.body(12, .bold)).tracking(1.5).foregroundStyle(Theme.textFaint)
113+
Text(finalResult).font(.display(34)).foregroundStyle(Theme.primary)
114+
Text("Saved to your log").font(.body(13)).foregroundStyle(Theme.textDim)
115+
}
116+
HStack(spacing: 12) {
117+
bigButton("Share", "square.and.arrow.up", Theme.surface2, fg: Theme.text) { showShare = true }
118+
bigButton("Done", "checkmark", Theme.primary) { dismiss() }
119+
}
120+
}
121+
}
122+
123+
private func bigButton(_ title: String, _ icon: String, _ bg: Color, fg: Color = .black, _ act: @escaping () -> Void) -> some View {
124+
Button(action: act) {
125+
Label(title, systemImage: icon).font(.body(16, .bold)).foregroundStyle(fg)
126+
.frame(maxWidth: .infinity).padding(.vertical, 16)
127+
.background(bg, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
128+
}.buttonStyle(.plain)
129+
}
130+
131+
private func finish() {
132+
phase = .done
133+
Haptics.success()
134+
finalResult = isAMRAP ? "\(rounds) rounds" : fmt(elapsed)
135+
let entry = LogEntry(type: "Metcon", name: metcon.name, code: metcon.code, level: level,
136+
gender: gender.rawValue, result: finalResult, notes: nil)
137+
ctx.insert(entry)
138+
saved = entry
139+
}
140+
141+
private var subtitle: String { "\(typeLabel(metcon.type)) · \(levelShort[max(1,min(7,level))-1])" }
142+
private func fmt(_ s: Int) -> String { String(format: "%d:%02d", s/60, s%60) }
143+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import SwiftUI
2+
3+
/// A branded, fixed-size card rendered to an image for sharing.
4+
struct ResultCard: View {
5+
let name: String
6+
let result: String
7+
let subtitle: String
8+
let levelNumber: Int
9+
10+
var body: some View {
11+
ZStack(alignment: .top) {
12+
Theme.bg
13+
Theme.levelGradient.frame(height: 6)
14+
VStack(alignment: .leading, spacing: 0) {
15+
HStack { Wordmark(size: 20); Spacer(); LevelDots(size: 8) }
16+
Spacer()
17+
Text("WORKOUT").font(.body(12, .bold)).tracking(2).foregroundStyle(Theme.textFaint)
18+
Text(name).font(.display(34)).foregroundStyle(Theme.text).lineLimit(2).minimumScaleFactor(0.7)
19+
Spacer().frame(height: 24)
20+
Text("RESULT").font(.body(12, .bold)).tracking(2).foregroundStyle(Theme.textFaint)
21+
Text(result).font(.system(size: 60, weight: .black, design: .rounded)).foregroundStyle(Theme.primary).lineLimit(1).minimumScaleFactor(0.5)
22+
Text(subtitle).font(.body(15, .semibold)).foregroundStyle(Theme.textDim).padding(.top, 4)
23+
Spacer()
24+
HStack {
25+
if levelNumber >= 1 && levelNumber <= 7 {
26+
Text(levelShort[levelNumber-1]).font(.system(size: 13, weight: .bold)).foregroundStyle(.black)
27+
.padding(.horizontal, 12).padding(.vertical, 6)
28+
.background(Theme.levelColor(levelNumber), in: Capsule())
29+
}
30+
Spacer()
31+
Text("openprogression.org").font(.body(13, .semibold)).foregroundStyle(Theme.textFaint)
32+
}
33+
}
34+
.padding(30)
35+
}
36+
.frame(width: 360, height: 480)
37+
.environment(\.colorScheme, .dark)
38+
}
39+
}
40+
41+
struct ShareResultView: View {
42+
let card: ResultCard
43+
@Environment(\.dismiss) private var dismiss
44+
@State private var url: URL?
45+
46+
var body: some View {
47+
VStack(spacing: 20) {
48+
Text("Share your result").font(.display(20)).foregroundStyle(Theme.text).padding(.top, 24)
49+
card
50+
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
51+
.overlay(RoundedRectangle(cornerRadius: 22, style: .continuous).strokeBorder(Theme.stroke))
52+
.scaleEffect(0.92)
53+
if let url {
54+
ShareLink(item: url) {
55+
Label("Share image", systemImage: "square.and.arrow.up")
56+
.font(.body(16, .bold)).foregroundStyle(.black)
57+
.frame(maxWidth: .infinity).padding(.vertical, 16)
58+
.background(Theme.primary, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
59+
}.buttonStyle(.plain).padding(.horizontal, 24)
60+
}
61+
Spacer()
62+
}
63+
.frame(maxWidth: .infinity, maxHeight: .infinity)
64+
.background(Theme.bg.ignoresSafeArea())
65+
.task { url = await Self.render(card) }
66+
}
67+
68+
@MainActor static func render(_ card: ResultCard) async -> URL? {
69+
let renderer = ImageRenderer(content: card)
70+
renderer.scale = 3
71+
guard let img = renderer.uiImage, let data = img.pngData() else { return nil }
72+
let out = FileManager.default.temporaryDirectory.appendingPathComponent("openprogression-result.png")
73+
try? data.write(to: out)
74+
return out
75+
}
76+
}

0 commit comments

Comments
 (0)