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,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