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.
182 lines
6.9 KiB
Swift
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)
|
|
}
|
|
}
|