Add machine comfort settings and tighten the document schemas
Machine-based exercises now carry ordered name/value comfort settings (seat height, back-rest position, ...) on both ExerciseDocument and, as a plan-time snapshot, WorkoutLogDocument — the optional array doubles as the machine flag. Editable from the exercise editor's new Machine section and from the workout row's settings sheet, whose mid-workout edits write back to the originating split (workout saved first, so seed clone-on-edit repointing can't clobber the log edit). Schema tightening rides the same rev: splits bump to v2 (weight-reminder fields and the unused exercise category removed), workouts to v3 (the derived `completed` flag removed; status is the single source). Starter seeds regenerated at v2 with unchanged ULIDs; SwiftData cache schema bumped to rebuild. SCHEMA.md documents the shapes. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
This commit is contained in:
@@ -30,7 +30,12 @@ struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
/// quarantining the user's whole routine.
|
||||
var activityType: Int?
|
||||
|
||||
static let currentSchemaVersion = 1
|
||||
// Bumped 1→2 when the weight-reminder fields (`weightLastUpdated`,
|
||||
// `weightReminderWeeks`) and `category` were removed and `machineSettings` was
|
||||
// added. Removing required fields means older apps can no longer decode new
|
||||
// files, so the forward-gate must quarantine them rather than let an older app
|
||||
// silently rewrite them.
|
||||
static let currentSchemaVersion = 2
|
||||
|
||||
var relativePath: String { "Splits/\(id).json" }
|
||||
}
|
||||
@@ -44,12 +49,19 @@ struct ExerciseDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
var weight: Int
|
||||
var loadType: Int
|
||||
var durationSeconds: Int // total seconds (0 when not a timed exercise)
|
||||
var weightLastUpdated: Date?
|
||||
var weightReminderWeeks: Int
|
||||
/// Raw `ExerciseCategory`; nil → main circuit. Like `activityType`, deliberately
|
||||
/// NOT schema-bumped: it only affects how the split screen groups its list, so an
|
||||
/// older app dropping it on rewrite beats quarantining the routine.
|
||||
var category: Int?
|
||||
/// Ordered, user-defined comfort settings for machine-based exercises (seat
|
||||
/// height, back rest incline, pin position…). `nil` → not a machine exercise;
|
||||
/// empty → a machine exercise with nothing recorded yet. This optionality
|
||||
/// doubles as the machine-based flag — there is no separate Bool.
|
||||
var machineSettings: [MachineSetting]? = nil
|
||||
}
|
||||
|
||||
/// A single free-form comfort setting for a machine-based exercise. `value` is a
|
||||
/// string, not a number, because machine dials aren't uniformly numeric ("3rd
|
||||
/// hole", "45°"). Ordered within `ExerciseDocument.machineSettings`.
|
||||
struct MachineSetting: Codable, Sendable, Equatable {
|
||||
var name: String // "Seat Height", "Back Rest Incline"
|
||||
var value: String // "4", "3rd hole", "45°" — free-form; machine dials aren't uniformly numeric
|
||||
}
|
||||
|
||||
// MARK: - Workout
|
||||
@@ -74,7 +86,11 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
// Bumped 1→2 when `metrics` was added: the captured HR/calorie data is
|
||||
// irreplaceable, so the forward-gate must quarantine these files on older apps
|
||||
// rather than let them strip `metrics` on rewrite.
|
||||
static let currentSchemaVersion = 2
|
||||
// Bumped 2→3 when the derived `completed` flag was removed from
|
||||
// `WorkoutLogDocument` and `machineSettings` was added to it. Dropping a
|
||||
// required field means older apps can no longer decode new files, so the
|
||||
// forward-gate must quarantine them.
|
||||
static let currentSchemaVersion = 3
|
||||
|
||||
var relativePath: String { Self.relativePath(id: id, start: start) }
|
||||
|
||||
@@ -119,8 +135,7 @@ extension WorkoutDocument {
|
||||
/// start-a-new-split prompt.
|
||||
mutating func endKeepingProgress() {
|
||||
for i in logs.indices where (WorkoutStatus(rawValue: logs[i].status) ?? .notStarted) != .completed {
|
||||
logs[i].status = WorkoutStatus.skipped.rawValue
|
||||
logs[i].completed = false
|
||||
logs[i].transition(to: .skipped)
|
||||
}
|
||||
recomputeStatusFromLogs()
|
||||
updatedAt = Date()
|
||||
@@ -137,10 +152,64 @@ struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
var loadType: Int
|
||||
var durationSeconds: Int // total seconds (0 when not a timed exercise)
|
||||
var currentStateIndex: Int
|
||||
var completed: Bool
|
||||
var status: String // WorkoutStatus raw value
|
||||
var notes: String?
|
||||
var date: Date
|
||||
/// Snapshot of the exercise's machine comfort settings at plan time (like the
|
||||
/// sets/reps/weight snapshot). `nil` → not a machine exercise; empty → a machine
|
||||
/// exercise with nothing recorded yet.
|
||||
var machineSettings: [MachineSetting]? = nil
|
||||
/// When the log first moved to `.inProgress` / last moved to `.completed`.
|
||||
/// Optional additions — absent in files written before they existed.
|
||||
var startedAt: Date? = nil
|
||||
var completedAt: Date? = nil
|
||||
}
|
||||
|
||||
extension WorkoutLogDocument {
|
||||
/// Build a fresh plan-time log from a split's exercise. Snapshots the plan
|
||||
/// fields (sets/reps/weight/duration) *and* the machine comfort settings, so a
|
||||
/// running workout carries the exercise's settings as its own editable copy —
|
||||
/// `nil` stays `nil` (not a machine exercise), and `[]` stays `[]` (machine
|
||||
/// exercise, nothing recorded yet). Every "start a workout" / "add exercise to
|
||||
/// a workout" path funnels through here so the snapshot can't drift per site.
|
||||
init(planFrom exercise: ExerciseDocument, order: Int, date: Date) {
|
||||
self.init(
|
||||
id: ULID.make(),
|
||||
exerciseName: exercise.name,
|
||||
order: order,
|
||||
sets: exercise.sets,
|
||||
reps: exercise.reps,
|
||||
weight: exercise.weight,
|
||||
loadType: exercise.loadType,
|
||||
durationSeconds: exercise.durationSeconds,
|
||||
currentStateIndex: 0,
|
||||
status: WorkoutStatus.notStarted.rawValue,
|
||||
notes: nil,
|
||||
date: date,
|
||||
machineSettings: exercise.machineSettings
|
||||
)
|
||||
}
|
||||
|
||||
/// Move to a new status, keeping the started/completed timestamps consistent.
|
||||
/// Every screen that flips a log's status (phone and watch) goes through here,
|
||||
/// so the timestamps can't drift per-call-site. Completion is derived from
|
||||
/// `status` (`completed == (status == WorkoutStatus.completed.rawValue)`), so
|
||||
/// there's no separate flag to keep in sync.
|
||||
mutating func transition(to newStatus: WorkoutStatus) {
|
||||
status = newStatus.rawValue
|
||||
switch newStatus {
|
||||
case .notStarted:
|
||||
startedAt = nil
|
||||
completedAt = nil
|
||||
case .inProgress:
|
||||
if startedAt == nil { startedAt = Date() }
|
||||
completedAt = nil
|
||||
case .completed:
|
||||
if completedAt == nil { completedAt = Date() }
|
||||
case .skipped:
|
||||
completedAt = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout health metrics
|
||||
|
||||
+19
-17
@@ -62,15 +62,16 @@ final class Exercise {
|
||||
var weight: Int = 0
|
||||
var loadType: Int = LoadType.weight.rawValue
|
||||
var durationTotalSeconds: Int = 0
|
||||
var weightLastUpdated: Date?
|
||||
var weightReminderTimeIntervalWeeks: Int = 2
|
||||
var categoryRaw: Int = ExerciseCategory.main.rawValue
|
||||
// Machine comfort settings — stored directly as an optional Codable array
|
||||
// (SwiftData persists the `[MachineSetting]` composite), mirroring how
|
||||
// `Workout` stores `hrZoneSeconds` as a plain `[Double]?`. `nil` → not a
|
||||
// machine exercise; empty → a machine exercise with nothing recorded yet.
|
||||
var machineSettings: [MachineSetting]?
|
||||
|
||||
var split: Split?
|
||||
|
||||
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Int,
|
||||
loadType: Int, durationTotalSeconds: Int, weightLastUpdated: Date?,
|
||||
weightReminderTimeIntervalWeeks: Int, categoryRaw: Int = ExerciseCategory.main.rawValue) {
|
||||
loadType: Int, durationTotalSeconds: Int, machineSettings: [MachineSetting]? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.order = order
|
||||
@@ -79,9 +80,7 @@ final class Exercise {
|
||||
self.weight = weight
|
||||
self.loadType = loadType
|
||||
self.durationTotalSeconds = durationTotalSeconds
|
||||
self.weightLastUpdated = weightLastUpdated
|
||||
self.weightReminderTimeIntervalWeeks = weightReminderTimeIntervalWeeks
|
||||
self.categoryRaw = categoryRaw
|
||||
self.machineSettings = machineSettings
|
||||
}
|
||||
|
||||
var loadTypeEnum: LoadType {
|
||||
@@ -89,11 +88,6 @@ final class Exercise {
|
||||
set { loadType = newValue.rawValue }
|
||||
}
|
||||
|
||||
var categoryEnum: ExerciseCategory {
|
||||
get { ExerciseCategory(rawValue: categoryRaw) ?? .main }
|
||||
set { categoryRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
/// One-line plan summary for list rows — duration exercises show their hold time
|
||||
/// ("3 × 45 sec"), unloaded ones drop the weight ("3 × 12 reps"), weighted ones
|
||||
/// keep the full "3 × 10 × 40 lb".
|
||||
@@ -233,16 +227,22 @@ final class WorkoutLog {
|
||||
var loadType: Int = LoadType.weight.rawValue
|
||||
var durationTotalSeconds: Int = 0
|
||||
var currentStateIndex: Int = 0
|
||||
var completed: Bool = false
|
||||
var statusRaw: String = WorkoutStatus.notStarted.rawValue
|
||||
var notes: String?
|
||||
var date: Date = Date()
|
||||
// Snapshot of the exercise's machine comfort settings at plan time — stored
|
||||
// directly as an optional Codable array, same pattern as `Exercise`.
|
||||
var machineSettings: [MachineSetting]?
|
||||
var startedAt: Date?
|
||||
var completedAt: Date?
|
||||
|
||||
var workout: Workout?
|
||||
|
||||
init(id: String, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Int,
|
||||
loadType: Int, durationTotalSeconds: Int, currentStateIndex: Int, completed: Bool,
|
||||
statusRaw: String, notes: String?, date: Date) {
|
||||
loadType: Int, durationTotalSeconds: Int, currentStateIndex: Int,
|
||||
statusRaw: String, notes: String?, date: Date,
|
||||
machineSettings: [MachineSetting]? = nil,
|
||||
startedAt: Date? = nil, completedAt: Date? = nil) {
|
||||
self.id = id
|
||||
self.exerciseName = exerciseName
|
||||
self.order = order
|
||||
@@ -252,10 +252,12 @@ final class WorkoutLog {
|
||||
self.loadType = loadType
|
||||
self.durationTotalSeconds = durationTotalSeconds
|
||||
self.currentStateIndex = currentStateIndex
|
||||
self.completed = completed
|
||||
self.statusRaw = statusRaw
|
||||
self.notes = notes
|
||||
self.date = date
|
||||
self.machineSettings = machineSettings
|
||||
self.startedAt = startedAt
|
||||
self.completedAt = completedAt
|
||||
}
|
||||
|
||||
var status: WorkoutStatus {
|
||||
|
||||
@@ -35,24 +35,6 @@ enum LoadType: Int, CaseIterable, Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which segment of a split an exercise belongs to — the split screen groups its
|
||||
/// exercise list by this. Persisted as its raw `Int` value; `main` is the default
|
||||
/// (and what older documents without the field decode to).
|
||||
enum ExerciseCategory: Int, CaseIterable, Codable, Sendable {
|
||||
case main = 0
|
||||
case warmup = 1
|
||||
|
||||
/// Order the categories appear on screen: warm-up first, then the main circuit.
|
||||
static let displayOrder: [ExerciseCategory] = [.warmup, .main]
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .main: "Main Circuit"
|
||||
case .warmup: "Warm-up"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of training a split represents — used to tag the Apple Health workout
|
||||
/// so it lands in the right category (and credits the rings correctly), and to pick
|
||||
/// a MET value when the phone has to estimate calories without watch sensor data.
|
||||
|
||||
+12
-13
@@ -17,9 +17,7 @@ extension ExerciseDocument {
|
||||
init(from e: Exercise) {
|
||||
self.init(id: e.id, name: e.name, order: e.order, sets: e.sets, reps: e.reps,
|
||||
weight: e.weight, loadType: e.loadType, durationSeconds: e.durationTotalSeconds,
|
||||
weightLastUpdated: e.weightLastUpdated,
|
||||
weightReminderWeeks: e.weightReminderTimeIntervalWeeks,
|
||||
category: e.categoryRaw)
|
||||
machineSettings: e.machineSettings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +36,9 @@ extension WorkoutLogDocument {
|
||||
self.init(id: log.id, exerciseName: log.exerciseName, order: log.order, sets: log.sets,
|
||||
reps: log.reps, weight: log.weight, loadType: log.loadType,
|
||||
durationSeconds: log.durationTotalSeconds, currentStateIndex: log.currentStateIndex,
|
||||
completed: log.completed, status: log.statusRaw, notes: log.notes, date: log.date)
|
||||
status: log.statusRaw, notes: log.notes, date: log.date,
|
||||
machineSettings: log.machineSettings,
|
||||
startedAt: log.startedAt, completedAt: log.completedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,9 +108,7 @@ enum CacheMapper {
|
||||
private static func makeExercise(_ d: ExerciseDocument) -> Exercise {
|
||||
Exercise(id: d.id, name: d.name, order: d.order, sets: d.sets, reps: d.reps, weight: d.weight,
|
||||
loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
|
||||
weightLastUpdated: d.weightLastUpdated,
|
||||
weightReminderTimeIntervalWeeks: d.weightReminderWeeks,
|
||||
categoryRaw: d.category ?? ExerciseCategory.main.rawValue)
|
||||
machineSettings: d.machineSettings)
|
||||
}
|
||||
|
||||
private static func apply(_ d: ExerciseDocument, to e: Exercise) {
|
||||
@@ -121,9 +119,7 @@ enum CacheMapper {
|
||||
e.weight = d.weight
|
||||
e.loadType = d.loadType
|
||||
e.durationTotalSeconds = d.durationSeconds
|
||||
e.weightLastUpdated = d.weightLastUpdated
|
||||
e.weightReminderTimeIntervalWeeks = d.weightReminderWeeks
|
||||
e.categoryRaw = d.category ?? ExerciseCategory.main.rawValue
|
||||
e.machineSettings = d.machineSettings
|
||||
}
|
||||
|
||||
// MARK: Workout
|
||||
@@ -169,8 +165,9 @@ enum CacheMapper {
|
||||
private static func makeLog(_ d: WorkoutLogDocument) -> WorkoutLog {
|
||||
WorkoutLog(id: d.id, exerciseName: d.exerciseName, order: d.order, sets: d.sets, reps: d.reps,
|
||||
weight: d.weight, loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
|
||||
currentStateIndex: d.currentStateIndex, completed: d.completed, statusRaw: d.status,
|
||||
notes: d.notes, date: d.date)
|
||||
currentStateIndex: d.currentStateIndex, statusRaw: d.status,
|
||||
notes: d.notes, date: d.date, machineSettings: d.machineSettings,
|
||||
startedAt: d.startedAt, completedAt: d.completedAt)
|
||||
}
|
||||
|
||||
private static func apply(_ d: WorkoutLogDocument, to l: WorkoutLog) {
|
||||
@@ -182,9 +179,11 @@ enum CacheMapper {
|
||||
l.loadType = d.loadType
|
||||
l.durationTotalSeconds = d.durationSeconds
|
||||
l.currentStateIndex = d.currentStateIndex
|
||||
l.completed = d.completed
|
||||
l.statusRaw = d.status
|
||||
l.notes = d.notes
|
||||
l.date = d.date
|
||||
l.machineSettings = d.machineSettings
|
||||
l.startedAt = d.startedAt
|
||||
l.completedAt = d.completedAt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ enum WorkoutsModelContainer {
|
||||
/// Bump whenever the cache schema changes — wipes and rebuilds from files.
|
||||
/// 2: added health-metric columns to `Workout` and `activityTypeRaw` to `Split`.
|
||||
/// 3: added `categoryRaw` to `Exercise`.
|
||||
static let currentSchemaVersion = 3
|
||||
/// 4: removed the weight-reminder columns and `categoryRaw` from `Exercise` and
|
||||
/// the `completed` column from `WorkoutLog`; added `machineSettings` to both.
|
||||
static let currentSchemaVersion = 4
|
||||
private static let schemaVersionKey = "Workouts.persistenceSchemaVersion"
|
||||
private static let identityTokenKey = "Workouts.iCloudIdentityToken"
|
||||
|
||||
|
||||
@@ -30,18 +30,20 @@ enum ScreenshotSeed {
|
||||
func daysAgo(_ n: Int) -> Date { cal.date(byAdding: .day, value: -n, to: today) ?? today }
|
||||
|
||||
// ---- Splits (with exercises) ------------------------------------------
|
||||
// Names match the bundled Exercise Library rigs so every screen shows its
|
||||
// animated figure (an unknown name would leave the figure slot empty).
|
||||
let splits: [(String, String, String, [(String, Int, Int, Int)])] = [
|
||||
("Upper Body", "purple", "dumbbell.fill", [
|
||||
("Bench Press", 4, 10, 135), ("Overhead Press", 4, 10, 75),
|
||||
("Lat Pulldown", 4, 12, 120), ("Bicep Curl", 3, 12, 30),
|
||||
("Upper Body", "blue", "figure.strengthtraining.traditional", [
|
||||
("Lat Pull Down", 4, 10, 110), ("Seated Row", 4, 10, 90),
|
||||
("Shoulder Press", 4, 10, 40), ("Arm Curl", 3, 12, 40),
|
||||
]),
|
||||
("Lower Body", "blue", "figure.strengthtraining.traditional", [
|
||||
("Back Squat", 5, 5, 185), ("Romanian Deadlift", 4, 8, 155),
|
||||
("Leg Press", 4, 12, 270), ("Calf Raise", 4, 15, 90),
|
||||
("Lower Body", "green", "figure.run", [
|
||||
("Leg Press", 4, 10, 140), ("Leg Curl", 4, 10, 40),
|
||||
("Leg Extension", 4, 10, 80), ("Calfs", 4, 15, 100),
|
||||
]),
|
||||
("Core", "orange", "figure.core.training", [
|
||||
("Plank", 3, 0, 0), ("Hanging Leg Raise", 3, 12, 0),
|
||||
("Cable Crunch", 3, 15, 50),
|
||||
("Plank", 3, 0, 0), ("Abdominal", 4, 10, 40),
|
||||
("Rotary", 4, 10, 40),
|
||||
]),
|
||||
]
|
||||
|
||||
@@ -53,8 +55,7 @@ enum ScreenshotSeed {
|
||||
let isDuration = e.0 == "Plank"
|
||||
let ex = Exercise(id: ULID.make(), name: e.0, order: eIndex, sets: e.1, reps: e.2,
|
||||
weight: e.3, loadType: (isDuration ? LoadType.duration : .weight).rawValue,
|
||||
durationTotalSeconds: isDuration ? 45 : 0, weightLastUpdated: today,
|
||||
weightReminderTimeIntervalWeeks: 2)
|
||||
durationTotalSeconds: isDuration ? 45 : 0)
|
||||
ex.split = split
|
||||
context.insert(ex)
|
||||
}
|
||||
@@ -62,19 +63,19 @@ enum ScreenshotSeed {
|
||||
|
||||
let upper = splits[0]
|
||||
|
||||
// ---- Past finished sessions (Bench Press trend for the chart) ----------
|
||||
let benchTrend = [115, 120, 125, 130]
|
||||
for (i, w) in benchTrend.enumerated() {
|
||||
let date = daysAgo((benchTrend.count - i) * 4)
|
||||
// ---- Past finished sessions (Lat Pull Down trend for the chart) --------
|
||||
let liftTrend = [95, 100, 105, 110]
|
||||
for (i, w) in liftTrend.enumerated() {
|
||||
let date = daysAgo((liftTrend.count - i) * 4)
|
||||
let workout = Workout(id: ULID.make(), splitID: nil, splitName: upper.0, start: date,
|
||||
end: date.addingTimeInterval(2400), statusRaw: WorkoutStatus.completed.rawValue,
|
||||
createdAt: date, updatedAt: date, jsonRelativePath: "")
|
||||
context.insert(workout)
|
||||
for (eIndex, e) in upper.3.enumerated() {
|
||||
let weight = e.0 == "Bench Press" ? w : e.3
|
||||
let weight = e.0 == "Lat Pull Down" ? w : e.3
|
||||
let log = WorkoutLog(id: ULID.make(), exerciseName: e.0, order: eIndex, sets: e.1,
|
||||
reps: e.2, weight: weight, loadType: LoadType.weight.rawValue,
|
||||
durationTotalSeconds: 0, currentStateIndex: e.1, completed: true,
|
||||
durationTotalSeconds: 0, currentStateIndex: e.1,
|
||||
statusRaw: WorkoutStatus.completed.rawValue, notes: nil, date: date)
|
||||
log.workout = workout
|
||||
context.insert(log)
|
||||
@@ -86,13 +87,13 @@ enum ScreenshotSeed {
|
||||
end: nil, statusRaw: WorkoutStatus.inProgress.rawValue,
|
||||
createdAt: today, updatedAt: today, jsonRelativePath: "")
|
||||
context.insert(workout)
|
||||
// Bench Press done, Overhead Press partway, the rest to go.
|
||||
// Lat Pull Down done, Seated Row partway, the rest to go.
|
||||
let progress = [(4, WorkoutStatus.completed), (2, .inProgress), (0, .notStarted), (0, .notStarted)]
|
||||
for (eIndex, e) in upper.3.enumerated() {
|
||||
let (idx, status) = progress[eIndex]
|
||||
let log = WorkoutLog(id: ULID.make(), exerciseName: e.0, order: eIndex, sets: e.1, reps: e.2,
|
||||
weight: e.0 == "Bench Press" ? 135 : e.3, loadType: LoadType.weight.rawValue,
|
||||
durationTotalSeconds: 0, currentStateIndex: idx, completed: status == .completed,
|
||||
weight: e.0 == "Lat Pull Down" ? 110 : e.3, loadType: LoadType.weight.rawValue,
|
||||
durationTotalSeconds: 0, currentStateIndex: idx,
|
||||
statusRaw: status.rawValue, notes: nil, date: today)
|
||||
log.workout = workout
|
||||
context.insert(log)
|
||||
|
||||
@@ -18,8 +18,16 @@ extension Date {
|
||||
private static let weekdayAbbrev: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "EEE"; return f
|
||||
}()
|
||||
private static let relativeDateTime: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .medium; f.timeStyle = .short
|
||||
f.doesRelativeDateFormatting = true
|
||||
return f
|
||||
}()
|
||||
|
||||
func formattedDate() -> String { Self.shortDateTime.string(from: self) }
|
||||
/// "Today at 10:44 AM" / "Yesterday at 9:12 AM" / "Jul 3, 2026 at 8:00 AM".
|
||||
func formattedRelativeDateTime() -> String { Self.relativeDateTime.string(from: self) }
|
||||
func formattedTime() -> String { Self.timeOnly.string(from: self) }
|
||||
func formatDate() -> String { Self.mediumDate.string(from: self) }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user