Files
honeyDueKMP/iosApp/iosApp/Helpers/WidgetDataManager.swift
Trey t e2d264da7e Add client-side summary calculation and lookup resolution from cache
- Add calculateSummaryFromKanban() to compute summary stats from cached kanban data
- Add refreshSummaryFromKanban() called after task CRUD operations
- Fix column name matching to use API format (e.g., "overdue_tasks" not "overdue")
- Fix tasksDueNextMonth to only include due_soon tasks (not upcoming)
- Update TaskResponse computed properties to resolve from DataManager cache
- Update iOS task cards to use computed properties for priority/frequency/category
- This enables API to skip preloading lookups for better performance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 01:04:52 -06:00

391 lines
14 KiB
Swift

import Foundation
import WidgetKit
import ComposeApp
/// Manages shared data between the main app and the widget extension
/// Uses App Group container to share files
final class WidgetDataManager {
static let shared = WidgetDataManager()
private let appGroupIdentifier = "group.com.tt.casera.CaseraDev"
private let tasksFileName = "widget_tasks.json"
private let actionsFileName = "widget_pending_actions.json"
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: - 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")
}
/// Get auth token from shared App Group
/// Used by widget to authenticate API requests
func getAuthToken() -> String? {
return sharedDefaults?.string(forKey: tokenKey)
}
/// Clear auth token from shared App Group
/// 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
func getAPIBaseURL() -> String? {
return sharedDefaults?.string(forKey: apiBaseURLKey)
}
// MARK: - Subscription Status Sharing
/// Save subscription status for widget to determine which view to show
/// Call this when subscription status changes
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()
}
/// 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: - Dirty Flag for Task Refresh
/// Mark tasks as dirty (needs refresh from server)
/// Called by widget after completing a task
func markTasksDirty() {
sharedDefaults?.set(true, forKey: dirtyFlagKey)
sharedDefaults?.synchronize()
print("WidgetDataManager: Marked tasks as dirty")
}
/// Check if tasks need refresh
func areTasksDirty() -> Bool {
return sharedDefaults?.bool(forKey: dirtyFlagKey) ?? false
}
/// Clear dirty flag after refreshing tasks
func clearDirtyFlag() {
sharedDefaults?.set(false, forKey: dirtyFlagKey)
sharedDefaults?.synchronize()
print("WidgetDataManager: Cleared dirty flag")
}
// MARK: - Widget Action Types (must match AppIntent.swift in widget extension)
enum WidgetAction: Codable, Equatable {
case completeTask(taskId: Int, taskTitle: String)
var taskId: Int {
switch self {
case .completeTask(let id, _):
return id
}
}
var taskTitle: String {
switch self {
case .completeTask(_, let title):
return title
}
}
}
// 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 {
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
}
}
/// Remove a specific action after processing
func removeAction(_ action: WidgetAction) {
var actions = loadPendingActions()
actions.removeAll { $0 == action }
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)")
}
}
}
/// 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 {
return
}
struct PendingTaskState: Codable {
let taskId: Int
let pendingAction: String
let timestamp: Date
}
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)
}
// Reload widget to reflect the change
WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
} catch {
print("WidgetDataManager: Error clearing pending state - \(error)")
}
}
/// Check if there are any pending actions from the widget
var hasPendingActions: Bool {
!loadPendingActions().isEmpty
}
/// Task model for widget display - simplified version of TaskDetail
struct WidgetTask: Codable {
let id: Int
let title: String
let description: String?
let priority: String?
let inProgress: Bool
let dueDate: String?
let category: String?
let residenceName: String?
let isOverdue: Bool
enum CodingKeys: String, CodingKey {
case id, title, description, priority, category
case inProgress = "in_progress"
case dueDate = "due_date"
case residenceName = "residence_name"
case isOverdue = "is_overdue"
}
}
/// Get the shared App Group container URL
private var sharedContainerURL: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
}
/// Get the URL for the tasks file
private var tasksFileURL: URL? {
sharedContainerURL?.appendingPathComponent(tasksFileName)
}
/// Save tasks to the shared container for widget access
/// Call this after loading tasks in the main app
func saveTasks(from response: TaskColumnsResponse) {
guard let fileURL = tasksFileURL else {
print("WidgetDataManager: Unable to access shared container")
return
}
// Columns to exclude from widget (these are "done" states)
let excludedColumns = ["completed_tasks", "cancelled_tasks"]
// Extract tasks from active columns only and convert to WidgetTask
var allTasks: [WidgetTask] = []
for column in response.columns {
// Skip completed and cancelled columns
if excludedColumns.contains(column.name) {
continue
}
for task in column.tasks {
let widgetTask = WidgetTask(
id: Int(task.id),
title: task.title,
description: task.description_,
priority: task.priorityName ?? "",
inProgress: task.inProgress,
dueDate: task.effectiveDueDate, // Use effective date (nextDueDate if set, otherwise dueDate)
category: task.categoryName ?? "",
residenceName: "", // No longer available in API, residence lookup needed
isOverdue: column.name == "overdue_tasks"
)
allTasks.append(widgetTask)
}
}
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")
// Reload widget timeline
WidgetCenter.shared.reloadAllTimelines()
} catch {
print("WidgetDataManager: Error saving tasks - \(error)")
}
}
/// Load tasks from the shared container
/// Used by the widget to read cached data
func loadTasks() -> [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 []
}
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
func getUpcomingTasks() -> [WidgetTask] {
let allTasks = loadTasks()
// All loaded tasks are already filtered (archived and completed columns are excluded during save)
// Sort by due date (earliest first), with overdue at top
return allTasks.sorted { task1, task2 in
// Overdue tasks first
if task1.isOverdue != task2.isOverdue {
return task1.isOverdue
}
// Then by due date
guard let date1 = task1.dueDate, let date2 = task2.dueDate else {
return task1.dueDate != nil
}
return date1 < date2
}
}
/// Clear cached task data (call on logout)
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)")
}
}
/// Check if a task is overdue based on due date and status
private func isTaskOverdue(dueDate: String?, status: String?) -> Bool {
guard let dueDateStr = dueDate else { return false }
var date: Date?
// Try parsing as yyyy-MM-dd first
let dateOnlyFormatter = DateFormatter()
dateOnlyFormatter.dateFormat = "yyyy-MM-dd"
date = dateOnlyFormatter.date(from: dueDateStr)
// Try parsing as ISO8601 (RFC3339) if that fails
if date == nil {
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
date = isoFormatter.date(from: dueDateStr)
// Try without fractional seconds
if date == nil {
isoFormatter.formatOptions = [.withInternetDateTime]
date = isoFormatter.date(from: dueDateStr)
}
}
guard let parsedDate = date else { return false }
// Task is overdue if due date is in the past and status is not completed
let statusLower = status?.lowercased() ?? ""
let isCompleted = statusLower == "completed" || statusLower == "done"
return !isCompleted && parsedDate < Calendar.current.startOfDay(for: Date())
}
}