feat: move HealthKit permission to onboarding, remove HR write
All checks were successful
CI / Detect Changes (pull_request) Successful in 3s
CI / Admin Web CI (pull_request) Has been skipped
CI / YouTube Worker (pull_request) Has been skipped
CI / Deploy (pull_request) Has been skipped

- Add .health step to onboarding between frequency and ready
- HealthStep with non-blocking permission flow (Not Now skips)
- Remove requestAuthorization() from PlayerViewModel.startWorkout()
- Guard live session start with isAuthorized check
- Remove heart rate write from HealthKit authorization popup
- Remove HR sample writing from saveWorkout (now without permission)
- Add L10n keys: healthAccess, healthAccessSubtitle, allowHealthAccess, notNow
- Add EN/DE/ES/FR translations
- Track permission decisions through analytics
- Entry animation on HealthStep (fade-in + slide-up)

HealthKit permission is now asked once during onboarding,
never interrupting workouts again.
This commit is contained in:
Millian Lamiaux
2026-05-24 15:18:11 +02:00
parent 72ad247136
commit 310124ad63
6 changed files with 197 additions and 23 deletions

View File

@@ -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