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:
2026-07-11 07:53:01 -04:00
parent 5c201289fb
commit 6e440317c4
97 changed files with 5354 additions and 1291 deletions
+98
View File
@@ -0,0 +1,98 @@
//
// DayPickerSheet.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import UIKit
/// The calendar toolbar button's sheet: jumps the Today board to any day. Days with
/// at least one logged workout carry a small dot. Built on `UICalendarView` rather
/// than the SwiftUI graphical `DatePicker` solely because only the UIKit calendar
/// supports per-day decorations. Picking a day (or "Today") dismisses immediately
/// this is navigation, not a form, so there's nothing to confirm.
struct DayPickerSheet: View {
@Binding var selectedDate: Date
/// Day-precision (year/month/day) components of the days to decorate.
let workoutDays: Set<DateComponents>
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
DayCalendarView(selectedDate: $selectedDate, workoutDays: workoutDays) {
dismiss()
}
.padding(.horizontal)
.navigationTitle("Go to Day")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Today") {
selectedDate = Date()
dismiss()
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
}
}
/// `UICalendarView` wrapper: single-date selection plus a dot decoration on each
/// day in `workoutDays`.
private struct DayCalendarView: UIViewRepresentable {
@Binding var selectedDate: Date
let workoutDays: Set<DateComponents>
/// Called after a day is picked the sheet dismisses itself here.
var onPick: () -> Void
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UICalendarView {
let view = UICalendarView()
view.calendar = Calendar.current
view.delegate = context.coordinator
let selection = UICalendarSelectionSingleDate(delegate: context.coordinator)
selection.setSelected(
Calendar.current.dateComponents([.year, .month, .day], from: selectedDate),
animated: false
)
view.selectionBehavior = selection
return view
}
func updateUIView(_ uiView: UICalendarView, context: Context) {
context.coordinator.parent = self
}
@MainActor
final class Coordinator: NSObject, UICalendarViewDelegate, UICalendarSelectionSingleDateDelegate {
var parent: DayCalendarView
init(_ parent: DayCalendarView) { self.parent = parent }
func calendarView(_ calendarView: UICalendarView,
decorationFor dateComponents: DateComponents) -> UICalendarView.Decoration? {
// The view hands back components richer than year/month/day (era, calendar,
// ) reduce to day precision so set membership compares equal.
var day = DateComponents()
day.year = dateComponents.year
day.month = dateComponents.month
day.day = dateComponents.day
return parent.workoutDays.contains(day) ? .default(color: .systemGreen, size: .small) : nil
}
func dateSelection(_ selection: UICalendarSelectionSingleDate,
didSelectDate dateComponents: DateComponents?) {
guard let dateComponents, let date = Calendar.current.date(from: dateComponents) else { return }
parent.selectedDate = date
parent.onPick()
}
}
}
@@ -0,0 +1,200 @@
//
// ScheduleAddEditView.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import IndieSync
import SwiftUI
import SwiftData
/// Add / edit a schedule: pick a routine, tag it with a goal, and set how often it
/// recurs. Fields are snapshotted in `init` so the sheet never holds the live entity.
struct ScheduleAddEditView: View {
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
private var routines: [Routine]
/// nil id add mode; a value edit mode (id / order / createdAt preserved).
@State private var scheduleID: String?
@State private var order: Int
@State private var createdAt: Date
@State private var routineID: String
/// The schedule's routine name when it was created surfaced when that routine
/// has since been deleted so the user knows what they're reassigning.
@State private var originalRoutineName: String
@State private var goal: GoalKind?
@State private var recurrence: ScheduleRecurrence
@State private var weekdays: Set<Int>
@State private var timesPerWeek: Int
@State private var date: Date
/// Calendar weekday numbers in Monday-first display order (MonSat = 27, Sun = 1).
private let weekdayOrder = [2, 3, 4, 5, 6, 7, 1]
/// Gregorian short names, 0-indexed from Sunday weekday `n` is `symbols[n - 1]`.
private let weekdaySymbols = Calendar(identifier: .gregorian).shortWeekdaySymbols
/// `defaultDate` seeds the `.once` day when adding the Today board passes the day
/// currently on screen, so "navigate to a day, tap +" plans a one-off for that day.
init(schedule: Schedule? = nil, defaultDate: Date = Date()) {
_scheduleID = State(initialValue: schedule?.id)
_order = State(initialValue: schedule?.order ?? 0)
_createdAt = State(initialValue: schedule?.createdAt ?? Date())
_routineID = State(initialValue: schedule?.routineID ?? "")
_originalRoutineName = State(initialValue: schedule?.routineName ?? "")
_goal = State(initialValue: schedule?.goalKind)
_recurrence = State(initialValue: schedule?.recurrenceEnum ?? .once)
_weekdays = State(initialValue: Set(schedule?.weekdays ?? []))
_timesPerWeek = State(initialValue: schedule?.timesPerWeek ?? 3)
_date = State(initialValue: schedule?.date ?? defaultDate)
}
private var isEditing: Bool { scheduleID != nil }
/// The live routine currently selected; nil when the stored id no longer resolves.
private var selectedRoutine: Routine? { routines.first { $0.id == routineID } }
private var canSave: Bool {
guard selectedRoutine != nil else { return false }
if recurrence == .fixedDays { return !weekdays.isEmpty }
return true
}
var body: some View {
NavigationStack {
Form {
routineSection
goalSection
recurrenceSection
}
.navigationTitle(isEditing ? "Edit Schedule" : "New Schedule")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save", action: save).disabled(!canSave)
}
}
.onAppear {
// Default to the first routine when adding (the @Query isn't populated at init).
if routineID.isEmpty { routineID = routines.first?.id ?? "" }
}
}
}
// MARK: - Sections
private var routineSection: some View {
Section {
Picker("Routine", selection: $routineID) {
ForEach(routines) { routine in
Text(routine.name).tag(routine.id)
}
}
} header: {
Text("Routine")
} footer: {
if selectedRoutine == nil, !originalRoutineName.isEmpty {
Text("\"\(originalRoutineName)\" is no longer available. Choose a routine.")
}
}
}
private var goalSection: some View {
Section {
Picker("Goal", selection: $goal) {
Text("None").tag(GoalKind?.none)
ForEach(GoalKind.orderedCases()) { kind in
Label(kind.displayName, systemImage: kind.systemImage)
.foregroundStyle(Color.color(from: kind.colorName))
.tag(GoalKind?.some(kind))
}
}
.pickerStyle(.inline)
.labelsHidden()
} header: {
Text("Goal")
}
}
@ViewBuilder
private var recurrenceSection: some View {
Section {
Picker("Repeat", selection: $recurrence) {
ForEach(ScheduleRecurrence.allCases, id: \.self) { r in
Text(r.displayName).tag(r)
}
}
switch recurrence {
case .once:
DatePicker("Date", selection: $date, displayedComponents: .date)
case .daily:
EmptyView()
case .fixedDays:
weekdayPicker
case .frequency:
Stepper(value: $timesPerWeek, in: 1...7) {
Text("\(timesPerWeek)× per week")
}
}
} header: {
Text("Repeat")
}
}
/// A row of toggleable weekday chips, Monday-first.
private var weekdayPicker: some View {
HStack(spacing: 6) {
ForEach(weekdayOrder, id: \.self) { n in
let on = weekdays.contains(n)
Button {
if on { weekdays.remove(n) } else { weekdays.insert(n) }
} label: {
Text(weekdaySymbols[n - 1])
.font(.caption.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(on ? Color.accentColor : Color.secondary.opacity(0.15), in: Capsule())
.foregroundStyle(on ? Color.white : Color.primary)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
// MARK: - Save
private func save() {
guard let routine = selectedRoutine else { return }
let doc = ScheduleDocument(
schemaVersion: ScheduleDocument.currentSchemaVersion,
id: scheduleID ?? ULID.make(),
routineID: routine.id,
routineName: routine.name,
goal: goal?.rawValue,
recurrence: recurrence.rawValue,
weekdays: recurrence == .fixedDays ? weekdays.sorted() : nil,
timesPerWeek: recurrence == .frequency ? timesPerWeek : nil,
date: recurrence == .once ? date : nil,
order: isEditing ? order : nextOrder(),
createdAt: createdAt,
updatedAt: Date()
)
Task { await sync.save(schedule: doc) }
dismiss()
}
/// One past the highest existing schedule order appends a new schedule to the end.
private func nextOrder() -> Int {
let existing = (try? modelContext.fetch(FetchDescriptor<Schedule>())) ?? []
return (existing.map(\.order).max() ?? -1) + 1
}
}
+293
View File
@@ -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)
}
}
}
}