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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user