- Models: TrainingPlan, Race, FamilyPlan, BeginnerMode, CommunityEvent - Services: 5 service layers with protocol-based architecture - ViewModels: 5 view models with @MainActor ObservableObject pattern - Views: 10 SwiftUI views for all Phase 3 features - Updated README with full Phase 3 documentation Co-Authored-By: Paperclip <noreply@paperclip.ing>
212 lines
6.9 KiB
Swift
212 lines
6.9 KiB
Swift
import SwiftUI
|
|
|
|
struct WorkoutSessionView: View {
|
|
let session: DailySession
|
|
@StateObject private var viewModel = TrainingPlanViewModel()
|
|
@State private var isRunning: Bool = false
|
|
@State private var elapsedSeconds: Int = 0
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
sessionHeader
|
|
metricsSection
|
|
workoutInstructions
|
|
actionButtons
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.bottom, 32)
|
|
}
|
|
.navigationTitle(session.workoutType.displayName)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
|
|
private var sessionHeader: some View {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: session.workoutType.icon)
|
|
.font(.system(size: 48))
|
|
.foregroundColor(session.workoutType.color)
|
|
|
|
VStack(spacing: 4) {
|
|
Text(session.title)
|
|
.font(.title2)
|
|
.fontWeight(.bold)
|
|
Text(session.dayOfWeek.displayName)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
HStack(spacing: 24) {
|
|
if let distance = session.targetDistanceKm {
|
|
metricCard(value: "\(distance)", label: "Target km", icon: "figure.run")
|
|
}
|
|
if let duration = session.targetDurationMinutes {
|
|
metricCard(value: "\(duration)", label: "Target min", icon: "clock")
|
|
}
|
|
if let pace = session.targetPaceMinPerKm {
|
|
metricCard(value: "\(Int(pace)):00", label: "Pace /km", icon: "speedometer")
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity)
|
|
.background(Color.secondary.opacity(0.1))
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
private func metricCard(value: String, label: String, icon: String) -> some View {
|
|
VStack(spacing: 4) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 18))
|
|
.foregroundColor(session.workoutType.color)
|
|
Text(value)
|
|
.font(.title3)
|
|
.fontWeight(.bold)
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
private var metricsSection: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Current Session")
|
|
.font(.headline)
|
|
|
|
HStack(spacing: 16) {
|
|
currentMetric(value: "\(formatElapsed(elapsedSeconds))", label: "Elapsed", icon: "stopwatch")
|
|
currentMetric(value: "0.0", label: "Distance", icon: "route")
|
|
currentMetric(value: "--:--", label: "Pace", icon: "speedometer")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func currentMetric(value: String, label: String, icon: String) -> some View {
|
|
VStack(spacing: 4) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 16))
|
|
.foregroundColor(.blue)
|
|
Text(value)
|
|
.font(.title3)
|
|
.fontWeight(.bold)
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 10)
|
|
.background(Color.blue.opacity(0.08))
|
|
.cornerRadius(10)
|
|
}
|
|
|
|
private var workoutInstructions: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Instructions")
|
|
.font(.headline)
|
|
Text(session.description)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
|
|
if session.intensity != .veryEasy {
|
|
HStack {
|
|
Text("Intensity")
|
|
Spacer()
|
|
HStack(spacing: 2) {
|
|
ForEach(1...5, id: \.self) { i in
|
|
Circle()
|
|
.fill(i <= sessionIntensityLevel ? session.workoutType.color : Color.secondary.opacity(0.2))
|
|
.frame(width: 8, height: 8)
|
|
}
|
|
}
|
|
}
|
|
.font(.caption)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var sessionIntensityLevel: Int {
|
|
switch session.intensity {
|
|
case .veryEasy: return 1
|
|
case .easy: return 2
|
|
case .moderate: return 3
|
|
case .hard: return 4
|
|
case .veryHard: return 5
|
|
}
|
|
}
|
|
|
|
private var actionButtons: some View {
|
|
VStack(spacing: 12) {
|
|
if isRunning {
|
|
Button {
|
|
isRunning = false
|
|
Task {
|
|
await viewModel.updateSessionStatus(sessionId: session.id, status: .completed)
|
|
}
|
|
} label: {
|
|
Text("Finish Workout")
|
|
.font(.headline)
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(Color.blue)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(10)
|
|
}
|
|
|
|
Button {
|
|
isRunning = false
|
|
Task {
|
|
await viewModel.updateSessionStatus(sessionId: session.id, status: .skipped)
|
|
}
|
|
} label: {
|
|
Text("Skip Session")
|
|
.font(.headline)
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(Color.orange.opacity(0.15))
|
|
.foregroundColor(.orange)
|
|
.cornerRadius(10)
|
|
}
|
|
} else {
|
|
Button {
|
|
isRunning = true
|
|
} label: {
|
|
Text("Start Workout")
|
|
.font(.headline)
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(session.workoutType.color)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(10)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func formatElapsed(_ seconds: Int) -> String {
|
|
let mins = seconds / 60
|
|
let secs = seconds % 60
|
|
return String(format: "%d:%02d", mins, secs)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationView {
|
|
WorkoutSessionView(session: sampleSession)
|
|
}
|
|
}
|
|
|
|
private var sampleSession: DailySession {
|
|
DailySession(
|
|
id: "1",
|
|
dayOfWeek: .monday,
|
|
workoutType: .easyRun,
|
|
title: "Easy Recovery Run",
|
|
description: "Keep the pace comfortable. Focus on maintaining good form and breathing rhythm.",
|
|
targetDistanceKm: 5.0,
|
|
targetDurationMinutes: 30,
|
|
targetPaceMinPerKm: 6,
|
|
intensity: .easy,
|
|
status: .pending
|
|
)
|
|
}
|