From d3e77326aaea44283d758172e793af26032f1d42 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 25 Nov 2025 20:41:02 -0600 Subject: [PATCH] Fix iOS widget to use App Group shared data and add large widget view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create WidgetDataManager to write task data as JSON to App Group shared container - Update widget CacheManager to read from App Group file instead of UserDefaults - Add LargeWidgetView for .systemLarge widget family showing 5-6 tasks - Add widget data save in AllTasksView after tasks load successfully - Clear widget cache on logout in AuthenticationManager - Add residenceName and isOverdue fields to widget task model 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- iosApp/MyCrib/MyCrib.swift | 350 ++++++++++++++++-- iosApp/iosApp/Helpers/WidgetDataManager.swift | 164 ++++++++ iosApp/iosApp/RootView.swift | 3 + iosApp/iosApp/Task/AllTasksView.swift | 8 +- 4 files changed, 501 insertions(+), 24 deletions(-) create mode 100644 iosApp/iosApp/Helpers/WidgetDataManager.swift diff --git a/iosApp/MyCrib/MyCrib.swift b/iosApp/MyCrib/MyCrib.swift index b3f3c54..b46abcd 100644 --- a/iosApp/MyCrib/MyCrib.swift +++ b/iosApp/MyCrib/MyCrib.swift @@ -8,6 +8,8 @@ import WidgetKit import SwiftUI +/// CacheManager reads tasks from the App Group shared container +/// Data is written by the main app via WidgetDataManager class CacheManager { struct CustomTask: Codable { let id: Int @@ -17,38 +19,69 @@ class CacheManager { let status: String? let dueDate: String? let category: String? - + let residenceName: String? + let isOverdue: Bool + enum CodingKeys: String, CodingKey { case id, title, description, priority, status, category case dueDate = "due_date" + case residenceName = "residence_name" + case isOverdue = "is_overdue" } } - - private let userDefaults = UserDefaults.standard - private static let cacheKey = "cached_tasks" - + + private static let appGroupIdentifier = "group.com.tt.mycrib.MyCribDev" + private static let tasksFileName = "widget_tasks.json" + + /// Get the shared App Group container URL + private static var sharedContainerURL: URL? { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) + } + + /// Get the URL for the tasks file + private static var tasksFileURL: URL? { + sharedContainerURL?.appendingPathComponent(tasksFileName) + } + static func getData() -> [CustomTask] { + guard let fileURL = tasksFileURL else { + print("CacheManager: Unable to access shared container") + return [] + } + + guard FileManager.default.fileExists(atPath: fileURL.path) else { + print("CacheManager: No cached tasks file found") + return [] + } + do { - let string = UserDefaults.standard.string(forKey: cacheKey) ?? "[]" - let jsonData = string.data(using: .utf8)! - let customTask = try JSONDecoder().decode([CustomTask].self, from: jsonData) - return customTask + let data = try Data(contentsOf: fileURL) + let tasks = try JSONDecoder().decode([CustomTask].self, from: data) + print("CacheManager: Loaded \(tasks.count) tasks from shared container") + return tasks } catch { - print("Error decoding tasks: \(error)") + print("CacheManager: Error decoding tasks - \(error)") return [] } } - + static func getUpcomingTasks() -> [CustomTask] { let allTasks = getData() + // Filter for pending/in-progress tasks, sorted by due date let upcoming = allTasks.filter { task in let status = task.status?.lowercased() ?? "" return status == "pending" || status == "in_progress" || status == "in progress" } - - // Sort by due date (earliest first) + + // Sort by due date (earliest first), with overdue at top return upcoming.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 } @@ -108,13 +141,15 @@ struct SimpleEntry: TimelineEntry { struct MyCribEntryView : View { var entry: Provider.Entry @Environment(\.widgetFamily) var family - + var body: some View { switch family { case .systemSmall: SmallWidgetView(entry: entry) case .systemMedium: MediumWidgetView(entry: entry) + case .systemLarge: + LargeWidgetView(entry: entry) default: SmallWidgetView(entry: entry) } @@ -363,9 +398,165 @@ struct TaskRowView: View { } } +// MARK: - Large Widget View +struct LargeWidgetView: View { + let entry: SimpleEntry + + private var maxTasksToShow: Int { + entry.upcomingTasks.count > 6 ? 5 : 6 + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + // Header + HStack(spacing: 6) { + Image(systemName: "house.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.blue) + + Text("MyCrib") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.primary) + + Spacer() + + Text("\(entry.taskCount)") + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(.blue) + + Text(entry.taskCount == 1 ? "task" : "tasks") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + } + + if entry.upcomingTasks.isEmpty { + Spacer() + + VStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 48)) + .foregroundStyle(.green) + Text("All caught up!") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + + Spacer() + } else { + // Show tasks + ForEach(Array(entry.upcomingTasks.prefix(maxTasksToShow).enumerated()), id: \.element.id) { index, task in + LargeTaskRowView(task: task, isOverdue: task.isOverdue) + } + + if entry.upcomingTasks.count > 6 { + Text("+ \(entry.upcomingTasks.count - 5) more tasks") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 2) + } + } + } + .padding(14) + } +} + +// MARK: - Large Task Row +struct LargeTaskRowView: View { + let task: CacheManager.CustomTask + let isOverdue: Bool + + var body: some View { + HStack(spacing: 8) { + // Priority indicator + RoundedRectangle(cornerRadius: 2) + .fill(priorityColor) + .frame(width: 3, height: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(task.title) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + .foregroundStyle(.primary) + + HStack(spacing: 10) { + if let residenceName = task.residenceName { + HStack(spacing: 2) { + Image(systemName: "house.fill") + .font(.system(size: 7)) + Text(residenceName) + .font(.system(size: 9)) + } + .foregroundStyle(.secondary) + } + + if let dueDate = task.dueDate { + HStack(spacing: 2) { + Image(systemName: "calendar") + .font(.system(size: 7)) + Text(formatDate(dueDate)) + .font(.system(size: 9, weight: isOverdue ? .semibold : .regular)) + } + .foregroundStyle(isOverdue ? .red : .secondary) + } + } + } + + Spacer() + + // Priority badge + if let priority = task.priority { + Text(priority.prefix(1).uppercased()) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 16, height: 16) + .background(Circle().fill(priorityColor)) + } + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.primary.opacity(0.05)) + ) + } + + private var priorityColor: Color { + switch task.priority?.lowercased() { + case "urgent": return .red + case "high": return .orange + case "medium": return .blue + default: return .gray + } + } + + private func formatDate(_ dateString: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + + if let date = formatter.date(from: dateString) { + let calendar = Calendar.current + + if calendar.isDateInToday(date) { + return "Today" + } else if calendar.isDateInTomorrow(date) { + return "Tomorrow" + } else if calendar.isDateInYesterday(date) { + return "Yesterday" + } else { + formatter.dateFormat = "MMM d" + return formatter.string(from: date) + } + } + + return dateString + } +} + struct MyCrib: Widget { let kind: String = "MyCrib" - + var body: some WidgetConfiguration { AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in MyCribEntryView(entry: entry) @@ -389,7 +580,9 @@ struct MyCrib: Widget { priority: "high", status: "pending", dueDate: "2024-12-15", - category: "plumbing" + category: "plumbing", + residenceName: "Home", + isOverdue: false ), CacheManager.CustomTask( id: 2, @@ -398,11 +591,13 @@ struct MyCrib: Widget { priority: "medium", status: "pending", dueDate: "2024-12-20", - category: "painting" + category: "painting", + residenceName: "Home", + isOverdue: false ) ] ) - + SimpleEntry( date: .now, configuration: ConfigurationAppIntent(), @@ -424,7 +619,9 @@ struct MyCrib: Widget { priority: "high", status: "pending", dueDate: "2024-12-15", - category: "plumbing" + category: "plumbing", + residenceName: "Home", + isOverdue: true ), CacheManager.CustomTask( id: 2, @@ -433,7 +630,9 @@ struct MyCrib: Widget { priority: "medium", status: "pending", dueDate: "2024-12-20", - category: "painting" + category: "painting", + residenceName: "Cabin", + isOverdue: false ), CacheManager.CustomTask( id: 3, @@ -442,11 +641,118 @@ struct MyCrib: Widget { priority: "low", status: "pending", dueDate: "2024-12-25", - category: "maintenance" + category: "maintenance", + residenceName: "Home", + isOverdue: false ) ] ) - + + SimpleEntry( + date: .now, + configuration: ConfigurationAppIntent(), + upcomingTasks: [] + ) +} + +#Preview(as: .systemLarge) { + MyCrib() +} timeline: { + SimpleEntry( + date: .now, + configuration: ConfigurationAppIntent(), + upcomingTasks: [ + CacheManager.CustomTask( + id: 1, + title: "Fix leaky faucet in kitchen", + description: "Kitchen sink needs repair", + priority: "high", + status: "pending", + dueDate: "2024-12-15", + category: "plumbing", + residenceName: "Home", + isOverdue: true + ), + CacheManager.CustomTask( + id: 2, + title: "Paint living room walls", + description: nil, + priority: "medium", + status: "pending", + dueDate: "2024-12-20", + category: "painting", + residenceName: "Cabin", + isOverdue: false + ), + CacheManager.CustomTask( + id: 3, + title: "Clean gutters", + description: "Remove debris", + priority: "low", + status: "pending", + dueDate: "2024-12-25", + category: "maintenance", + residenceName: "Home", + isOverdue: false + ), + CacheManager.CustomTask( + id: 4, + title: "Replace HVAC filter", + description: nil, + priority: "medium", + status: "pending", + dueDate: "2024-12-28", + category: "hvac", + residenceName: "Beach House", + isOverdue: false + ), + CacheManager.CustomTask( + id: 5, + title: "Check smoke detectors", + description: "Replace batteries if needed", + priority: "high", + status: "pending", + dueDate: "2024-12-30", + category: "safety", + residenceName: "Home", + isOverdue: false + ), + CacheManager.CustomTask( + id: 6, + title: "Service water heater", + description: nil, + priority: "medium", + status: "pending", + dueDate: "2025-01-05", + category: "plumbing", + residenceName: "Cabin", + isOverdue: false + ), + CacheManager.CustomTask( + id: 7, + title: "Inspect roof shingles", + description: nil, + priority: "low", + status: "pending", + dueDate: "2025-01-10", + category: "exterior", + residenceName: "Home", + isOverdue: false + ), + CacheManager.CustomTask( + id: 8, + title: "Clean dryer vent", + description: "Fire hazard prevention", + priority: "urgent", + status: "pending", + dueDate: "2025-01-12", + category: "appliances", + residenceName: "Beach House", + isOverdue: false + ) + ] + ) + SimpleEntry( date: .now, configuration: ConfigurationAppIntent(), diff --git a/iosApp/iosApp/Helpers/WidgetDataManager.swift b/iosApp/iosApp/Helpers/WidgetDataManager.swift new file mode 100644 index 0000000..6c97dff --- /dev/null +++ b/iosApp/iosApp/Helpers/WidgetDataManager.swift @@ -0,0 +1,164 @@ +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.mycrib.MyCribDev" + private let tasksFileName = "widget_tasks.json" + + private init() {} + + /// 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 status: String? + let dueDate: String? + let category: String? + let residenceName: String? + let isOverdue: Bool + + enum CodingKeys: String, CodingKey { + case id, title, description, priority, status, category + 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 + } + + // Extract tasks from all columns and convert to WidgetTask + var allTasks: [WidgetTask] = [] + + for column in response.columns { + for task in column.tasks { + let widgetTask = WidgetTask( + id: Int(task.id), + title: task.title, + description: task.description_, + priority: task.priority.name, + status: task.status?.name, + dueDate: task.dueDate, + category: task.category.name, + residenceName: task.residenceName, + isOverdue: isTaskOverdue(dueDate: task.dueDate, status: task.status?.name) + ) + 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() + + // Filter for pending/in-progress tasks (non-archived, non-completed) + let upcoming = allTasks.filter { task in + let status = task.status?.lowercased() ?? "" + return status == "pending" || status == "in_progress" || status == "in progress" + } + + // Sort by due date (earliest first), with overdue at top + return upcoming.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 } + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + + guard let date = formatter.date(from: dueDateStr) 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 && date < Calendar.current.startOfDay(for: Date()) + } +} diff --git a/iosApp/iosApp/RootView.swift b/iosApp/iosApp/RootView.swift index 5fda2b3..64b20e4 100644 --- a/iosApp/iosApp/RootView.swift +++ b/iosApp/iosApp/RootView.swift @@ -50,6 +50,9 @@ class AuthenticationManager: ObservableObject { // Clear all cached data DataCache.shared.clearAll() + // Clear widget task data + WidgetDataManager.shared.clearCache() + // Update authentication state isAuthenticated = false diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index 42755a7..39dc33c 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -281,10 +281,14 @@ struct AllTasksView: View { do { let result = try await APILayer.shared.getTasks(forceRefresh: forceRefresh) await MainActor.run { - if let success = result as? ApiResultSuccess { - self.tasksResponse = success.data + if let success = result as? ApiResultSuccess, + let data = success.data { + self.tasksResponse = data self.isLoadingTasks = false self.tasksError = nil + + // Update widget data + WidgetDataManager.shared.saveTasks(from: data) } else if let error = result as? ApiResultError { self.tasksError = error.message self.isLoadingTasks = false