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,293 @@
|
||||
//
|
||||
// TodayView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// The Today board — the today-projection of the user's schedules, grouped by goal.
|
||||
/// A schedule row shows its routine, its recurrence, and today's status for that
|
||||
/// routine; tapping starts (or opens) that routine's workout for today.
|
||||
struct TodayView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(AppServices.self) private var services
|
||||
|
||||
@Query(sort: [SortDescriptor(\Schedule.order), SortDescriptor(\Schedule.createdAt)])
|
||||
private var schedules: [Schedule]
|
||||
|
||||
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
|
||||
private var routines: [Routine]
|
||||
|
||||
@Query(sort: \Workout.start, order: .reverse)
|
||||
private var workouts: [Workout]
|
||||
|
||||
/// ID of the workout to push into — a just-started one (the navigator polls for
|
||||
/// its cache entity) or an existing today-workout (resolves immediately). One
|
||||
/// id-based path for both, so no destination ever retains a `@Model`.
|
||||
@State private var pendingWorkoutID: String?
|
||||
@State private var scheduleToEdit: Schedule?
|
||||
@State private var showingAddSchedule = false
|
||||
@State private var showingDayPicker = false
|
||||
|
||||
/// The day the board is projecting — today by default; the calendar toolbar
|
||||
/// button navigates to any other day.
|
||||
@State private var selectedDate = Date()
|
||||
|
||||
private var isViewingToday: Bool { selectedDate.isSameDay(as: Date()) }
|
||||
|
||||
/// The viewed day is strictly before today (day-precision).
|
||||
private var isViewingPastDay: Bool { selectedDate < Calendar.current.startOfDay(for: Date()) }
|
||||
|
||||
/// Schedules that belong on the viewed day's board: recurring ones always show
|
||||
/// (the board displays their recurrence, not a due-filter — yet); a one-off shows
|
||||
/// only on its own day. A dateless `.once` (shouldn't happen) shows everywhere
|
||||
/// rather than nowhere.
|
||||
private var visibleSchedules: [Schedule] {
|
||||
schedules.filter { schedule in
|
||||
guard schedule.recurrenceEnum == .once, let date = schedule.date else { return true }
|
||||
return date.isSameDay(as: selectedDate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-empty goal groups in the user's chosen goal order.
|
||||
private var goalGroups: [(kind: GoalKind, schedules: [Schedule])] {
|
||||
GoalKind.orderedCases().compactMap { kind in
|
||||
let matches = visibleSchedules.filter { $0.goalKind == kind }
|
||||
return matches.isEmpty ? nil : (kind, matches)
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules with no goal, or a goal raw value this version doesn't know.
|
||||
private var unassignedSchedules: [Schedule] {
|
||||
visibleSchedules.filter { $0.goalKind == nil }
|
||||
}
|
||||
|
||||
/// Motivational openers for the board title — each reads as a phrase completed
|
||||
/// by "Today" ("Seize Today"). One per day, rotating by day-of-era: stable all
|
||||
/// day, fresh the next.
|
||||
private static let greetings: [String] = [
|
||||
"Seize",
|
||||
"Own",
|
||||
"Win",
|
||||
"Conquer",
|
||||
"Show Up",
|
||||
"Stay the Course",
|
||||
"Make It Count",
|
||||
"Do the Work",
|
||||
"Start Strong",
|
||||
"No Excuses",
|
||||
"Begin Again",
|
||||
"Keep the Streak",
|
||||
]
|
||||
|
||||
private var greetingLine: String {
|
||||
let day = Calendar.current.ordinality(of: .day, in: .era, for: Date()) ?? 0
|
||||
return Self.greetings[day % Self.greetings.count]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(goalGroups, id: \.kind) { group in
|
||||
Section {
|
||||
ForEach(group.schedules) { scheduleRow($0) }
|
||||
} header: {
|
||||
Label(group.kind.displayName, systemImage: group.kind.systemImage)
|
||||
.foregroundStyle(Color.color(from: group.kind.colorName))
|
||||
}
|
||||
}
|
||||
|
||||
if !unassignedSchedules.isEmpty {
|
||||
Section("Unassigned") {
|
||||
ForEach(unassignedSchedules) { scheduleRow($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if schedules.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Schedules Yet",
|
||||
systemImage: "calendar.badge.plus",
|
||||
description: Text("Plan your week by adding a schedule for a routine.")
|
||||
)
|
||||
} else if visibleSchedules.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Nothing Planned",
|
||||
systemImage: "calendar",
|
||||
description: Text("Nothing is scheduled for this day. Tap + to plan something.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle(isViewingToday ? "\(greetingLine) Today" : selectedDate.formatDate())
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
showingDayPicker = true
|
||||
} label: {
|
||||
Label("Go to Day", systemImage: "calendar")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showingAddSchedule = true
|
||||
} label: {
|
||||
Label("Add Schedule", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Row taps land here for both cases: a fresh start (entity arrives a beat
|
||||
// after the file write) and an existing today-workout (resolves at once).
|
||||
.navigatesToStartedWorkout(pendingWorkoutID: $pendingWorkoutID)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
SyncStatusBanner()
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.sheet(isPresented: $showingDayPicker) {
|
||||
DayPickerSheet(selectedDate: $selectedDate, workoutDays: workoutDays)
|
||||
}
|
||||
.sheet(isPresented: $showingAddSchedule) {
|
||||
// Seed a would-be one-off with the day on screen, so navigating to a
|
||||
// day and tapping + plans that day.
|
||||
ScheduleAddEditView(defaultDate: selectedDate)
|
||||
}
|
||||
.sheet(item: $scheduleToEdit) { schedule in
|
||||
ScheduleAddEditView(schedule: schedule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row
|
||||
|
||||
private func scheduleRow(_ schedule: Schedule) -> some View {
|
||||
let routine = resolvedRoutine(for: schedule)
|
||||
let today = todaysWorkout(for: schedule)
|
||||
return Button {
|
||||
openOrStart(schedule: schedule, routine: routine, today: today)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: routine?.systemImage ?? "figure.strengthtraining.traditional")
|
||||
.font(.title3)
|
||||
.foregroundStyle(iconColor(routine: routine, schedule: schedule))
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(routine?.name ?? schedule.routineName)
|
||||
.foregroundStyle(.primary)
|
||||
Text(schedule.recurrenceSummary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let today {
|
||||
todayStatus(for: today)
|
||||
} else if isViewingPastDay {
|
||||
skippedTag
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.tint(.primary)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
Task { await sync.delete(schedule: schedule) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
|
||||
Button {
|
||||
scheduleToEdit = schedule
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Today's status decoration: a completed run shows a green check + its finish
|
||||
/// time; an unfinished one shows an orange "In Progress" tag.
|
||||
@ViewBuilder
|
||||
private func todayStatus(for workout: Workout) -> some View {
|
||||
if workout.status == .completed {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text((workout.end ?? workout.start).formattedTime())
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("In Progress")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(.orange.opacity(0.15), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
/// Signpost on a past-day schedule row with no logged workout. Deliberately
|
||||
/// undifferentiated for now — a later pass will distinguish *abandoned* from an
|
||||
/// explicit skip carrying an excuse ("sick day"); see UX-REDESIGN.md.
|
||||
private var skippedTag: some View {
|
||||
Text("Skipped")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(.secondary.opacity(0.15), in: Capsule())
|
||||
}
|
||||
|
||||
// MARK: - Resolution helpers
|
||||
|
||||
/// The live routine this schedule points at, following the seed clone-on-edit
|
||||
/// redirect; nil when the routine has been deleted.
|
||||
private func resolvedRoutine(for schedule: Schedule) -> Routine? {
|
||||
let liveID = sync.currentRoutineID(for: schedule.routineID)
|
||||
return routines.first { $0.id == liveID }
|
||||
}
|
||||
|
||||
/// The most recent workout started on the viewed day for this schedule's routine
|
||||
/// (comparing through the clone redirect on both sides). `workouts` is
|
||||
/// start-descending, so the first match is the newest.
|
||||
private func todaysWorkout(for schedule: Schedule) -> Workout? {
|
||||
let liveRoutineID = sync.currentRoutineID(for: schedule.routineID)
|
||||
return workouts.first { workout in
|
||||
guard let rid = workout.routineID, workout.start.isSameDay(as: selectedDate) else { return false }
|
||||
return sync.currentRoutineID(for: rid) == liveRoutineID
|
||||
}
|
||||
}
|
||||
|
||||
/// Day-precision (year/month/day) components of every day with at least one
|
||||
/// logged workout — the day picker decorates these with a dot.
|
||||
private var workoutDays: Set<DateComponents> {
|
||||
Set(workouts.map { Calendar.current.dateComponents([.year, .month, .day], from: $0.start) })
|
||||
}
|
||||
|
||||
/// Row icon color: the routine's own color, or the goal's color as a generic
|
||||
/// fallback when the routine is gone.
|
||||
private func iconColor(routine: Routine?, schedule: Schedule) -> Color {
|
||||
if let routine { return Color.color(from: routine.color) }
|
||||
return Color.color(from: schedule.goalKind?.colorName ?? "")
|
||||
}
|
||||
|
||||
/// Tap: open the viewed day's workout if one exists, otherwise start a fresh one
|
||||
/// from the resolved routine — but only when the board is on today, since a start
|
||||
/// always happens *now*. Does nothing on other days without a workout, or for an
|
||||
/// orphaned schedule (no live routine).
|
||||
private func openOrStart(schedule: Schedule, routine: Routine?, today: Workout?) {
|
||||
if let today {
|
||||
guard !today.isDeleted else { return }
|
||||
pendingWorkoutID = today.id
|
||||
} else if isViewingToday, let routine {
|
||||
Task {
|
||||
pendingWorkoutID = await WorkoutStarter.start(routine: routine, sync: sync)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user