diff --git a/Shared/Model/ScheduleEditing.swift b/Shared/Model/ScheduleEditing.swift new file mode 100644 index 0000000..6c207ce --- /dev/null +++ b/Shared/Model/ScheduleEditing.swift @@ -0,0 +1,41 @@ +// +// ScheduleEditing.swift +// Workouts +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import Foundation +import SwiftData + +/// Shared schedule-editing helpers used by both the iOS `ScheduleAddEditView` and the +/// Mac `MacScheduleEditorSheet` / `MacSchedulesView` — weekday ordering, the +/// minutes-from-midnight wire-format conversions, and the next-order helper for new +/// schedules. Kept in `Shared/` (compiled into every target) so the two platforms never +/// drift on these byte-identical semantics. +enum ScheduleEditing { + /// Calendar weekday numbers in Monday-first display order (Mon…Sat = 2…7, Sun = 1). + static let weekdayOrder = [2, 3, 4, 5, 6, 7, 1] + /// Gregorian short names, 0-indexed from Sunday — weekday `n` is `symbols[n - 1]`. + static let weekdaySymbols = Calendar(identifier: .gregorian).shortWeekdaySymbols + + /// A Date (today, local calendar) at the stored minutes-from-midnight, for the + /// hour-and-minute picker. Nil (no reminder yet) seeds a sensible 8:00 AM default. + static func time(fromMinutes minutes: Int?) -> Date { + let startOfDay = Calendar.current.startOfDay(for: Date()) + return Calendar.current.date(byAdding: .minute, value: minutes ?? 8 * 60, to: startOfDay) + ?? startOfDay + } + + /// The picked time back to minutes from midnight (local wall-clock). + static func minutesFromMidnight(of time: Date) -> Int { + let comps = Calendar.current.dateComponents([.hour, .minute], from: time) + return (comps.hour ?? 0) * 60 + (comps.minute ?? 0) + } + + /// One past the highest existing schedule order — appends a new schedule to the end. + static func nextOrder(in modelContext: ModelContext) -> Int { + let existing = (try? modelContext.fetch(FetchDescriptor())) ?? [] + return (existing.map(\.order).max() ?? -1) + 1 + } +} diff --git a/Workouts/Views/Library/StarterGalleryView.swift b/Workouts/Views/Library/StarterGalleryView.swift index 4ae0a21..eca4a80 100644 --- a/Workouts/Views/Library/StarterGalleryView.swift +++ b/Workouts/Views/Library/StarterGalleryView.swift @@ -8,31 +8,6 @@ import SwiftUI import SwiftData -/// Where a bundled seed currently stands relative to the live cache — computed fresh -/// from `@Query` routines on every render so a local edit, a remote change, or a -/// fork-on-edit clone is reflected immediately. Shared by the gallery list and the -/// preview screen so both classify a seed identically. -enum StarterSeedState { - /// A live routine with the seed's fixed id exists — the starter is already in the - /// user's library (possibly since edited elsewhere; the id itself only forks at - /// edit time, so "added" still matches a pristine seed). - case added(Routine) - /// Not added, but some *other* live routine already carries the seed's name — - /// restoring would read as a confusing duplicate, so the add action is blocked. - case nameTaken - /// Neither added nor blocked — free to restore. - case restorable -} - -extension SeedLibrary.Seed { - /// Classify this seed against the live routine set. See `StarterSeedState`. - func state(among routines: [Routine]) -> StarterSeedState { - if let live = routines.first(where: { $0.id == id }) { return .added(live) } - if routines.contains(where: { $0.name == doc.name }) { return .nameTaken } - return .restorable - } -} - /// Every bundled starter routine — including ones the user has deleted — with a /// per-seed Add/Restore action and a bulk "re-add everything missing" fallback. Unlike /// `RoutinesLibraryView` (which only ever shows *live* routines), this reads straight diff --git a/Workouts/Views/Library/StarterSeedState.swift b/Workouts/Views/Library/StarterSeedState.swift new file mode 100644 index 0000000..f5c1729 --- /dev/null +++ b/Workouts/Views/Library/StarterSeedState.swift @@ -0,0 +1,33 @@ +// +// StarterSeedState.swift +// Workouts +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftData + +/// Where a bundled seed currently stands relative to the live cache — computed fresh +/// from `@Query` routines on every render so a local edit, a remote change, or a +/// fork-on-edit clone is reflected immediately. Shared by the iOS gallery list, its +/// preview screen, and the Mac equivalents so all classify a seed identically. +enum StarterSeedState { + /// A live routine with the seed's fixed id exists — the starter is already in the + /// user's library (possibly since edited elsewhere; the id itself only forks at + /// edit time, so "added" still matches a pristine seed). + case added(Routine) + /// Not added, but some *other* live routine already carries the seed's name — + /// restoring would read as a confusing duplicate, so the add action is blocked. + case nameTaken + /// Neither added nor blocked — free to restore. + case restorable +} + +extension SeedLibrary.Seed { + /// Classify this seed against the live routine set. See `StarterSeedState`. + func state(among routines: [Routine]) -> StarterSeedState { + if let live = routines.first(where: { $0.id == id }) { return .added(live) } + if routines.contains(where: { $0.name == doc.name }) { return .nameTaken } + return .restorable + } +} diff --git a/Workouts/Views/Today/ScheduleAddEditView.swift b/Workouts/Views/Today/ScheduleAddEditView.swift index bb64f13..4beac5e 100644 --- a/Workouts/Views/Today/ScheduleAddEditView.swift +++ b/Workouts/Views/Today/ScheduleAddEditView.swift @@ -85,11 +85,6 @@ struct ScheduleAddEditView: View { /// the routine to start. The Today board starts it and navigates in. private let onStartNow: (Routine) -> Void - /// Calendar weekday numbers in Monday-first display order (Mon…Sat = 2…7, Sun = 1). - private let weekdayOrder = [2, 3, 4, 5, 6, 7, 1] - /// Gregorian short names, 0-indexed from Sunday — weekday `n` is `symbols[n - 1]`. - private let weekdaySymbols = Calendar(identifier: .gregorian).shortWeekdaySymbols - /// `defaultDate` seeds the `.once` day when adding — the Today board passes the day /// currently on screen, so "navigate to a day, tap +" plans a one-off for that day. init(schedule: Schedule? = nil, defaultDate: Date = Date(), @@ -103,7 +98,7 @@ struct ScheduleAddEditView: View { _weekdays = State(initialValue: Set(schedule?.weekdays ?? [])) _date = State(initialValue: schedule?.date ?? defaultDate) _reminderEnabled = State(initialValue: schedule?.reminderMinutes != nil) - _reminderTime = State(initialValue: Self.time(fromMinutes: schedule?.reminderMinutes)) + _reminderTime = State(initialValue: ScheduleEditing.time(fromMinutes: schedule?.reminderMinutes)) self.onStartNow = onStartNow let offersNow = schedule == nil && defaultDate.isSameDay(as: Date()) @@ -270,12 +265,12 @@ struct ScheduleAddEditView: View { /// A row of toggleable weekday chips, Monday-first. private var weekdayPicker: some View { HStack(spacing: 6) { - ForEach(weekdayOrder, id: \.self) { n in + ForEach(ScheduleEditing.weekdayOrder, id: \.self) { n in let on = weekdays.contains(n) Button { if on { weekdays.remove(n) } else { weekdays.insert(n) } } label: { - Text(weekdaySymbols[n - 1]) + Text(ScheduleEditing.weekdaySymbols[n - 1]) .font(.caption.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 8) @@ -310,36 +305,14 @@ struct ScheduleAddEditView: View { recurrence: recurrence.rawValue, weekdays: recurrence == .fixedDays ? weekdays.sorted() : nil, date: recurrence == .once ? date : nil, - reminderMinutes: reminderEnabled ? Self.minutesFromMidnight(of: reminderTime) : nil, - order: isEditing ? order : nextOrder(), + reminderMinutes: reminderEnabled ? ScheduleEditing.minutesFromMidnight(of: reminderTime) : nil, + order: isEditing ? order : ScheduleEditing.nextOrder(in: modelContext), createdAt: createdAt, updatedAt: Date() ) Task { await sync.save(schedule: doc) } dismiss() } - - /// One past the highest existing schedule order — appends a new schedule to the end. - private func nextOrder() -> Int { - let existing = (try? modelContext.fetch(FetchDescriptor())) ?? [] - return (existing.map(\.order).max() ?? -1) + 1 - } - - // MARK: - Reminder time conversion - - /// A Date (today, local calendar) at the stored minutes-from-midnight, for the - /// hour-and-minute picker. Nil (no reminder yet) seeds a sensible 8:00 AM default. - private static func time(fromMinutes minutes: Int?) -> Date { - let startOfDay = Calendar.current.startOfDay(for: Date()) - return Calendar.current.date(byAdding: .minute, value: minutes ?? 8 * 60, to: startOfDay) - ?? startOfDay - } - - /// The picked time back to minutes from midnight (local wall-clock). - private static func minutesFromMidnight(of time: Date) -> Int { - let comps = Calendar.current.dateComponents([.hour, .minute], from: time) - return (comps.hour ?? 0) * 60 + (comps.minute ?? 0) - } } // MARK: - Routine picker