Files
workouts/Workouts Watch App/Views/WorkoutLogListView.swift
T
rzen 85d0eaddbb Workouts 2.0: re-base persistence on iCloud Drive documents
Replace Core Data + NSPersistentCloudKitContainer + App-Group store +
WatchConnectivity dictionary sync with the QuickRabbit iCloud-documents
architecture:

- iCloud Drive JSON documents are the sole source of truth (one file per
  aggregate: Splits/<ULID>.json, Workouts/YYYY/MM/<ULID>.json), with a
  rebuildable SwiftData cache populated only by an NSMetadataQuery observer
  and a connect-time reconcile. Soft-delete tombstones; hard iCloud gate.
- Shared model layer (ULID, Codable *Documents + stateless mappers, @Model
  cache entities, SwiftData container) compiled into both targets.
- New iPhone<->Watch bridge over WatchConnectivity keyed by ULIDs; the phone
  is the sole writer of iCloud Drive, the watch round-trips documents.
- AppServices DI + iCloud-required root gate; Swift 6 strict concurrency.
- Starter splits generated on demand from the bundled YAML catalogs.
- Migrate to XcodeGen (project.yml), iOS 26 / watchOS 26; CloudDocuments
  entitlement (drop CloudKit/App Group/aps-environment).
- Duration stored as Int seconds (was a Date epoch hack); fix workout
  end-on-create, undismissable delete dialog, toolbar-hiding nav stacks,
  and the Settings placeholder.
- Add README/CHANGELOG/LICENSE, .gitignore, refreshed REQUIREMENTS, and the
  Scripts/ TestFlight pipeline (release.sh + ASC API scripts).

MARKETING_VERSION 2.0.
2026-06-19 14:25:27 -04:00

