Files
workouts/Workouts Mac/Views/Routines/MacRoutineEditorSheet.swift
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

182 lines
6.9 KiB
Swift

import IndieSync
import SwiftUI
import SwiftData
/// Create or edit a routine's identity: name, icon, color, and activity type. Mac-
/// native sheet (grouped Form, fixed width, Cancel/Save). The workout-run settings
/// the iOS editor also carries (rest, auto-advance, target heart rate) are omitted —
/// the Mac never runs a workout — but any values already set on the routine are
/// preserved on save, since we rebuild from `RoutineDocument(from:)` and touch only
/// these four fields.
struct MacRoutineEditorSheet: View {
@Environment(SyncEngine.self) private var syncEngine
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
// Resolve by id, not a captured entity: a clone-on-edit swaps a seed's identity
// mid-edit. `routineID` is nil in create mode. Mirrors iOS `RoutineAddEditView`.
@State private var routineID: String?
@Query private var routines: [Routine]
@State private var name: String
@State private var color: String
@State private var systemImage: String
@State private var activityType: WorkoutActivityType
var isEditing: Bool { routineID != nil }
init(routine: Routine?) {
_routineID = State(initialValue: routine?.id)
_name = State(initialValue: routine?.name ?? "")
_color = State(initialValue: routine?.color ?? "indigo")
_systemImage = State(initialValue: routine?.systemImage ?? "dumbbell.fill")
_activityType = State(initialValue: routine?.activityTypeEnum ?? .traditionalStrength)
}
private var routine: Routine? {
guard let routineID else { return nil }
let id = syncEngine.currentRoutineID(for: routineID)
return routines.first { $0.id == id }
}
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("Name", text: $name)
if let routineID, SeedLibrary.isSeed(id: routineID) {
Text("Saving changes to a starter routine creates your own copy; the starter stays available in the gallery.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section("Icon") {
IconGridPicker(selection: $systemImage, tint: Color.color(from: color))
}
Section("Color") {
ColorSwatchPicker(selection: $color)
}
Section {
Picker("Activity Type", selection: $activityType) {
ForEach(WorkoutActivityType.allCases, id: \.self) { type in
Label(type.displayName, systemImage: type.systemImage).tag(type)
}
}
} header: {
Text("Activity Type")
} footer: {
Text("Determines how this workout is categorized in Apple Health and how it credits your Activity rings.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.navigationTitle(isEditing ? "Edit Routine" : "New Routine")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save(); dismiss() }
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
.frame(width: 460, height: 520)
}
private func save() {
if isEditing {
guard let routine else { return }
var doc = RoutineDocument(from: routine)
doc.name = name
doc.color = color
doc.systemImage = systemImage
doc.activityType = activityType.rawValue
doc.updatedAt = Date()
Task { await syncEngine.save(routine: doc) }
} else {
let existing = (try? modelContext.fetch(FetchDescriptor<Routine>())) ?? []
let now = Date()
let doc = RoutineDocument(
schemaVersion: RoutineDocument.currentSchemaVersion,
id: ULID.make(),
name: name,
color: color,
systemImage: systemImage,
order: existing.count,
createdAt: now,
updatedAt: now,
exercises: [],
activityType: activityType.rawValue
)
Task { await syncEngine.save(routine: doc) }
}
}
}
/// A grid of the curated `availableIcons`, single-select, with the chosen one ringed.
private struct IconGridPicker: View {
@Binding var selection: String
let tint: Color
private let columns = [GridItem(.adaptive(minimum: 44), spacing: 8)]
var body: some View {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(availableIcons, id: \.self) { icon in
Button {
selection = icon
} label: {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(icon == selection ? tint : .secondary)
.frame(width: 40, height: 40)
.background(
(icon == selection ? tint.opacity(0.15) : Color.secondary.opacity(0.08)),
in: RoundedRectangle(cornerRadius: 8)
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(tint, lineWidth: icon == selection ? 2 : 0)
)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
}
/// A row of the canonical `availableColors` as tappable swatches, single-select.
private struct ColorSwatchPicker: View {
@Binding var selection: String
private let columns = [GridItem(.adaptive(minimum: 36), spacing: 10)]
var body: some View {
LazyVGrid(columns: columns, spacing: 10) {
ForEach(availableColors, id: \.self) { name in
Button {
selection = name
} label: {
Circle()
.fill(Color.color(from: name))
.frame(width: 28, height: 28)
.overlay(
Circle()
.strokeBorder(.primary, lineWidth: name == selection ? 2 : 0)
.padding(-2)
)
}
.buttonStyle(.plain)
.help(name.capitalized)
}
}
.padding(.vertical, 4)
}
}