|
|
|
@@ -7,6 +7,11 @@ import ComposeApp
|
|
|
|
|
final class WidgetDataManager {
|
|
|
|
|
static let shared = WidgetDataManager()
|
|
|
|
|
|
|
|
|
|
/// Tracks the last time `reloadAllTimelines()` was called for debouncing
|
|
|
|
|
private static var lastReloadTime: Date = .distantPast
|
|
|
|
|
/// Minimum interval between `reloadAllTimelines()` calls (seconds)
|
|
|
|
|
private static let reloadDebounceInterval: TimeInterval = 2.0
|
|
|
|
|
|
|
|
|
|
// MARK: - API Column Names (Single Source of Truth)
|
|
|
|
|
// These match the column names returned by the API's task columns endpoint
|
|
|
|
|
static let overdueColumn = "overdue_tasks"
|
|
|
|
@@ -25,19 +30,31 @@ final class WidgetDataManager {
|
|
|
|
|
private let limitationsEnabledKey = "widget_limitations_enabled"
|
|
|
|
|
private let isPremiumKey = "widget_is_premium"
|
|
|
|
|
|
|
|
|
|
/// Serial queue for thread-safe file I/O operations
|
|
|
|
|
private let fileQueue = DispatchQueue(label: "com.casera.widget.fileio")
|
|
|
|
|
|
|
|
|
|
private var sharedDefaults: UserDefaults? {
|
|
|
|
|
UserDefaults(suiteName: appGroupIdentifier)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private init() {}
|
|
|
|
|
|
|
|
|
|
/// Reload all widget timelines, debounced to avoid excessive reloads.
|
|
|
|
|
/// Only triggers a reload if at least `reloadDebounceInterval` has elapsed since the last reload.
|
|
|
|
|
private func reloadWidgetTimelinesIfNeeded() {
|
|
|
|
|
let now = Date()
|
|
|
|
|
if now.timeIntervalSince(Self.lastReloadTime) >= Self.reloadDebounceInterval {
|
|
|
|
|
Self.lastReloadTime = now
|
|
|
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Auth Token Sharing
|
|
|
|
|
|
|
|
|
|
/// Save auth token to shared App Group for widget access
|
|
|
|
|
/// Call this after successful login or when token is refreshed
|
|
|
|
|
func saveAuthToken(_ token: String) {
|
|
|
|
|
sharedDefaults?.set(token, forKey: tokenKey)
|
|
|
|
|
sharedDefaults?.synchronize()
|
|
|
|
|
print("WidgetDataManager: Saved auth token to shared container")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -51,14 +68,12 @@ final class WidgetDataManager {
|
|
|
|
|
/// Call this on logout
|
|
|
|
|
func clearAuthToken() {
|
|
|
|
|
sharedDefaults?.removeObject(forKey: tokenKey)
|
|
|
|
|
sharedDefaults?.synchronize()
|
|
|
|
|
print("WidgetDataManager: Cleared auth token from shared container")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Save API base URL to shared container for widget
|
|
|
|
|
func saveAPIBaseURL(_ url: String) {
|
|
|
|
|
sharedDefaults?.set(url, forKey: apiBaseURLKey)
|
|
|
|
|
sharedDefaults?.synchronize()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get API base URL from shared container
|
|
|
|
@@ -73,7 +88,6 @@ final class WidgetDataManager {
|
|
|
|
|
func saveSubscriptionStatus(limitationsEnabled: Bool, isPremium: Bool) {
|
|
|
|
|
sharedDefaults?.set(limitationsEnabled, forKey: limitationsEnabledKey)
|
|
|
|
|
sharedDefaults?.set(isPremium, forKey: isPremiumKey)
|
|
|
|
|
sharedDefaults?.synchronize()
|
|
|
|
|
print("WidgetDataManager: Saved subscription status - limitations=\(limitationsEnabled), premium=\(isPremium)")
|
|
|
|
|
// Reload widget to reflect new subscription status
|
|
|
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
|
|
@@ -104,7 +118,6 @@ final class WidgetDataManager {
|
|
|
|
|
/// Called by widget after completing a task
|
|
|
|
|
func markTasksDirty() {
|
|
|
|
|
sharedDefaults?.set(true, forKey: dirtyFlagKey)
|
|
|
|
|
sharedDefaults?.synchronize()
|
|
|
|
|
print("WidgetDataManager: Marked tasks as dirty")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -116,7 +129,6 @@ final class WidgetDataManager {
|
|
|
|
|
/// Clear dirty flag after refreshing tasks
|
|
|
|
|
func clearDirtyFlag() {
|
|
|
|
|
sharedDefaults?.set(false, forKey: dirtyFlagKey)
|
|
|
|
|
sharedDefaults?.synchronize()
|
|
|
|
|
print("WidgetDataManager: Cleared dirty flag")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -142,56 +154,100 @@ final class WidgetDataManager {
|
|
|
|
|
|
|
|
|
|
// MARK: - Pending Action Processing
|
|
|
|
|
|
|
|
|
|
/// Load pending actions queued by the widget
|
|
|
|
|
func loadPendingActions() -> [WidgetAction] {
|
|
|
|
|
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName),
|
|
|
|
|
FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
|
|
|
/// Load pending actions queued by the widget (async, non-blocking)
|
|
|
|
|
func loadPendingActions() async -> [WidgetAction] {
|
|
|
|
|
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else {
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
let data = try Data(contentsOf: fileURL)
|
|
|
|
|
return try JSONDecoder().decode([WidgetAction].self, from: data)
|
|
|
|
|
} catch {
|
|
|
|
|
print("WidgetDataManager: Error loading pending actions - \(error)")
|
|
|
|
|
return await withCheckedContinuation { continuation in
|
|
|
|
|
fileQueue.async {
|
|
|
|
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
|
|
|
continuation.resume(returning: [])
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
let data = try Data(contentsOf: fileURL)
|
|
|
|
|
let actions = try JSONDecoder().decode([WidgetAction].self, from: data)
|
|
|
|
|
continuation.resume(returning: actions)
|
|
|
|
|
} catch {
|
|
|
|
|
print("WidgetDataManager: Error loading pending actions - \(error)")
|
|
|
|
|
continuation.resume(returning: [])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Load pending actions synchronously (blocks calling thread).
|
|
|
|
|
/// Prefer the async overload from the main app. This is kept for widget extension use.
|
|
|
|
|
func loadPendingActionsSync() -> [WidgetAction] {
|
|
|
|
|
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else {
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return fileQueue.sync {
|
|
|
|
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
let data = try Data(contentsOf: fileURL)
|
|
|
|
|
return try JSONDecoder().decode([WidgetAction].self, from: data)
|
|
|
|
|
} catch {
|
|
|
|
|
print("WidgetDataManager: Error loading pending actions - \(error)")
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Clear all pending actions after processing
|
|
|
|
|
func clearPendingActions() {
|
|
|
|
|
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else { return }
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
try FileManager.default.removeItem(at: fileURL)
|
|
|
|
|
print("WidgetDataManager: Cleared pending actions")
|
|
|
|
|
} catch {
|
|
|
|
|
// File might not exist
|
|
|
|
|
fileQueue.async {
|
|
|
|
|
do {
|
|
|
|
|
try FileManager.default.removeItem(at: fileURL)
|
|
|
|
|
print("WidgetDataManager: Cleared pending actions")
|
|
|
|
|
} catch {
|
|
|
|
|
// File might not exist
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Remove a specific action after processing
|
|
|
|
|
func removeAction(_ action: WidgetAction) {
|
|
|
|
|
var actions = loadPendingActions()
|
|
|
|
|
actions.removeAll { $0 == action }
|
|
|
|
|
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else { return }
|
|
|
|
|
|
|
|
|
|
if actions.isEmpty {
|
|
|
|
|
clearPendingActions()
|
|
|
|
|
} else {
|
|
|
|
|
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else { return }
|
|
|
|
|
do {
|
|
|
|
|
let data = try JSONEncoder().encode(actions)
|
|
|
|
|
try data.write(to: fileURL, options: .atomic)
|
|
|
|
|
} catch {
|
|
|
|
|
print("WidgetDataManager: Error saving actions - \(error)")
|
|
|
|
|
fileQueue.async {
|
|
|
|
|
// Load actions within the serial queue to avoid race conditions
|
|
|
|
|
var actions: [WidgetAction]
|
|
|
|
|
if FileManager.default.fileExists(atPath: fileURL.path),
|
|
|
|
|
let data = try? Data(contentsOf: fileURL),
|
|
|
|
|
let decoded = try? JSONDecoder().decode([WidgetAction].self, from: data) {
|
|
|
|
|
actions = decoded
|
|
|
|
|
} else {
|
|
|
|
|
actions = []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
actions.removeAll { $0 == action }
|
|
|
|
|
|
|
|
|
|
if actions.isEmpty {
|
|
|
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
|
|
|
} else {
|
|
|
|
|
do {
|
|
|
|
|
let data = try JSONEncoder().encode(actions)
|
|
|
|
|
try data.write(to: fileURL, options: .atomic)
|
|
|
|
|
} catch {
|
|
|
|
|
print("WidgetDataManager: Error saving actions - \(error)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Clear pending state for a task after it's been synced
|
|
|
|
|
func clearPendingState(forTaskId taskId: Int) {
|
|
|
|
|
guard let fileURL = sharedContainerURL?.appendingPathComponent(pendingTasksFileName),
|
|
|
|
|
FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
|
|
|
guard let fileURL = sharedContainerURL?.appendingPathComponent(pendingTasksFileName) else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -201,28 +257,36 @@ final class WidgetDataManager {
|
|
|
|
|
let timestamp: Date
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
let data = try Data(contentsOf: fileURL)
|
|
|
|
|
var states = try JSONDecoder().decode([PendingTaskState].self, from: data)
|
|
|
|
|
states.removeAll { $0.taskId == taskId }
|
|
|
|
|
fileQueue.async {
|
|
|
|
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if states.isEmpty {
|
|
|
|
|
try FileManager.default.removeItem(at: fileURL)
|
|
|
|
|
} else {
|
|
|
|
|
let updatedData = try JSONEncoder().encode(states)
|
|
|
|
|
try updatedData.write(to: fileURL, options: .atomic)
|
|
|
|
|
do {
|
|
|
|
|
let data = try Data(contentsOf: fileURL)
|
|
|
|
|
var states = try JSONDecoder().decode([PendingTaskState].self, from: data)
|
|
|
|
|
states.removeAll { $0.taskId == taskId }
|
|
|
|
|
|
|
|
|
|
if states.isEmpty {
|
|
|
|
|
try FileManager.default.removeItem(at: fileURL)
|
|
|
|
|
} else {
|
|
|
|
|
let updatedData = try JSONEncoder().encode(states)
|
|
|
|
|
try updatedData.write(to: fileURL, options: .atomic)
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
print("WidgetDataManager: Error clearing pending state - \(error)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reload widget to reflect the change
|
|
|
|
|
WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
|
|
|
|
|
} catch {
|
|
|
|
|
print("WidgetDataManager: Error clearing pending state - \(error)")
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Check if there are any pending actions from the widget
|
|
|
|
|
var hasPendingActions: Bool {
|
|
|
|
|
!loadPendingActions().isEmpty
|
|
|
|
|
!loadPendingActionsSync().isEmpty
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Task model for widget display - simplified version of TaskDetail
|
|
|
|
@@ -285,6 +349,7 @@ final class WidgetDataManager {
|
|
|
|
|
private static let dateFormatter: DateFormatter = {
|
|
|
|
|
let formatter = DateFormatter()
|
|
|
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
|
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
|
|
|
return formatter
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
@@ -364,47 +429,82 @@ final class WidgetDataManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
let encoder = JSONEncoder()
|
|
|
|
|
encoder.outputFormatting = .prettyPrinted
|
|
|
|
|
let data = try encoder.encode(allTasks)
|
|
|
|
|
try data.write(to: fileURL, options: .atomic)
|
|
|
|
|
print("WidgetDataManager: Saved \(allTasks.count) tasks to widget cache")
|
|
|
|
|
fileQueue.async {
|
|
|
|
|
do {
|
|
|
|
|
let encoder = JSONEncoder()
|
|
|
|
|
encoder.outputFormatting = .prettyPrinted
|
|
|
|
|
let data = try encoder.encode(allTasks)
|
|
|
|
|
try data.write(to: fileURL, options: .atomic)
|
|
|
|
|
print("WidgetDataManager: Saved \(allTasks.count) tasks to widget cache")
|
|
|
|
|
} catch {
|
|
|
|
|
print("WidgetDataManager: Error saving tasks - \(error)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reload widget timeline
|
|
|
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
|
|
|
} catch {
|
|
|
|
|
print("WidgetDataManager: Error saving tasks - \(error)")
|
|
|
|
|
// Reload widget timeline (debounced) after file write completes
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
self.reloadWidgetTimelinesIfNeeded()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Load tasks from the shared container
|
|
|
|
|
/// Used by the widget to read cached data
|
|
|
|
|
func loadTasks() -> [WidgetTask] {
|
|
|
|
|
/// Load tasks from the shared container (async, non-blocking)
|
|
|
|
|
func loadTasks() async -> [WidgetTask] {
|
|
|
|
|
guard let fileURL = tasksFileURL else {
|
|
|
|
|
print("WidgetDataManager: Unable to access shared container")
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
|
|
|
print("WidgetDataManager: No cached tasks file found")
|
|
|
|
|
return await withCheckedContinuation { continuation in
|
|
|
|
|
fileQueue.async {
|
|
|
|
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
|
|
|
print("WidgetDataManager: No cached tasks file found")
|
|
|
|
|
continuation.resume(returning: [])
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
let data = try Data(contentsOf: fileURL)
|
|
|
|
|
let tasks = try JSONDecoder().decode([WidgetTask].self, from: data)
|
|
|
|
|
print("WidgetDataManager: Loaded \(tasks.count) tasks from widget cache")
|
|
|
|
|
continuation.resume(returning: tasks)
|
|
|
|
|
} catch {
|
|
|
|
|
print("WidgetDataManager: Error loading tasks - \(error)")
|
|
|
|
|
continuation.resume(returning: [])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Load tasks synchronously (blocks calling thread).
|
|
|
|
|
/// Prefer the async overload from the main app. This is kept for widget extension use.
|
|
|
|
|
func loadTasksSync() -> [WidgetTask] {
|
|
|
|
|
guard let fileURL = tasksFileURL else {
|
|
|
|
|
print("WidgetDataManager: Unable to access shared container")
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
let data = try Data(contentsOf: fileURL)
|
|
|
|
|
let tasks = try JSONDecoder().decode([WidgetTask].self, from: data)
|
|
|
|
|
print("WidgetDataManager: Loaded \(tasks.count) tasks from widget cache")
|
|
|
|
|
return tasks
|
|
|
|
|
} catch {
|
|
|
|
|
print("WidgetDataManager: Error loading tasks - \(error)")
|
|
|
|
|
return []
|
|
|
|
|
return fileQueue.sync {
|
|
|
|
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
|
|
|
print("WidgetDataManager: No cached tasks file found")
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
let data = try Data(contentsOf: fileURL)
|
|
|
|
|
let tasks = try JSONDecoder().decode([WidgetTask].self, from: data)
|
|
|
|
|
print("WidgetDataManager: Loaded \(tasks.count) tasks from widget cache")
|
|
|
|
|
return tasks
|
|
|
|
|
} catch {
|
|
|
|
|
print("WidgetDataManager: Error loading tasks - \(error)")
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get upcoming/pending tasks for widget display
|
|
|
|
|
/// Uses synchronous loading since this is typically called from widget timeline providers
|
|
|
|
|
func getUpcomingTasks() -> [WidgetTask] {
|
|
|
|
|
let allTasks = loadTasks()
|
|
|
|
|
let allTasks = loadTasksSync()
|
|
|
|
|
|
|
|
|
|
// All loaded tasks are already filtered (archived and completed columns are excluded during save)
|
|
|
|
|
// Sort by due date (earliest first), with overdue at top
|
|
|
|
@@ -426,12 +526,17 @@ final class WidgetDataManager {
|
|
|
|
|
func clearCache() {
|
|
|
|
|
guard let fileURL = tasksFileURL else { return }
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
try FileManager.default.removeItem(at: fileURL)
|
|
|
|
|
print("WidgetDataManager: Cleared widget cache")
|
|
|
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
|
|
|
} catch {
|
|
|
|
|
print("WidgetDataManager: Error clearing cache - \(error)")
|
|
|
|
|
fileQueue.async {
|
|
|
|
|
do {
|
|
|
|
|
try FileManager.default.removeItem(at: fileURL)
|
|
|
|
|
print("WidgetDataManager: Cleared widget cache")
|
|
|
|
|
} catch {
|
|
|
|
|
print("WidgetDataManager: Error clearing cache - \(error)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|