diff --git a/KisaniCal/KisaniCal.entitlements b/KisaniCal/KisaniCal.entitlements
index 0c67376..6860d2e 100644
--- a/KisaniCal/KisaniCal.entitlements
+++ b/KisaniCal/KisaniCal.entitlements
@@ -1,5 +1,12 @@
-
+
+ com.apple.developer.healthkit
+
+ com.apple.developer.applesignin
+
+ Default
+
+
diff --git a/KisaniCal/KisaniCalApp.swift b/KisaniCal/KisaniCalApp.swift
index a79f146..258137d 100644
--- a/KisaniCal/KisaniCalApp.swift
+++ b/KisaniCal/KisaniCalApp.swift
@@ -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 {
- ContentView()
+ 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)
}
}
}
diff --git a/KisaniCal/Managers/AuthManager.swift b/KisaniCal/Managers/AuthManager.swift
new file mode 100644
index 0000000..e58a72b
--- /dev/null
+++ b/KisaniCal/Managers/AuthManager.swift
@@ -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) {
+ 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)
+ }
+}
diff --git a/KisaniCal/Views/AuthView.swift b/KisaniCal/Views/AuthView.swift
new file mode 100644
index 0000000..949fd4c
--- /dev/null
+++ b/KisaniCal/Views/AuthView.swift
@@ -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)
+ }
+ }
+ }
+}
diff --git a/KisaniCal/Views/SettingsView.swift b/KisaniCal/Views/SettingsView.swift
index 99c0034..29b33f5 100644
--- a/KisaniCal/Views/SettingsView.swift
+++ b/KisaniCal/Views/SettingsView.swift
@@ -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: View {
@Environment(\.colorScheme) private var cs
@@ -511,12 +585,21 @@ private struct WorkoutSettingsSheet: View {
@Binding var unit: Int
@Binding var sets: Int
@Binding var reps: Int
- @AppStorage("bodyWeightKg") private var bodyWeightKg: Double = 80
- @AppStorage("heightCm") private var heightCm: Double = 175
+ @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