261 lines
8.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// WorkoutLogListView.swift
// Workouts Watch App
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
struct WorkoutLogListView: View {
@Environment(WatchConnectivityBridge.self) private var bridge
/// The split this workout came from (read-only on the watch), used to offer
/// additional exercises that aren't logged yet.
@Query private var matchingSplits: [Split]
/// Working copy of the workout. We drive the UI from this and mutate it on
/// every edit (then forward through the bridge) to avoid the read-after-write
/// race against the cache, which lags local writes by a beat.
@State private var doc: WorkoutDocument
@State private var showingExercisePicker = false
@State private var selectedLogID: String?
init(workout: Workout) {
_doc = State(initialValue: WorkoutDocument(from: workout))
if let splitID = workout.splitID {
_matchingSplits = Query(filter: #Predicate<Split> { $0.id == splitID })
} else {
// No source split: never match anything.
_matchingSplits = Query(filter: #Predicate<Split> { _ in false })
}
}
private var split: Split? { matchingSplits.first }
private var sortedLogs: [WorkoutLogDocument] {
doc.logs.sorted { $0.order < $1.order }
}
private var availableExercises: [Exercise] {
guard let split else { return [] }
let existingNames = Set(doc.logs.map { $0.exerciseName })
return split.exercisesArray.filter { !existingNames.contains($0.name) }
}
var body: some View {
List {
Section(header: Text(label)) {
ForEach(sortedLogs) { log in
Button {
selectedLogID = log.id
} label: {
WorkoutLogRowLabel(log: log)
}
.buttonStyle(.plain)
}
}
if !availableExercises.isEmpty {
Section {
Button {
showingExercisePicker = true
} label: {
HStack {
Image(systemName: "plus.circle.fill")
.foregroundColor(.green)
Text("Add Exercise")
}
}
}
}
}
.overlay {
if sortedLogs.isEmpty {
ContentUnavailableView(
"No Exercises",
systemImage: "figure.strengthtraining.traditional",
description: Text(availableExercises.isEmpty
? "No exercises in this workout."
: "Tap + to add exercises.")
)
}
}
.navigationTitle(doc.splitName ?? Split.unnamed)
.navigationDestination(item: $selectedLogID) { logID in
ExerciseProgressView(doc: $doc, logID: logID, onChange: { bridge.update(workout: doc) })
}
.sheet(isPresented: $showingExercisePicker) {
ExercisePickerView(exercises: availableExercises) { exercise in
addExercise(exercise)
}
}
}
private var label: String {
let start = doc.start
if doc.status == WorkoutStatus.completed.rawValue, let end = doc.end {
if start.isSameDay(as: end) {
return "\(start.formattedDate())\(end.formattedTime())"
} else {
return "\(start.formattedDate())\(end.formattedDate())"
}
}
return start.formattedDate()
}
private func addExercise(_ exercise: Exercise) {
let newLog = WorkoutLogDocument(
id: ULID.make(),
exerciseName: exercise.name,
order: doc.logs.count,
sets: exercise.sets,
reps: exercise.reps,
weight: exercise.weight,
loadType: exercise.loadType,
durationSeconds: exercise.durationTotalSeconds,
currentStateIndex: 0,
completed: false,
status: WorkoutStatus.notStarted.rawValue,
notes: nil,
date: doc.start
)
doc.logs.append(newLog)
doc.updatedAt = Date()
bridge.update(workout: doc)
showingExercisePicker = false
}
}
// MARK: - Workout Log Row Label
struct WorkoutLogRowLabel: View {
let log: WorkoutLogDocument
var body: some View {
HStack {
statusIcon
.foregroundColor(statusColor)
VStack(alignment: .leading, spacing: 2) {
Text(log.exerciseName)
.font(.headline)
.lineLimit(1)
Text(subtitle)
.font(.caption2)
.foregroundColor(.secondary)
}
Spacer()
}
}
private var status: WorkoutStatus {
WorkoutStatus(rawValue: log.status) ?? .notStarted
}
private var statusIcon: Image {
switch status {
case .completed:
Image(systemName: "checkmark.circle.fill")
case .inProgress:
Image(systemName: "circle.dotted")
case .notStarted:
Image(systemName: "circle")
case .skipped:
Image(systemName: "xmark.circle")
}
}
private var statusColor: Color {
switch status {
case .completed:
.green
case .inProgress:
.orange
case .notStarted:
.secondary
case .skipped:
.secondary
}
}
private var subtitle: String {
if LoadType(rawValue: log.loadType) == .duration {
let mins = log.durationSeconds / 60
let secs = log.durationSeconds % 60
if mins > 0 && secs > 0 {
return "\(log.sets) × \(mins)m \(secs)s"
} else if mins > 0 {
return "\(log.sets) × \(mins) min"
} else {
return "\(log.sets) × \(secs) sec"
}
} else {
return "\(log.sets) × \(log.reps) × \(log.weight) lbs"
}
}
}
// MARK: - Exercise Picker View
struct ExercisePickerView: View {
@Environment(\.dismiss) private var dismiss
let exercises: [Exercise]
let onSelect: (Exercise) -> Void
var body: some View {
NavigationStack {
List {
if exercises.isEmpty {
Text("All exercises added")
.foregroundColor(.secondary)
} else {
ForEach(exercises) { exercise in
Button {
onSelect(exercise)
dismiss()
} label: {
VStack(alignment: .leading) {
Text(exercise.name)
.font(.headline)
Text(exerciseSubtitle(exercise))
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
}
}
.navigationTitle("Add Exercise")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
}
private func exerciseSubtitle(_ exercise: Exercise) -> String {
if exercise.loadTypeEnum == .duration {
let mins = exercise.durationMinutes
let secs = exercise.durationSeconds
if mins > 0 && secs > 0 {
return "\(exercise.sets) × \(mins)m \(secs)s"
} else if mins > 0 {
return "\(exercise.sets) × \(mins) min"
} else {
return "\(exercise.sets) × \(secs) sec"
}
} else {
return "\(exercise.sets) × \(exercise.reps) × \(exercise.weight) lbs"
}
}
}