mirror of
https://github.com/cagnulein/qdomyos-zwift.git
synced 2026-02-18 00:17:41 +01:00
Compare commits
3 Commits
Computrain
...
applewatch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c64106e5b | ||
|
|
30a761e63f | ||
|
|
c993eacb71 |
@@ -0,0 +1,286 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// watchkit Extension
|
||||
//
|
||||
// Created by Claude on 2025-06-30.
|
||||
// SwiftUI version of the QZ Fitness Watch App
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import HealthKit
|
||||
import CoreMotion
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var workoutManager = WorkoutManager()
|
||||
@StateObject private var watchConnection = WatchConnectionManager()
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
if workoutManager.isWorkoutActive {
|
||||
WorkoutView()
|
||||
.environmentObject(workoutManager)
|
||||
.environmentObject(watchConnection)
|
||||
} else {
|
||||
SportSelectionView()
|
||||
.environmentObject(workoutManager)
|
||||
.environmentObject(watchConnection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout Manager
|
||||
class WorkoutManager: NSObject, ObservableObject {
|
||||
@Published var isWorkoutActive = false
|
||||
@Published var workoutState: WorkoutState = .stopped
|
||||
@Published var elapsedTime: TimeInterval = 0
|
||||
@Published var heartRate: Double = 0
|
||||
@Published var distance: Double = 0
|
||||
@Published var calories: Double = 0
|
||||
@Published var speed: Double = 0
|
||||
@Published var power: Double = 0
|
||||
@Published var cadence: Double = 0
|
||||
@Published var stepCadence: Int = 0
|
||||
@Published var steps: Int = 0
|
||||
@Published var selectedSport: Sport = .cycling
|
||||
|
||||
private var workoutTracking = WorkoutTracking.shared
|
||||
private var timer: Timer?
|
||||
private var metricsTimer: Timer?
|
||||
private var startDate: Date?
|
||||
private let pedometer = CMPedometer()
|
||||
|
||||
enum WorkoutState {
|
||||
case stopped, running, paused
|
||||
}
|
||||
|
||||
enum Sport: Int, CaseIterable {
|
||||
case cycling = 0
|
||||
case running = 1
|
||||
case walking = 2
|
||||
case elliptical = 3
|
||||
case rowing = 4
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .cycling: return "Cycling"
|
||||
case .running: return "Running"
|
||||
case .walking: return "Walking"
|
||||
case .elliptical: return "Elliptical"
|
||||
case .rowing: return "Rowing"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .cycling: return "bicycle"
|
||||
case .running: return "figure.run"
|
||||
case .walking: return "figure.walk"
|
||||
case .elliptical: return "figure.elliptical"
|
||||
case .rowing: return "figure.rower"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
setupWorkoutTracking()
|
||||
setupPedometer()
|
||||
loadSelectedSport()
|
||||
}
|
||||
|
||||
private func setupWorkoutTracking() {
|
||||
workoutTracking.delegate = self
|
||||
WorkoutTracking.authorizeHealthKit()
|
||||
|
||||
// Update workout tracking with connection data
|
||||
updateMetricsFromConnection()
|
||||
}
|
||||
|
||||
private func updateMetricsFromConnection() {
|
||||
// Get metrics from WatchKitConnection
|
||||
self.distance = WatchKitConnection.distance
|
||||
self.calories = WatchKitConnection.kcal
|
||||
self.speed = WatchKitConnection.speed
|
||||
self.power = WatchKitConnection.power
|
||||
self.cadence = WatchKitConnection.cadence
|
||||
self.steps = WatchKitConnection.steps
|
||||
}
|
||||
|
||||
private func setupPedometer() {
|
||||
if CMPedometer.isStepCountingAvailable() {
|
||||
pedometer.startUpdates(from: Date()) { [weak self] pedometerData, error in
|
||||
guard let self = self, let pedometerData = pedometerData, error == nil else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.stepCadence = Int(((pedometerData.currentCadence?.doubleValue ?? 0) * 60.0 / 2.0))
|
||||
|
||||
// Send step cadence to iPhone/iPad
|
||||
WatchKitConnection.stepCadence = self.stepCadence
|
||||
WatchKitConnection.shared.sendMessage(message: ["stepCadence": "\(self.stepCadence)" as AnyObject])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSelectedSport() {
|
||||
let sportValue = UserDefaults.standard.integer(forKey: "sport")
|
||||
selectedSport = Sport(rawValue: sportValue) ?? .cycling
|
||||
}
|
||||
|
||||
func selectSport(_ sport: Sport) {
|
||||
selectedSport = sport
|
||||
UserDefaults.standard.set(sport.rawValue, forKey: "sport")
|
||||
}
|
||||
|
||||
func startWorkout() {
|
||||
guard !isWorkoutActive else { return }
|
||||
|
||||
isWorkoutActive = true
|
||||
workoutState = .running
|
||||
startDate = Date()
|
||||
elapsedTime = 0
|
||||
|
||||
// Set up workout tracking
|
||||
workoutTracking.setSport(selectedSport.rawValue)
|
||||
workoutTracking.startWorkOut()
|
||||
|
||||
// Set up watch connectivity
|
||||
watchConnection.delegate = self
|
||||
watchConnection.startSession()
|
||||
|
||||
startTimer()
|
||||
startMetricsTimer()
|
||||
}
|
||||
|
||||
func pauseWorkout() {
|
||||
guard workoutState == .running else { return }
|
||||
workoutState = .paused
|
||||
timer?.invalidate()
|
||||
metricsTimer?.invalidate()
|
||||
}
|
||||
|
||||
func resumeWorkout() {
|
||||
guard workoutState == .paused else { return }
|
||||
workoutState = .running
|
||||
startTimer()
|
||||
startMetricsTimer()
|
||||
}
|
||||
|
||||
func stopWorkout() {
|
||||
guard isWorkoutActive else { return }
|
||||
|
||||
isWorkoutActive = false
|
||||
workoutState = .stopped
|
||||
timer?.invalidate()
|
||||
metricsTimer?.invalidate()
|
||||
|
||||
workoutTracking.stopWorkOut()
|
||||
|
||||
// Reset values
|
||||
elapsedTime = 0
|
||||
heartRate = 0
|
||||
distance = 0
|
||||
calories = 0
|
||||
speed = 0
|
||||
power = 0
|
||||
cadence = 0
|
||||
steps = 0
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
||||
guard let self = self, let startDate = self.startDate else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.elapsedTime = Date().timeIntervalSince(startDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startMetricsTimer() {
|
||||
metricsTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.updateMetricsFromConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WorkoutTrackingDelegate
|
||||
extension WorkoutManager: WorkoutTrackingDelegate {
|
||||
func didReceiveHealthKitHeartRate(_ heartRate: Double) {
|
||||
DispatchQueue.main.async {
|
||||
self.heartRate = heartRate
|
||||
// Send heart rate to iPhone/iPad via WatchKitConnection
|
||||
WatchKitConnection.shared.sendMessage(message: ["heartRate": "\(heartRate)" as AnyObject])
|
||||
|
||||
// Update WorkoutTracking with connection data
|
||||
self.updateMetricsFromConnection()
|
||||
}
|
||||
}
|
||||
|
||||
func didReceiveHealthKitStepCounts(_ stepCounts: Double) {
|
||||
DispatchQueue.main.async {
|
||||
self.steps = Int(stepCounts)
|
||||
}
|
||||
}
|
||||
|
||||
func didReceiveHealthKitStepCadence(_ stepCadence: Double) {
|
||||
// Step cadence is handled in setupPedometer via CoreMotion
|
||||
// This provides more accurate real-time data
|
||||
}
|
||||
|
||||
func didReceiveHealthKitDistanceCycling(_ distanceCycling: Double) {
|
||||
DispatchQueue.main.async {
|
||||
// Distance comes from the main app via WatchKitConnection
|
||||
// HealthKit distance is used for validation/backup
|
||||
self.updateMetricsFromConnection()
|
||||
}
|
||||
}
|
||||
|
||||
func didReceiveHealthKitActiveEnergyBurned(_ activeEnergyBurned: Double) {
|
||||
DispatchQueue.main.async {
|
||||
// Calories comes from the main app via WatchKitConnection
|
||||
// HealthKit calories is used for validation/backup
|
||||
self.updateMetricsFromConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WorkoutManager WatchKitConnectionDelegate
|
||||
extension WorkoutManager: WatchKitConnectionDelegate {
|
||||
func didReceiveUserName(_ userName: String) {
|
||||
// This will be handled by WatchConnectionManager
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Watch Connection Manager
|
||||
class WatchConnectionManager: ObservableObject {
|
||||
@Published var userName: String = "QZ Fitness"
|
||||
|
||||
private var watchConnection = WatchKitConnection.shared
|
||||
|
||||
init() {
|
||||
setupConnection()
|
||||
}
|
||||
|
||||
private func setupConnection() {
|
||||
watchConnection.delegate = self
|
||||
watchConnection.startSession()
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchConnectionManager: WatchKitConnectionDelegate {
|
||||
func didReceiveUserName(_ userName: String) {
|
||||
DispatchQueue.main.async {
|
||||
self.userName = userName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//
|
||||
// SportSelectionView.swift
|
||||
// watchkit Extension
|
||||
//
|
||||
// Apple-style sport selection interface
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SportSelectionView: View {
|
||||
@EnvironmentObject var workoutManager: WorkoutManager
|
||||
@EnvironmentObject var watchConnection: WatchConnectionManager
|
||||
@State private var showingWorkoutTypeSelection = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// Header
|
||||
VStack(spacing: 4) {
|
||||
Text(watchConnection.userName)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Choose a workout")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
// Sport Selection Grid
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible(), spacing: 8),
|
||||
GridItem(.flexible(), spacing: 8)
|
||||
], spacing: 12) {
|
||||
ForEach(WorkoutManager.Sport.allCases, id: \.self) { sport in
|
||||
SportSelectionButton(
|
||||
sport: sport,
|
||||
isSelected: workoutManager.selectedSport == sport
|
||||
) {
|
||||
workoutManager.selectSport(sport)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
// Current Metrics Display
|
||||
if workoutManager.stepCadence > 0 || workoutManager.heartRate > 0 {
|
||||
VStack(spacing: 8) {
|
||||
Divider()
|
||||
.background(Color.gray.opacity(0.3))
|
||||
|
||||
Text("Current Activity")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
if workoutManager.stepCadence > 0 {
|
||||
MetricView(
|
||||
value: "\(workoutManager.stepCadence)",
|
||||
unit: "STEP CAD",
|
||||
color: .blue
|
||||
)
|
||||
}
|
||||
|
||||
if workoutManager.heartRate > 0 {
|
||||
MetricView(
|
||||
value: "\(Int(workoutManager.heartRate))",
|
||||
unit: "BPM",
|
||||
color: .red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// Start Button
|
||||
Button(action: {
|
||||
workoutManager.startWorkout()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
Text("Start \(workoutManager.selectedSport.displayName)")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.background(Color.green)
|
||||
.cornerRadius(22)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sport Selection Button
|
||||
struct SportSelectionButton: View {
|
||||
let sport: WorkoutManager.Sport
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 8) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isSelected ? Color.green : Color.gray.opacity(0.2))
|
||||
.frame(width: 60, height: 60)
|
||||
|
||||
Image(systemName: sport.icon)
|
||||
.font(.system(size: 24, weight: .medium))
|
||||
.foregroundColor(isSelected ? .white : .primary)
|
||||
}
|
||||
|
||||
Text(sport.displayName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isSelected ? Color.green.opacity(0.1) : Color.clear)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(isSelected ? Color.green : Color.clear, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metric View
|
||||
struct MetricView: View {
|
||||
let value: String
|
||||
let unit: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(value)
|
||||
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(color)
|
||||
|
||||
Text(unit)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout Type Selection (Modal)
|
||||
struct WorkoutTypeSelectionView: View {
|
||||
@Binding var isPresented: Bool
|
||||
@EnvironmentObject var workoutManager: WorkoutManager
|
||||
|
||||
let workoutTypes = [
|
||||
("Indoor Cycling", "bicycle"),
|
||||
("Outdoor Cycling", "location"),
|
||||
("Virtual Cycling", "tv"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
ForEach(workoutTypes, id: \.0) { type in
|
||||
Button(action: {
|
||||
// Handle workout type selection
|
||||
isPresented = false
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: type.1)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.green)
|
||||
.frame(width: 24)
|
||||
|
||||
Text(type.0)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.navigationTitle("Cycling")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SportSelectionView()
|
||||
.environmentObject(WorkoutManager())
|
||||
.environmentObject(WatchConnectionManager())
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
//
|
||||
// WorkoutView.swift
|
||||
// watchkit Extension
|
||||
//
|
||||
// Apple Watch Workout UI with paginated metrics display
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct WorkoutView: View {
|
||||
@EnvironmentObject var workoutManager: WorkoutManager
|
||||
@State private var currentPage = 0
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
VStack(spacing: 0) {
|
||||
// Status Bar
|
||||
HStack {
|
||||
if workoutManager.workoutState == .paused {
|
||||
Text("Paused")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
Spacer()
|
||||
Text(getCurrentTime())
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.top, 2)
|
||||
|
||||
// Main Content with Pagination
|
||||
TabView(selection: $currentPage) {
|
||||
// Page 1: Primary Metrics
|
||||
PrimaryMetricsView()
|
||||
.environmentObject(workoutManager)
|
||||
.tag(0)
|
||||
|
||||
// Page 2: Secondary Metrics
|
||||
SecondaryMetricsView()
|
||||
.environmentObject(workoutManager)
|
||||
.tag(1)
|
||||
|
||||
// Page 3: Activity Rings & Additional Metrics
|
||||
ActivityRingsView()
|
||||
.environmentObject(workoutManager)
|
||||
.tag(2)
|
||||
}
|
||||
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
// Control Buttons
|
||||
HStack(spacing: 20) {
|
||||
Button(action: {
|
||||
if workoutManager.workoutState == .running {
|
||||
workoutManager.pauseWorkout()
|
||||
} else if workoutManager.workoutState == .paused {
|
||||
workoutManager.resumeWorkout()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: workoutManager.workoutState == .running ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Color.orange)
|
||||
.clipShape(Circle())
|
||||
|
||||
Button(action: {
|
||||
workoutManager.stopWorkout()
|
||||
}) {
|
||||
Image(systemName: "stop.fill")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Color.red)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
}
|
||||
|
||||
private func getCurrentTime() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Primary Metrics View
|
||||
struct PrimaryMetricsView: View {
|
||||
@EnvironmentObject var workoutManager: WorkoutManager
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
// Main Timer
|
||||
Text(formatElapsedTime(workoutManager.elapsedTime))
|
||||
.font(.system(size: 36, weight: .light, design: .rounded))
|
||||
.foregroundColor(.yellow)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
|
||||
// Split Time (if applicable)
|
||||
if workoutManager.selectedSport == .cycling || workoutManager.selectedSport == .running {
|
||||
Text(formatSplitTime())
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.pink)
|
||||
}
|
||||
|
||||
// Speed
|
||||
HStack {
|
||||
Text(formatSpeed())
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
Text("MPH")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
// Distance
|
||||
HStack {
|
||||
Image(systemName: "location")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.green)
|
||||
Text(formatDistance())
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
Text("SPLIT")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
// Heart Rate
|
||||
HStack {
|
||||
Text(formatHeartRate())
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
Text("❤️")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
private func formatElapsedTime(_ time: TimeInterval) -> String {
|
||||
let hours = Int(time) / 3600
|
||||
let minutes = Int(time) % 3600 / 60
|
||||
let seconds = Int(time) % 60
|
||||
let milliseconds = Int((time.truncatingRemainder(dividingBy: 1)) * 100)
|
||||
|
||||
if hours > 0 {
|
||||
return String(format: "%d:%02d:%02d.%02d", hours, minutes, seconds, milliseconds)
|
||||
} else {
|
||||
return String(format: "%02d:%02d.%02d", minutes, seconds, milliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatSplitTime() -> String {
|
||||
// Calculate split time based on distance
|
||||
let splitDistance = 1.0 // 1 mile split
|
||||
if workoutManager.distance > 0 {
|
||||
let splitTime = workoutManager.elapsedTime / workoutManager.distance * splitDistance
|
||||
let minutes = Int(splitTime) / 60
|
||||
let seconds = Int(splitTime) % 60
|
||||
return String(format: "%d:%02d SPLIT", minutes, seconds)
|
||||
}
|
||||
return "0:00 SPLIT"
|
||||
}
|
||||
|
||||
private func formatSpeed() -> String {
|
||||
return String(format: "%.1f", workoutManager.speed)
|
||||
}
|
||||
|
||||
private func formatDistance() -> String {
|
||||
if Locale.current.measurementSystem == "Metric" {
|
||||
return String(format: "%.2fKM", workoutManager.distance * 1.60934)
|
||||
} else {
|
||||
return String(format: "%.2fMI", workoutManager.distance)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatHeartRate() -> String {
|
||||
return String(format: "%.0fBPM", workoutManager.heartRate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Secondary Metrics View
|
||||
struct SecondaryMetricsView: View {
|
||||
@EnvironmentObject var workoutManager: WorkoutManager
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
// Sport Icon
|
||||
Image(systemName: workoutManager.selectedSport.icon)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundColor(.green)
|
||||
|
||||
// Main Timer (smaller)
|
||||
Text(formatElapsedTime(workoutManager.elapsedTime))
|
||||
.font(.system(size: 28, weight: .light, design: .rounded))
|
||||
.foregroundColor(.yellow)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// Elevation Chart Placeholder (if cycling/running)
|
||||
if workoutManager.selectedSport == .cycling || workoutManager.selectedSport == .running {
|
||||
ElevationChartView()
|
||||
.frame(height: 40)
|
||||
}
|
||||
|
||||
// Elevation Metrics
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text("69FT")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundColor(.green)
|
||||
Text("ELEV")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text("GAINED")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("816FT")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
Text("ELEV")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
private func formatElapsedTime(_ time: TimeInterval) -> String {
|
||||
let hours = Int(time) / 3600
|
||||
let minutes = Int(time) % 3600 / 60
|
||||
let seconds = Int(time) % 60
|
||||
let milliseconds = Int((time.truncatingRemainder(dividingBy: 1)) * 100)
|
||||
|
||||
if hours > 0 {
|
||||
return String(format: "%d:%02d:%02d.%02d", hours, minutes, seconds, milliseconds)
|
||||
} else {
|
||||
return String(format: "%02d:%02d.%02d", minutes, seconds, milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Activity Rings View
|
||||
struct ActivityRingsView: View {
|
||||
@EnvironmentObject var workoutManager: WorkoutManager
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
// Main Timer (smaller)
|
||||
Text(formatElapsedTime(workoutManager.elapsedTime))
|
||||
.font(.system(size: 24, weight: .light, design: .rounded))
|
||||
.foregroundColor(.yellow)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// Activity Rings
|
||||
ZStack {
|
||||
// Move Ring (Red)
|
||||
Circle()
|
||||
.stroke(Color.red.opacity(0.3), lineWidth: 8)
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: min(workoutManager.calories / 500, 1.0))
|
||||
.stroke(Color.red, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||
.frame(width: 80, height: 80)
|
||||
.rotationEffect(.degrees(-90))
|
||||
|
||||
// Exercise Ring (Green)
|
||||
Circle()
|
||||
.stroke(Color.green.opacity(0.3), lineWidth: 8)
|
||||
.frame(width: 64, height: 64)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: min(workoutManager.elapsedTime / 1800, 1.0)) // 30 min goal
|
||||
.stroke(Color.green, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||
.frame(width: 64, height: 64)
|
||||
.rotationEffect(.degrees(-90))
|
||||
|
||||
// Stand Ring (Blue)
|
||||
Circle()
|
||||
.stroke(Color.blue.opacity(0.3), lineWidth: 8)
|
||||
.frame(width: 48, height: 48)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: 0.25) // Static for demo
|
||||
.stroke(Color.blue, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||
.frame(width: 48, height: 48)
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
|
||||
// Ring Stats
|
||||
VStack(spacing: 4) {
|
||||
HStack {
|
||||
Text("MOVE")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.red)
|
||||
Spacer()
|
||||
Text("\(Int(workoutManager.calories))/500")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("EXERCISE")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.green)
|
||||
Spacer()
|
||||
Text("\(Int(workoutManager.elapsedTime/60))/30")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("STAND")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.blue)
|
||||
Spacer()
|
||||
Text("3/12")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
private func formatElapsedTime(_ time: TimeInterval) -> String {
|
||||
let hours = Int(time) / 3600
|
||||
let minutes = Int(time) % 3600 / 60
|
||||
let seconds = Int(time) % 60
|
||||
let milliseconds = Int((time.truncatingRemainder(dividingBy: 1)) * 100)
|
||||
|
||||
if hours > 0 {
|
||||
return String(format: "%d:%02d:%02d.%02d", hours, minutes, seconds, milliseconds)
|
||||
} else {
|
||||
return String(format: "%02d:%02d.%02d", minutes, seconds, milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Elevation Chart View
|
||||
struct ElevationChartView: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 1) {
|
||||
ForEach(0..<30, id: \.self) { index in
|
||||
Rectangle()
|
||||
.fill(Color.green)
|
||||
.frame(width: 2, height: CGFloat.random(in: 5...25))
|
||||
}
|
||||
}
|
||||
.background(
|
||||
HStack {
|
||||
Text("30 MIN AGO")
|
||||
.font(.system(size: 8, weight: .medium))
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text("NOW")
|
||||
.font(.system(size: 8, weight: .medium))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding(.horizontal, 4),
|
||||
alignment: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
extension Locale {
|
||||
var measurementSystem: String? {
|
||||
return (self as NSLocale).object(forKey: NSLocale.Key.measurementSystem) as? String
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WorkoutView()
|
||||
.environmentObject(WorkoutManager())
|
||||
}
|
||||
@@ -3,20 +3,18 @@
|
||||
// watchkit Extension
|
||||
//
|
||||
// Created by Roberto Viola on 24/12/2020.
|
||||
// Updated for SwiftUI by Claude on 2025-06-30
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
/*
|
||||
|
||||
@main
|
||||
struct qdomyoszwiftApp: App {
|
||||
@SceneBuilder var body: some Scene {
|
||||
WindowGroup {
|
||||
NavigationView {
|
||||
ContentView()
|
||||
}
|
||||
ContentView()
|
||||
}
|
||||
|
||||
WKNotificationScene(controller: NotificationController.self, category: "myCategory")
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user