Files
workouts/Workouts Watch App/Views/WorkoutLogListView.swift
T
rzen 21ee05053e Make the brand purple the accent color and exercise Done check
Populate the previously-empty AccentColor asset (iOS + watch) with the
logo purple — a deep shade in light mode, brightened for dark mode and
the watch's black background. The exercise Done check now uses that
accent color and the in-progress indicator reads as a neutral gray, on
both iPhone and Apple Watch.
2026-06-20 17:48:05 -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:
.accentColor
case .inProgress:
.gray
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"
}
}
}