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.
This commit is contained in:
2026-07-16 19:55:06 -04:00
parent f6c5bc911f
commit cbdf02bca7
17 changed files with 2212 additions and 9 deletions
@@ -0,0 +1,229 @@
import IndieSync
import SwiftUI
import SwiftData
/// Add or edit a schedule: pick a routine, choose a recurrence (once / daily / fixed
/// days), tag it with a goal, and optionally set a reminder time. Mac-native sheet
/// (grouped Form, fixed width, Cancel/Save) mirrors iOS `ScheduleAddEditView` minus
/// the "Now" start-immediately choice (the Mac never runs a workout) and the
/// notification-permission footer (the Mac never schedules a notification; the
/// iPhone's `ReminderScheduler` resyncs once it observes this document). Fields are
/// snapshotted in `init` so the sheet never holds the live entity.
struct MacScheduleEditorSheet: View {
@Environment(SyncEngine.self) private var syncEngine
@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. Mirrors iOS.
@State private var originalRoutineName: String
@State private var goal: GoalKind?
@State private var recurrence: ScheduleRecurrence
@State private var weekdays: Set<Int>
@State private var date: Date
@State private var reminderEnabled: Bool
@State private var reminderTime: Date
init(schedule: Schedule?) {
_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 ?? []))
_date = State(initialValue: schedule?.date ?? Date())
_reminderEnabled = State(initialValue: schedule?.reminderMinutes != nil)
_reminderTime = State(initialValue: ScheduleEditing.time(fromMinutes: schedule?.reminderMinutes))
}
private var isEditing: Bool { scheduleID != nil }
/// The live routine currently selected, following the seed clone-on-edit redirect
/// (a schedule made against a starter seed keeps the retired seed id after the
/// routine is edited); nil when the routine has been deleted. Mirrors iOS
/// `ScheduleAddEditView.selectedRoutine`.
private var selectedRoutine: Routine? {
guard !routineID.isEmpty else { return nil }
let liveID = syncEngine.currentRoutineID(for: routineID)
return routines.first { $0.id == liveID }
}
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
recurrenceSection
goalSection
reminderSection
}
.formStyle(.grouped)
.navigationTitle(isEditing ? "Edit Schedule" : "New Schedule")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
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 ?? "" }
}
}
.frame(width: 460, height: 560)
}
// MARK: - Sections
private var routineSection: some View {
Section {
Picker("Routine", selection: routineSelectionBinding) {
if selectedRoutine == nil {
Text("Please select").tag("")
}
ForEach(routines) { routine in
Label(routine.name, systemImage: routine.systemImage)
.tag(routine.id)
}
}
.labelsHidden()
} header: {
Text("Routine")
} footer: {
if selectedRoutine == nil, !originalRoutineName.isEmpty {
Text("\"\(originalRoutineName)\" is no longer available. Choose a routine.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
/// The Picker always shows the *resolved* current selection (following a
/// clone-on-edit redirect), but writes back the raw id the user picked save()
/// re-resolves through `selectedRoutine` regardless.
private var routineSelectionBinding: Binding<String> {
Binding(
get: { selectedRoutine?.id ?? "" },
set: { routineID = $0 }
)
}
@ViewBuilder
private var recurrenceSection: some View {
Section {
Picker("When", selection: $recurrence) {
ForEach(ScheduleRecurrence.allCases, id: \.self) { r in
Text(r.displayName).tag(r)
}
}
.pickerStyle(.segmented)
.labelsHidden()
switch recurrence {
case .daily:
EmptyView()
case .once:
DatePicker("Date", selection: $date, displayedComponents: .date)
.datePickerStyle(.field)
case .fixedDays:
weekdayPicker
}
} header: {
Text("When")
}
}
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))
}
}
} header: {
Text("Goal")
}
}
private var reminderSection: some View {
Section {
Toggle("Remind Me", isOn: $reminderEnabled)
if reminderEnabled {
DatePicker("Time", selection: $reminderTime, displayedComponents: .hourAndMinute)
.datePickerStyle(.field)
}
} header: {
Text("Reminder")
} footer: {
Text("The Mac doesn't send reminders itself — your iPhone schedules the notification once it syncs this schedule.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
/// A row of toggleable weekday chips, Monday-first. Mirrors iOS
/// `ScheduleAddEditView.weekdayPicker`.
private var weekdayPicker: some View {
HStack(spacing: 6) {
ForEach(ScheduleEditing.weekdayOrder, id: \.self) { n in
let on = weekdays.contains(n)
Button {
if on { weekdays.remove(n) } else { weekdays.insert(n) }
} label: {
Text(ScheduleEditing.weekdaySymbols[n - 1])
.font(.caption.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.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,
date: recurrence == .once ? date : nil,
reminderMinutes: reminderEnabled ? ScheduleEditing.minutesFromMidnight(of: reminderTime) : nil,
order: isEditing ? order : ScheduleEditing.nextOrder(in: modelContext),
createdAt: createdAt,
updatedAt: Date()
)
Task { await syncEngine.save(schedule: doc) }
dismiss()
}
}
@@ -0,0 +1,176 @@
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()
}
}