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