// // Casera.swift // Casera // // Created by Trey Tartt on 11/5/25. // import WidgetKit import SwiftUI import AppIntents // 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, Identifiable { 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" } /// Whether this task is pending completion (tapped on widget, waiting for sync) var isPendingCompletion: Bool { WidgetActionManager.shared.isTaskPendingCompletion(taskId: id) } /// Whether this task should be shown (not pending completion) var shouldShow: Bool { !isPendingCompletion } } 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 actionable tasks (not completed, including in-progress and overdue) // Also exclude tasks that are pending completion via widget let upcoming = allTasks.filter { task in // Include if: not pending completion return task.shouldShow } // 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: [], isInteractive: true ) } func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry { let tasks = CacheManager.getUpcomingTasks() let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget() return SimpleEntry( date: Date(), configuration: configuration, upcomingTasks: tasks, isInteractive: isInteractive ) } func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline { let tasks = CacheManager.getUpcomingTasks() let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget() // Update every 30 minutes (more frequent for interactive widgets) let currentDate = Date() let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: currentDate)! let entry = SimpleEntry( date: currentDate, configuration: configuration, upcomingTasks: tasks, isInteractive: isInteractive ) return Timeline(entries: [entry], policy: .after(nextUpdate)) } } struct SimpleEntry: TimelineEntry { let date: Date let configuration: ConfigurationAppIntent let upcomingTasks: [CacheManager.CustomTask] let isInteractive: Bool var taskCount: Int { upcomingTasks.count } var nextTask: CacheManager.CustomTask? { upcomingTasks.first } /// Tasks due within the next 7 days var dueThisWeekCount: Int { let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) let weekFromNow = calendar.date(byAdding: .day, value: 7, to: today)! return upcomingTasks.filter { task in guard let dueDateString = task.dueDate else { return false } guard let dueDate = parseDate(dueDateString) else { return false } let dueDay = calendar.startOfDay(for: dueDate) return dueDay <= weekFromNow }.count } /// Tasks due within the next 30 days var dueNext30DaysCount: Int { let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) let thirtyDaysFromNow = calendar.date(byAdding: .day, value: 30, to: today)! return upcomingTasks.filter { task in guard let dueDateString = task.dueDate else { return false } guard let dueDate = parseDate(dueDateString) else { return false } let dueDay = calendar.startOfDay(for: dueDate) return dueDay <= thirtyDaysFromNow }.count } /// Parse date string to Date private func parseDate(_ dateString: String) -> Date? { let dateOnlyFormatter = DateFormatter() dateOnlyFormatter.dateFormat = "yyyy-MM-dd" if let date = dateOnlyFormatter.date(from: dateString) { return date } let isoFormatter = ISO8601DateFormatter() isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = isoFormatter.date(from: dateString) { return date } isoFormatter.formatOptions = [.withInternetDateTime] return isoFormatter.date(from: dateString) } } struct CaseraEntryView : View { var entry: Provider.Entry @Environment(\.widgetFamily) var family var body: some View { if entry.isInteractive { // Premium/no-limitations: Show interactive widget with task completion switch family { case .systemSmall: SmallWidgetView(entry: entry) case .systemMedium: MediumWidgetView(entry: entry) case .systemLarge: LargeWidgetView(entry: entry) default: SmallWidgetView(entry: entry) } } else { // Free tier with limitations: Show simple task count view FreeWidgetView(entry: entry) } } } // MARK: - Free Tier Widget View (Non-Interactive) struct FreeWidgetView: View { let entry: SimpleEntry var body: some View { VStack(spacing: 12) { Spacer() // Task count display Text("\(entry.taskCount)") .font(.system(size: 56, weight: .bold)) .foregroundStyle(.blue) Text(entry.taskCount == 1 ? "task waiting on you" : "tasks waiting on you") .font(.system(size: 14, weight: .medium)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) Spacer() // Subtle upgrade hint Text("Upgrade for interactive widgets") .font(.system(size: 10)) .foregroundStyle(.tertiary) } .padding(16) } } // 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 with Complete Button if let nextTask = entry.nextTask { VStack(alignment: .leading, spacing: 6) { Text("NEXT UP") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(.secondary) .tracking(0.5) Text(nextTask.title) .font(.system(size: 12, weight: .semibold)) .lineLimit(1) .foregroundStyle(.primary) HStack(spacing: 8) { 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(nextTask.isOverdue ? .red : .orange) } Spacer() // Complete button Button(intent: CompleteTaskIntent(taskId: nextTask.id, taskTitle: nextTask.title)) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 20)) .foregroundStyle(.green) } .buttonStyle(.plain) } } .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 with interactive buttons VStack(alignment: .leading, spacing: 6) { if entry.nextTask != nil { Text("NEXT UP") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(.secondary) .tracking(0.5) ForEach(Array(entry.upcomingTasks.prefix(3).enumerated()), id: \.element.id) { index, task in InteractiveTaskRowView(task: task) } 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: - Interactive Task Row (for Medium widget) struct InteractiveTaskRowView: View { let task: CacheManager.CustomTask var body: some View { HStack(spacing: 8) { // Checkbox to complete task (color indicates priority) Button(intent: CompleteTaskIntent(taskId: task.id, taskTitle: task.title)) { Image(systemName: "circle") .font(.system(size: 18)) .foregroundStyle(priorityColor) } .buttonStyle(.plain) 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(task.isOverdue ? .red : .secondary) } } Spacer() } .padding(.vertical, 3) } private var priorityColor: Color { // Overdue tasks are always red if task.isOverdue { return .red } switch task.priority?.lowercased() { case "urgent": return .red case "high": return .orange case "medium": return .yellow default: return .green } } } // MARK: - Large Widget View struct LargeWidgetView: View { let entry: SimpleEntry private var maxTasksToShow: Int { 5 } var body: some View { VStack(alignment: .leading, spacing: 0) { if entry.upcomingTasks.isEmpty { // Empty state - centered 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() // Stats even when empty LargeWidgetStatsView(entry: entry) } else { // Tasks section - always at top VStack(alignment: .leading, spacing: 6) { ForEach(Array(entry.upcomingTasks.prefix(maxTasksToShow).enumerated()), id: \.element.id) { index, task in LargeInteractiveTaskRowView(task: task) } if entry.upcomingTasks.count > maxTasksToShow { Text("+ \(entry.upcomingTasks.count - maxTasksToShow) more") .font(.system(size: 10, weight: .medium)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .center) .padding(.top, 2) } } Spacer(minLength: 12) // Stats section at bottom LargeWidgetStatsView(entry: entry) } } .padding(14) } } // MARK: - Large Widget Stats View struct LargeWidgetStatsView: View { let entry: SimpleEntry var body: some View { HStack(spacing: 0) { // Total Tasks StatItem( value: entry.taskCount, label: "Total", color: .blue ) Divider() .frame(height: 30) // Due This Week StatItem( value: entry.dueThisWeekCount, label: "This Week", color: .orange ) Divider() .frame(height: 30) // Due Next 30 Days StatItem( value: entry.dueNext30DaysCount, label: "Next 30 Days", color: .green ) } .padding(.vertical, 10) .padding(.horizontal, 8) .background( RoundedRectangle(cornerRadius: 10) .fill(Color.primary.opacity(0.05)) ) } } // MARK: - Stat Item View struct StatItem: View { let value: Int let label: String let color: Color var body: some View { VStack(spacing: 2) { Text("\(value)") .font(.system(size: 20, weight: .bold)) .foregroundStyle(color) Text(label) .font(.system(size: 9, weight: .medium)) .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.8) } .frame(maxWidth: .infinity) } } // MARK: - Large Interactive Task Row struct LargeInteractiveTaskRowView: View { let task: CacheManager.CustomTask var body: some View { HStack(spacing: 8) { // Checkbox to complete task (color indicates priority) Button(intent: CompleteTaskIntent(taskId: task.id, taskTitle: task.title)) { Image(systemName: "circle") .font(.system(size: 20)) .foregroundStyle(priorityColor) } .buttonStyle(.plain) VStack(alignment: .leading, spacing: 2) { Text(task.title) .font(.system(size: 12, weight: .medium)) .lineLimit(2) .foregroundStyle(.primary) HStack(spacing: 10) { if let residenceName = task.residenceName, !residenceName.isEmpty { 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: task.isOverdue ? .semibold : .regular)) } .foregroundStyle(task.isOverdue ? .red : .secondary) } } } Spacer() } .padding(.vertical, 4) .padding(.horizontal, 6) .background( RoundedRectangle(cornerRadius: 6) .fill(Color.primary.opacity(0.05)) ) } private var priorityColor: Color { // Overdue tasks are always red if task.isOverdue { return .red } switch task.priority?.lowercased() { case "urgent": return .red case "high": return .orange case "medium": return .yellow default: return .green } } } 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) } .configurationDisplayName("Casera Tasks") .description("View and complete your upcoming tasks.") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } } // 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", inProgress: false, dueDate: "2024-12-15", category: "plumbing", residenceName: "Home", isOverdue: false ), CacheManager.CustomTask( id: 2, title: "Paint living room", description: nil, priority: "medium", inProgress: false, dueDate: "2024-12-20", category: "painting", residenceName: "Home", isOverdue: false ) ], isInteractive: true ) // Free tier preview SimpleEntry( date: .now, configuration: ConfigurationAppIntent(), upcomingTasks: [ CacheManager.CustomTask( id: 1, title: "Fix leaky faucet", description: nil, priority: "high", inProgress: false, dueDate: "2024-12-15", category: "plumbing", residenceName: "Home", isOverdue: false ), CacheManager.CustomTask( id: 2, title: "Paint living room", description: nil, priority: "medium", inProgress: false, dueDate: "2024-12-20", category: "painting", residenceName: "Home", isOverdue: false ), CacheManager.CustomTask( id: 3, title: "Clean gutters", description: nil, priority: "low", inProgress: false, dueDate: "2024-12-25", category: "maintenance", residenceName: "Home", isOverdue: false ) ], isInteractive: false ) SimpleEntry( date: .now, configuration: ConfigurationAppIntent(), upcomingTasks: [], isInteractive: true ) } #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", inProgress: false, dueDate: "2024-12-15", category: "plumbing", residenceName: "Home", isOverdue: true ), CacheManager.CustomTask( id: 2, title: "Paint living room", description: nil, priority: "medium", inProgress: true, dueDate: "2024-12-20", category: "painting", residenceName: "Cabin", isOverdue: false ), CacheManager.CustomTask( id: 3, title: "Clean gutters", description: "Remove debris", priority: "low", inProgress: false, dueDate: "2024-12-25", category: "maintenance", residenceName: "Home", isOverdue: false ) ], isInteractive: true ) // Free tier preview SimpleEntry( date: .now, configuration: ConfigurationAppIntent(), upcomingTasks: [ CacheManager.CustomTask( id: 1, title: "Fix leaky faucet", description: nil, priority: "high", inProgress: false, dueDate: nil, category: nil, residenceName: nil, isOverdue: false ), CacheManager.CustomTask( id: 2, title: "Paint living room", description: nil, priority: "medium", inProgress: false, dueDate: nil, category: nil, residenceName: nil, isOverdue: false ), CacheManager.CustomTask( id: 3, title: "Clean gutters", description: nil, priority: "low", inProgress: false, dueDate: nil, category: nil, residenceName: nil, isOverdue: false ), CacheManager.CustomTask( id: 4, title: "Replace HVAC filter", description: nil, priority: "medium", inProgress: false, dueDate: nil, category: nil, residenceName: nil, isOverdue: false ), CacheManager.CustomTask( id: 5, title: "Check smoke detectors", description: nil, priority: "high", inProgress: false, dueDate: nil, category: nil, residenceName: nil, isOverdue: false ) ], isInteractive: false ) SimpleEntry( date: .now, configuration: ConfigurationAppIntent(), upcomingTasks: [], isInteractive: true ) } #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", inProgress: false, dueDate: "2024-12-15", category: "plumbing", residenceName: "Home", isOverdue: true ), CacheManager.CustomTask( id: 2, title: "Paint living room walls", description: nil, priority: "medium", inProgress: true, dueDate: "2024-12-20", category: "painting", residenceName: "Cabin", isOverdue: false ), CacheManager.CustomTask( id: 3, title: "Clean gutters", description: "Remove debris", priority: "low", inProgress: false, dueDate: "2024-12-25", category: "maintenance", residenceName: "Home", isOverdue: false ), CacheManager.CustomTask( id: 4, title: "Replace HVAC filter", description: nil, priority: "medium", inProgress: false, 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", inProgress: false, dueDate: "2024-12-30", category: "safety", residenceName: "Home", isOverdue: false ), CacheManager.CustomTask( id: 6, title: "Service water heater", description: nil, priority: "medium", inProgress: false, dueDate: "2025-01-05", category: "plumbing", residenceName: "Cabin", isOverdue: false ), CacheManager.CustomTask( id: 7, title: "Inspect roof shingles", description: nil, priority: "low", inProgress: false, dueDate: "2025-01-10", category: "exterior", residenceName: "Home", isOverdue: false ), CacheManager.CustomTask( id: 8, title: "Clean dryer vent", description: "Fire hazard prevention", priority: "urgent", inProgress: false, dueDate: "2025-01-12", category: "appliances", residenceName: "Beach House", isOverdue: false ) ], isInteractive: true ) // Free tier preview SimpleEntry( date: .now, configuration: ConfigurationAppIntent(), upcomingTasks: [ CacheManager.CustomTask( id: 1, title: "Task 1", description: nil, priority: "high", inProgress: false, dueDate: nil, category: nil, residenceName: nil, isOverdue: false ), CacheManager.CustomTask( id: 2, title: "Task 2", description: nil, priority: "medium", inProgress: false, dueDate: nil, category: nil, residenceName: nil, isOverdue: false ), CacheManager.CustomTask( id: 3, title: "Task 3", description: nil, priority: "low", inProgress: false, dueDate: nil, category: nil, residenceName: nil, isOverdue: false ), CacheManager.CustomTask( id: 4, title: "Task 4", description: nil, priority: "medium", inProgress: false, dueDate: nil, category: nil, residenceName: nil, isOverdue: false ), CacheManager.CustomTask( id: 5, title: "Task 5", description: nil, priority: "high", inProgress: false, dueDate: nil, category: nil, residenceName: nil, isOverdue: false ), CacheManager.CustomTask( id: 6, title: "Task 6", description: nil, priority: "low", inProgress: false, dueDate: nil, category: nil, residenceName: nil, isOverdue: false ), CacheManager.CustomTask( id: 7, title: "Task 7", description: nil, priority: "medium", inProgress: false, dueDate: nil, category: nil, residenceName: nil, isOverdue: false ) ], isInteractive: false ) SimpleEntry( date: .now, configuration: ConfigurationAppIntent(), upcomingTasks: [], isInteractive: true ) }