feat: My Tasks widget, event countdown redesign, completed section
- My Tasks widget: today's tasks with interactive check-off (ToggleTaskIntent) and a "+" that opens the app's add-task sheet; writes preserve all task fields and sync via the App Group. - Event Countdown: AZURE/AWS-style header (accent name + countdown label + percent); tap the label to cycle days → weeks → time remaining. - Today: collapsible "Completed" archive section below Upcoming. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -67,11 +67,25 @@ struct EventQuery: EntityQuery {
|
||||
|
||||
// MARK: - Entry
|
||||
|
||||
// Tapping the countdown label cycles this unit (shared by all event widgets).
|
||||
let countdownUnitKey = "kisani.widget.countdownUnit" // 0 = days, 1 = weeks, 2 = time
|
||||
|
||||
struct CycleCountdownUnitIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Change Countdown Unit"
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
let cur = UserDefaults.kisani.integer(forKey: countdownUnitKey)
|
||||
UserDefaults.kisani.set((cur + 1) % 3, forKey: countdownUnitKey)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct EventEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let eventName: String
|
||||
let start: Date
|
||||
let target: Date
|
||||
var unitMode: Int = 0 // 0 days, 1 weeks, 2 time
|
||||
|
||||
/// Fraction of the countdown window that has elapsed (0…1).
|
||||
var progress: Double {
|
||||
@@ -97,6 +111,30 @@ struct EventEntry: TimelineEntry {
|
||||
to: cal.startOfDay(for: target)).day ?? 0
|
||||
return max(0, d)
|
||||
}
|
||||
|
||||
/// Countdown from the current date. Tapping the label cycles the unit:
|
||||
/// days → weeks → time remaining.
|
||||
var timeLeftLabel: String {
|
||||
if target <= date { return "Today" }
|
||||
let cal = Calendar.current
|
||||
switch unitMode {
|
||||
case 1: // weeks
|
||||
let days = cal.dateComponents([.day], from: cal.startOfDay(for: date),
|
||||
to: cal.startOfDay(for: target)).day ?? 0
|
||||
if days < 7 { return "< 1 week left" }
|
||||
let weeks = days / 7
|
||||
return "\(weeks) week\(weeks == 1 ? "" : "s") left"
|
||||
case 2: // time remaining (detailed)
|
||||
let c = cal.dateComponents([.day, .hour], from: date, to: target)
|
||||
let days = c.day ?? 0, hours = c.hour ?? 0
|
||||
if days == 0 { return hours <= 0 ? "Due now" : "\(hours) hr left" }
|
||||
return hours > 0 ? "\(days)d, \(hours) hr left" : "\(days) day\(days == 1 ? "" : "s") left"
|
||||
default: // days
|
||||
let days = cal.dateComponents([.day], from: cal.startOfDay(for: date),
|
||||
to: cal.startOfDay(for: target)).day ?? 0
|
||||
return "\(days) day\(days == 1 ? "" : "s") left"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider
|
||||
@@ -133,7 +171,8 @@ struct EventProvider: AppIntentTimelineProvider {
|
||||
}
|
||||
// Never let the start sit after the target.
|
||||
let start = min(config.startDate, target)
|
||||
return EventEntry(date: .now, eventName: name, start: start, target: target)
|
||||
let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey)
|
||||
return EventEntry(date: .now, eventName: name, start: start, target: target, unitMode: mode)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ struct LockScreenCircularWidget: Widget {
|
||||
struct KisaniWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
EventCountdownWidget()
|
||||
MyTasksWidget()
|
||||
DayProgressWidget()
|
||||
TasksCompleteWidget()
|
||||
LockScreenWidget()
|
||||
|
||||
148
KisaniCalWidgets/MyTasksWidget.swift
Normal file
148
KisaniCalWidgets/MyTasksWidget.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
import AppIntents
|
||||
|
||||
// MARK: - Interactive intents
|
||||
|
||||
/// Tapping a checkbox toggles the task's completion directly in the App Group.
|
||||
struct ToggleTaskIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Toggle Task Complete"
|
||||
|
||||
@Parameter(title: "Task ID")
|
||||
var taskId: String
|
||||
|
||||
init() {}
|
||||
init(taskId: String) { self.taskId = taskId }
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
toggleWidgetTaskComplete(id: taskId)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
/// The "+" button opens the app and asks it to present the add-task sheet.
|
||||
struct AddTaskFromWidgetIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Add Task"
|
||||
static var openAppWhenRun = true
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
UserDefaults.kisani.set(true, forKey: "kisani.widget.openAddTask")
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entry + Provider
|
||||
|
||||
struct TasksEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let tasks: [WidgetTask]
|
||||
let openCount: Int
|
||||
}
|
||||
|
||||
struct TasksProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> TasksEntry {
|
||||
TasksEntry(date: .now, tasks: [
|
||||
WidgetTask(id: UUID(), title: "Pray for Uganda", dueDate: .now, isComplete: false,
|
||||
quadrant: "Urgent + Important", category: "reminder"),
|
||||
WidgetTask(id: UUID(), title: "Masters of the Universe", dueDate: .now, isComplete: false,
|
||||
quadrant: "Schedule It", category: "reminder")
|
||||
], openCount: 2)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (TasksEntry) -> Void) {
|
||||
completion(loadEntry())
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<TasksEntry>) -> Void) {
|
||||
let next = Calendar.current.startOfDay(for: Date().addingTimeInterval(86400))
|
||||
completion(Timeline(entries: [loadEntry()], policy: .after(next)))
|
||||
}
|
||||
|
||||
private func loadEntry() -> TasksEntry {
|
||||
let tasks = todayWidgetTasks()
|
||||
return TasksEntry(date: .now, tasks: tasks, openCount: tasks.filter { !$0.isComplete }.count)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
struct MyTasksWidget: Widget {
|
||||
let kind = "KisaniMyTasks"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: TasksProvider()) { entry in
|
||||
MyTasksView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("My Tasks")
|
||||
.description("Today's tasks — check them off right from the widget.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
|
||||
struct MyTasksView: View {
|
||||
let entry: TasksEntry
|
||||
@Environment(\.widgetFamily) var family
|
||||
|
||||
private var maxRows: Int {
|
||||
switch family {
|
||||
case .systemLarge: return 9
|
||||
case .systemMedium: return 4
|
||||
default: return 3
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Text("Today")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundColor(Color(red: 0.42, green: 0.5, blue: 0.96))
|
||||
Text("\(entry.openCount)")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
Spacer()
|
||||
Button(intent: AddTaskFromWidgetIntent()) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundColor(Color(red: 0.42, green: 0.5, blue: 0.96))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if entry.tasks.isEmpty {
|
||||
Spacer()
|
||||
Text("All clear for today 🎉")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Spacer()
|
||||
} else {
|
||||
ForEach(entry.tasks.prefix(maxRows)) { task in
|
||||
HStack(spacing: 10) {
|
||||
Button(intent: ToggleTaskIntent(taskId: task.id.uuidString)) {
|
||||
Image(systemName: task.isComplete ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(task.isComplete ? task.accentColor : Color(white: 0.45))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Text(task.title)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(task.isComplete ? Color(white: 0.45) : .white)
|
||||
.strikethrough(task.isComplete, color: Color(white: 0.45))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
if entry.tasks.count > maxRows {
|
||||
Text("+\(entry.tasks.count - maxRows) more")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(Color(white: 0.4))
|
||||
.padding(.leading, 28)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.containerBackground(.black, for: .widget)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// Minimal task model shared between widget and main app via App Group
|
||||
struct WidgetTask: Codable, Identifiable {
|
||||
@@ -84,6 +85,59 @@ func nextDueTask(from tasks: [WidgetTask]) -> WidgetTask? {
|
||||
.first
|
||||
}
|
||||
|
||||
private func activeTasksKey() -> String {
|
||||
let uid = UserDefaults.kisani.string(forKey: "kisani.activeUserId") ?? "shared"
|
||||
return "kisani.tasks.v2.\(uid)"
|
||||
}
|
||||
|
||||
// Tasks due today (incomplete first, then by due time) for the interactive list widget.
|
||||
func todayWidgetTasks() -> [WidgetTask] {
|
||||
let cal = Calendar.current
|
||||
let start = cal.startOfDay(for: Date())
|
||||
let end = cal.date(byAdding: .day, value: 1, to: start)!
|
||||
return loadWidgetTasks()
|
||||
.filter { t in
|
||||
guard let due = t.dueDate else { return false }
|
||||
return due >= start && due < end
|
||||
}
|
||||
.sorted {
|
||||
if $0.isComplete != $1.isComplete { return !$0.isComplete }
|
||||
return ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle a task's completion from the widget. Uses JSONSerialization so ALL task
|
||||
// fields are preserved on write (the widget only models a subset of TaskItem).
|
||||
func toggleWidgetTaskComplete(id: String) {
|
||||
let key = activeTasksKey()
|
||||
guard let data = UserDefaults.kisani.data(forKey: key),
|
||||
var arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]]
|
||||
else { return }
|
||||
for i in arr.indices where (arr[i]["id"] as? String) == id {
|
||||
let done = (arr[i]["isComplete"] as? Bool) ?? false
|
||||
arr[i]["isComplete"] = !done
|
||||
if !done {
|
||||
// TaskItem uses the default (deferredToDate) Date strategy → seconds since reference date.
|
||||
arr[i]["completedAt"] = Date().timeIntervalSinceReferenceDate
|
||||
} else {
|
||||
arr[i].removeValue(forKey: "completedAt")
|
||||
}
|
||||
}
|
||||
if let out = try? JSONSerialization.data(withJSONObject: arr) {
|
||||
UserDefaults.kisani.set(out, forKey: key)
|
||||
CloudSyncManager_widgetPush(out, key)
|
||||
}
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
// The widget extension can't see CloudSyncManager; mirror to iCloud KV store directly.
|
||||
private func CloudSyncManager_widgetPush(_ data: Data, _ key: String) {
|
||||
guard data.count < 900_000 else { return }
|
||||
let kv = NSUbiquitousKeyValueStore.default
|
||||
kv.set(data, forKey: key)
|
||||
kv.synchronize()
|
||||
}
|
||||
|
||||
// Today's task completion: (done, total) for tasks due today.
|
||||
func todayTaskCompletion() -> (done: Int, total: Int) {
|
||||
let cal = Calendar.current
|
||||
|
||||
@@ -191,22 +191,31 @@ struct EventCountdownView: View {
|
||||
.containerBackground(.black, for: .widget)
|
||||
}
|
||||
|
||||
// Wide layout that mirrors the reference: title + percent, then a dot grid.
|
||||
private var filled: Int { Int(entry.progress * Double(entry.totalDays)) }
|
||||
|
||||
// Wide layout: event name + days-left countdown, then the dot-grid progress.
|
||||
private var medium: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(LocalizedStringKey(entry.eventName))
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundColor(teal)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text("\(percent)%")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
Spacer(minLength: 4)
|
||||
VStack(alignment: .trailing, spacing: 1) {
|
||||
Button(intent: CycleCountdownUnitIntent()) {
|
||||
Text(entry.timeLeftLabel)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(Color(white: 0.62))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Text("\(percent)%")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundColor(Color(white: 0.4))
|
||||
}
|
||||
}
|
||||
DotGrid(total: entry.totalDays,
|
||||
filled: Int(entry.progress * Double(entry.totalDays)),
|
||||
color: teal)
|
||||
DotGrid(total: entry.totalDays, filled: filled, color: teal)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
@@ -215,20 +224,17 @@ struct EventCountdownView: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(LocalizedStringKey(entry.eventName))
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundColor(teal)
|
||||
.lineLimit(2)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(entry.daysLeft)")
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundColor(teal)
|
||||
Text(entry.daysLeft == 1 ? "day" : "days")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
Button(intent: CycleCountdownUnitIntent()) {
|
||||
Text(entry.timeLeftLabel)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(Color(white: 0.62))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer(minLength: 2)
|
||||
DotGrid(total: entry.totalDays,
|
||||
filled: Int(entry.progress * Double(entry.totalDays)),
|
||||
color: teal)
|
||||
DotGrid(total: entry.totalDays, filled: filled, color: teal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user