Restructure into a three-tab app with Progress, goals, and Meditation
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
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user