feat: Sign in with Apple authentication
- AuthManager: native AuthenticationServices, session in Keychain - AuthView: branded screen with Apple Sign In button - App gates ContentView behind auth; animates on sign-in/out - Settings: Account section shows name/email + Sign Out button - Entitlements: added com.apple.developer.applesignin Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
<dict>
|
||||
<key>com.apple.developer.healthkit</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -3,18 +3,26 @@ import SwiftUI
|
||||
@main
|
||||
struct KisaniCalApp: App {
|
||||
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
|
||||
@ObservedObject private var auth = AuthManager.shared
|
||||
@State private var splashDone = false
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ZStack {
|
||||
if auth.isAuthenticated {
|
||||
ContentView()
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
AuthView()
|
||||
.transition(.opacity)
|
||||
}
|
||||
if !splashDone {
|
||||
SplashView { splashDone = true }
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeIn(duration: 0.2), value: splashDone)
|
||||
.animation(.easeIn(duration: 0.25), value: splashDone)
|
||||
.animation(KisaniSpring.entrance, value: auth.isAuthenticated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
114
KisaniCal/Managers/AuthManager.swift
Normal file
114
KisaniCal/Managers/AuthManager.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
import SwiftUI
|
||||
import AuthenticationServices
|
||||
import Security
|
||||
|
||||
// MARK: - User model
|
||||
|
||||
struct AppUser: Codable, Equatable {
|
||||
let id: String
|
||||
var email: String?
|
||||
var displayName: String?
|
||||
}
|
||||
|
||||
// MARK: - Auth Manager
|
||||
|
||||
@MainActor
|
||||
final class AuthManager: NSObject, ObservableObject {
|
||||
static let shared = AuthManager()
|
||||
|
||||
@Published var currentUser: AppUser?
|
||||
@Published var isLoading = false
|
||||
@Published var authError: String?
|
||||
|
||||
private let keychainUser = "kisani.auth.user.v1"
|
||||
private let keychainEmail = "kisani.auth.apple.email."
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
currentUser = loadUser()
|
||||
}
|
||||
|
||||
var isAuthenticated: Bool { currentUser != nil }
|
||||
|
||||
// MARK: - Sign in with Apple
|
||||
|
||||
func handleApple(_ result: Result<ASAuthorization, Error>) {
|
||||
authError = nil
|
||||
switch result {
|
||||
case .success(let auth):
|
||||
guard let cred = auth.credential as? ASAuthorizationAppleIDCredential else { return }
|
||||
|
||||
// Apple only provides email + name on the very first sign-in — cache them
|
||||
var email = cred.email ?? loadAppleEmail(for: cred.user)
|
||||
if let e = cred.email { saveAppleEmail(e, for: cred.user) }
|
||||
|
||||
let fullName = [cred.fullName?.givenName, cred.fullName?.familyName]
|
||||
.compactMap { $0 }.joined(separator: " ")
|
||||
|
||||
let user = AppUser(
|
||||
id: cred.user,
|
||||
email: email,
|
||||
displayName: fullName.isEmpty ? nil : fullName
|
||||
)
|
||||
persist(user)
|
||||
|
||||
case .failure(let err):
|
||||
if (err as NSError).code != ASAuthorizationError.canceled.rawValue {
|
||||
authError = err.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sign Out
|
||||
|
||||
func signOut() {
|
||||
currentUser = nil
|
||||
deleteKeychain(key: keychainUser)
|
||||
}
|
||||
|
||||
// MARK: - Keychain
|
||||
|
||||
private func persist(_ user: AppUser) {
|
||||
currentUser = user
|
||||
if let data = try? JSONEncoder().encode(user) {
|
||||
saveKeychain(key: keychainUser, data: data)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadUser() -> AppUser? {
|
||||
guard let data = readKeychain(key: keychainUser) else { return nil }
|
||||
return try? JSONDecoder().decode(AppUser.self, from: data)
|
||||
}
|
||||
|
||||
private func saveKeychain(key: String, data: Data) {
|
||||
let q: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrAccount: key, kSecValueData: data,
|
||||
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock]
|
||||
SecItemDelete(q as CFDictionary)
|
||||
SecItemAdd(q as CFDictionary, nil)
|
||||
}
|
||||
|
||||
private func readKeychain(key: String) -> Data? {
|
||||
let q: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrAccount: key,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne]
|
||||
var ref: AnyObject?
|
||||
SecItemCopyMatching(q as CFDictionary, &ref)
|
||||
return ref as? Data
|
||||
}
|
||||
|
||||
private func deleteKeychain(key: String) {
|
||||
let q: [CFString: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key]
|
||||
SecItemDelete(q as CFDictionary)
|
||||
}
|
||||
|
||||
// Apple only provides email on first sign-in — store it ourselves
|
||||
private func saveAppleEmail(_ email: String, for uid: String) {
|
||||
saveKeychain(key: keychainEmail + uid, data: Data(email.utf8))
|
||||
}
|
||||
private func loadAppleEmail(for uid: String) -> String? {
|
||||
guard let d = readKeychain(key: keychainEmail + uid) else { return nil }
|
||||
return String(data: d, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
69
KisaniCal/Views/AuthView.swift
Normal file
69
KisaniCal/Views/AuthView.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
import SwiftUI
|
||||
import AuthenticationServices
|
||||
|
||||
struct AuthView: View {
|
||||
@ObservedObject private var auth = AuthManager.shared
|
||||
@Environment(\.colorScheme) private var cs
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppColors.background(cs).ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
|
||||
Spacer()
|
||||
|
||||
// ── Brand ────────────────────────────────────────────────
|
||||
VStack(spacing: 16) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.fill(AppColors.accentSoft)
|
||||
.frame(width: 88, height: 88)
|
||||
Image(systemName: "calendar")
|
||||
.font(.system(size: 40, weight: .light))
|
||||
.foregroundColor(AppColors.accent)
|
||||
}
|
||||
VStack(spacing: 6) {
|
||||
Text("Kisani Cal")
|
||||
.font(AppFonts.sans(32, weight: .bold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text("Your calendar, your way.")
|
||||
.font(AppFonts.sans(15))
|
||||
.foregroundColor(AppColors.text2(cs))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// ── Sign In ──────────────────────────────────────────────
|
||||
VStack(spacing: 16) {
|
||||
if let err = auth.authError {
|
||||
Text(err)
|
||||
.font(AppFonts.sans(13))
|
||||
.foregroundColor(AppColors.red)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 28)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
SignInWithAppleButton(
|
||||
.signIn,
|
||||
onRequest: { $0.requestedScopes = [.fullName, .email] },
|
||||
onCompletion: { auth.handleApple($0) }
|
||||
)
|
||||
.signInWithAppleButtonStyle(cs == .dark ? .white : .black)
|
||||
.frame(height: 54)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal, 28)
|
||||
|
||||
Text("Your data stays on your device and iCloud.")
|
||||
.font(AppFonts.sans(12))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.padding(.bottom, 56)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ struct SettingsView: View {
|
||||
@AppStorage("iCloudSync") private var iCloudSync = true
|
||||
@AppStorage("googleCalSync") private var googleCalSync = false
|
||||
@AppStorage("iCloudCalSync") private var iCloudCalSync = true
|
||||
@AppStorage("streakGoal") private var streakGoal = 5
|
||||
|
||||
// Sheet presentation
|
||||
@State private var showProfile = false
|
||||
@@ -71,7 +72,7 @@ struct SettingsView: View {
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
HStack(spacing: 5) {
|
||||
AppBadge(
|
||||
text: workoutVM.streakDays > 0 ? "\(workoutVM.streakDays)d streak 🔥" : "No streak yet",
|
||||
text: workoutVM.streakDays > 0 ? "\(workoutVM.streakDays)d \(streakBars(workoutVM.streakDays, goal: streakGoal))" : "no streak",
|
||||
bg: AppColors.accentSoft, fg: AppColors.accent
|
||||
)
|
||||
AppBadge(
|
||||
@@ -114,7 +115,34 @@ struct SettingsView: View {
|
||||
SttSection(label: "Fitness") {
|
||||
SttNavRow(icon: "dumbbell", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Workout Settings", value: weightLabel) { showWorkoutSettings = true }
|
||||
SttNavRow(icon: "figure.run", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Rest Timer", value: restLabel) { showRestTimer = true }
|
||||
SttToggleRow(icon: "heart", bg: AppColors.greenSoft, fg: AppColors.green, label: "Health Integration", isOn: $healthOn, isLast: true)
|
||||
HealthToggleRow(isOn: $healthOn, isLast: true)
|
||||
}
|
||||
|
||||
// ── ACCOUNT ──
|
||||
SttSection(label: "Account", secondary: true) {
|
||||
if let user = AuthManager.shared.currentUser {
|
||||
SttNavRow(icon: "person.circle", bg: AppColors.surface3(cs),
|
||||
label: user.displayName ?? "Apple Account",
|
||||
value: user.email, isLast: false) { }
|
||||
}
|
||||
Button {
|
||||
AuthManager.shared.signOut()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(AppColors.redSoft).frame(width: 32, height: 32)
|
||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(AppColors.red)
|
||||
}
|
||||
Text("Sign Out")
|
||||
.font(AppFonts.sans(15))
|
||||
.foregroundColor(AppColors.red)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16).frame(height: 52)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// ── SUPPORT ──
|
||||
@@ -144,6 +172,52 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Health Toggle Row (requests auth when turned on)
|
||||
private struct HealthToggleRow: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
@Binding var isOn: Bool
|
||||
var isLast: Bool = false
|
||||
@ObservedObject private var hk = HealthKitManager.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 11) {
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(AppColors.green)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(AppColors.greenSoft)
|
||||
.cornerRadius(8)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Health Integration")
|
||||
.font(AppFonts.sans(13))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
if isOn && hk.authorized {
|
||||
Text("Connected")
|
||||
.font(AppFonts.mono(9))
|
||||
.foregroundColor(AppColors.green)
|
||||
} else if isOn && !hk.isAvailable {
|
||||
Text("Not available on this device")
|
||||
.font(AppFonts.mono(9))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: $isOn)
|
||||
.labelsHidden()
|
||||
.tint(AppColors.green)
|
||||
.onChange(of: isOn) { enabled in
|
||||
if enabled {
|
||||
Task { await HealthKitManager.shared.requestAuthorization() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 11)
|
||||
if !isLast { Divider().background(AppColors.border(cs)).padding(.leading, 53) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Section
|
||||
private struct SttSection<Content: View>: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
@@ -513,10 +587,19 @@ private struct WorkoutSettingsSheet: View {
|
||||
@Binding var reps: Int
|
||||
@AppStorage("bodyWeightKg") private var bodyWeightKg: Double = 80
|
||||
@AppStorage("heightCm") private var heightCm: Double = 175
|
||||
@AppStorage("workoutReminderEnabled") private var workoutReminderEnabled: Bool = true
|
||||
@AppStorage("workoutHour") private var workoutHour: Int = 18
|
||||
@AppStorage("workoutMinute") private var workoutMinute: Int = 0
|
||||
@AppStorage("workoutCheckInEnabled") private var workoutCheckInEnabled: Bool = false
|
||||
@AppStorage("checkInHour") private var checkInHour: Int = 19
|
||||
@AppStorage("checkInMinute") private var checkInMinute: Int = 30
|
||||
@AppStorage("streakGoal") private var streakGoal: Int = 5
|
||||
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
|
||||
|
||||
@State private var bwStr = ""
|
||||
@State private var htStr = ""
|
||||
@State private var workoutTime: Date = Date()
|
||||
@State private var checkInTime: Date = Date()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -524,6 +607,97 @@ private struct WorkoutSettingsSheet: View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
|
||||
// ── Reminders ──
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("REMINDERS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
VStack(spacing: 0) {
|
||||
// Pre-workout
|
||||
HStack(spacing: 11) {
|
||||
Image(systemName: "bell.fill")
|
||||
.font(.system(size: 13)).foregroundColor(AppColors.accent)
|
||||
.frame(width: 28, height: 28).background(AppColors.accentSoft).cornerRadius(8)
|
||||
Text("Pre-workout reminder").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
Toggle("", isOn: $workoutReminderEnabled).labelsHidden().tint(AppColors.accent)
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 11)
|
||||
if workoutReminderEnabled {
|
||||
Divider().background(AppColors.border(cs)).padding(.leading, 53)
|
||||
HStack {
|
||||
Text("Remind me at")
|
||||
.font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
|
||||
Spacer()
|
||||
DatePicker("", selection: $workoutTime, displayedComponents: .hourAndMinute)
|
||||
.labelsHidden().tint(AppColors.accent)
|
||||
.onChange(of: workoutTime) { v in
|
||||
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
|
||||
workoutHour = c.hour ?? 18
|
||||
workoutMinute = c.minute ?? 0
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 8)
|
||||
}
|
||||
Divider().background(AppColors.border(cs)).padding(.leading, 53)
|
||||
// Post-workout check-in
|
||||
HStack(spacing: 11) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 13)).foregroundColor(AppColors.green)
|
||||
.frame(width: 28, height: 28).background(AppColors.greenSoft).cornerRadius(8)
|
||||
Text("Post-workout check-in").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
Toggle("", isOn: $workoutCheckInEnabled).labelsHidden().tint(AppColors.accent)
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 11)
|
||||
if workoutCheckInEnabled {
|
||||
Divider().background(AppColors.border(cs)).padding(.leading, 53)
|
||||
HStack {
|
||||
Text("Check in at")
|
||||
.font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
|
||||
Spacer()
|
||||
DatePicker("", selection: $checkInTime, displayedComponents: .hourAndMinute)
|
||||
.labelsHidden().tint(AppColors.accent)
|
||||
.onChange(of: checkInTime) { v in
|
||||
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
|
||||
checkInHour = c.hour ?? 19
|
||||
checkInMinute = c.minute ?? 30
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
.cardStyle()
|
||||
.animation(KisaniSpring.snappy, value: workoutReminderEnabled)
|
||||
.animation(KisaniSpring.snappy, value: workoutCheckInEnabled)
|
||||
}
|
||||
|
||||
// ── Streak Goal ──
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("STREAK GOAL").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
HStack(spacing: 7) {
|
||||
ForEach([3, 4, 5, 6, 7], id: \.self) { days in
|
||||
Button { withAnimation(KisaniSpring.micro) { streakGoal = days } } label: {
|
||||
VStack(spacing: 4) {
|
||||
Text("\(days)")
|
||||
.font(AppFonts.mono(15, weight: .bold))
|
||||
.foregroundColor(streakGoal == days ? AppColors.accent : AppColors.text2(cs))
|
||||
Text("days")
|
||||
.font(AppFonts.mono(8))
|
||||
.foregroundColor(streakGoal == days ? AppColors.accent : AppColors.text3(cs))
|
||||
}
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 10)
|
||||
.background(streakGoal == days ? AppColors.accentSoft : AppColors.surface2(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||||
.overlay(RoundedRectangle(cornerRadius: 9).stroke(
|
||||
streakGoal == days ? AppColors.accent : AppColors.border(cs),
|
||||
lineWidth: streakGoal == days ? 1.5 : 1
|
||||
))
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
Text("Reach your goal every week to maintain your streak.")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
|
||||
// ── Body Profile ──
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("BODY PROFILE").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
@@ -608,6 +782,11 @@ private struct WorkoutSettingsSheet: View {
|
||||
.onAppear {
|
||||
bwStr = bodyWeightKg > 0 ? (bodyWeightKg.truncatingRemainder(dividingBy: 1) == 0 ? "\(Int(bodyWeightKg))" : String(format: "%.1f", bodyWeightKg)) : ""
|
||||
htStr = heightCm > 0 ? "\(Int(heightCm))" : ""
|
||||
let today = Calendar.current.dateComponents([.year, .month, .day], from: Date())
|
||||
var wc = today; wc.hour = workoutHour; wc.minute = workoutMinute
|
||||
workoutTime = Calendar.current.date(from: wc) ?? Date()
|
||||
var cc = today; cc.hour = checkInHour; cc.minute = checkInMinute
|
||||
checkInTime = Calendar.current.date(from: cc) ?? Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -757,6 +936,9 @@ private struct ProfileStatsSheet: View {
|
||||
@Environment(\.colorScheme) var cs
|
||||
@EnvironmentObject var taskVM: TaskViewModel
|
||||
@EnvironmentObject var workoutVM: WorkoutViewModel
|
||||
@AppStorage("streakGoal") private var streakGoal = 5
|
||||
@AppStorage("healthOn") private var healthOn = false
|
||||
@ObservedObject private var hk = HealthKitManager.shared
|
||||
|
||||
// ── Task metrics ──
|
||||
private var totalTasks: Int { taskVM.tasks.count }
|
||||
@@ -874,7 +1056,7 @@ private struct ProfileStatsSheet: View {
|
||||
Text("WORKOUTS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
|
||||
HStack(spacing: 8) {
|
||||
PStatCell(value: "\(workoutVM.streakDays)", label: "day streak", color: workoutVM.streakDays > 0 ? AppColors.accent : AppColors.text3(cs), suffix: workoutVM.streakDays > 0 ? "🔥" : "")
|
||||
PStatCell(value: "\(workoutVM.streakDays)", label: streakBars(workoutVM.streakDays, goal: streakGoal), color: workoutVM.streakDays >= streakGoal ? AppColors.green : workoutVM.streakDays > 0 ? AppColors.accent : AppColors.text3(cs))
|
||||
PStatCell(value: "\(workoutVM.doneSets)/\(workoutVM.totalSets)", label: "sets today", color: AppColors.blue)
|
||||
PStatCell(value: "\(Int(workoutVM.progress * 100))%", label: "today", color: workoutVM.progress >= 1 ? AppColors.green : AppColors.text2(cs))
|
||||
}
|
||||
@@ -945,6 +1127,55 @@ private struct ProfileStatsSheet: View {
|
||||
.padding(14).background(AppColors.surface(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
||||
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
||||
|
||||
// ── Health card ──
|
||||
if healthOn && hk.authorized {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Text("HEALTH")
|
||||
.font(AppFonts.mono(9, weight: .bold))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 8))
|
||||
.foregroundColor(AppColors.green)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
PStatCell(value: "\(hk.stepsToday.formatted())", label: "steps today", color: AppColors.blue)
|
||||
PStatCell(value: "\(hk.activeCaloriesToday)", label: "kcal active", color: AppColors.accent)
|
||||
PStatCell(value: hk.restingHeartRate > 0 ? "\(hk.restingHeartRate)" : "—",
|
||||
label: "bpm resting", color: AppColors.green)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "figure.strengthtraining.traditional")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(AppColors.green)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(AppColors.greenSoft)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Workouts this week")
|
||||
.font(AppFonts.sans(12, weight: .semibold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text("From Apple Health")
|
||||
.font(AppFonts.mono(9))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
Spacer()
|
||||
Text("\(hk.workoutsThisWeek)")
|
||||
.font(AppFonts.mono(17, weight: .bold))
|
||||
.foregroundColor(hk.workoutsThisWeek > 0 ? AppColors.green : AppColors.text3(cs))
|
||||
}
|
||||
.padding(10)
|
||||
.background(AppColors.surface2(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
||||
}
|
||||
.padding(14).background(AppColors.surface(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
||||
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.green.opacity(0.25), lineWidth: 1))
|
||||
.onAppear { Task { await hk.refresh() } }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.bottom, 30)
|
||||
}
|
||||
@@ -974,6 +1205,11 @@ private struct PStatCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func streakBars(_ streak: Int, goal: Int) -> String {
|
||||
let filled = min(max(streak, 0), goal)
|
||||
return String(repeating: "|", count: filled) + String(repeating: "·", count: goal - filled)
|
||||
}
|
||||
|
||||
private struct QuadStat: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
let label: String; let count: Int; let color: Color
|
||||
|
||||
Reference in New Issue
Block a user