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:
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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user