Files
workouts/Workouts Mac/Views/Schedules/MacSchedulesView.swift
T
rzen cbdf02bca7 Add the macOS app: a library-management companion (routines, schedules, exercise browser)
A new 'Workouts Mac' target (same bundle ID as iOS, universal purchase)
with a NavigationSplitView shell over the shared data/sync layers:
routine management with starter gallery and seed-fork follow, schedule
editing (reminders stay iPhone-scheduled), and a browse-only exercise
library with the animated figures and reference guides.

Multi-writer stance: the Mac writes only routine and schedule documents
(whole-document last-writer-wins) and never workout documents, whose
per-log merge exists only on the watch ingest path. The Mac reconciles
on window activation (throttled) since iCloud syncs while the app is
closed, and flushes pending writes on deactivation.
2026-07-16 19:55:06 -04:00

177 lines
7.4 KiB
Swift

import SwiftUI
import SwiftData
/// A schedule flagged for deletion — captured as plain values (id + a display name),
/// not a retained `@Model`. Reading a persisted property on a since-deleted entity
/// traps (the repo's known crash class), so the confirmation dialog reads only these
/// captured strings and re-resolves the live schedule by id at delete time. Mirrors
/// `MacContentView.PendingRoutineDelete`.
private struct PendingScheduleDelete: Identifiable {
let id: String
let name: String
}
/// A schedule flagged for editing — captured as just its id, not a retained `@Model`
/// (see `PendingScheduleDelete`). The sheet re-resolves the live schedule by id when it
/// presents, so a remote delete between the tap and the sheet's next body evaluation
/// can't hand the editor a dangling entity.
private struct PendingScheduleEdit: Identifiable {
let id: String
}
/// The Schedules management pane: every `Schedules/<ULID>.json` document as a row
/// (routine name, recurrence summary, reminder time, goal tag), with add/edit/delete.
/// All writes go through `syncEngine.save(schedule:)` / `delete(schedule:)` — never the
/// SwiftData cache directly. Mac does not schedule notifications; the iPhone's
/// `ReminderScheduler` resyncs from the document once it observes the change. Mirrors
/// iOS `TodayView`'s schedule rows, sans the per-day "today" status (Mac doesn't run
/// workouts).
struct MacSchedulesView: View {
@Environment(SyncEngine.self) private var syncEngine
// Mirrors iOS `TodayView`'s schedule ordering.
@Query(sort: [SortDescriptor(\Schedule.order), SortDescriptor(\Schedule.createdAt)])
private var schedules: [Schedule]
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
private var routines: [Routine]
@State private var showingNewSchedule = false
@State private var scheduleToEdit: PendingScheduleEdit?
@State private var pendingDelete: PendingScheduleDelete?
var body: some View {
Group {
if schedules.isEmpty {
ContentUnavailableView(
"No Schedules",
systemImage: "calendar",
description: Text("Schedule a routine to see it here.")
)
} else {
List(schedules) { schedule in
ScheduleRow(schedule: schedule, routine: resolvedRoutine(for: schedule))
.contentShape(Rectangle())
.onTapGesture(count: 2) { scheduleToEdit = PendingScheduleEdit(id: schedule.id) }
.contextMenu {
Button("Edit…") { scheduleToEdit = PendingScheduleEdit(id: schedule.id) }
Divider()
Button("Delete", role: .destructive) {
pendingDelete = PendingScheduleDelete(
id: schedule.id,
name: resolvedRoutine(for: schedule)?.name ?? schedule.routineName)
}
}
}
}
}
.navigationTitle("Schedules")
.toolbar {
ToolbarItem {
Button {
showingNewSchedule = true
} label: {
Label("New Schedule…", systemImage: "plus")
}
.help("New Schedule")
}
}
.sheet(isPresented: $showingNewSchedule) {
MacScheduleEditorSheet(schedule: nil)
}
.sheet(item: $scheduleToEdit) { pending in
if let schedule = schedules.first(where: { $0.id == pending.id }), schedule.isLive {
MacScheduleEditorSheet(schedule: schedule)
}
}
.confirmationDialog(
"Delete Schedule?",
isPresented: Binding(
get: { pendingDelete != nil },
set: { if !$0 { pendingDelete = nil } }
),
titleVisibility: .visible,
presenting: pendingDelete
) { pending in
Button("Delete", role: .destructive) { confirmDelete(pending) }
Button("Cancel", role: .cancel) { pendingDelete = nil }
} message: { pending in
Text("This removes \"\(pending.name)\" from your schedule. The routine itself is not affected.")
}
}
// MARK: - Actions
/// The live routine this schedule points at, following the seed clone-on-edit
/// redirect; nil when the routine has been deleted. Mirrors iOS
/// `TodayView.resolvedRoutine(for:)`.
private func resolvedRoutine(for schedule: Schedule) -> Routine? {
let liveID = syncEngine.currentRoutineID(for: schedule.routineID)
return routines.first { $0.id == liveID }
}
private func confirmDelete(_ pending: PendingScheduleDelete) {
if let schedule = schedules.first(where: { $0.id == pending.id }), schedule.isLive {
Task { await syncEngine.delete(schedule: schedule) }
}
pendingDelete = nil
}
}
/// One schedule row: a color badge (the routine's own icon/color, falling back to the
/// goal's color — or plain secondary — when the routine is gone), the routine name
/// (falling back to the schedule's denormalized `routineName`), the recurrence summary,
/// an optional goal tag, and the reminder time if one is set.
private struct ScheduleRow: View {
let schedule: Schedule
/// The resolved live routine, or nil when it's been deleted — a dangling
/// `routineID` is handled the same way iOS's `TodayView` handles it: fall back to
/// the schedule's own denormalized name/goal color rather than showing an error.
let routine: Routine?
var body: some View {
HStack(spacing: 12) {
Image(systemName: routine?.systemImage ?? "figure.strengthtraining.traditional")
.font(.title3)
.foregroundStyle(badgeColor)
.frame(width: 32, height: 32)
.background(badgeColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: 2) {
Text(routine?.name ?? schedule.routineName)
HStack(spacing: 8) {
Text(schedule.recurrenceSummary)
.font(.caption)
.foregroundStyle(.secondary)
if let goal = schedule.goalKind {
Label(goal.displayName, systemImage: goal.systemImage)
.font(.caption)
.foregroundStyle(Color.color(from: goal.colorName))
}
}
}
Spacer()
if let minutes = schedule.reminderMinutes {
Label(Self.timeString(fromMinutes: minutes), systemImage: "bell.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 3)
}
private var badgeColor: Color {
if let routine { return Color.color(from: routine.color) }
return Color.color(from: schedule.goalKind?.colorName ?? "")
}
/// Render minutes-from-midnight (local wall-clock) as a short time string, e.g.
/// "8:00 AM" — the same interpretation `ScheduleAddEditView` writes on save.
private static func timeString(fromMinutes minutes: Int) -> String {
ScheduleEditing.time(fromMinutes: minutes).formattedTime()
}
}