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:
Robin Kutesa
2026-06-01 00:44:02 +03:00
parent 68efa02803
commit ea869f51b0
5 changed files with 442 additions and 8 deletions

View File

@@ -1,5 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <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> </plist>

View File

@@ -3,18 +3,26 @@ import SwiftUI
@main @main
struct KisaniCalApp: App { struct KisaniCalApp: App {
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
@ObservedObject private var auth = AuthManager.shared
@State private var splashDone = false @State private var splashDone = false
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ZStack { ZStack {
if auth.isAuthenticated {
ContentView() ContentView()
.transition(.opacity)
} else {
AuthView()
.transition(.opacity)
}
if !splashDone { if !splashDone {
SplashView { splashDone = true } SplashView { splashDone = true }
.transition(.opacity) .transition(.opacity)
} }
} }
.animation(.easeIn(duration: 0.2), value: splashDone) .animation(.easeIn(duration: 0.25), value: splashDone)
.animation(KisaniSpring.entrance, value: auth.isAuthenticated)
} }
} }
} }

View 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)
}
}

View 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)
}
}
}
}

View File

@@ -20,6 +20,7 @@ struct SettingsView: View {
@AppStorage("iCloudSync") private var iCloudSync = true @AppStorage("iCloudSync") private var iCloudSync = true
@AppStorage("googleCalSync") private var googleCalSync = false @AppStorage("googleCalSync") private var googleCalSync = false
@AppStorage("iCloudCalSync") private var iCloudCalSync = true @AppStorage("iCloudCalSync") private var iCloudCalSync = true
@AppStorage("streakGoal") private var streakGoal = 5
// Sheet presentation // Sheet presentation
@State private var showProfile = false @State private var showProfile = false
@@ -71,7 +72,7 @@ struct SettingsView: View {
.foregroundColor(AppColors.text(cs)) .foregroundColor(AppColors.text(cs))
HStack(spacing: 5) { HStack(spacing: 5) {
AppBadge( 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 bg: AppColors.accentSoft, fg: AppColors.accent
) )
AppBadge( AppBadge(
@@ -114,7 +115,34 @@ struct SettingsView: View {
SttSection(label: "Fitness") { SttSection(label: "Fitness") {
SttNavRow(icon: "dumbbell", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Workout Settings", value: weightLabel) { showWorkoutSettings = true } 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 } 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 // 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 // MARK: - Section
private struct SttSection<Content: View>: View { private struct SttSection<Content: View>: View {
@Environment(\.colorScheme) private var cs @Environment(\.colorScheme) private var cs
@@ -513,10 +587,19 @@ private struct WorkoutSettingsSheet: View {
@Binding var reps: Int @Binding var reps: Int
@AppStorage("bodyWeightKg") private var bodyWeightKg: Double = 80 @AppStorage("bodyWeightKg") private var bodyWeightKg: Double = 80
@AppStorage("heightCm") private var heightCm: Double = 175 @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 @Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
@State private var bwStr = "" @State private var bwStr = ""
@State private var htStr = "" @State private var htStr = ""
@State private var workoutTime: Date = Date()
@State private var checkInTime: Date = Date()
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -524,6 +607,97 @@ private struct WorkoutSettingsSheet: View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 20) { 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 // Body Profile
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("BODY PROFILE").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) Text("BODY PROFILE").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
@@ -608,6 +782,11 @@ private struct WorkoutSettingsSheet: View {
.onAppear { .onAppear {
bwStr = bodyWeightKg > 0 ? (bodyWeightKg.truncatingRemainder(dividingBy: 1) == 0 ? "\(Int(bodyWeightKg))" : String(format: "%.1f", bodyWeightKg)) : "" bwStr = bodyWeightKg > 0 ? (bodyWeightKg.truncatingRemainder(dividingBy: 1) == 0 ? "\(Int(bodyWeightKg))" : String(format: "%.1f", bodyWeightKg)) : ""
htStr = heightCm > 0 ? "\(Int(heightCm))" : "" 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 @Environment(\.colorScheme) var cs
@EnvironmentObject var taskVM: TaskViewModel @EnvironmentObject var taskVM: TaskViewModel
@EnvironmentObject var workoutVM: WorkoutViewModel @EnvironmentObject var workoutVM: WorkoutViewModel
@AppStorage("streakGoal") private var streakGoal = 5
@AppStorage("healthOn") private var healthOn = false
@ObservedObject private var hk = HealthKitManager.shared
// Task metrics // Task metrics
private var totalTasks: Int { taskVM.tasks.count } 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)) Text("WORKOUTS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
HStack(spacing: 8) { 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: "\(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)) 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)) .padding(14).background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) .clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1)) .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) .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 { private struct QuadStat: View {
@Environment(\.colorScheme) private var cs @Environment(\.colorScheme) private var cs
let label: String; let count: Int; let color: Color let label: String; let count: Int; let color: Color