Files
donetick/ios/App/DonetickWidget/DonetickWidget.swift
2026-07-28 02:02:44 -04:00

1108 lines
39 KiB
Swift

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<TaskEntry> {
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<TaskEntry> {
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<TaskEntry>) -> 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<QuickCaptureEntry>) -> 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()
}
}