import AppIntents import SwiftUI import WidgetKit // MARK: - Palette private func dynamicColor(light: UInt32, dark: UInt32) -> Color { func uiColor(_ hex: UInt32) -> UIColor { UIColor( red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1 ) } return Color(UIColor { trait in trait.userInterfaceStyle == .dark ? uiColor(dark) : uiColor(light) }) } enum Palette { static let accent = dynamicColor(light: 0x0B6BCB, dark: 0x4B9BE8) static let accentSoft = dynamicColor(light: 0xE3EFFB, dark: 0x12395F) static let danger = dynamicColor(light: 0xC41C1C, dark: 0xF09898) static let warning = dynamicColor(light: 0xB26A00, dark: 0xF3C896) static let ringNeutral = dynamicColor(light: 0xB8C0C9, dark: 0x555E68) // Initials-disc colors; indexed by a stable hash of the member id so each // person keeps their color (mirror of AvatarCache.java). static let avatarColors: [Color] = [ Color(red: 0x0B / 255, green: 0x6B / 255, blue: 0xCB / 255), Color(red: 0x14 / 255, green: 0x7D / 255, blue: 0x57 / 255), Color(red: 0x9C / 255, green: 0x4D / 255, blue: 0xD3 / 255), Color(red: 0xC2 / 255, green: 0x41 / 255, blue: 0x0C / 255), Color(red: 0x0E / 255, green: 0x74 / 255, blue: 0x90 / 255), Color(red: 0xB0 / 255, green: 0x2A / 255, blue: 0x5B / 255), Color(red: 0x5B / 255, green: 0x21 / 255, blue: 0xB6 / 255), Color(red: 0x93 / 255, green: 0x78 / 255, blue: 0x00 / 255), ] } // MARK: - Model struct WidgetTask: Identifiable { let id: String let name: String let dueDate: Date? let priority: Int let approval: Bool let assignedTo: String? var overdue: Bool { guard !approval, let due = dueDate else { return false } return due < Date() } var deepLink: URL? { URL(string: "donetick://chores/\(id)") } } struct WidgetMember: Identifiable { let id: String let name: String let image: String? var color: Color { let hash = id.unicodeScalars.reduce(0) { $0 + Int($1.value) } return Palette.avatarColors[hash % Palette.avatarColors.count] } var initial: String { name.trimmingCharacters(in: .whitespaces).first.map(String.init)?.uppercased() ?? "?" } } // MARK: - Shared store (App Group) enum WidgetStore { static let appGroup = "group.com.donetick.app" static let dataKey = "widget_tasks" static let configKey = "widget_config" // Same filtering window as src/service/WidgetService.js static let windowDays = 7 static let maxTasks = 100 static let staleInterval: TimeInterval = 10 * 60 static var defaults: UserDefaults? { UserDefaults(suiteName: appGroup) } static var signedIn: Bool { defaults?.string(forKey: configKey) != nil } static var lastUpdated: Date? { guard let snapshot = snapshotDict(), let millis = snapshot["lastUpdated"] as? Double, millis > 0 else { return nil } return Date(timeIntervalSince1970: millis / 1000) } static var userId: String? { guard let raw = defaults?.string(forKey: configKey), let data = raw.data(using: .utf8), let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let id = config["userId"] else { return nil } return "\(id)" } private static func snapshotDict() -> [String: Any]? { guard let raw = defaults?.string(forKey: dataKey), let data = raw.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } return json } static func loadTasks() -> [WidgetTask] { guard let items = snapshotDict()?["tasks"] as? [[String: Any]] else { return [] } return items.compactMap { item in guard let rawId = item["id"] else { return nil } var dueDate: Date? if let millis = item["dueDate"] as? Double { dueDate = Date(timeIntervalSince1970: millis / 1000) } // v1 snapshots carried only the user's own tasks and had no // assignedTo — treat those rows as "mine". var assignedTo: String? if let raw = item["assignedTo"], !(raw is NSNull) { assignedTo = "\(raw)" } else if item.index(forKey: "assignedTo") == nil { assignedTo = userId } return WidgetTask( id: "\(rawId)", name: item["name"] as? String ?? "", dueDate: dueDate, priority: item["priority"] as? Int ?? 0, approval: item["approval"] as? Bool ?? false, assignedTo: assignedTo ) } } static func loadMembers() -> [WidgetMember] { guard let items = snapshotDict()?["members"] as? [[String: Any]] else { return [] } return items.compactMap { item in guard let rawId = item["id"] else { return nil } return WidgetMember( id: "\(rawId)", name: item["name"] as? String ?? "", image: item["image"] as? String ) } } /// Tasks a today/week widget should render: everything when includeOthers, /// otherwise the user's own tasks plus approvals (which wait on them). static func visibleTasks(_ tasks: [WidgetTask], includeOthers: Bool) -> [WidgetTask] { guard !includeOthers else { return tasks } let me = userId return tasks.filter { $0.approval || (me != nil && $0.assignedTo == me) } } /// Tasks the Today widget shows: awaiting approval, overdue, or due today. static func todaySubset(_ tasks: [WidgetTask]) -> [WidgetTask] { let endOfToday = endOfDay(daysFromNow: 0) return tasks.filter { $0.approval || ($0.dueDate.map { $0 <= endOfToday } ?? false) } } static func endOfDay(daysFromNow: Int) -> Date { let calendar = Calendar.current let day = calendar.date(byAdding: .day, value: daysFromNow, to: Date()) ?? Date() let start = calendar.startOfDay(for: day) return calendar.date(byAdding: DateComponents(day: 1, second: -1), to: start) ?? day } // MARK: Background refresh /// Re-fetch /chores/ when the app has not pushed a snapshot recently, so /// the widget stays current while the app is closed. On any failure the /// last snapshot stays; the UI shows staleness via "Updated …". static func refreshIfStale() async { if let updated = lastUpdated, Date().timeIntervalSince(updated) < staleInterval { return } guard let raw = defaults?.string(forKey: configKey), let configData = raw.data(using: .utf8), let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any], let serverUrl = config["serverUrl"] as? String, let token = config["token"] as? String, let url = URL(string: serverUrl + "/chores/") else { return } var request = URLRequest(url: url, timeoutInterval: 15) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") guard let (data, response) = try? await URLSession.shared.data(for: request), (response as? HTTPURLResponse)?.statusCode == 200, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let chores = json["res"] as? [[String: Any]] else { return } // The chores endpoint has no member profiles, so carry the member // list over from the previous snapshot (it changes rarely and the // app re-pushes it on every open). let members = snapshotDict()?["members"] ?? [[String: Any]]() let snapshot: [String: Any] = [ "version": 2, "lastUpdated": Date().timeIntervalSince1970 * 1000, "tasks": filterChores(chores), "members": members, ] if let encoded = try? JSONSerialization.data(withJSONObject: snapshot), let string = String(data: encoded, encoding: .utf8) { defaults?.set(string, forKey: dataKey) } } /// Mirror of buildWidgetTasks in src/service/WidgetService.js. private static func filterChores(_ chores: [[String: Any]]) -> [[String: Any]] { let cutoff = endOfDay(daysFromNow: windowDays) var selected: [[String: Any]] = [] for chore in chores { guard let id = chore["id"] else { continue } let approval = (chore["status"] as? Int ?? 0) == 3 let dueDate = parseDate(chore["nextDueDate"] as? String) let inWindow = dueDate != nil && dueDate! <= cutoff guard approval || inWindow else { continue } var task: [String: Any] = [ "id": id, "name": chore["name"] as? String ?? "", "priority": chore["priority"] as? Int ?? 0, "approval": approval, ] task["dueDate"] = dueDate.map { $0.timeIntervalSince1970 * 1000 } ?? NSNull() if let assignee = chore["assignedTo"], !(assignee is NSNull) { task["assignedTo"] = "\(assignee)" } else { task["assignedTo"] = NSNull() } selected.append(task) } selected.sort { a, b in let aApproval = a["approval"] as? Bool ?? false let bApproval = b["approval"] as? Bool ?? false if aApproval != bApproval { return aApproval } let aDue = a["dueDate"] as? Double ?? .greatestFiniteMagnitude let bDue = b["dueDate"] as? Double ?? .greatestFiniteMagnitude return aDue < bDue } return Array(selected.prefix(maxTasks)) } private static func parseDate(_ value: String?) -> Date? { guard let value = value, !value.isEmpty else { return nil } let fractional = ISO8601DateFormatter() fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = fractional.date(from: value) { return date } return ISO8601DateFormatter().date(from: value) } } // MARK: - Avatars /// Downloads member profile photos and caches them in the App Group container /// for a day. Members without a photo (or failed downloads) render as colored /// initials discs instead — see AvatarView. enum AvatarStore { private static let maxAge: TimeInterval = 24 * 60 * 60 private static let sizePx: CGFloat = 96 private static var cacheDir: URL? { FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: WidgetStore.appGroup)? .appendingPathComponent("widget_avatars", isDirectory: true) } static func loadAll(_ members: [WidgetMember]) async -> [String: UIImage] { var images: [String: UIImage] = [:] for member in members { if let image = await load(member) { images[member.id] = image } } return images } private static func load(_ member: WidgetMember) async -> UIImage? { guard let urlString = member.image, urlString.hasPrefix("http"), let url = URL(string: urlString), let dir = cacheDir else { return nil } try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let file = dir.appendingPathComponent("\(member.id).png") if let attrs = try? FileManager.default.attributesOfItem(atPath: file.path), let modified = attrs[.modificationDate] as? Date, Date().timeIntervalSince(modified) < maxAge, let cached = UIImage(contentsOfFile: file.path) { return cached } guard let (data, response) = try? await URLSession.shared.data(from: url), (response as? HTTPURLResponse)?.statusCode == 200, let raw = UIImage(data: data) else { // Keep serving a stale copy rather than nothing. return UIImage(contentsOfFile: file.path) } let scaled = downscale(raw) if let png = scaled.pngData() { try? png.write(to: file) } return scaled } private static func downscale(_ image: UIImage) -> UIImage { let size = CGSize(width: sizePx, height: sizePx) let renderer = UIGraphicsImageRenderer(size: size) return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: size)) } } } // MARK: - Configuration intent (long-press → Edit Widget) struct WidgetOptionsIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource = "Widget Options" static var description = IntentDescription("Choose whose tasks the widget shows.") @Parameter(title: "Show everyone's tasks", default: false) var includeOthers: Bool } // MARK: - Timeline struct TaskEntry: TimelineEntry { let date: Date let tasks: [WidgetTask] let members: [WidgetMember] let avatars: [String: UIImage] let lastUpdated: Date? let signedIn: Bool let includeOthers: Bool let myUserId: String? static func sample() -> TaskEntry { let calendar = Calendar.current let today = calendar.date(bySettingHour: 18, minute: 0, second: 0, of: Date())! return TaskEntry( date: Date(), tasks: [ WidgetTask(id: "1", name: "Take out the trash", dueDate: today, priority: 1, approval: false, assignedTo: "1"), WidgetTask(id: "2", name: "Water the plants", dueDate: today, priority: 0, approval: false, assignedTo: "1"), WidgetTask(id: "3", name: "Vacuum living room", dueDate: calendar.date(byAdding: .day, value: 1, to: today), priority: 2, approval: false, assignedTo: "2"), WidgetTask(id: "4", name: "Clean the garage", dueDate: calendar.date(byAdding: .day, value: 3, to: today), priority: 0, approval: false, assignedTo: "1"), ], members: [ WidgetMember(id: "1", name: "Alex", image: nil), WidgetMember(id: "2", name: "Sam", image: nil), ], avatars: [:], lastUpdated: Date(), signedIn: true, includeOthers: false, myUserId: "1" ) } } private func makeEntry(includeOthers: Bool) async -> TaskEntry { await WidgetStore.refreshIfStale() let members = WidgetStore.loadMembers() let avatars = includeOthers ? await AvatarStore.loadAll(members) : [:] return TaskEntry( date: Date(), tasks: WidgetStore.loadTasks(), members: members, avatars: avatars, lastUpdated: WidgetStore.lastUpdated, signedIn: WidgetStore.signedIn, includeOthers: includeOthers, myUserId: WidgetStore.userId ) } private func makeTimeline(includeOthers: Bool) async -> Timeline { Timeline( entries: [await makeEntry(includeOthers: includeOthers)], policy: .after(Date().addingTimeInterval(30 * 60)) ) } struct DonetickProvider: AppIntentTimelineProvider { func placeholder(in context: Context) -> TaskEntry { .sample() } func snapshot(for configuration: WidgetOptionsIntent, in context: Context) async -> TaskEntry { if context.isPreview { return .sample() } return await makeEntry(includeOthers: configuration.includeOthers) } func timeline(for configuration: WidgetOptionsIntent, in context: Context) async -> Timeline { await makeTimeline(includeOthers: configuration.includeOthers) } } /// The People widget always covers the whole circle, so it needs no intent. struct PeopleProvider: TimelineProvider { func placeholder(in context: Context) -> TaskEntry { .sample() } func getSnapshot(in context: Context, completion: @escaping (TaskEntry) -> Void) { if context.isPreview { completion(.sample()) return } Task { completion(await makeEntry(includeOthers: true)) } } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { Task { completion(await makeTimeline(includeOthers: true)) } } } // MARK: - Formatting helpers private let timeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.timeStyle = .short formatter.dateStyle = .none return formatter }() private let dayFormatter: DateFormatter = { let formatter = DateFormatter() formatter.setLocalizedDateFormatFromTemplate("EEEMMMd") return formatter }() private func dayLabel(for date: Date) -> String { let calendar = Calendar.current if calendar.isDateInToday(date) { return "Today" } if calendar.isDateInTomorrow(date) { return "Tomorrow" } if date < calendar.startOfDay(for: Date()) { return "Overdue" } return dayFormatter.string(from: date) } private let addTaskURL = URL(string: "donetick://chores/add") // MARK: - Shared views extension View { func widgetShell() -> some View { containerBackground(for: .widget) { Color(UIColor.systemBackground) } } } struct AvatarView: View { let member: WidgetMember let image: UIImage? var size: CGFloat = 18 var body: some View { if let image = image { Image(uiImage: image) .resizable() .scaledToFill() .frame(width: size, height: size) .clipShape(Circle()) } else { ZStack { Circle().fill(member.color) Text(member.initial) .font(.system(size: size * 0.48, weight: .bold)) .foregroundColor(.white) } .frame(width: size, height: size) } } } struct TaskRow: View { let task: WidgetTask var showDay = false var assignee: WidgetMember? var assigneeImage: UIImage? private var ringColor: Color { if task.approval { return Palette.warning } if task.overdue || task.priority == 1 { return Palette.danger } if task.priority == 2 { return Palette.warning } return Palette.ringNeutral } private var meta: (text: String, color: Color) { if task.approval { return ("Approve", Palette.warning) } guard let due = task.dueDate else { return ("", .secondary) } if task.overdue { return ("Overdue", Palette.danger) } if showDay && !Calendar.current.isDateInToday(due) { return (dayLabel(for: due), .secondary) } return (timeFormatter.string(from: due), .secondary) } var body: some View { let row = HStack(spacing: 9) { Circle() .strokeBorder(ringColor, lineWidth: 2) .frame(width: 15, height: 15) Text(task.name) .font(.system(size: 13, weight: .medium)) .foregroundColor(.primary) .lineLimit(1) Spacer(minLength: 6) Text(meta.text) .font(.system(size: 11)) .foregroundColor(meta.color) if let assignee = assignee { AvatarView(member: assignee, image: assigneeImage) } } .frame(minHeight: 22) if let url = task.deepLink { Link(destination: url) { row } } else { row } } } struct WidgetHeader: View { let title: String let count: Int let lastUpdated: Date? var showAdd = false var body: some View { VStack(alignment: .leading, spacing: 1) { HStack(spacing: 6) { Text(title) .font(.system(size: 14, weight: .bold)) .foregroundColor(.primary) Spacer() if count > 0 { Text("\(count)") .font(.system(size: 11, weight: .bold)) .foregroundColor(Palette.accent) .padding(.horizontal, 7) .padding(.vertical, 2) .background(Palette.accentSoft) .clipShape(Capsule()) } if showAdd, let url = addTaskURL { Link(destination: url) { Image(systemName: "plus") .font(.system(size: 11, weight: .bold)) .foregroundColor(Palette.accent) .frame(width: 22, height: 22) .background(Palette.accentSoft) .clipShape(Circle()) } } } Text(subtitle) .font(.system(size: 10)) .foregroundColor(.secondary) } } private var subtitle: String { let date = dayFormatter.string(from: Date()) guard let updated = lastUpdated else { return date } return "\(date) · Updated \(timeFormatter.string(from: updated))" } } struct StateMessage: View { let systemImage: String let title: String let detail: String var body: some View { VStack(spacing: 5) { Image(systemName: systemImage) .font(.system(size: 22)) .foregroundColor(Palette.accent) Text(title) .font(.system(size: 13, weight: .semibold)) .foregroundColor(.primary) Text(detail) .font(.system(size: 11)) .foregroundColor(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } private extension TaskEntry { /// Tasks this widget instance shows, respecting its includeOthers option. var visibleTasks: [WidgetTask] { WidgetStore.visibleTasks(tasks, includeOthers: includeOthers) } /// Assignee decoration for a row — only in "everyone" mode, and only for /// tasks that are someone else's (own tasks stay clean). func assignee(for task: WidgetTask) -> WidgetMember? { guard includeOthers, let owner = task.assignedTo, owner != myUserId else { return nil } return members.first { $0.id == owner } } } // MARK: - Today widget struct TodayWidgetView: View { let entry: TaskEntry @Environment(\.widgetFamily) private var family private var tasks: [WidgetTask] { WidgetStore.todaySubset(entry.visibleTasks) } var body: some View { if !entry.signedIn { StateMessage( systemImage: "person.crop.circle.badge.exclamationmark", title: "Sign in", detail: "Open Donetick to see your tasks" ) } else if family == .systemSmall { smallView } else if tasks.isEmpty { VStack(alignment: .leading, spacing: 0) { WidgetHeader(title: "Today", count: 0, lastUpdated: entry.lastUpdated, showAdd: true) StateMessage( systemImage: "checkmark.circle", title: "All caught up!", detail: "Nothing due today" ) } } else { listView } } private var smallView: some View { let overdueCount = tasks.filter(\.overdue).count return VStack(alignment: .leading, spacing: 2) { Text("Today") .font(.system(size: 12, weight: .semibold)) .foregroundColor(.secondary) Text("\(tasks.count)") .font(.system(size: 40, weight: .bold, design: .rounded)) .foregroundColor(tasks.isEmpty ? .secondary : Palette.accent) Text(tasks.isEmpty ? "all caught up" : (tasks.count == 1 ? "task left" : "tasks left")) .font(.system(size: 12)) .foregroundColor(.secondary) Spacer(minLength: 2) if overdueCount > 0 { Text("\(overdueCount) overdue") .font(.system(size: 11, weight: .semibold)) .foregroundColor(Palette.danger) } else if let first = tasks.first { Text(first.name) .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) } else if let updated = entry.lastUpdated { Text("Updated \(timeFormatter.string(from: updated))") .font(.system(size: 10)) .foregroundColor(.secondary) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .widgetURL(URL(string: "donetick://chores")) } private var listView: some View { let limit = family == .systemLarge ? 9 : 3 let visible = Array(tasks.prefix(limit)) let remaining = tasks.count - visible.count return VStack(alignment: .leading, spacing: 4) { WidgetHeader(title: "Today", count: tasks.count, lastUpdated: entry.lastUpdated, showAdd: true) Spacer(minLength: 2) ForEach(visible) { task in TaskRow( task: task, assignee: entry.assignee(for: task), assigneeImage: task.assignedTo.flatMap { entry.avatars[$0] } ) } if remaining > 0 { Text("+\(remaining) more") .font(.system(size: 10, weight: .medium)) .foregroundColor(.secondary) } Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } struct TodayWidget: Widget { var body: some WidgetConfiguration { AppIntentConfiguration( kind: "DonetickTodayWidget", intent: WidgetOptionsIntent.self, provider: DonetickProvider() ) { entry in TodayWidgetView(entry: entry).widgetShell() } .configurationDisplayName("Today") .description("Tasks due today, plus anything waiting on you.") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } } // MARK: - Next 7 days widget struct WeekWidgetView: View { let entry: TaskEntry @Environment(\.widgetFamily) private var family private var tasks: [WidgetTask] { entry.visibleTasks } private enum WeekRow: Identifiable { case header(String) case task(WidgetTask) var id: String { switch self { case .header(let label): return "header-\(label)" case .task(let task): return "task-\(task.id)" } } } var body: some View { if !entry.signedIn { StateMessage( systemImage: "person.crop.circle.badge.exclamationmark", title: "Sign in", detail: "Open Donetick to see your tasks" ) } else if tasks.isEmpty { VStack(alignment: .leading, spacing: 0) { WidgetHeader(title: "Next 7 days", count: 0, lastUpdated: entry.lastUpdated) StateMessage( systemImage: "checkmark.circle", title: "All caught up!", detail: "Nothing due this week" ) } } else if family == .systemMedium { compactView } else { groupedView } } // Medium: flat rows with the day in the meta column. private var compactView: some View { let visible = Array(tasks.prefix(3)) let remaining = tasks.count - visible.count return VStack(alignment: .leading, spacing: 4) { WidgetHeader(title: "Next 7 days", count: tasks.count, lastUpdated: entry.lastUpdated) Spacer(minLength: 2) ForEach(visible) { task in TaskRow( task: task, showDay: true, assignee: entry.assignee(for: task), assigneeImage: task.assignedTo.flatMap { entry.avatars[$0] } ) } if remaining > 0 { Text("+\(remaining) more") .font(.system(size: 10, weight: .medium)) .foregroundColor(.secondary) } Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } // Large: rows grouped under day headers. private var groupedView: some View { var rows: [WeekRow] = [] var currentGroup: String? let approvals = tasks.filter(\.approval) if !approvals.isEmpty { rows.append(.header("Needs approval")) rows.append(contentsOf: approvals.map(WeekRow.task)) } for task in tasks where !task.approval { guard let due = task.dueDate else { continue } let group = dayLabel(for: due) if group != currentGroup { rows.append(.header(group)) currentGroup = group } rows.append(.task(task)) } let visible = Array(rows.prefix(12)) let remainingTasks = rows.dropFirst(12).filter { if case .task = $0 { return true } return false }.count return VStack(alignment: .leading, spacing: 3) { WidgetHeader(title: "Next 7 days", count: tasks.count, lastUpdated: entry.lastUpdated) Spacer(minLength: 2) ForEach(visible) { row in switch row { case .header(let label): Text(label.uppercased()) .font(.system(size: 9, weight: .bold)) .foregroundColor(.secondary) .kerning(0.8) .padding(.top, 3) case .task(let task): TaskRow( task: task, assignee: entry.assignee(for: task), assigneeImage: task.assignedTo.flatMap { entry.avatars[$0] } ) } } if remainingTasks > 0 { Text("+\(remainingTasks) more") .font(.system(size: 10, weight: .medium)) .foregroundColor(.secondary) } Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } struct WeekWidget: Widget { var body: some WidgetConfiguration { AppIntentConfiguration( kind: "DonetickWeekWidget", intent: WidgetOptionsIntent.self, provider: DonetickProvider() ) { entry in WeekWidgetView(entry: entry).widgetShell() } .configurationDisplayName("Next 7 Days") .description("Tasks for the next 7 days, grouped by day.") .supportedFamilies([.systemMedium, .systemLarge]) } } // MARK: - People widget private struct PersonLoad: Identifiable { let member: WidgetMember let todayCount: Int let weekCount: Int var id: String { member.id } } struct PeopleWidgetView: View { let entry: TaskEntry @Environment(\.widgetFamily) private var family private var people: [PersonLoad] { let todayTasks = WidgetStore.todaySubset(entry.tasks) return entry.members .map { member in PersonLoad( member: member, todayCount: todayTasks.filter { $0.assignedTo == member.id }.count, weekCount: entry.tasks.filter { $0.assignedTo == member.id }.count ) } .sorted { a, b in if a.todayCount != b.todayCount { return a.todayCount > b.todayCount } if a.weekCount != b.weekCount { return a.weekCount > b.weekCount } return a.member.name.localizedCaseInsensitiveCompare(b.member.name) == .orderedAscending } } var body: some View { if !entry.signedIn { StateMessage( systemImage: "person.crop.circle.badge.exclamationmark", title: "Sign in", detail: "Open Donetick to see your circle" ) } else if entry.members.isEmpty { VStack(alignment: .leading, spacing: 0) { WidgetHeader(title: "People", count: 0, lastUpdated: entry.lastUpdated) StateMessage( systemImage: "person.2", title: "No members yet", detail: "Invite your circle in Donetick" ) } } else if family == .systemMedium { mediumView } else { largeView } } // Medium: up to four members side by side, avatar first. private var mediumView: some View { let visible = Array(people.prefix(4)) return VStack(alignment: .leading, spacing: 6) { WidgetHeader(title: "People", count: 0, lastUpdated: entry.lastUpdated) Spacer(minLength: 2) HStack(alignment: .top, spacing: 0) { ForEach(visible) { person in VStack(spacing: 3) { AvatarView( member: person.member, image: entry.avatars[person.member.id], size: 34 ) Text(person.member.name) .font(.system(size: 10, weight: .semibold)) .foregroundColor(.primary) .lineLimit(1) Text("\(person.todayCount) today") .font(.system(size: 9, weight: person.todayCount > 0 ? .bold : .regular)) .foregroundColor(person.todayCount > 0 ? Palette.accent : .secondary) Text("\(person.weekCount) this week") .font(.system(size: 9)) .foregroundColor(.secondary) } .frame(maxWidth: .infinity) } } Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } // Large: one row per member. private var largeView: some View { let visible = Array(people.prefix(9)) return VStack(alignment: .leading, spacing: 4) { WidgetHeader(title: "People", count: 0, lastUpdated: entry.lastUpdated) Spacer(minLength: 2) ForEach(visible) { person in HStack(spacing: 9) { AvatarView( member: person.member, image: entry.avatars[person.member.id], size: 26 ) Text(person.member.name) .font(.system(size: 13, weight: .semibold)) .foregroundColor(.primary) .lineLimit(1) Spacer(minLength: 6) Text("\(person.todayCount) today · \(person.weekCount) this week") .font(.system(size: 11)) .foregroundColor(person.todayCount > 0 ? Palette.accent : .secondary) } .frame(minHeight: 28) } Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } struct PeopleWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: "DonetickPeopleWidget", provider: PeopleProvider()) { entry in PeopleWidgetView(entry: entry).widgetShell() } .configurationDisplayName("People") .description("Everyone in your circle with their tasks for today and the week ahead.") .supportedFamilies([.systemMedium, .systemLarge]) } } // MARK: - Quick Capture widget /// One of the three ways into the add-task flow. Purely a launcher — the /// destinations are handled in src/CapacitorListener.js. private struct QuickCaptureAction: Identifiable { let id: String let title: String let systemImage: String let url: URL? static let all: [QuickCaptureAction] = [ QuickCaptureAction( id: "type", title: "Type", systemImage: "plus", url: URL(string: "donetick://chores/add") ), QuickCaptureAction( id: "scan", title: "Scan", systemImage: "doc.viewfinder", url: URL(string: "donetick://chores/add?mode=scan") ), QuickCaptureAction( id: "voice", title: "Speak", systemImage: "mic.fill", url: URL(string: "donetick://chores/add?mode=voice") ), ] } private struct QuickCaptureTile: View { let action: QuickCaptureAction var body: some View { let tile = VStack(spacing: 5) { Image(systemName: action.systemImage) .font(.system(size: 22, weight: .medium)) .foregroundColor(Palette.accent) Text(action.title) .font(.system(size: 11, weight: .semibold)) .foregroundColor(Palette.accent) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Palette.accentSoft) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) if let url = action.url { Link(destination: url) { tile } } else { tile } } } struct QuickCaptureEntry: TimelineEntry { let date: Date } /// Static content — one entry, never reloaded. struct QuickCaptureProvider: TimelineProvider { func placeholder(in context: Context) -> QuickCaptureEntry { QuickCaptureEntry(date: Date()) } func getSnapshot(in context: Context, completion: @escaping (QuickCaptureEntry) -> Void) { completion(QuickCaptureEntry(date: Date())) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { completion(Timeline(entries: [QuickCaptureEntry(date: Date())], policy: .never)) } } struct QuickCaptureWidgetView: View { var body: some View { HStack(spacing: 8) { ForEach(QuickCaptureAction.all) { action in QuickCaptureTile(action: action) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } } struct QuickCaptureWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: "DonetickQuickCaptureWidget", provider: QuickCaptureProvider()) { _ in QuickCaptureWidgetView().widgetShell() } .configurationDisplayName("Quick Capture") .description("Capture a task in one tap — type it, scan it, or say it.") // Medium only: systemSmall gives the whole widget a single tap target, // which can't carry three separate destinations. .supportedFamilies([.systemMedium]) } } // MARK: - Bundle @main struct DonetickWidgetBundle: WidgetBundle { var body: some Widget { TodayWidget() WeekWidget() PeopleWidget() QuickCaptureWidget() } }