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>
134 lines
4.4 KiB
Swift
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)
|
|
}
|
|
}
|