Restructure into a three-tab app with Progress, goals, and Meditation
The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView becomes a Today / Progress / Settings TabView, "Routine" replaces "Split" in every user-facing string and view name (code-level types keep their names), and workout starting moves to shared WorkoutStarter / StartedWorkoutNavigator plumbing. - New Progress tab: weekly goal streaks, workout trends, per-exercise weight progression, achievements, and the full history list (WorkoutLogsView -> WorkoutHistoryView). - Goals: stable categories workouts roll up to, managed from Settings. - New Meditation exercise + starter routine; timed sits record to Apple Health as Mind & Body sessions. Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
This commit is contained in:
+69
-10
@@ -11,10 +11,10 @@ import SwiftData
|
||||
// SwiftData PersistentIdentifier). Computed helpers preserve the API the views
|
||||
// used against the old Core Data classes.
|
||||
|
||||
// MARK: - Split
|
||||
// MARK: - Routine
|
||||
|
||||
@Model
|
||||
final class Split {
|
||||
final class Routine {
|
||||
@Attribute(.unique) var id: String = ULID.make()
|
||||
var name: String = ""
|
||||
var color: String = "indigo"
|
||||
@@ -27,7 +27,7 @@ final class Split {
|
||||
var restSeconds: Int?
|
||||
var autoAdvance: Bool?
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \Exercise.split)
|
||||
@Relationship(deleteRule: .cascade, inverse: \Exercise.routine)
|
||||
var exercises: [Exercise] = []
|
||||
|
||||
init(id: String, name: String, color: String, systemImage: String, order: Int,
|
||||
@@ -42,7 +42,7 @@ final class Split {
|
||||
self.jsonRelativePath = jsonRelativePath
|
||||
}
|
||||
|
||||
static let unnamed = "Unnamed Split"
|
||||
static let unnamed = "Unnamed Routine"
|
||||
|
||||
var exercisesArray: [Exercise] { exercises.sorted { $0.order < $1.order } }
|
||||
|
||||
@@ -70,7 +70,7 @@ final class Exercise {
|
||||
// machine exercise; empty → a machine exercise with nothing recorded yet.
|
||||
var machineSettings: [MachineSetting]?
|
||||
|
||||
var split: Split?
|
||||
var routine: Routine?
|
||||
|
||||
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Double,
|
||||
loadType: Int, durationTotalSeconds: Int, machineSettings: [MachineSetting]? = nil) {
|
||||
@@ -124,8 +124,8 @@ final class Exercise {
|
||||
@Model
|
||||
final class Workout {
|
||||
@Attribute(.unique) var id: String = ULID.make()
|
||||
var splitID: String?
|
||||
var splitName: String?
|
||||
var routineID: String?
|
||||
var routineName: String?
|
||||
var start: Date = Date()
|
||||
var end: Date?
|
||||
var statusRaw: String = WorkoutStatus.notStarted.rawValue
|
||||
@@ -156,11 +156,11 @@ final class Workout {
|
||||
@Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
|
||||
var logs: [WorkoutLog] = []
|
||||
|
||||
init(id: String, splitID: String?, splitName: String?, start: Date, end: Date?,
|
||||
init(id: String, routineID: String?, routineName: String?, start: Date, end: Date?,
|
||||
statusRaw: String, createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
|
||||
self.id = id
|
||||
self.splitID = splitID
|
||||
self.splitName = splitName
|
||||
self.routineID = routineID
|
||||
self.routineName = routineName
|
||||
self.start = start
|
||||
self.end = end
|
||||
self.statusRaw = statusRaw
|
||||
@@ -174,6 +174,14 @@ final class Workout {
|
||||
set { statusRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
/// True while this workout is an untouched draft: created by a start tap, but no
|
||||
/// exercise has ever left `.notStarted`. Peeking into a routine must leave no
|
||||
/// trace, so the start paths discard a pristine draft when the user backs out of
|
||||
/// it instead of persisting a phantom "in progress" workout.
|
||||
var isPristineDraft: Bool {
|
||||
status == .notStarted && logs.allSatisfy { $0.status == .notStarted }
|
||||
}
|
||||
|
||||
var statusName: String { status.displayName }
|
||||
|
||||
var logsArray: [WorkoutLog] { logs.sorted { $0.order < $1.order } }
|
||||
@@ -298,3 +306,54 @@ final class WorkoutLog {
|
||||
set { durationTotalSeconds = durationMinutes * 60 + newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schedule
|
||||
|
||||
@Model
|
||||
final class Schedule {
|
||||
@Attribute(.unique) var id: String = ULID.make()
|
||||
var routineID: String = ""
|
||||
var routineName: String = ""
|
||||
var goalRaw: String?
|
||||
var recurrenceRaw: String = ScheduleRecurrence.daily.rawValue
|
||||
var weekdays: [Int]?
|
||||
var timesPerWeek: Int?
|
||||
var date: Date?
|
||||
var order: Int = 0
|
||||
var createdAt: Date = Date()
|
||||
var updatedAt: Date = Date()
|
||||
var jsonRelativePath: String = ""
|
||||
|
||||
init(id: String, routineID: String, routineName: String, goalRaw: String?,
|
||||
recurrenceRaw: String, weekdays: [Int]?, timesPerWeek: Int?, date: Date? = nil,
|
||||
order: Int, createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
|
||||
self.id = id
|
||||
self.routineID = routineID
|
||||
self.routineName = routineName
|
||||
self.goalRaw = goalRaw
|
||||
self.recurrenceRaw = recurrenceRaw
|
||||
self.weekdays = weekdays
|
||||
self.timesPerWeek = timesPerWeek
|
||||
self.date = date
|
||||
self.order = order
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.jsonRelativePath = jsonRelativePath
|
||||
}
|
||||
|
||||
var goalKind: GoalKind? {
|
||||
get { goalRaw.flatMap(GoalKind.init(rawValue:)) }
|
||||
set { goalRaw = newValue?.rawValue }
|
||||
}
|
||||
|
||||
var recurrenceEnum: ScheduleRecurrence {
|
||||
get { ScheduleRecurrence(rawValue: recurrenceRaw) ?? .daily }
|
||||
set { recurrenceRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
/// Human-readable recurrence line — shares its formatting with `ScheduleDocument`
|
||||
/// via `ScheduleRecurrence.summary(weekdays:timesPerWeek:date:)`.
|
||||
var recurrenceSummary: String {
|
||||
recurrenceEnum.summary(weekdays: weekdays, timesPerWeek: timesPerWeek, date: date)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user