Compare commits

...

3 Commits

Author SHA1 Message Date
Roberto Viola
2c64106e5b Merge branch 'master' into applewatch_swiftui 2025-07-16 14:12:54 +02:00
Roberto Viola
30a761e63f Merge branch 'master' into applewatch_swiftui 2025-07-15 14:53:43 +02:00
Roberto Viola
c993eacb71 Add SwiftUI watchOS app with workout and sport selection
Introduces a new SwiftUI-based Apple Watch app for QZ Fitness, including ContentView, SportSelectionView, and WorkoutView. Implements workout management, sport selection, real-time metrics display, and Apple-style activity rings. Updates the app entry point to use the new SwiftUI ContentView.
2025-06-30 10:26:50 +02:00
4 changed files with 900 additions and 5 deletions

View File

@@ -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()
}

View File

@@ -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())
}

View File

@@ -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())
}

View File

@@ -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")
}
}
*/