Files
KisaniCal/KisaniCal/Managers/AuthManager.swift
kutesir d0d982f4f5 Add unified task menu, Live Activity, and calendar event actions
Task context menu (new shared TaskMenuItems) consistent across Today,
Matrix, and Calendar: Pin · Date · Move · Priority · Tags · Add to Live
Activity · Delete. Adds TaskViewModel.setPriority.

Live Activity (ActivityKit):
- TaskActivityAttributes shared between app and widget targets
- LiveActivityManager (start/update/end/toggle, iOS 16.1+)
- TaskLiveActivity widget: lock-screen banner + Dynamic Island
- NSSupportsLiveActivities via project.yml; project regenerated

Calendar events (non-subscription, writable only):
- Edit via system EKEventEditViewController (EventEditView wrapper)
- Delete via new CalendarStore.deleteEvent

Also sweeps in prior in-progress edits already present in the working
tree (AuthManager, NotificationManager, TaskItem, task views) and the
updated ISSUES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:29:25 +03:00

134 lines
4.4 KiB
Swift

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."
nonisolated static let activeUserIdKey = "kisani.activeUserId"
override init() {
super.init()
currentUser = loadUser()
#if DEBUG
if currentUser == nil, ProcessInfo.processInfo.environment["KISANI_PREVIEW_AUTH"] == "1" {
currentUser = AppUser(id: "preview-user", email: "preview@kisani.app", displayName: "Preview")
}
#endif
if let uid = currentUser?.id {
UserDefaults.kisani.set(uid, forKey: Self.activeUserIdKey)
}
}
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
let 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: - Profile
func updateDisplayName(_ name: String) {
guard var user = currentUser, !name.trimmingCharacters(in: .whitespaces).isEmpty else { return }
user.displayName = name.trimmingCharacters(in: .whitespaces)
persist(user)
}
// MARK: - Sign Out
func signOut() {
currentUser = nil
UserDefaults.kisani.removeObject(forKey: Self.activeUserIdKey)
deleteKeychain(key: keychainUser)
}
// MARK: - Keychain
private func persist(_ user: AppUser) {
currentUser = user
UserDefaults.kisani.set(user.id, forKey: Self.activeUserIdKey)
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)
}
}