Compare commits
1 Commits
72ad247136
...
fix/health
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
310124ad63 |
Binary file not shown.
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user