FRE-4665: Implement Phase 3 AI training plans and premium features
- 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>
This commit is contained in:
173
Lendair/Views/BeginnerModeView.swift
Normal file
173
Lendair/Views/BeginnerModeView.swift
Normal file
@@ -0,0 +1,173 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BeginnerModeView: View {
|
||||
@StateObject private var viewModel = BeginnerModeViewModel()
|
||||
@State private var showingOnboarding = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Group {
|
||||
if viewModel.isLoading && viewModel.config == nil {
|
||||
loadingView
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
.navigationTitle("Beginner Mode")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if let config = viewModel.config {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Toggle("", isOn: Binding(
|
||||
get: { config.isEnabled },
|
||||
set: { isEnabled in
|
||||
Task {
|
||||
await viewModel.toggleBeginnerMode(isEnabled: isEnabled)
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.fetchConfig()
|
||||
await viewModel.fetchMilestoneProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
List {
|
||||
Section("Current Level") {
|
||||
if let config = viewModel.config {
|
||||
HStack {
|
||||
Image(systemName: config.currentLevel.icon)
|
||||
.font(.system(size: 28))
|
||||
.foregroundColor(.blue)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(config.currentLevel.displayName)
|
||||
.font(.headline)
|
||||
Text("Workout #\(config.currentLevel.requiredWorkouts) to advance")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Onboarding Progress") {
|
||||
Text("\(viewModel.completedOnboardingCount)/\(viewModel.onboardingSteps.count) steps completed")
|
||||
.font(.subheadline)
|
||||
|
||||
ForEach(viewModel.remainingOnboardingSteps, id: \.self) { step in
|
||||
HStack {
|
||||
Image(systemName: "circle")
|
||||
.foregroundColor(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(step.displayName)
|
||||
.font(.subheadline)
|
||||
Text(step.description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.remainingOnboardingSteps.isEmpty {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("All steps completed!")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Milestones") {
|
||||
Text("\(viewModel.completedMilestoneCount)/\(viewModel.totalMilestoneCount) achieved")
|
||||
.font(.subheadline)
|
||||
|
||||
ForEach(viewModel.milestones) { milestone in
|
||||
MilestoneRow(milestone: milestone)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Quick Tips") {
|
||||
tipRow(icon: "lightbulb.fill", title: "Start Slow", message: "Begin with shorter distances and gradually increase.")
|
||||
tipRow(icon: "heart.fill", title: "Stay Consistent", message: "Regular workouts yield better results than occasional long ones.")
|
||||
tipRow(icon: "drop.fill", title: "Hydrate", message: "Keep water nearby during all workouts.")
|
||||
tipRow(icon: "moon.fill", title: "Rest Days", message: "Recovery is when your body gets stronger.")
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
private func tipRow(icon: String, title: String, message: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.orange)
|
||||
.frame(width: 32)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Loading Beginner Mode...")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
}
|
||||
}
|
||||
|
||||
struct MilestoneRow: View {
|
||||
let milestone: Milestone
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(milestone.isCompleted ? Color.orange.opacity(0.2) : Color.secondary.opacity(0.1))
|
||||
.frame(width: 40, height: 40)
|
||||
Image(systemName: milestone.isCompleted ? "\(milestone.icon).fill" : milestone.icon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(milestone.isCompleted ? .orange : .secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(milestone.title)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
Text(milestone.description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if milestone.isCompleted {
|
||||
Image(systemName: "star.fill")
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
BeginnerModeView()
|
||||
}
|
||||
209
Lendair/Views/CommunityEventDetailView.swift
Normal file
209
Lendair/Views/CommunityEventDetailView.swift
Normal file
@@ -0,0 +1,209 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CommunityEventDetailView: View {
|
||||
let event: CommunityEvent
|
||||
@StateObject private var viewModel = CommunityEventViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
eventHeader
|
||||
eventInfoSection
|
||||
eventDescription
|
||||
rsvpSection
|
||||
participantsSection
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.navigationTitle(event.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.selectEvent(id: event.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var eventHeader: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Image(systemName: event.eventType.icon)
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(event.eventType.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(event.eventType.displayName)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
if let distance = event.distanceKm {
|
||||
Text("\(distance) km \u2022 \(event.location)")
|
||||
} else {
|
||||
Text(event.location)
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 20) {
|
||||
infoItem(label: "Date", value: formatDate(event.startDate))
|
||||
infoItem(label: "Participants", value: "\(event.participantCount)")
|
||||
if let difficulty = event.difficulty {
|
||||
infoItem(label: "Difficulty", value: difficulty.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
private func infoItem(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(value)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
|
||||
private var eventInfoSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Event Details")
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
detailRow(label: "Organizer", value: event.organizerName)
|
||||
detailRow(label: "Location", value: event.location)
|
||||
detailRow(label: "Start", value: formatDateTime(event.startDate))
|
||||
detailRow(label: "End", value: formatDateTime(event.endDate))
|
||||
if let max = event.maxParticipants {
|
||||
detailRow(label: "Capacity", value: "\(event.participantCount)/\(max)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detailRow(label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 100, alignment: .leading)
|
||||
Text(value)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
|
||||
private var eventDescription: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("About This Event")
|
||||
.font(.headline)
|
||||
Text(event.description)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var rsvpSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Your RSVP")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
rsvpButton(title: "Going", icon: "checkmark.circle", status: .going, color: .green)
|
||||
rsvpButton(title: "Maybe", icon: "questionmark.circle", status: .maybe, color: .orange)
|
||||
rsvpButton(title: "Not Going", icon: "xmark.circle", status: .notGoing, color: .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func rsvpButton(title: String, icon: String, status: RSVPStatus, color: Color) -> some View {
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.RSVP(eventId: event.id, status: status)
|
||||
}
|
||||
} label: {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: event.rsvpStatus == status ? "\(icon).fill" : icon)
|
||||
.foregroundColor(event.rsvpStatus == status ? color : .secondary)
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(event.rsvpStatus == status ? color : .secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(event.rsvpStatus == status ? color.opacity(0.15) : Color.secondary.opacity(0.08))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
private var participantsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Participants (\(event.participantCount))")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(viewModel.participants) { participant in
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
.frame(width: 32, height: 32)
|
||||
Text(participant.name)
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text(participant.rsvpStatus.rawValue.capitalized)
|
||||
.font(.caption)
|
||||
.foregroundColor(participant.rsvpStatus == .going ? .green : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func formatDateTime(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
CommunityEventDetailView(event: sampleEvent)
|
||||
}
|
||||
}
|
||||
|
||||
private var sampleEvent: CommunityEvent {
|
||||
CommunityEvent(
|
||||
id: "1",
|
||||
title: "Sunday Morning Group Run",
|
||||
description: "Join us for a friendly morning run through the park. All paces welcome!",
|
||||
eventType: .groupRun,
|
||||
location: "Central Park",
|
||||
latitude: 40.7851,
|
||||
longitude: -73.9683,
|
||||
startDate: Date().addingTimeInterval(7 * 24 * 3600),
|
||||
endDate: Date().addingTimeInterval(7 * 24 * 3600 + 3600),
|
||||
distanceKm: 10,
|
||||
organizerId: "user1",
|
||||
organizerName: "Running Club",
|
||||
maxParticipants: 50,
|
||||
participantCount: 23,
|
||||
imageUrl: nil,
|
||||
difficulty: .beginner,
|
||||
rsvpStatus: .pending,
|
||||
createdAt: Date()
|
||||
)
|
||||
}
|
||||
236
Lendair/Views/CommunityEventsView.swift
Normal file
236
Lendair/Views/CommunityEventsView.swift
Normal file
@@ -0,0 +1,236 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CommunityEventsView: View {
|
||||
@StateObject private var viewModel = CommunityEventViewModel()
|
||||
@State private var showingCreateSheet = false
|
||||
@State private var selectedTab: EventTab = .upcoming
|
||||
|
||||
enum EventTab: String, CaseIterable {
|
||||
case upcoming, ongoing, past
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Group {
|
||||
if viewModel.isLoading && viewModel.events.isEmpty {
|
||||
loadingView
|
||||
} else if currentEvents.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
eventListView
|
||||
}
|
||||
}
|
||||
.navigationTitle("Community Events")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showingCreateSheet = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingCreateSheet) {
|
||||
CreateEventSheet()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.fetchEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var currentEvents: [CommunityEvent] {
|
||||
switch selectedTab {
|
||||
case .upcoming: return viewModel.upcomingEvents
|
||||
case .ongoing: return viewModel.ongoingEvents
|
||||
case .past: return viewModel.pastEvents
|
||||
}
|
||||
}
|
||||
|
||||
private var eventListView: some View {
|
||||
List {
|
||||
Picker("Events", selection: $selectedTab) {
|
||||
ForEach(EventTab.allCases, id: \.self) { tab in
|
||||
Text(tab.rawValue.capitalized).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.top, 8)
|
||||
|
||||
Section(currentSectionTitle) {
|
||||
ForEach(currentEvents) { event in
|
||||
NavigationLink(destination: CommunityEventDetailView(event: event)) {
|
||||
EventRowView(event: event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable {
|
||||
await viewModel.fetchEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private var currentSectionTitle: String {
|
||||
switch selectedTab {
|
||||
case .upcoming: return "Upcoming"
|
||||
case .ongoing: return "Happening Now"
|
||||
case .past: return "Past Events"
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Loading Events...")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "person.3.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundColor(.secondary)
|
||||
Text("No \(selectedTab.rawValue) Events")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Text("Create or discover community running events in your area.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateEventSheet: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var title = ""
|
||||
@State private var description = ""
|
||||
@State private var eventType: EventType = .groupRun
|
||||
@State private var location = ""
|
||||
@State private var distanceKm = ""
|
||||
@State private var maxParticipants = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("Event Details") {
|
||||
TextField("Event Title", text: $title)
|
||||
TextField("Description", text: $description)
|
||||
TextField("Location", text: $location)
|
||||
}
|
||||
|
||||
Section("Type") {
|
||||
Picker("Event Type", selection: $eventType) {
|
||||
ForEach(EventType.allCases, id: \.self) { type in
|
||||
Text(type.displayName).tag(type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Optional") {
|
||||
TextField("Distance (km)", text: $distanceKm)
|
||||
.keyboardType(.decimalPad)
|
||||
TextField("Max Participants", text: $maxParticipants)
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Create Event")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Create") {
|
||||
let request = CreateEventRequest(
|
||||
title: title,
|
||||
description: description,
|
||||
eventType: eventType,
|
||||
location: location,
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
startDate: Date(),
|
||||
endDate: Date().addingTimeInterval(3600),
|
||||
distanceKm: Double(distanceKm),
|
||||
maxParticipants: Int(maxParticipants),
|
||||
difficulty: nil
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(title.isEmpty || location.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EventRowView: View {
|
||||
let event: CommunityEvent
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: event.eventType.icon)
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(event.eventType.color)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(event.eventType.color.opacity(0.15))
|
||||
.cornerRadius(10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(event.title)
|
||||
.font(.headline)
|
||||
Text("\(event.location) \u2022 \(event.organizerName)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 8) {
|
||||
Text(formatDate(event.startDate))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
if let spots = event.availableSpots, spots > 0 {
|
||||
Text("\(spots) spots left")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
switch event.rsvpStatus {
|
||||
case .going:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
case .maybe:
|
||||
Image(systemName: "questionmark.circle.fill")
|
||||
.foregroundColor(.orange)
|
||||
case .notGoing:
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
case .pending:
|
||||
Image(systemName: "circle")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CommunityEventsView()
|
||||
}
|
||||
125
Lendair/Views/FamilyMemberView.swift
Normal file
125
Lendair/Views/FamilyMemberView.swift
Normal file
@@ -0,0 +1,125 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FamilyMemberView: View {
|
||||
let member: FamilyMember
|
||||
@State private var weeklyData: [(day: String, distance: Double)] = []
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
memberHeader
|
||||
statsSection
|
||||
weeklyActivitySection
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.navigationTitle(member.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var memberHeader: some View {
|
||||
VStack(spacing: 16) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(member.isPrimary ? Color.blue.opacity(0.2) : Color.green.opacity(0.2))
|
||||
.frame(width: 80, height: 80)
|
||||
Image(systemName: member.role.icon)
|
||||
.font(.system(size: 36))
|
||||
.foregroundColor(member.isPrimary ? .blue : .green)
|
||||
}
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text(member.name)
|
||||
.font(.title3)
|
||||
.fontWeight(.bold)
|
||||
Text(member.role.displayName)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
private var statsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Statistics")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
statCard(value: "\(Int(member.totalDistanceKm))", label: "Total km", icon: "figure.run")
|
||||
statCard(value: "\(member.totalWorkouts)", label: "Workouts", icon: "checkmark.circle")
|
||||
statCard(value: "\(Int(member.weeklyDistanceKm))", label: "This Week", icon: "chart.bar.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func statCard(value: String, label: String, icon: String) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.blue)
|
||||
Text(value)
|
||||
.font(.title3)
|
||||
.fontWeight(.bold)
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
private var weeklyActivitySection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Weekly Activity")
|
||||
.font(.headline)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
ForEach(DayOfWeek.allCases, id: \.self) { day in
|
||||
HStack {
|
||||
Text(day.displayName)
|
||||
.font(.subheadline)
|
||||
.frame(width: 36)
|
||||
GeometryReader { geo in
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color.blue.opacity(0.6))
|
||||
.frame(width: min(geo.size.width, 200), height: 24)
|
||||
}
|
||||
.frame(height: 24)
|
||||
Text("\(Int(member.weeklyDistanceKm / 7)) km")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
FamilyMemberView(member: sampleMember)
|
||||
}
|
||||
}
|
||||
|
||||
private var sampleMember: FamilyMember {
|
||||
FamilyMember(
|
||||
id: "1",
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
role: .owner,
|
||||
joinedAt: Date().addingTimeInterval(-30 * 24 * 3600),
|
||||
avatarUrl: nil,
|
||||
isPrimary: true,
|
||||
totalDistanceKm: 245.5,
|
||||
totalWorkouts: 42,
|
||||
weeklyDistanceKm: 32.0,
|
||||
weeklyWorkouts: 5
|
||||
)
|
||||
}
|
||||
244
Lendair/Views/FamilyPlanView.swift
Normal file
244
Lendair/Views/FamilyPlanView.swift
Normal file
@@ -0,0 +1,244 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FamilyPlanView: View {
|
||||
@StateObject private var viewModel = FamilyPlanViewModel()
|
||||
@State private var showingInviteSheet = false
|
||||
@State private var selectedMetric: LeaderboardMetric = .distance
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Group {
|
||||
if viewModel.isLoading && viewModel.familyPlan == nil {
|
||||
loadingView
|
||||
} else if let plan = viewModel.familyPlan {
|
||||
planContent(plan)
|
||||
} else {
|
||||
emptyStateView
|
||||
}
|
||||
}
|
||||
.navigationTitle("Family Plan")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if let plan = viewModel.familyPlan, plan.isActive {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showingInviteSheet = true
|
||||
} label: {
|
||||
Text("Invite")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingInviteSheet) {
|
||||
InviteMemberSheet()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.fetchFamilyPlan()
|
||||
await viewModel.fetchLeaderboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func planContent(_ plan: FamilyPlan) -> some View {
|
||||
List {
|
||||
Section("Plan Status") {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(plan.ownerName)
|
||||
.font(.headline)
|
||||
Text("\(plan.members.count)/\(plan.maxMembers) members")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(plan.subscriptionStatus.displayName)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(plan.subscriptionStatus.color.opacity(0.2))
|
||||
.cornerRadius(6)
|
||||
.foregroundColor(plan.subscriptionStatus.color)
|
||||
}
|
||||
|
||||
if let renewalDate = plan.renewalDate {
|
||||
HStack {
|
||||
Text("Renews")
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(formatDate(renewalDate))
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Members") {
|
||||
ForEach(plan.members) { member in
|
||||
MemberRowView(member: member)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Leaderboard") {
|
||||
Picker("Metric", selection: $selectedMetric) {
|
||||
ForEach(viewModel.metrics, id: \.self) { metric in
|
||||
Text(metric.displayName).tag(metric)
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedMetric) { newValue in
|
||||
viewModel.selectedMetric = newValue
|
||||
Task { await viewModel.fetchLeaderboard() }
|
||||
}
|
||||
|
||||
if viewModel.leaderboard.isEmpty {
|
||||
Text("No data yet")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
ForEach(viewModel.leaderboard) { entry in
|
||||
LeaderboardRow(entry: entry, metric: selectedMetric)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Loading Family Plan...")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "person.3.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundColor(.secondary)
|
||||
Text("No Family Plan")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Text("Create a family plan to share your subscription with up to 6 members.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
struct InviteMemberSheet: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var email = ""
|
||||
@State private var name = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("New Member") {
|
||||
TextField("Name", text: $name)
|
||||
TextField("Email", text: $email)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Invite Member")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Send Invite") {
|
||||
dismiss()
|
||||
}
|
||||
.disabled(email.isEmpty || name.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MemberRowView: View {
|
||||
let member: FamilyMember
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(member.isPrimary ? Color.blue.opacity(0.2) : Color.secondary.opacity(0.15))
|
||||
.frame(width: 40, height: 40)
|
||||
Image(systemName: member.role.icon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(member.isPrimary ? .blue : .secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(member.name)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
Text(member.email)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("\(Int(member.weeklyDistanceKm)) km")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
Text("this week")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
struct LeaderboardRow: View {
|
||||
let entry: FamilyLeaderboardEntry
|
||||
let metric: LeaderboardMetric
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Text("#\(entry.rank)")
|
||||
.font(.headline)
|
||||
.frame(width: 30)
|
||||
|
||||
Circle()
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
Text(entry.memberName)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(Int(entry.value))\(metric.unit)")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
FamilyPlanView()
|
||||
}
|
||||
182
Lendair/Views/RaceDetailView.swift
Normal file
182
Lendair/Views/RaceDetailView.swift
Normal file
@@ -0,0 +1,182 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RaceDetailView: View {
|
||||
let race: Race
|
||||
@StateObject private var viewModel = RaceDiscoveryViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
raceHeader
|
||||
raceInfoSection
|
||||
raceDescription
|
||||
actionButtons
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.navigationTitle(race.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var raceHeader: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Image(systemName: race.raceType.icon)
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.orange)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(race.raceType.displayName)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
Text("\(race.distanceKm) km \u2022 \(race.terrainType.displayName)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.toggleSaveRace(id: race.id)
|
||||
}
|
||||
}) {
|
||||
Image(systemName: race.isSaved ? "bookmark.fill" : "bookmark")
|
||||
.font(.title3)
|
||||
.foregroundColor(race.isSaved ? .blue : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 20) {
|
||||
infoItem(label: "Date", value: formatDate(race.raceDate))
|
||||
infoItem(label: "Location", value: race.location)
|
||||
infoItem(label: "Days Left", value: "\(race.daysUntilRace)")
|
||||
}
|
||||
|
||||
if let count = race.participantCount {
|
||||
HStack(spacing: 20) {
|
||||
infoItem(label: "Participants", value: "\(count)")
|
||||
infoItem(label: "Elevation", value: "\(Int(race.elevationGain))m")
|
||||
infoItem(label: "Terrain", value: race.terrainType.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
private func infoItem(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(value)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
|
||||
private var raceInfoSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Race Details")
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
detailRow(label: "Organizer", value: race.organizerName)
|
||||
detailRow(label: "Distance", value: "\(race.distanceKm) km")
|
||||
detailRow(label: "Elevation Gain", value: "\(Int(race.elevationGain))m")
|
||||
detailRow(label: "Terrain", value: race.terrainType.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detailRow(label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 120, alignment: .leading)
|
||||
Text(value)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
|
||||
private var raceDescription: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("About This Race")
|
||||
.font(.headline)
|
||||
Text(race.description)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var actionButtons: some View {
|
||||
VStack(spacing: 12) {
|
||||
if let url = race.registrationUrl, !race.isRegistered {
|
||||
Button {
|
||||
if let registrationUrl = URL(string: url) {
|
||||
UIApplication.shared.open(registrationUrl)
|
||||
}
|
||||
} label: {
|
||||
Text("Register Now")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
|
||||
if race.isRegistered {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
Text("Registered")
|
||||
}
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.green.opacity(0.15))
|
||||
.foregroundColor(.green)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
RaceDetailView(race: sampleRace)
|
||||
}
|
||||
}
|
||||
|
||||
private var sampleRace: Race {
|
||||
Race(
|
||||
id: "1",
|
||||
name: "City Marathon 2026",
|
||||
description: "An annual marathon through the heart of the city. Features a flat, fast course suitable for all levels.",
|
||||
location: "Downtown",
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
raceDate: Date().addingTimeInterval(90 * 24 * 3600),
|
||||
distanceKm: 42.2,
|
||||
raceType: .road,
|
||||
organizerName: "City Athletics Club",
|
||||
registrationUrl: "https://example.com/register",
|
||||
imageUrl: nil,
|
||||
participantCount: 5000,
|
||||
isRegistered: false,
|
||||
isSaved: true,
|
||||
elevationGain: 120,
|
||||
terrainType: .flat
|
||||
)
|
||||
}
|
||||
165
Lendair/Views/RaceDiscoveryView.swift
Normal file
165
Lendair/Views/RaceDiscoveryView.swift
Normal file
@@ -0,0 +1,165 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RaceDiscoveryView: View {
|
||||
@StateObject private var viewModel = RaceDiscoveryViewModel()
|
||||
@State private var showingFilters = false
|
||||
@State private var showingSavedRaces = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Group {
|
||||
if viewModel.isLoading && viewModel.races.isEmpty {
|
||||
loadingView
|
||||
} else if viewModel.races.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
raceListView
|
||||
}
|
||||
}
|
||||
.navigationTitle("Race Discovery")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Menu {
|
||||
Button {
|
||||
showingSavedRaces.toggle()
|
||||
} label: {
|
||||
Text("Saved Races")
|
||||
Image(systemName: "bookmark.fill")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSavedRaces) {
|
||||
NavigationView {
|
||||
SavedRacesSheet(viewModel: viewModel)
|
||||
.navigationTitle("Saved Races")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.fetchRaces()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var raceListView: some View {
|
||||
List {
|
||||
Section("Upcoming Races") {
|
||||
ForEach(viewModel.upcomingRaces) { race in
|
||||
NavigationLink(destination: RaceDetailView(race: race)) {
|
||||
RaceRowView(race: race)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable {
|
||||
await viewModel.fetchRaces()
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Loading Races...")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "flag.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundColor(.secondary)
|
||||
Text("No Races Found")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Text("Discover local races and events to train for.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
}
|
||||
}
|
||||
|
||||
struct SavedRacesSheet: View {
|
||||
@ObservedObject var viewModel: RaceDiscoveryViewModel
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if viewModel.savedRaces.isEmpty {
|
||||
Text("No saved races yet")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.savedRaces) { race in
|
||||
RaceRowView(race: race)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RaceRowView: View {
|
||||
let race: Race
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: race.raceType.icon)
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(.orange)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Color.orange.opacity(0.15))
|
||||
.cornerRadius(10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(race.name)
|
||||
.font(.headline)
|
||||
Text("\(race.location) \u2022 \(race.distanceKm) km")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 8) {
|
||||
Text(formatDate(race.raceDate))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
if race.isRegistered {
|
||||
Text("Registered")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.green.opacity(0.2))
|
||||
.cornerRadius(4)
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if race.isSaved {
|
||||
Image(systemName: "bookmark.fill")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
RaceDiscoveryView()
|
||||
}
|
||||
213
Lendair/Views/TrainingPlanDetailView.swift
Normal file
213
Lendair/Views/TrainingPlanDetailView.swift
Normal file
@@ -0,0 +1,213 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TrainingPlanDetailView: View {
|
||||
let plan: TrainingPlan
|
||||
@State private var expandedWeek: Int? = 1
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
planHeader
|
||||
progressSection
|
||||
planDescription
|
||||
weeklyWorkoutsSection
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.navigationTitle(plan.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var planHeader: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: plan.planType.icon)
|
||||
.font(.system(size: 36))
|
||||
.foregroundColor(plan.difficulty.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(plan.planType.displayName)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
Text(plan.difficulty.displayName)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(plan.difficulty.color)
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: Binding(
|
||||
get: { plan.isFollowing },
|
||||
set: { _ in }
|
||||
))
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 24) {
|
||||
statLabel(value: "\(plan.durationWeeks)", label: "Weeks")
|
||||
statLabel(value: "\(plan.progress.totalSessions)", label: "Sessions")
|
||||
statLabel(value: "\(Int(plan.progress.percentage))%", label: "Progress")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
private func statLabel(value: String, label: String) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(value)
|
||||
.font(.title3)
|
||||
.fontWeight(.bold)
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var progressSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Overall Progress")
|
||||
.font(.headline)
|
||||
ProgressView(value: plan.progress.percentage)
|
||||
.tint(.blue)
|
||||
HStack {
|
||||
Text("\(plan.progress.completedWeeks)/\(plan.progress.totalWeeks) weeks")
|
||||
Spacer()
|
||||
Text("\(plan.progress.completedSessions)/\(plan.progress.totalSessions) sessions")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var planDescription: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("About This Plan")
|
||||
.font(.headline)
|
||||
Text(plan.description)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var weeklyWorkoutsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Weekly Schedule")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(plan.weeklyWorkouts, id: \.id) { week in
|
||||
WeekCard(week: week, isExpanded: expandedWeek == week.weekNumber) {
|
||||
expandedWeek = expandedWeek == week.weekNumber ? nil : week.weekNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WeekCard: View {
|
||||
let week: WeeklyWorkout
|
||||
let isExpanded: Bool
|
||||
let toggleAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Week \(week.weekNumber)")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("\(week.completedSessions)/\(week.totalSessions)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.onTapGesture { toggleAction() }
|
||||
|
||||
if isExpanded {
|
||||
ForEach(week.dailySessions) { session in
|
||||
DailySessionRow(session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DailySessionRow: View {
|
||||
let session: DailySession
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: session.workoutType.icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(session.workoutType.color)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(session.dayOfWeek.displayName): \(session.workoutType.displayName)")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
if session.status == .completed {
|
||||
Text("Completed")
|
||||
.font(.caption)
|
||||
.foregroundColor(.green)
|
||||
} else if let distance = session.targetDistanceKm {
|
||||
Text("\(distance) km")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
switch session.status {
|
||||
case .completed:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
case .inProgress:
|
||||
Image(systemName: "pause.circle.fill")
|
||||
.foregroundColor(.orange)
|
||||
case .skipped:
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
case .pending:
|
||||
Image(systemName: "circle")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
TrainingPlanDetailView(plan: samplePlan)
|
||||
}
|
||||
}
|
||||
|
||||
private var samplePlan: TrainingPlan {
|
||||
TrainingPlan(
|
||||
id: "1",
|
||||
title: "5K Beginner Plan",
|
||||
description: "A 8-week plan designed to help beginners complete their first 5K race.",
|
||||
planType: .fiveK,
|
||||
durationWeeks: 8,
|
||||
difficulty: .beginner,
|
||||
startDate: Date(),
|
||||
endDate: Date().addingTimeInterval(8 * 7 * 24 * 3600),
|
||||
weeklyWorkouts: [
|
||||
WeeklyWorkout(id: "w1", weekNumber: 1, dailySessions: [
|
||||
DailySession(id: "s1", dayOfWeek: .monday, workoutType: .easyRun, title: "Easy Run", description: "Start with a comfortable pace", targetDistanceKm: 2.0, targetDurationMinutes: 20, targetPaceMinPerKm: 10, intensity: .easy, status: .completed),
|
||||
DailySession(id: "s2", dayOfWeek: .wednesday, workoutType: .rest, title: "Rest Day", description: "Recovery", targetDistanceKm: nil, targetDurationMinutes: nil, targetPaceMinPerKm: nil, intensity: .veryEasy, status: .completed),
|
||||
DailySession(id: "s3", dayOfWeek: .friday, workoutType: .easyRun, title: "Easy Run", description: "Build endurance", targetDistanceKm: 2.5, targetDurationMinutes: 25, targetPaceMinPerKm: 10, intensity: .easy, status: .pending),
|
||||
DailySession(id: "s4", dayOfWeek: .saturday, workoutType: .longRun, title: "Long Run", description: "Gradually increase distance", targetDistanceKm: 3.0, targetDurationMinutes: 30, targetPaceMinPerKm: 10, intensity: .moderate, status: .pending)
|
||||
])
|
||||
],
|
||||
progress: PlanProgress(completedWeeks: 0, totalWeeks: 8, completedSessions: 2, totalSessions: 32, currentWeekNumber: 1),
|
||||
isFollowing: true,
|
||||
createdAt: Date()
|
||||
)
|
||||
}
|
||||
219
Lendair/Views/TrainingPlanView.swift
Normal file
219
Lendair/Views/TrainingPlanView.swift
Normal file
@@ -0,0 +1,219 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TrainingPlanView: View {
|
||||
@StateObject private var viewModel = TrainingPlanViewModel()
|
||||
@State private var selectedType: PlanType? = nil
|
||||
@State private var selectedDifficulty: Difficulty? = nil
|
||||
@State private var showingGenerateSheet = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Group {
|
||||
if viewModel.isLoading && viewModel.plans.isEmpty {
|
||||
loadingView
|
||||
} else if viewModel.plans.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
planListView
|
||||
}
|
||||
}
|
||||
.navigationTitle("Training Plans")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showingGenerateSheet = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingGenerateSheet) {
|
||||
GeneratePlanSheet()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.fetchPlans()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var planListView: some View {
|
||||
List {
|
||||
if selectedType != nil || selectedDifficulty != nil {
|
||||
Section("Filters") {
|
||||
HStack {
|
||||
Text("Type: \(selectedType?.displayName ?? "All")")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text("Difficulty: \(selectedDifficulty?.displayName ?? "All")")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Button("Clear Filters") {
|
||||
selectedType = nil
|
||||
selectedDifficulty = nil
|
||||
Task { await viewModel.fetchPlans() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Plans") {
|
||||
ForEach(viewModel.plans) { plan in
|
||||
NavigationLink(destination: TrainingPlanDetailView(plan: plan)) {
|
||||
PlanRowView(plan: plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Filter by Type") {
|
||||
ForEach(viewModel.planTypes, id: \.self) { type in
|
||||
Button(type.displayName) {
|
||||
selectedType = type
|
||||
Task { await viewModel.fetchPlans(type: type) }
|
||||
}
|
||||
.foregroundColor(type == selectedType ? .blue : .primary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Filter by Difficulty") {
|
||||
ForEach(viewModel.difficulties, id: \.self) { difficulty in
|
||||
Button(difficulty.displayName) {
|
||||
selectedDifficulty = difficulty
|
||||
Task { await viewModel.fetchPlans(difficulty: difficulty) }
|
||||
}
|
||||
.foregroundColor(difficulty == selectedDifficulty ? .blue : .primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable {
|
||||
await viewModel.fetchPlans()
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Loading Plans...")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "figure.run")
|
||||
.font(.system(size: 64))
|
||||
.foregroundColor(.secondary)
|
||||
Text("No Training Plans")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Text("Start by generating a personalized plan or browse available plans.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
}
|
||||
}
|
||||
|
||||
struct GeneratePlanSheet: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var planType: PlanType = .fiveK
|
||||
@State private var difficulty: Difficulty = .beginner
|
||||
@State private var weeklyMileage: String = ""
|
||||
@State private var goalTime: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("Plan Type") {
|
||||
Picker("Type", selection: $planType) {
|
||||
ForEach(PlanType.allCases, id: \.self) { type in
|
||||
Text(type.displayName).tag(type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Difficulty") {
|
||||
Picker("Difficulty", selection: $difficulty) {
|
||||
ForEach(Difficulty.allCases, id: \.self) { diff in
|
||||
Text(diff.displayName).tag(diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Optional Details") {
|
||||
TextField("Current Weekly Mileage (km)", text: $weeklyMileage)
|
||||
.keyboardType(.decimalPad)
|
||||
TextField("Goal Time (minutes)", text: $goalTime)
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Generate Plan")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Generate") {
|
||||
let request = GeneratePlanRequest(
|
||||
planType: planType,
|
||||
difficulty: difficulty,
|
||||
startDate: Date(),
|
||||
currentWeeklyMileageKm: Double(weeklyMileage),
|
||||
goalTimeMinutes: Int(goalTime),
|
||||
availableDays: [.monday, .wednesday, .friday, .saturday]
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PlanRowView: View {
|
||||
let plan: TrainingPlan
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: plan.planType.icon)
|
||||
.font(.system(size: 28))
|
||||
.foregroundColor(plan.difficulty.color)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(plan.difficulty.color.opacity(0.15))
|
||||
.cornerRadius(10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(plan.title)
|
||||
.font(.headline)
|
||||
Text("\(plan.planType.displayName) \u2022 \(plan.durationWeeks) weeks \u2022 \(plan.difficulty.displayName)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
ProgressView(value: plan.progress.percentage)
|
||||
.tint(.blue)
|
||||
.scaleEffect(y: 0.5)
|
||||
.padding(.vertical, -4)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if plan.isFollowing {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TrainingPlanView()
|
||||
}
|
||||
211
Lendair/Views/WorkoutSessionView.swift
Normal file
211
Lendair/Views/WorkoutSessionView.swift
Normal file
@@ -0,0 +1,211 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user