// // AppIntent.swift // Casera // // Created by Trey Tartt on 11/5/25. // import WidgetKit import AppIntents import Foundation // MARK: - Widget Configuration Intent struct ConfigurationAppIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource { "Casera Configuration" } static var description: IntentDescription { "Configure your Casera widget" } } // MARK: - Complete Task Intent /// Marks a task as completed from the widget by calling the API directly struct CompleteTaskIntent: AppIntent { static var title: LocalizedStringResource { "Complete Task" } static var description: IntentDescription { "Mark a task as completed" } @Parameter(title: "Task ID") var taskId: Int @Parameter(title: "Task Title") var taskTitle: String init() { self.taskId = 0 self.taskTitle = "" } init(taskId: Int, taskTitle: String) { self.taskId = taskId self.taskTitle = taskTitle } func perform() async throws -> some IntentResult { print("CompleteTaskIntent: Starting completion for task \(taskId)") // Mark task as pending completion immediately (optimistic UI) WidgetActionManager.shared.markTaskPendingCompletion(taskId: taskId) // Reload widget immediately to update task list and stats WidgetCenter.shared.reloadTimelines(ofKind: "Casera") // Get auth token and API URL from shared container guard let token = WidgetActionManager.shared.getAuthToken() else { print("CompleteTaskIntent: No auth token available") WidgetCenter.shared.reloadTimelines(ofKind: "Casera") return .result() } guard let baseURL = WidgetActionManager.shared.getAPIBaseURL() else { print("CompleteTaskIntent: No API base URL available") WidgetCenter.shared.reloadTimelines(ofKind: "Casera") return .result() } // Make API call to complete the task let success = await WidgetAPIClient.quickCompleteTask( taskId: taskId, token: token, baseURL: baseURL ) if success { print("CompleteTaskIntent: Task \(taskId) completed successfully") // Mark tasks as dirty so main app refreshes on next launch WidgetActionManager.shared.markTasksDirty() } else { print("CompleteTaskIntent: Failed to complete task \(taskId)") // Task will remain hidden until it times out or app refreshes } // Reload widget WidgetCenter.shared.reloadTimelines(ofKind: "Casera") return .result() } } // MARK: - Widget API Client /// Lightweight API client for widget network calls enum WidgetAPIClient { /// Complete a task via the quick-complete endpoint /// Returns true on success, false on failure static func quickCompleteTask(taskId: Int, token: String, baseURL: String) async -> Bool { let urlString = "\(baseURL)/tasks/\(taskId)/quick-complete/" guard let url = URL(string: urlString) else { print("WidgetAPIClient: Invalid URL: \(urlString)") return false } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Token \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.timeoutInterval = 10 // Short timeout for widget do { let (_, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse { let isSuccess = httpResponse.statusCode == 200 print("WidgetAPIClient: quick-complete response: \(httpResponse.statusCode)") return isSuccess } return false } catch { print("WidgetAPIClient: Error completing task: \(error.localizedDescription)") return false } } } // MARK: - Open Task Intent /// Opens a specific task in the main app struct OpenTaskIntent: AppIntent { static var title: LocalizedStringResource { "Open Task" } static var description: IntentDescription { "Open task details in the app" } @Parameter(title: "Task ID") var taskId: Int init() { self.taskId = 0 } init(taskId: Int) { self.taskId = taskId } static var openAppWhenRun: Bool { true } func perform() async throws -> some IntentResult { // The app will handle deep linking via URL scheme return .result() } } // MARK: - Widget Action Manager /// Manages shared data between the main app and widget extension via App Group final class WidgetActionManager { static let shared = WidgetActionManager() private let appGroupIdentifier = "group.com.tt.casera.CaseraDev" private let pendingTasksFileName = "widget_pending_tasks.json" private let tokenKey = "widget_auth_token" private let dirtyFlagKey = "widget_tasks_dirty" private let apiBaseURLKey = "widget_api_base_url" private let limitationsEnabledKey = "widget_limitations_enabled" private let isPremiumKey = "widget_is_premium" private var sharedDefaults: UserDefaults? { UserDefaults(suiteName: appGroupIdentifier) } private init() {} // MARK: - Shared Container Access private var sharedContainerURL: URL? { FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) } private var pendingTasksFileURL: URL? { sharedContainerURL?.appendingPathComponent(pendingTasksFileName) } // MARK: - Auth Token Access /// Get auth token from shared App Group (set by main app) func getAuthToken() -> String? { return sharedDefaults?.string(forKey: tokenKey) } /// Get API base URL from shared App Group (set by main app) func getAPIBaseURL() -> String? { return sharedDefaults?.string(forKey: apiBaseURLKey) } // MARK: - Dirty Flag /// Mark tasks as dirty (needs refresh from server) func markTasksDirty() { sharedDefaults?.set(true, forKey: dirtyFlagKey) sharedDefaults?.synchronize() print("WidgetActionManager: Marked tasks as dirty") } // MARK: - Subscription Status /// Check if limitations are enabled (from backend) func areLimitationsEnabled() -> Bool { return sharedDefaults?.bool(forKey: limitationsEnabledKey) ?? false } /// Check if user has premium subscription func isPremium() -> Bool { return sharedDefaults?.bool(forKey: isPremiumKey) ?? false } /// Check if widget should show interactive features /// Returns true if: limitations disabled OR user is premium func shouldShowInteractiveWidget() -> Bool { let limitationsEnabled = areLimitationsEnabled() let premium = isPremium() // Interactive if limitations are off, or if user is premium return !limitationsEnabled || premium } // MARK: - Pending Task State Management /// Tracks which tasks have pending completion (not yet synced with server) struct PendingTaskState: Codable { let taskId: Int let timestamp: Date } /// Mark a task as pending completion (optimistic UI update - hides from widget) func markTaskPendingCompletion(taskId: Int) { var pendingTasks = loadPendingTaskStates() // Remove any existing state for this task pendingTasks.removeAll { $0.taskId == taskId } // Add new pending state pendingTasks.append(PendingTaskState( taskId: taskId, timestamp: Date() )) savePendingTaskStates(pendingTasks) } /// Load pending task states func loadPendingTaskStates() -> [PendingTaskState] { guard let fileURL = pendingTasksFileURL, FileManager.default.fileExists(atPath: fileURL.path) else { return [] } do { let data = try Data(contentsOf: fileURL) let states = try JSONDecoder().decode([PendingTaskState].self, from: data) // Filter out stale states (older than 5 minutes) let freshStates = states.filter { state in Date().timeIntervalSince(state.timestamp) < 300 // 5 minutes } if freshStates.count != states.count { savePendingTaskStates(freshStates) } return freshStates } catch { return [] } } /// Save pending task states private func savePendingTaskStates(_ states: [PendingTaskState]) { guard let fileURL = pendingTasksFileURL else { return } do { let data = try JSONEncoder().encode(states) try data.write(to: fileURL, options: .atomic) } catch { print("WidgetActionManager: Error saving pending task states - \(error)") } } /// Clear pending state for a task (called after synced with server) func clearPendingState(forTaskId taskId: Int) { var pendingTasks = loadPendingTaskStates() pendingTasks.removeAll { $0.taskId == taskId } savePendingTaskStates(pendingTasks) // Also reload widget WidgetCenter.shared.reloadTimelines(ofKind: "Casera") } /// Clear all pending states func clearAllPendingStates() { guard let fileURL = pendingTasksFileURL else { return } do { try FileManager.default.removeItem(at: fileURL) } catch { // File might not exist } } /// Check if a task is pending completion func isTaskPendingCompletion(taskId: Int) -> Bool { let states = loadPendingTaskStates() return states.contains { $0.taskId == taskId } } }