The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView becomes a Today / Progress / Settings TabView, "Routine" replaces "Split" in every user-facing string and view name (code-level types keep their names), and workout starting moves to shared WorkoutStarter / StartedWorkoutNavigator plumbing. - New Progress tab: weekly goal streaks, workout trends, per-exercise weight progression, achievements, and the full history list (WorkoutLogsView -> WorkoutHistoryView). - Goals: stable categories workouts roll up to, managed from Settings. - New Meditation exercise + starter routine; timed sits record to Apple Health as Mind & Body sessions. Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
139 lines
5.1 KiB
Swift
139 lines
5.1 KiB
Swift
//
|
|
// GoalTrackCard.swift
|
|
// Workouts
|
|
//
|
|
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
/// One goal's adherence card: a 12-week strip (each square filled by that week's
|
|
/// met/expected fraction), the weekly streak flame, and a this-week dot row per
|
|
/// member schedule. The goal is the ledger category — this track continues unbroken
|
|
/// across schedule rewrites.
|
|
struct GoalTrackCard: View {
|
|
let track: GoalTrack
|
|
|
|
private var color: Color {
|
|
track.goal.map { Color.color(from: $0.colorName) } ?? Color(.systemGray)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Label(track.goal?.displayName ?? "Unassigned",
|
|
systemImage: track.goal?.systemImage ?? "tag")
|
|
.font(.headline)
|
|
.foregroundStyle(color)
|
|
Spacer()
|
|
if track.streakWeeks > 0 {
|
|
streakBadge
|
|
}
|
|
}
|
|
|
|
weekStrip
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
ForEach(track.scheduleTracks, id: \.schedule.id) { scheduleTrack in
|
|
scheduleRow(scheduleTrack)
|
|
}
|
|
}
|
|
}
|
|
.progressCard()
|
|
}
|
|
|
|
/// The streak flame: orange while the current week is still meetable, dimmed the
|
|
/// moment it can no longer be (the streak is at stake, not yet erased).
|
|
private var streakBadge: some View {
|
|
HStack(spacing: 3) {
|
|
Image(systemName: "flame.fill")
|
|
Text("\(track.streakWeeks) wk")
|
|
.monospacedDigit()
|
|
}
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(track.streakAlive ? Color.orange : Color.secondary)
|
|
.accessibilityLabel("\(track.streakWeeks)-week streak\(track.streakAlive ? "" : ", at risk")")
|
|
}
|
|
|
|
private var weekStrip: some View {
|
|
HStack(spacing: 4) {
|
|
ForEach(Array(track.weeks.enumerated()), id: \.offset) { index, week in
|
|
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
|
.fill(fill(for: week))
|
|
.frame(height: 16)
|
|
.overlay {
|
|
// Outline the in-flight week so "unfilled" reads as "not over
|
|
// yet" rather than "missed".
|
|
if index == track.weeks.count - 1 {
|
|
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
|
.strokeBorder(color.opacity(0.8), lineWidth: 1.5)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(stripSummary)
|
|
}
|
|
|
|
private func fill(for week: WeekMark) -> Color {
|
|
if week.isVacuous { return Color(.systemFill).opacity(0.4) }
|
|
if week.fraction == 0 { return Color(.systemFill) }
|
|
return color.opacity(0.25 + 0.75 * week.fraction)
|
|
}
|
|
|
|
private var stripSummary: String {
|
|
let hit = track.weeks.filter { !$0.isVacuous && $0.hit }.count
|
|
let active = track.weeks.filter { !$0.isVacuous }.count
|
|
return "Hit \(hit) of the last \(active) weeks"
|
|
}
|
|
|
|
private func scheduleRow(_ scheduleTrack: ScheduleTrack) -> some View {
|
|
let schedule = scheduleTrack.schedule
|
|
return HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(schedule.routineName)
|
|
.font(.subheadline)
|
|
Text(schedule.recurrence.summary(
|
|
weekdays: schedule.weekdays,
|
|
timesPerWeek: schedule.timesPerWeek,
|
|
date: schedule.onceDate))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
thisWeekDots(scheduleTrack)
|
|
}
|
|
}
|
|
|
|
/// This week for one schedule: `expected` dots, `met` of them filled. A broken
|
|
/// week adds a quiet "missed" tag — informative, not scolding.
|
|
@ViewBuilder
|
|
private func thisWeekDots(_ scheduleTrack: ScheduleTrack) -> some View {
|
|
if let week = scheduleTrack.currentWeek, !week.isVacuous {
|
|
HStack(spacing: 5) {
|
|
if scheduleTrack.currentWeekBroken {
|
|
Text("missed")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
ForEach(0..<week.expected, id: \.self) { index in
|
|
Circle()
|
|
.fill(index < week.met ? color : Color.clear)
|
|
.overlay {
|
|
if index >= week.met {
|
|
Circle().strokeBorder(color.opacity(0.4), lineWidth: 1.5)
|
|
}
|
|
}
|
|
.frame(width: 9, height: 9)
|
|
}
|
|
}
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel("\(week.met) of \(week.expected) this week")
|
|
} else {
|
|
Text("—")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
}
|