From 646cdf6a9b5c1d15c27823056e1e66b0f6bcccb4 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Wed, 3 Jun 2026 13:31:04 +0300 Subject: [PATCH] feat: My Tasks widget, event countdown redesign, completed section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- KisaniCal.xcodeproj/project.pbxproj | 26 ++- .../xcshareddata/xcschemes/KisaniCal.xcscheme | 16 +- KisaniCal/ContentView.swift | 10 ++ KisaniCal/Models/TaskItem.swift | 6 + KisaniCal/Views/TodayView.swift | 78 +++++++++ KisaniCalWidgets/EventCountdownWidget.swift | 41 ++++- KisaniCalWidgets/KisaniCalWidgets.swift | 1 + KisaniCalWidgets/MyTasksWidget.swift | 148 ++++++++++++++++++ KisaniCalWidgets/WidgetData.swift | 54 +++++++ KisaniCalWidgets/WidgetViews.swift | 50 +++--- 10 files changed, 386 insertions(+), 44 deletions(-) create mode 100644 KisaniCalWidgets/MyTasksWidget.swift diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index d047b6c..0f5ef69 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */; }; 3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89550F2CD19B950CCC6AD37F /* AuthManager.swift */; }; 3C793FD5DA00D3E9C0D51FEC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 72FDF9C8DD37134576356B89 /* Assets.xcassets */; }; + 3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */; }; 497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */; }; 552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; }; 55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; }; @@ -70,9 +71,10 @@ 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalApp.swift; sourceTree = ""; }; 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = ""; }; 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = ""; }; - 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = ""; }; + 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = ""; }; 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = ""; }; 23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = ""; }; 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = ""; }; @@ -91,7 +93,7 @@ 8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCalWidgets.entitlements; sourceTree = ""; }; 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = ""; }; 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; - 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; }; ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = ""; }; AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = ""; }; BC5E179A9189B0A8C3F856F6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -183,6 +185,7 @@ 8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */, 8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */, 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */, + 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */, 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */, 429806CE1021C8DE2EB770CE /* WidgetViews.swift */, ); @@ -267,7 +270,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 2620; + LastUpgradeCheck = 1500; TargetAttributes = { 28B43516AD88946E21D9BFE0 = { DevelopmentTeam = K8BLMMR883; @@ -351,6 +354,7 @@ 12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */, 552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */, C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */, + 3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */, AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */, 55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */, ); @@ -438,7 +442,6 @@ DEVELOPMENT_TEAM = K8BLMMR883; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -453,7 +456,6 @@ MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; - STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; @@ -469,11 +471,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = KisaniCalWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets; SDKROOT = iphoneos; SWIFT_VERSION = 5.9; @@ -520,7 +518,6 @@ DEVELOPMENT_TEAM = K8BLMMR883; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -542,7 +539,6 @@ ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; - STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -558,11 +554,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = KisaniCalWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets; SDKROOT = iphoneos; SWIFT_VERSION = 5.9; diff --git a/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme b/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme index 675309e..9db359c 100644 --- a/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme +++ b/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme @@ -1,10 +1,11 @@ + LastUpgradeVersion = "1500" + version = "1.7"> + buildImplicitDependencies = "YES" + runPostActionsOnFailure = "NO"> + shouldUseLaunchSchemeArgsEnv = "YES" + onlyGenerateCoverageForSpecifiedTargets = "NO"> + + + + + + diff --git a/KisaniCal/ContentView.swift b/KisaniCal/ContentView.swift index f4e239f..5cbdda2 100644 --- a/KisaniCal/ContentView.swift +++ b/KisaniCal/ContentView.swift @@ -68,6 +68,14 @@ struct ContentView: View { private var isIPad: Bool { UIDevice.current.userInterfaceIdiom == .pad } + /// Picks up the "+" tapped on the My Tasks widget and opens the add-task sheet. + private func consumeWidgetAddTask() { + guard UserDefaults.kisani.bool(forKey: "kisani.widget.openAddTask") else { return } + UserDefaults.kisani.removeObject(forKey: "kisani.widget.openAddTask") + selectedTab = 0 + showQuickAddTask = true + } + var body: some View { Group { if isIPad { @@ -89,6 +97,7 @@ struct ContentView: View { CloudSyncManager.shared.backupSettings() HealthKitManager.shared.bootstrap() NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) + consumeWidgetAddTask() // Pull workouts from Health, then reschedule so a detected workout updates reminders. Task { await workoutVM.syncFromHealthKit() @@ -98,6 +107,7 @@ struct ContentView: View { .onChange(of: scenePhase) { phase in if phase == .active { taskVM.reload() + consumeWidgetAddTask() workoutVM.checkPendingHealthConfirm() CloudSyncManager.shared.backupSettings() NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index ea1f681..b844920 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -200,6 +200,12 @@ class TaskViewModel: ObservableObject { .filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= startOfDay } .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } } + /// All completed tasks, most-recent first (for the Completed archive section). + var completedTasks: [TaskItem] { + tasks + .filter { $0.isComplete } + .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } + } func toggle(_ task: TaskItem) { guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index e69889c..500aa21 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -123,6 +123,16 @@ struct TodayView: View { ) } + // ── Completed (all, collapsible) ── + if !taskVM.completedTasks.isEmpty { + CompletedSection( + tasks: taskVM.completedTasks, + onUndo: { taskVM.toggle($0) } + ) + .padding(.horizontal, 18) + .padding(.top, 10) + } + let hasAnything = !taskVM.overdueTasks.isEmpty || !taskVM.todayTasks.isEmpty || !taskVM.upcomingTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty || !taskVM.next3DaysTasks.isEmpty || !todayCalEvents.isEmpty @@ -1009,6 +1019,74 @@ struct CompletedTodaySection: View { } } +// MARK: - Completed Section (all completed, collapsible archive) +struct CompletedSection: View { + @Environment(\.colorScheme) private var cs + let tasks: [TaskItem] + let onUndo: (TaskItem) -> Void + + @State private var expanded = false + + private let dateFmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "MMM d"; return f + }() + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Completed") + .font(AppFonts.sans(15, weight: .bold)) + .foregroundColor(AppColors.text2(cs)) + Spacer() + Text("\(tasks.count)") + .font(AppFonts.mono(11)) + .foregroundColor(AppColors.text3(cs)) + Image(systemName: "chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(AppColors.text3(cs)) + .rotationEffect(.degrees(expanded ? 0 : -90)) + .animation(KisaniSpring.micro, value: expanded) + } + .padding(.horizontal, 14).padding(.vertical, 12) + .contentShape(Rectangle()) + .onTapGesture { withAnimation(KisaniSpring.snappy) { expanded.toggle() } } + + if expanded { + ForEach(tasks) { task in + HStack(spacing: 12) { + Button { + withAnimation(KisaniSpring.snappy) { onUndo(task) } + } label: { + Image(systemName: "checkmark.square.fill") + .font(.system(size: 22)) + .foregroundColor(AppColors.text3(cs).opacity(0.6)) + } + .buttonStyle(.plain) + + Text(task.title) + .font(AppFonts.sans(14)) + .foregroundColor(AppColors.text3(cs)) + .strikethrough(true, color: AppColors.text3(cs).opacity(0.7)) + .lineLimit(1) + Spacer(minLength: 8) + if let d = task.completedAt { + Text(dateFmt.string(from: d)) + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text3(cs)) + } + } + .padding(.horizontal, 14).padding(.vertical, 10) + } + .padding(.bottom, 4) + } + } + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1)) + } +} + // MARK: - Task Row struct TaskRowView: View { @Environment(\.colorScheme) private var cs diff --git a/KisaniCalWidgets/EventCountdownWidget.swift b/KisaniCalWidgets/EventCountdownWidget.swift index 23d57f2..45c4fab 100644 --- a/KisaniCalWidgets/EventCountdownWidget.swift +++ b/KisaniCalWidgets/EventCountdownWidget.swift @@ -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) } } diff --git a/KisaniCalWidgets/KisaniCalWidgets.swift b/KisaniCalWidgets/KisaniCalWidgets.swift index 36ef86d..abaa4b8 100644 --- a/KisaniCalWidgets/KisaniCalWidgets.swift +++ b/KisaniCalWidgets/KisaniCalWidgets.swift @@ -106,6 +106,7 @@ struct LockScreenCircularWidget: Widget { struct KisaniWidgetBundle: WidgetBundle { var body: some Widget { EventCountdownWidget() + MyTasksWidget() DayProgressWidget() TasksCompleteWidget() LockScreenWidget() diff --git a/KisaniCalWidgets/MyTasksWidget.swift b/KisaniCalWidgets/MyTasksWidget.swift new file mode 100644 index 0000000..738072d --- /dev/null +++ b/KisaniCalWidgets/MyTasksWidget.swift @@ -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) -> 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) + } +} diff --git a/KisaniCalWidgets/WidgetData.swift b/KisaniCalWidgets/WidgetData.swift index e3986ce..1d6b83f 100644 --- a/KisaniCalWidgets/WidgetData.swift +++ b/KisaniCalWidgets/WidgetData.swift @@ -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 diff --git a/KisaniCalWidgets/WidgetViews.swift b/KisaniCalWidgets/WidgetViews.swift index 8b244b0..5faffa3 100644 --- a/KisaniCalWidgets/WidgetViews.swift +++ b/KisaniCalWidgets/WidgetViews.swift @@ -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) } } }