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
201 lines
7.5 KiB
Swift
201 lines
7.5 KiB
Swift
//
|
||
// 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 (Mon…Sat = 2…7, 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
|
||
}
|
||
}
|