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