Total rebrand across KMM project: - Kotlin package: com.example.casera -> com.tt.honeyDue (dirs + declarations) - Gradle: rootProject.name, namespace, applicationId - Android: manifest, strings.xml (all languages), widget resources - iOS: pbxproj bundle IDs, Info.plist, entitlements, xcconfig - iOS directories: Casera/ -> HoneyDue/, CaseraTests/ -> HoneyDueTests/, etc. - Swift source: all class/struct/enum renames - Deep links: casera:// -> honeydue://, .casera -> .honeydue - App icons replaced with honeyDue honeycomb icon - Domains: casera.treytartt.com -> honeyDue.treytartt.com - Bundle IDs: com.tt.casera -> com.tt.honeyDue - Database table names preserved Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
548 lines
20 KiB
Swift
548 lines
20 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()
|
|
|
|
/// 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"
|
|
static let dueWithin7DaysColumn = "due_soon_tasks"
|
|
static let due8To30DaysColumn = "upcoming_tasks"
|
|
static let completedColumn = "completed_tasks"
|
|
static let cancelledColumn = "cancelled_tasks"
|
|
|
|
private let appGroupIdentifier = "group.com.tt.honeyDue.HoneyDueDev"
|
|
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"
|
|
|
|
/// Serial queue for thread-safe file I/O operations
|
|
private let fileQueue = DispatchQueue(label: "com.honeydue.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)
|
|
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)
|
|
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)
|
|
}
|
|
|
|
/// 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)
|
|
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)
|
|
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)
|
|
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 (async, non-blocking)
|
|
func loadPendingActions() async -> [WidgetAction] {
|
|
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else {
|
|
return []
|
|
}
|
|
|
|
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 }
|
|
|
|
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) {
|
|
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else { return }
|
|
|
|
fileQueue.async {
|
|
// Load actions within the serial queue to avoid race conditions
|
|
var actions: [WidgetAction]
|
|
if FileManager.default.fileExists(atPath: fileURL.path) {
|
|
do {
|
|
let data = try Data(contentsOf: fileURL)
|
|
actions = try JSONDecoder().decode([WidgetAction].self, from: data)
|
|
} catch {
|
|
print("WidgetDataManager: Failed to decode pending actions: \(error)")
|
|
actions = []
|
|
}
|
|
} 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) else {
|
|
return
|
|
}
|
|
|
|
struct PendingTaskState: Codable {
|
|
let taskId: Int
|
|
let pendingAction: String
|
|
let timestamp: Date
|
|
}
|
|
|
|
fileQueue.async {
|
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
return
|
|
}
|
|
|
|
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
|
|
DispatchQueue.main.async {
|
|
WidgetCenter.shared.reloadTimelines(ofKind: "honeyDue")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if there are any pending actions from the widget
|
|
var hasPendingActions: Bool {
|
|
!loadPendingActionsSync().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
|
|
let isDueWithin7Days: Bool
|
|
let isDue8To30Days: 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"
|
|
case isDueWithin7Days = "is_due_within_7_days"
|
|
case isDue8To30Days = "is_due_8_to_30_days"
|
|
}
|
|
}
|
|
|
|
/// Metrics calculated from an array of tasks - shared between app and widget
|
|
struct TaskMetrics {
|
|
let totalCount: Int
|
|
let overdueCount: Int
|
|
let upcoming7Days: Int
|
|
let upcoming30Days: Int
|
|
}
|
|
|
|
/// Calculate metrics from an array of WidgetTasks
|
|
/// This is the SINGLE SOURCE OF TRUTH for all task metrics
|
|
/// Used by: dashboard summary, residence cards, widget
|
|
static func calculateMetrics(from tasks: [WidgetTask]) -> TaskMetrics {
|
|
var overdueCount = 0
|
|
var upcoming7Days = 0
|
|
var upcoming30Days = 0
|
|
|
|
for task in tasks {
|
|
if task.isOverdue { overdueCount += 1 }
|
|
if task.isDueWithin7Days { upcoming7Days += 1 }
|
|
if task.isDue8To30Days { upcoming30Days += 1 }
|
|
}
|
|
|
|
return TaskMetrics(
|
|
totalCount: tasks.count,
|
|
overdueCount: overdueCount,
|
|
upcoming7Days: upcoming7Days,
|
|
upcoming30Days: upcoming30Days
|
|
)
|
|
}
|
|
|
|
/// Parse a date string (handles both "yyyy-MM-dd" and ISO datetime formats)
|
|
/// Extracts date part if it includes time (e.g., "2025-12-26T00:00:00Z" -> "2025-12-26")
|
|
private static let dateFormatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
return formatter
|
|
}()
|
|
|
|
static func parseDate(_ dateString: String?) -> Date? {
|
|
guard let dateString = dateString, !dateString.isEmpty else { return nil }
|
|
// Extract date part if it includes time
|
|
let datePart = dateString.components(separatedBy: "T").first ?? dateString
|
|
return dateFormatter.date(from: datePart)
|
|
}
|
|
|
|
/// 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
|
|
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 = [Self.completedColumn, Self.cancelledColumn]
|
|
|
|
// Date calculation setup
|
|
let today = Calendar.current.startOfDay(for: Date())
|
|
|
|
// 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
|
|
}
|
|
|
|
// isOverdue is based on column (API correctly calculates this)
|
|
let isOverdue = column.name == Self.overdueColumn
|
|
|
|
for task in column.tasks {
|
|
// Calculate isDueWithin7Days and isDue8To30Days from actual due date
|
|
var isDueWithin7Days = false
|
|
var isDue8To30Days = false
|
|
|
|
if let dueDate = Self.parseDate(task.effectiveDueDate) {
|
|
let dueDay = Calendar.current.startOfDay(for: dueDate)
|
|
let daysUntilDue = Calendar.current.dateComponents([.day], from: today, to: dueDay).day ?? 0
|
|
|
|
// Only count future tasks (not overdue)
|
|
if daysUntilDue >= 0 && daysUntilDue <= 7 {
|
|
isDueWithin7Days = true
|
|
} else if daysUntilDue > 7 && daysUntilDue <= 30 {
|
|
isDue8To30Days = true
|
|
}
|
|
}
|
|
|
|
let widgetTask = WidgetTask(
|
|
id: Int(task.id),
|
|
title: task.title,
|
|
description: task.description_,
|
|
priority: task.priorityName ?? "",
|
|
inProgress: task.inProgress,
|
|
dueDate: task.effectiveDueDate,
|
|
category: task.categoryName ?? "",
|
|
residenceName: "",
|
|
isOverdue: isOverdue,
|
|
isDueWithin7Days: isDueWithin7Days,
|
|
isDue8To30Days: isDue8To30Days
|
|
)
|
|
allTasks.append(widgetTask)
|
|
}
|
|
}
|
|
|
|
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 (debounced) after file write completes
|
|
DispatchQueue.main.async {
|
|
self.reloadWidgetTimelinesIfNeeded()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 []
|
|
}
|
|
|
|
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 []
|
|
}
|
|
|
|
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 = 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
|
|
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 }
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|