// // Casera.swift // Casera // // Created by Trey Tartt on 11/5/25. // import WidgetKit import SwiftUI // MARK: - Date Formatting Helper /// Parses date strings in either yyyy-MM-dd or ISO8601 (RFC3339) format /// and returns a user-friendly string like "Today" or "in X days" private func formatWidgetDate(_ dateString: String) -> String { let calendar = Calendar.current var date: Date? // Try parsing as yyyy-MM-dd first let dateOnlyFormatter = DateFormatter() dateOnlyFormatter.dateFormat = "yyyy-MM-dd" date = dateOnlyFormatter.date(from: dateString) // Try parsing as ISO8601 (RFC3339) if that fails if date == nil { let isoFormatter = ISO8601DateFormatter() isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] date = isoFormatter.date(from: dateString) // Try without fractional seconds if date == nil { isoFormatter.formatOptions = [.withInternetDateTime] date = isoFormatter.date(from: dateString) } } guard let parsedDate = date else { return dateString } let today = calendar.startOfDay(for: Date()) let dueDay = calendar.startOfDay(for: parsedDate) if calendar.isDateInToday(parsedDate) { return "Today" } let components = calendar.dateComponents([.day], from: today, to: dueDay) let days = components.day ?? 0 if days > 0 { return days == 1 ? "in 1 day" : "in \(days) days" } else { let overdueDays = abs(days) return overdueDays == 1 ? "1 day ago" : "\(overdueDays) days ago" } } /// 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 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" } } private static let appGroupIdentifier = "group.com.tt.casera.CaseraDev" 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 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("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), 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 } } } struct Provider: AppIntentTimelineProvider { func placeholder(in context: Context) -> SimpleEntry { SimpleEntry( date: Date(), configuration: ConfigurationAppIntent(), upcomingTasks: [] ) } func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry { let tasks = CacheManager.getUpcomingTasks() return SimpleEntry( date: Date(), configuration: configuration, upcomingTasks: tasks ) } func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline { let tasks = CacheManager.getUpcomingTasks() // Update every hour let currentDate = Date() let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)! let entry = SimpleEntry( date: currentDate, configuration: configuration, upcomingTasks: tasks ) return Timeline(entries: [entry], policy: .after(nextUpdate)) } } struct SimpleEntry: TimelineEntry { let date: Date let configuration: ConfigurationAppIntent let upcomingTasks: [CacheManager.CustomTask] var taskCount: Int { upcomingTasks.count } var nextTask: CacheManager.CustomTask? { upcomingTasks.first } } struct CaseraEntryView : 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) } } } // MARK: - Small Widget View struct SmallWidgetView: View { let entry: SimpleEntry var body: some View { VStack(alignment: .leading, spacing: 0) { // Task Count VStack(alignment: .leading, spacing: 4) { Text("\(entry.taskCount)") .font(.system(size: 36, weight: .bold)) .foregroundStyle(.blue) if entry.taskCount > 0 { Text(entry.taskCount == 1 ? "upcoming task" : "upcoming tasks") .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) } } // Next Task if let nextTask = entry.nextTask { VStack(alignment: .leading, spacing: 4) { Text("NEXT UP") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(.secondary) .tracking(0.5) Text(nextTask.title) .font(.system(size: 12, weight: .semibold)) .lineLimit(2) .foregroundStyle(.primary) if let dueDate = nextTask.dueDate { HStack(spacing: 4) { Image(systemName: "calendar") .font(.system(size: 9)) Text(formatWidgetDate(dueDate)) .font(.system(size: 10, weight: .medium)) } .foregroundStyle(.orange) } } .padding(.top, 8) .padding(.horizontal, 10) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 8) .fill(Color.blue.opacity(0.1)) ) } else { HStack { Image(systemName: "checkmark.circle.fill") .font(.system(size: 16)) .foregroundStyle(.green) // Text("All caught up!") // .font(.system(size: 11, weight: .medium)) // .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 8) .fill(Color.green.opacity(0.1)) ) } } .padding(16) } } // MARK: - Medium Widget View struct MediumWidgetView: View { let entry: SimpleEntry var body: some View { HStack(spacing: 16) { // Left side - Task count VStack(alignment: .leading, spacing: 4) { Spacer() Text("\(entry.taskCount)") .font(.system(size: 42, weight: .bold)) .foregroundStyle(.blue) VStack(alignment: .leading) { Text("upcoming:") .font(.system(size: 11, weight: .medium)) .foregroundStyle(.secondary) .lineLimit(2) Text(entry.taskCount == 1 ? "task" : "tasks") .font(.system(size: 11, weight: .medium)) .foregroundStyle(.secondary) .lineLimit(2) } Spacer() } .frame(maxWidth: 75) Divider() // Right side - Next tasks VStack(alignment: .leading, spacing: 8) { if let nextTask = entry.nextTask { Text("NEXT UP") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(.secondary) .tracking(0.5) } if entry.nextTask != nil { ForEach(Array(entry.upcomingTasks.prefix(3).enumerated()), id: \.element.id) { index, task in TaskRowView(task: task, index: index) } Spacer() } else { Spacer() VStack(spacing: 8) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 24)) .foregroundStyle(.green) Text("All caught up!") .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) Spacer() } } .frame(maxWidth: .infinity) } .padding(16) } } // MARK: - Task Row struct TaskRowView: View { let task: CacheManager.CustomTask let index: Int var body: some View { HStack(spacing: 8) { Circle() .fill(priorityColor) .frame(width: 6, height: 6) VStack(alignment: .leading, spacing: 2) { Text(task.title) .font(.system(size: 11, weight: .semibold)) // .lineLimit(1) .foregroundStyle(.primary) if let dueDate = task.dueDate { HStack(spacing: 3) { Image(systemName: "calendar") .font(.system(size: 8)) Text(formatWidgetDate(dueDate)) .font(.system(size: 9, weight: .medium)) } .foregroundStyle(.secondary) } } Spacer() if let priority = task.priority { Text(priority.prefix(1).uppercased()) .font(.system(size: 9, weight: .bold)) .foregroundStyle(.white) .frame(width: 16, height: 16) .background( Circle() .fill(priorityColor) ) } } .padding(.vertical, 4) } private var priorityColor: Color { switch task.priority?.lowercased() { case "urgent": return .red case "high": return .orange case "medium": return .blue default: return .gray } } } // 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) { 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("icon") .resizable() .frame(width: 7, height: 7) .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(formatWidgetDate(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 } } } struct Casera: Widget { let kind: String = "Casera" var body: some WidgetConfiguration { AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in CaseraEntryView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } } } // MARK: - Previews #Preview(as: .systemSmall) { Casera() } timeline: { SimpleEntry( date: .now, configuration: ConfigurationAppIntent(), upcomingTasks: [ CacheManager.CustomTask( id: 1, title: "Fix leaky faucet", description: "Kitchen sink needs repair", priority: "high", status: "pending", dueDate: "2024-12-15", category: "plumbing", residenceName: "Home", isOverdue: false ), CacheManager.CustomTask( id: 2, title: "Paint living room", description: nil, priority: "medium", status: "pending", dueDate: "2024-12-20", category: "painting", residenceName: "Home", isOverdue: false ) ] ) SimpleEntry( date: .now, configuration: ConfigurationAppIntent(), upcomingTasks: [] ) } #Preview(as: .systemMedium) { Casera() } timeline: { SimpleEntry( date: .now, configuration: ConfigurationAppIntent(), upcomingTasks: [ CacheManager.CustomTask( id: 1, title: "Fix leaky faucet", 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", 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 ) ] ) SimpleEntry( date: .now, configuration: ConfigurationAppIntent(), upcomingTasks: [] ) } #Preview(as: .systemLarge) { Casera() } 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(), upcomingTasks: [] ) }