4 Commits

Author SHA1 Message Date
77c17046d5 Merge pull request 'feat: move HealthKit permission to onboarding, remove HR write' (#3) from fix/healthkit-setup-popup into main
All checks were successful
CI / Detect Changes (push) Successful in 3s
CI / Admin Web CI (push) Has been skipped
CI / YouTube Worker (push) Has been skipped
CI / Deploy (push) Has been skipped
Reviewed-on: #3
2026-05-24 15:29:12 +02:00
Millian Lamiaux
310124ad63 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.
2026-05-24 15:18:11 +02:00
Millian Lamiaux
72ad247136 chore: update .gitignore.
All checks were successful
CI / Detect Changes (push) Successful in 3s
CI / Admin Web CI (push) Has been skipped
CI / YouTube Worker (push) Has been skipped
CI / Deploy (push) Has been skipped
2026-05-23 12:29:54 +02:00
f71ba55e8b Merge pull request 'feat: redesign player with Dynamic Island, compact timer, and fix Live Activity timer drift' (#2) from revamp-timer-video-layout into main
Some checks failed
CI / Detect Changes (push) Successful in 3s
CI / Admin Web CI (push) Failing after 34s
CI / YouTube Worker (push) Failing after 5s
CI / Deploy (push) Failing after 2s
Reviewed-on: #2
2026-05-23 12:24:34 +02:00
8 changed files with 247 additions and 23 deletions

3
.gitignore vendored
View File

@@ -54,4 +54,7 @@ coverage/
node-compile-cache/
.gitnexus
Config/Secrets.xcconfig
_Users_*
swift-generated-sources/
tabatago-swift/build/

47
skills-lock.json Normal file
View File

@@ -0,0 +1,47 @@
{
"version": 1,
"skills": {
"core-data-expert": {
"source": "avdlee/core-data-agent-skill",
"sourceType": "github",
"skillPath": "core-data-expert/SKILL.md",
"computedHash": "b8d2829005b1f2fefbaa8af2ea7d7d64e2fbeca2f2172033176ad0780edc3970"
},
"swift-architecture-skill": {
"source": "efremidze/swift-architecture-skill",
"sourceType": "github",
"skillPath": "swift-architecture-skill/SKILL.md",
"computedHash": "67d3359424b19084631998def14666fd5a77284a45ac0353c41a86a7ed216923"
},
"swift-concurrency-pro": {
"source": "twostraws/swift-concurrency-agent-skill",
"sourceType": "github",
"skillPath": "swift-concurrency-pro/SKILL.md",
"computedHash": "dec65531b4bd37d15e6243dbb0d2d1f554b4f4087bcb2e8deb7273f570fa4069"
},
"swift-testing-pro": {
"source": "twostraws/swift-testing-agent-skill",
"sourceType": "github",
"skillPath": "swift-testing-pro/SKILL.md",
"computedHash": "90504b29146ccd7e88d8ba7244c6c4e4d2b410fb21bdd4ce578f10583b158481"
},
"swiftdata-pro": {
"source": "twostraws/swiftdata-agent-skill",
"sourceType": "github",
"skillPath": "swiftdata-pro/SKILL.md",
"computedHash": "2f979bad98ea3a6744084c5f93e27897f02e8d0ffe15dd03042e88aaae4da14c"
},
"swiftui-pro": {
"source": "twostraws/swiftui-agent-skill",
"sourceType": "github",
"skillPath": "swiftui-pro/SKILL.md",
"computedHash": "07033426e384295a4b49cf0b2ffdefd4098cae4af53fef16bc1f2d9281118c41"
},
"writing-for-interfaces": {
"source": "andrewgleave/skills",
"sourceType": "github",
"skillPath": "writing-for-interfaces/SKILL.md",
"computedHash": "fff061810c3e63b97fea546da1b86d88629f422a5d38d4ac13497b689a18419e"
}
}
}

View File

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

View File

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

View File

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

View File

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

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