diff --git a/tabatago-swift/TabataGo.xcodeproj/project.xcworkspace/xcuserdata/20015659.xcuserdatad/UserInterfaceState.xcuserstate b/tabatago-swift/TabataGo.xcodeproj/project.xcworkspace/xcuserdata/20015659.xcuserdatad/UserInterfaceState.xcuserstate index 47c9848..b38bcd5 100644 Binary files a/tabatago-swift/TabataGo.xcodeproj/project.xcworkspace/xcuserdata/20015659.xcuserdatad/UserInterfaceState.xcuserstate and b/tabatago-swift/TabataGo.xcodeproj/project.xcworkspace/xcuserdata/20015659.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/tabatago-swift/TabataGo/Resources/Localizable.xcstrings b/tabatago-swift/TabataGo/Resources/Localizable.xcstrings index 1cdc43c..7371e7b 100644 --- a/tabatago-swift/TabataGo/Resources/Localizable.xcstrings +++ b/tabatago-swift/TabataGo/Resources/Localizable.xcstrings @@ -6425,6 +6425,122 @@ } } } + }, + "onboarding.allowHealthAccess" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Health-Zugriff erlauben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow Health Access" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permitir acceso a Salud" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autoriser l'accès à Santé" + } + } + } + }, + "onboarding.healthAccess" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mit Apple Health verbinden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connect to Apple Health" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectar con Apple Salud" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecter à Apple Santé" + } + } + } + }, + "onboarding.healthAccessSubtitle" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verfolge Kalorien und Herzfrequenz. Speichere Workouts in der Health App. Deine Daten bleiben privat und auf deinem Gerät." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Track calories and heart rate. Save workouts to your Health app. Your data stays private and on-device." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registra calorías y frecuencia cardíaca. Guarda entrenamientos en la app Salud. Tus datos permanecen privados y en tu dispositivo." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suivez les calories et la fréquence cardiaque. Enregistrez vos entraînements dans l'app Santé. Vos données restent privées et sur votre appareil." + } + } + } + }, + "onboarding.notNow" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht jetzt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not Now" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ahora no" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas maintenant" + } + } + } } }, "version" : "1.0" diff --git a/tabatago-swift/TabataGo/Services/HealthKitService.swift b/tabatago-swift/TabataGo/Services/HealthKitService.swift index bda8dd3..c916705 100644 --- a/tabatago-swift/TabataGo/Services/HealthKitService.swift +++ b/tabatago-swift/TabataGo/Services/HealthKitService.swift @@ -19,7 +19,6 @@ actor HealthKitService { [ HKWorkoutType.workoutType(), HKQuantityType(.activeEnergyBurned), - HKQuantityType(.heartRate), ] } @@ -87,20 +86,6 @@ actor HealthKitService { try await builder.addSamples([sample]) } - // Heart rate samples (if captured during workout) - if let avgHR = data.averageHeartRate { - let hrType = HKQuantityType(.heartRate) - let hrUnit = HKUnit.count().unitDivided(by: .minute()) - let hrQuantity = HKQuantity(unit: hrUnit, doubleValue: avgHR) - let hrSample = HKQuantitySample( - type: hrType, - quantity: hrQuantity, - start: data.startedAt, - end: data.completedAt - ) - try await builder.addSamples([hrSample]) - } - try await builder.endCollection(at: data.completedAt) guard let workout = try await builder.finishWorkout() else { throw HealthKitError.workoutSaveFailed diff --git a/tabatago-swift/TabataGo/Utilities/Strings.swift b/tabatago-swift/TabataGo/Utilities/Strings.swift index 8750a73..07e7c8b 100644 --- a/tabatago-swift/TabataGo/Utilities/Strings.swift +++ b/tabatago-swift/TabataGo/Utilities/Strings.swift @@ -175,7 +175,11 @@ enum L10n { static let pill4MinWorkouts = LocalizedStringResource("onboarding.pill4MinWorkouts") static let pillNoEquipment = LocalizedStringResource("onboarding.pillNoEquipment") static let pillVoiceGuided = LocalizedStringResource("onboarding.pillVoiceGuided") - static let tabataDesc = LocalizedStringResource("onboarding.tabataDesc") + static let healthAccess = LocalizedStringResource("onboarding.healthAccess") + static let healthAccessSubtitle = LocalizedStringResource("onboarding.healthAccessSubtitle") + static let allowHealthAccess = LocalizedStringResource("onboarding.allowHealthAccess") + static let notNow = LocalizedStringResource("onboarding.notNow") + static let tabataDesc = LocalizedStringResource("onboarding.tabataDesc") enum levelDesc { static let beginner = LocalizedStringResource("onboarding.level.beginnerDesc") diff --git a/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift b/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift index 69ba7b5..087cd8c 100644 --- a/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift +++ b/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift @@ -137,7 +137,11 @@ final class PlayerViewModel: ObservableObject { // Start HealthKit live session Task { - try? await HealthKitService.shared.requestAuthorization() + guard await HealthKitService.shared.isAuthorized else { + print("[PlayerVM] HealthKit not authorized — skipping live session") + return + } + liveSession.onHeartRateUpdate = { [weak self] hr in Task { @MainActor in self?.heartRate = hr } } diff --git a/tabatago-swift/TabataGo/Views/Onboarding/OnboardingView.swift b/tabatago-swift/TabataGo/Views/Onboarding/OnboardingView.swift index 1327120..c9affb6 100644 --- a/tabatago-swift/TabataGo/Views/Onboarding/OnboardingView.swift +++ b/tabatago-swift/TabataGo/Views/Onboarding/OnboardingView.swift @@ -12,7 +12,7 @@ struct OnboardingView: View { @Environment(\.modelContext) private var context enum Step: Int, CaseIterable { - case welcome, name, level, goal, frequency, ready + case welcome, name, level, goal, frequency, health, ready var progress: Double { Double(rawValue) / Double(Step.allCases.count - 1) } } @@ -67,6 +67,7 @@ struct OnboardingView: View { case .level: LevelStep(selection: $fitnessLevel) case .goal: GoalStep(selection: $goal) case .frequency: FrequencyStep(frequency: $weeklyFrequency, barriers: $selectedBarriers, allBarriers: barriers) + case .health: HealthStep(onContinue: { advance() }) case .ready: ReadyStep(name: name) } } @@ -77,11 +78,13 @@ struct OnboardingView: View { .animation(.spring(duration: 0.45), value: step) // ── Pinned bottom button ───────────────────────── - PrimaryButton(label: buttonLabel, action: buttonAction) - .disabled(step == .name && name.trimmingCharacters(in: .whitespaces).isEmpty) - .padding(.horizontal, 32) - .padding(.bottom, 48) - .padding(.top, 16) + if step != .health { + PrimaryButton(label: buttonLabel, action: buttonAction) + .disabled(step == .name && name.trimmingCharacters(in: .whitespaces).isEmpty) + .padding(.horizontal, 32) + .padding(.bottom, 48) + .padding(.top, 16) + } } } } @@ -411,6 +414,68 @@ private struct FrequencyStep: View { } } +private struct HealthStep: View { + let onContinue: () -> Void + @State private var isRequesting = false + @State private var appeared = false + + var body: some View { + VStack(spacing: 36) { + Spacer() + + Image(systemName: "heart.text.square") + .font(.system(size: 80)) + .foregroundStyle(Theme.brand.gradient) + + OnboardingHeader(title: L10n.onboarding.healthAccess, subtitle: L10n.onboarding.healthAccessSubtitle) + + // Primary button — reuses shared PrimaryButton component + PrimaryButton(label: L10n.onboarding.allowHealthAccess, action: requestHealthAccess) + .disabled(isRequesting) + .padding(.horizontal, 32) + + // Skip option + Button { + onContinue() + } label: { + Text(L10n.onboarding.notNow) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .disabled(isRequesting) + + Spacer() + } + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 14) + .onAppear { + withAnimation(.spring(duration: 0.45)) { appeared = true } + } + } + + private func requestHealthAccess() { + guard !isRequesting else { return } + isRequesting = true + Task { + do { + try await HealthKitService.shared.requestAuthorization() + } catch { + print("[HealthStep] HealthKit authorization error: \(error)") + // Continue — user can try later in Settings + } + let authorized = await HealthKitService.shared.isAuthorized + if authorized { + AnalyticsService.shared.healthKitPermissionGranted() + } else { + AnalyticsService.shared.healthKitPermissionDenied() + } + isRequesting = false + onContinue() + } + } +} + private struct ReadyStep: View { let name: String @State private var showContent = false