Share schedule-editing and starter-seed-state helpers across platforms
This commit is contained in:
@@ -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<Schedule>())) ?? []
|
||||
return (existing.map(\.order).max() ?? -1) + 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Schedule>())) ?? []
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user