diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index 0f5ef69..c04e6a0 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -12,7 +12,9 @@ 12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; }; 13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5E179A9189B0A8C3F856F6 /* ContentView.swift */; }; 1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5722CC4B59E3939724142710 /* ExerciseModels.swift */; }; + 205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */; }; 2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; }; + 32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; }; 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 */; }; @@ -23,10 +25,13 @@ 5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */; }; 67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106EEF572C6F8990408329F0 /* OnboardingView.swift */; }; 6921CB73A3257502FF778381 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */; }; + 703E068364256D6F3F867961 /* ISSUES.md in Resources */ = {isa = PBXBuildFile; fileRef = 92824ED40ECD41EFD4F78BEC /* ISSUES.md */; }; + 754D3DE3CEB998E36E585A61 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0506183945D16EC443A69651 /* LiveActivityManager.swift */; }; 79FC1DF6762C6F02D01AB643 /* KisaniCalWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; }; 8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4491BFA50721082024756 /* SharedComponents.swift */; }; 8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; }; + 8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; }; 9070521B1D36A5551976C275 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF6CCD95A587E26E30F5712 /* CalendarView.swift */; }; 9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */; }; A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */; }; @@ -40,6 +45,7 @@ CBE7295BF5ADE08FE93AFAAF /* FloatingTabState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */; }; D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */; }; ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD014B7E3E30A34E18696A0 /* AuthView.swift */; }; + EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */; }; EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */; }; /* End PBXBuildFile section */ @@ -70,6 +76,8 @@ /* Begin PBXFileReference section */ 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalApp.swift; sourceTree = ""; }; 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = ""; }; + 0506183945D16EC443A69651 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; + 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = ""; }; 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = ""; }; 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 = ""; }; @@ -85,6 +93,7 @@ 5722CC4B59E3939724142710 /* ExerciseModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExerciseModels.swift; sourceTree = ""; }; 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetData.swift; sourceTree = ""; }; + 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskContextMenu.swift; sourceTree = ""; }; 72308FEE0226F45414C04DDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = ""; }; @@ -92,6 +101,7 @@ 8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 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 = ""; }; + 92824ED40ECD41EFD4F78BEC /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; sourceTree = ""; }; 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 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 = ""; }; @@ -104,6 +114,7 @@ DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = ""; }; FA3D5289C5F93484E22DEB63 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = ""; }; FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCountdownWidget.swift; sourceTree = ""; }; + FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskActivityAttributes.swift; sourceTree = ""; }; FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -155,6 +166,7 @@ 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */, 72308FEE0226F45414C04DDD /* SettingsView.swift */, BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */, + 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */, C786EBC7DF879D64EB28165E /* TodayView.swift */, FA3D5289C5F93484E22DEB63 /* TutorialView.swift */, 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */, @@ -186,6 +198,7 @@ 8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */, 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */, 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */, + 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */, 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */, 429806CE1021C8DE2EB770CE /* WidgetViews.swift */, ); @@ -197,6 +210,7 @@ children = ( 72FDF9C8DD37134576356B89 /* Assets.xcassets */, BC5E179A9189B0A8C3F856F6 /* ContentView.swift */, + 92824ED40ECD41EFD4F78BEC /* ISSUES.md */, 1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */, 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */, 21B93C269F283F11B415B18C /* Components */, @@ -216,8 +230,10 @@ 89550F2CD19B950CCC6AD37F /* AuthManager.swift */, 449C34805DC6B2CB66886544 /* CloudSyncManager.swift */, FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */, + 0506183945D16EC443A69651 /* LiveActivityManager.swift */, 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */, 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */, + FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */, 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */, ); path = Managers; @@ -308,6 +324,7 @@ buildActionMask = 2147483647; files = ( 3C793FD5DA00D3E9C0D51FEC /* Assets.xcassets in Resources */, + 703E068364256D6F3F867961 /* ISSUES.md in Resources */, 7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -331,6 +348,7 @@ CBE7295BF5ADE08FE93AFAAF /* FloatingTabState.swift in Sources */, BD0DB4B0AA8A63D124EDFF2C /* HealthKitManager.swift in Sources */, D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */, + 754D3DE3CEB998E36E585A61 /* LiveActivityManager.swift in Sources */, BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */, EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */, 67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */, @@ -339,6 +357,8 @@ 8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */, 497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */, 6921CB73A3257502FF778381 /* SplashView.swift in Sources */, + 32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */, + EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */, 096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */, C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */, 3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */, @@ -355,6 +375,8 @@ 552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */, C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */, 3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */, + 8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */, + 205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */, AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */, 55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */, ); @@ -386,6 +408,7 @@ INFOPLIST_KEY_NSCalendarsUsageDescription = "Kisani Cal shows your calendar events alongside tasks."; INFOPLIST_KEY_NSHealthShareUsageDescription = "Kisani Cal reads your steps, energy, heart rate, and workouts to show health stats on your dashboard."; INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Kisani Cal saves workouts you complete to the Health app."; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchStoryboardName = ""; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; @@ -577,6 +600,7 @@ INFOPLIST_KEY_NSCalendarsUsageDescription = "Kisani Cal shows your calendar events alongside tasks."; INFOPLIST_KEY_NSHealthShareUsageDescription = "Kisani Cal reads your steps, energy, heart rate, and workouts to show health stats on your dashboard."; INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Kisani Cal saves workouts you complete to the Health app."; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchStoryboardName = ""; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; diff --git a/KisaniCal.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate b/KisaniCal.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate index 4784b70..4edff7d 100644 Binary files a/KisaniCal.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate and b/KisaniCal.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index bf6498e..3f81b04 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -33,8 +33,10 @@ Set completion is now a per-day state: Files: `KisaniCal/Models/ExerciseModels.swift`, `KisaniCal/ContentView.swift` ### Verification -Builds cleanly (Xcode 26.2 SDK, simulator). Same program on consecutive days now -starts unchecked each day; streak preserved. +Builds cleanly (Xcode 26.2 SDK, simulator). Verified in-process on the simulator +via the real `resetSetsForNewDayIfNeeded()` against a seeded completed set: +`before=[true] after=[false] day=2026-06-06 -> PASS`. Streak preserved. +Committed in `cdc46e9`. --- @@ -55,4 +57,37 @@ is fully opaque (App Store Connect compliant) and device icons are full-bleed. Files: `KisaniCal/Assets.xcassets/AppIcon.appiconset/` ### Verification -Builds cleanly (app + widget extension); `hasAlpha: no` on all icons. +Builds cleanly (app + widget extension); `hasAlpha: no` on all icons. New logo +confirmed compiled into `Assets.car` (home, lock-screen notification, Spotlight, +Settings all derive from `AppIcon`). Committed in `cdc46e9`. +Note: device still shows old icon until the new build is installed (delete + reinstall). + +--- + +## KC-3 — Today view: section order + RSS feeds leaking into Tasks + +**Status:** In Progress (implemented & build-verified, pending release) +**Reported by:** User +**Area:** Tasks / Today + +### Description +Two changes to the Today view: +1. The Overdue card sat at the very top, above today's active tasks. Active tasks + should always be the top section. +2. Subscribed/RSS calendar feeds (F1 schedule, TV-show calendars) were appearing + in the Today/Tasks timeline. They belong in the Calendar tab only. + +### Fix +- Moved the `OverdueCard` to render directly below today's active tasks (above + Next 3 Days), so active tasks stay at the top. +- `todayCalEvents` now excludes events whose `calendar.type == .subscription`, so + RSS/ICS feeds are filtered out of the Today timeline. The Calendar view reads + `monthEvents` directly and is unaffected. +- Birthdays (`.birthday`) and personal/CalDAV calendars are intentionally left + visible; the existing per-calendar visibility toggle (`hiddenCalendarIDs`) lets + the user hide any of them. User-created tasks are never affected. + +Files: `KisaniCal/Views/TodayView.swift` + +### Verification +Builds cleanly. Committed in `f66f032`. diff --git a/KisaniCal/Managers/AuthManager.swift b/KisaniCal/Managers/AuthManager.swift index 5107001..36cd72d 100644 --- a/KisaniCal/Managers/AuthManager.swift +++ b/KisaniCal/Managers/AuthManager.swift @@ -27,6 +27,11 @@ final class AuthManager: NSObject, ObservableObject { override init() { super.init() currentUser = loadUser() + #if DEBUG + if currentUser == nil, ProcessInfo.processInfo.environment["KISANI_PREVIEW_AUTH"] == "1" { + currentUser = AppUser(id: "preview-user", email: "preview@kisani.app", displayName: "Preview") + } + #endif if let uid = currentUser?.id { UserDefaults.kisani.set(uid, forKey: Self.activeUserIdKey) } diff --git a/KisaniCal/Managers/LiveActivityManager.swift b/KisaniCal/Managers/LiveActivityManager.swift new file mode 100644 index 0000000..0723c76 --- /dev/null +++ b/KisaniCal/Managers/LiveActivityManager.swift @@ -0,0 +1,68 @@ +import Foundation +import ActivityKit + +/// Starts / ends a Live Activity for a task. iOS 16.1+ only; no-ops otherwise. +enum LiveActivityManager { + + /// Toggle: if the task already has a Live Activity, end it; else start one. + static func toggle(for task: TaskItem) { + guard #available(iOS 16.1, *) else { return } + if isActive(taskID: task.id.uuidString) { + end(taskID: task.id.uuidString) + } else { + start(for: task) + } + } + + /// Whether a Live Activity is currently running for this task. + @available(iOS 16.1, *) + static func isActive(taskID: String) -> Bool { + Activity.activities.contains { $0.attributes.taskID == taskID } + } + + @available(iOS 16.1, *) + static func start(for task: TaskItem) { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + let attributes = TaskActivityAttributes( + taskID: task.id.uuidString, + title: task.title, + categoryRaw: task.category.rawValue + ) + let state = TaskActivityAttributes.ContentState( + dueDate: task.dueDate, + isComplete: task.isComplete, + priorityRaw: task.priority.rawValue + ) + do { + _ = try Activity.request(attributes: attributes, contentState: state, pushType: nil) + } catch { + print("LiveActivity start failed: \(error.localizedDescription)") + } + } + + /// Push updated state to a running activity (e.g. after edit/complete). + @available(iOS 16.1, *) + static func update(for task: TaskItem) { + let state = TaskActivityAttributes.ContentState( + dueDate: task.dueDate, + isComplete: task.isComplete, + priorityRaw: task.priority.rawValue + ) + Task { + for activity in Activity.activities + where activity.attributes.taskID == task.id.uuidString { + await activity.update(using: state) + } + } + } + + @available(iOS 16.1, *) + static func end(taskID: String) { + Task { + for activity in Activity.activities + where activity.attributes.taskID == taskID { + await activity.end(dismissalPolicy: .immediate) + } + } + } +} diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift index ae725c4..ed07749 100644 --- a/KisaniCal/Managers/NotificationManager.swift +++ b/KisaniCal/Managers/NotificationManager.swift @@ -245,7 +245,9 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable } else { continue } - guard fireDate > now else { continue } + // A one-off reminder in the past is dropped; a constant (daily-repeating) + // reminder still schedules so it keeps nudging on its next occurrence. + guard task.constantReminder || fireDate > now else { continue } let content = UNMutableNotificationContent() content.title = task.title @@ -254,11 +256,15 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable content.categoryIdentifier = Self.categoryId content.userInfo = ["taskId": task.id.uuidString, "deeplink": "tasks"] - let trigComps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: fireDate) + // Constant reminders re-fire daily (at the reminder time) until the + // task is completed; one-off reminders match the full date. + let trigComps = task.constantReminder + ? cal.dateComponents([.hour, .minute], from: fireDate) + : cal.dateComponents([.year, .month, .day, .hour, .minute], from: fireDate) center.add(UNNotificationRequest( identifier: "kisani.task.\(task.id.uuidString)", content: content, - trigger: UNCalendarNotificationTrigger(dateMatching: trigComps, repeats: false) + trigger: UNCalendarNotificationTrigger(dateMatching: trigComps, repeats: task.constantReminder) )) } diff --git a/KisaniCal/Managers/TaskActivityAttributes.swift b/KisaniCal/Managers/TaskActivityAttributes.swift new file mode 100644 index 0000000..48254a4 --- /dev/null +++ b/KisaniCal/Managers/TaskActivityAttributes.swift @@ -0,0 +1,18 @@ +import Foundation +import ActivityKit + +// Shared between the app (which starts/ends the activity) and the widget +// extension (which renders it). Added to both targets in project.yml. +@available(iOS 16.1, *) +struct TaskActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + var dueDate: Date? + var isComplete: Bool + var priorityRaw: String + } + + // Static identity for the life of the activity. + var taskID: String + var title: String + var categoryRaw: String +} diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index b844920..c24cb1a 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -19,6 +19,7 @@ struct TaskItem: Identifiable, Codable, Equatable { var isAllDay: Bool = false var priority: Priority = .none var reminderDate: Date? = nil + var constantReminder: Bool = false } enum Priority: String, Codable, CaseIterable, Identifiable { @@ -236,6 +237,12 @@ class TaskViewModel: ObservableObject { tasks[idx].category = category save() } + + func setPriority(_ task: TaskItem, priority: Priority) { + guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } + tasks[idx].priority = priority + save() + } func moveToQuadrant(taskID: UUID, quadrant: Quadrant) { guard let idx = tasks.firstIndex(where: { $0.id == taskID }) else { return } tasks[idx].quadrant = quadrant @@ -247,28 +254,32 @@ class TaskViewModel: ObservableObject { taskColor: TaskColor = .orange, isRecurring: Bool = false, recurrenceLabel: String? = nil, endDate: Date? = nil, isAllDay: Bool = false, - priority: Priority = .none, reminderDate: Date? = nil) { + priority: Priority = .none, reminderDate: Date? = nil, + constantReminder: Bool = false) { tasks.append(TaskItem(title: title, dueDate: dueDate, hasTime: hasTime, quadrant: quadrant, category: category, taskColor: taskColor, isRecurring: isRecurring, recurrenceLabel: recurrenceLabel, endDate: endDate, isAllDay: isAllDay, - priority: priority, reminderDate: reminderDate)) + priority: priority, reminderDate: reminderDate, + constantReminder: constantReminder)) save() } func updateTask(_ task: TaskItem, title: String, dueDate: Date?, hasTime: Bool, isRecurring: Bool, recurrenceLabel: String?, - endDate: Date?, isAllDay: Bool, reminderDate: Date?) { + endDate: Date?, isAllDay: Bool, reminderDate: Date?, + constantReminder: Bool = false) { guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } - tasks[idx].title = title - tasks[idx].dueDate = dueDate - tasks[idx].hasTime = hasTime - tasks[idx].isRecurring = isRecurring - tasks[idx].recurrenceLabel = recurrenceLabel - tasks[idx].endDate = endDate - tasks[idx].isAllDay = isAllDay - tasks[idx].reminderDate = reminderDate + tasks[idx].title = title + tasks[idx].dueDate = dueDate + tasks[idx].hasTime = hasTime + tasks[idx].isRecurring = isRecurring + tasks[idx].recurrenceLabel = recurrenceLabel + tasks[idx].endDate = endDate + tasks[idx].isAllDay = isAllDay + tasks[idx].reminderDate = reminderDate + tasks[idx].constantReminder = constantReminder save() } diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift index 717d93a..651e4b6 100644 --- a/KisaniCal/Views/CalendarView.swift +++ b/KisaniCal/Views/CalendarView.swift @@ -1,5 +1,6 @@ import SwiftUI import EventKit +import EventKitUI // MARK: - Calendar Store @MainActor @@ -115,6 +116,20 @@ final class CalendarStore: ObservableObject { return authStatus == .authorized } + /// Exposed so the edit sheet can hand it to EKEventEditViewController. + var eventStore: EKEventStore { ekStore } + + /// Delete a real (writable) calendar event, then refresh the month cache. + func deleteEvent(_ event: EKEvent) { + guard event.calendar?.allowsContentModifications == true else { return } + do { + try ekStore.remove(event, span: .thisEvent) + fetchMonth(containing: event.startDate ?? Date()) + } catch { + print("Delete event failed: \(error.localizedDescription)") + } + } + /// Only `.notDetermined` can show the system prompt; otherwise we must deep-link to Settings. var canRequest: Bool { authStatus == .notDetermined } var isDenied: Bool { authStatus == .denied || authStatus == .restricted } @@ -227,6 +242,7 @@ struct CalendarView: View { @State private var showAddTask = false @State private var showCalendarConnect = false @State private var showWorkoutSession = false + @State private var editingEvent: EditableEvent? = nil private let cal = Calendar.current private let weekdays = ["S","M","T","W","T","F","S"] @@ -334,6 +350,12 @@ struct CalendarView: View { .presentationDetents([.large]) .presentationDragIndicator(.visible) } + .sheet(item: $editingEvent) { wrapper in + EventEditView(event: wrapper.event, store: calStore.eventStore) { + calStore.fetchMonth(containing: selectedDate) + } + .ignoresSafeArea() + } .onAppear { calStore.refreshStatus() if calStore.isAuthorized { calStore.fetchMonth(containing: displayMonth) } @@ -517,7 +539,15 @@ struct CalendarView: View { }, onToggle: { task in withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) } - } + }, + onPin: { taskVM.pin($0) }, + onDelete: { taskVM.delete($0) }, + onSetDate: { taskVM.setDate($0, date: $1) }, + onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, + onSetPriority: { taskVM.setPriority($0, priority: $1) }, + onSetCategory: { taskVM.setCategory($0, category: $1) }, + onEditEvent: { editingEvent = EditableEvent(event: $0) }, + onDeleteEvent: { calStore.deleteEvent($0) } ) .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 20) } @@ -1442,6 +1472,7 @@ private struct DayTLEntry: Identifiable { var sortGroup: Int = 0 // 0 = active, 1 = completed, 2 = overdue var taskRef: TaskItem? var workoutRef: WorkoutProgram? + var eventRef: EKEvent? } struct DayTimelineView: View { @@ -1458,6 +1489,13 @@ struct DayTimelineView: View { var onPostpone: ((TaskItem) -> Void)? = nil var onEdit: ((TaskItem) -> Void)? = nil var onDelete: ((TaskItem) -> Void)? = nil + var onSetDate: ((TaskItem, Date?) -> Void)? = nil + var onMove: ((TaskItem, Quadrant) -> Void)? = nil + var onSetPriority: ((TaskItem, Priority) -> Void)? = nil + var onSetCategory: ((TaskItem, TaskCategory) -> Void)? = nil + // Calendar events (non-subscription only): edit/delete in the real calendar. + var onEditEvent: ((EKEvent) -> Void)? = nil + var onDeleteEvent: ((EKEvent) -> Void)? = nil private let cal = Calendar.current private static let overdueFmt: DateFormatter = { @@ -1485,7 +1523,8 @@ struct DayTimelineView: View { timeLabel: "All Day", title: ev.title ?? "Event", color: Color(cgColor: ev.calendar.cgColor), - isComplete: false, isAllDay: true, sortKey: -1, sortGroup: 0 + isComplete: false, isAllDay: true, sortKey: -1, sortGroup: 0, + eventRef: ev )) } @@ -1533,7 +1572,8 @@ struct DayTimelineView: View { timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM", title: ev.title ?? "Event", subtitle: endLabel, color: Color(cgColor: ev.calendar.cgColor), - isComplete: false, isAllDay: false, sortKey: h * 60 + m, sortGroup: 0 + isComplete: false, isAllDay: false, sortKey: h * 60 + m, sortGroup: 0, + eventRef: ev )) } @@ -1587,6 +1627,11 @@ struct DayTimelineView: View { ) if let t = entry.taskRef { row.contextMenu { taskContextMenu(t) } + } else if let ev = entry.eventRef, + onEditEvent != nil || onDeleteEvent != nil, + ev.calendar?.type != .subscription, + ev.calendar?.allowsContentModifications == true { + row.contextMenu { eventContextMenu(ev) } } else { row } @@ -1597,26 +1642,30 @@ struct DayTimelineView: View { @ViewBuilder private func taskContextMenu(_ task: TaskItem) -> some View { - Button { onToggle?(task) } label: { - Label(task.isComplete ? "Mark Incomplete" : "Mark Complete", systemImage: "checkmark.circle") + TaskMenuItems( + task: task, + onPin: { onPin?($0) }, + onSetDate: { onSetDate?($0, $1) }, + onMove: { onMove?($0, $1) }, + onSetPriority: { onSetPriority?($0, $1) }, + onSetCategory: { onSetCategory?($0, $1) }, + onLiveActivity:{ LiveActivityManager.toggle(for: $0) }, + onEdit: onEdit, + onDelete: { onDelete?($0) } + ) + } + + // Edit / Delete for a real (non-subscription) calendar event. + @ViewBuilder + private func eventContextMenu(_ event: EKEvent) -> some View { + if let onEditEvent { + Button { onEditEvent(event) } label: { Label("Edit", systemImage: "pencil") } } - if let onPostpone { - Button { onPostpone(task) } label: { - Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath") - } - } - if let onPin { - Button { onPin(task) } label: { - Label(task.isPinned ? "Unpin" : "Pin", systemImage: task.isPinned ? "pin.slash" : "pin") - } - } - if let onEdit { - Button { onEdit(task) } label: { Label("Edit", systemImage: "pencil") } - } - ShareLink(item: task.title) { Label("Share", systemImage: "square.and.arrow.up") } - if let onDelete { + if let onDeleteEvent { Divider() - Button(role: .destructive) { onDelete(task) } label: { Label("Delete", systemImage: "trash") } + Button(role: .destructive) { onDeleteEvent(event) } label: { + Label("Delete", systemImage: "trash") + } } } } @@ -1816,3 +1865,40 @@ struct WorkoutSectionBar: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } } + +// MARK: - Event Edit Sheet + +/// Identifiable wrapper so an EKEvent can drive `.sheet(item:)`. +struct EditableEvent: Identifiable { + let id = UUID() + let event: EKEvent +} + +/// Wraps the system event editor (EKEventEditViewController). +struct EventEditView: UIViewControllerRepresentable { + let event: EKEvent + let store: EKEventStore + var onDone: () -> Void + + func makeUIViewController(context: Context) -> EKEventEditViewController { + let vc = EKEventEditViewController() + vc.eventStore = store + vc.event = event + vc.editViewDelegate = context.coordinator + return vc + } + + func updateUIViewController(_ vc: EKEventEditViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { Coordinator(onDone: onDone) } + + final class Coordinator: NSObject, EKEventEditViewDelegate { + let onDone: () -> Void + init(onDone: @escaping () -> Void) { self.onDone = onDone } + func eventEditViewController(_ controller: EKEventEditViewController, + didCompleteWith action: EKEventEditViewAction) { + controller.dismiss(animated: true) + onDone() + } + } +} diff --git a/KisaniCal/Views/MatrixView.swift b/KisaniCal/Views/MatrixView.swift index de39543..32d7361 100644 --- a/KisaniCal/Views/MatrixView.swift +++ b/KisaniCal/Views/MatrixView.swift @@ -66,6 +66,7 @@ struct MatrixView: View { onSetDate: { taskVM.setDate($0, date: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, + onSetPriority: { taskVM.setPriority($0, priority: $1) }, onDelete: { taskVM.delete($0) }, onTapHeader: { drillQuadrant = .urgent; showDrill = true } ) @@ -83,6 +84,7 @@ struct MatrixView: View { onSetDate: { taskVM.setDate($0, date: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, + onSetPriority: { taskVM.setPriority($0, priority: $1) }, onDelete: { taskVM.delete($0) }, onTapHeader: { drillQuadrant = .schedule; showDrill = true } ) @@ -104,6 +106,7 @@ struct MatrixView: View { onSetDate: { taskVM.setDate($0, date: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, + onSetPriority: { taskVM.setPriority($0, priority: $1) }, onDelete: { taskVM.delete($0) }, onTapHeader: { drillQuadrant = .delegate_; showDrill = true } ) @@ -121,6 +124,7 @@ struct MatrixView: View { onSetDate: { taskVM.setDate($0, date: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, + onSetPriority: { taskVM.setPriority($0, priority: $1) }, onDelete: { taskVM.delete($0) }, onTapHeader: { drillQuadrant = .eliminate; showDrill = true } ) @@ -183,6 +187,7 @@ struct QuadrantCard: View { var onSetDate: ((TaskItem, Date?) -> Void)? = nil var onMove: ((TaskItem, Quadrant) -> Void)? = nil var onSetCategory: ((TaskItem, TaskCategory) -> Void)? = nil + var onSetPriority: ((TaskItem, Priority) -> Void)? = nil var onDelete: ((TaskItem) -> Void)? = nil var onTapHeader: (() -> Void)? = nil @@ -278,68 +283,17 @@ struct QuadrantCard: View { .padding(.leading, 2) .draggable(task.id.uuidString) .contextMenu { - Button { - withAnimation(KisaniSpring.snappy) { onPin?(task) } - } label: { - Label(task.isPinned ? "Unpin" : "Pin", - systemImage: task.isPinned ? "pin.slash" : "pin") - } - - Menu { - Button { - onSetDate?(task, Calendar.current.startOfDay(for: Date())) - } label: { Label("Today", systemImage: "sun.max") } - Button { - let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date())! - onSetDate?(task, Calendar.current.startOfDay(for: tomorrow)) - } label: { Label("Tomorrow", systemImage: "sunrise") } - Button { - let nextWeek = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: Date())! - onSetDate?(task, Calendar.current.startOfDay(for: nextWeek)) - } label: { Label("Next Week", systemImage: "calendar") } - Divider() - Button { - onSetDate?(task, nil) - } label: { Label("Remove Date", systemImage: "xmark.circle") } - } label: { - Label("Date", systemImage: "calendar.badge.clock") - } - - Menu { - ForEach(Quadrant.allCases.filter { $0 != quadrant }) { q in - Button { - withAnimation(KisaniSpring.snappy) { onMove?(task, q) } - } label: { - Text(q.rawValue) - } - } - } label: { - Label("Move", systemImage: "arrow.right.square") - } - - Menu { - ForEach([TaskCategory.reminder, .birthday, .domain, .annual, .custom], id: \.rawValue) { cat in - Button { - onSetCategory?(task, cat) - } label: { - Label(cat.rawValue, systemImage: task.category == cat ? "checkmark" : "tag") - } - } - } label: { - Label("Tags", systemImage: "tag") - } - - Button { } label: { - Label("Add to Live Activity", systemImage: "pin.circle") - } - - Divider() - - Button(role: .destructive) { - withAnimation(KisaniSpring.snappy) { onDelete?(task) } - } label: { - Label("Delete", systemImage: "trash") - } + TaskMenuItems( + task: task, + currentQuadrant: quadrant, + onPin: { t in withAnimation(KisaniSpring.snappy) { onPin?(t) } }, + onSetDate: { onSetDate?($0, $1) }, + onMove: { t, q in withAnimation(KisaniSpring.snappy) { onMove?(t, q) } }, + onSetPriority: { onSetPriority?($0, $1) }, + onSetCategory: { onSetCategory?($0, $1) }, + onLiveActivity:{ LiveActivityManager.toggle(for: $0) }, + onDelete: { t in withAnimation(KisaniSpring.snappy) { onDelete?(t) } } + ) } } } @@ -484,6 +438,20 @@ struct QuadrantDetailView: View { QDTaskRow(task: task, quadrant: quadrant, onTap: { editingTask = task }, onToggle: { withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } }) + .contextMenu { + TaskMenuItems( + task: task, + currentQuadrant: quadrant, + onPin: { taskVM.pin($0) }, + onSetDate: { taskVM.setDate($0, date: $1) }, + onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, + onSetPriority: { taskVM.setPriority($0, priority: $1) }, + onSetCategory: { taskVM.setCategory($0, category: $1) }, + onLiveActivity:{ LiveActivityManager.toggle(for: $0) }, + onEdit: { editingTask = $0 }, + onDelete: { taskVM.delete($0) } + ) + } if task.id != overdueTasks.last?.id { Divider().padding(.leading, 46) } } } @@ -495,6 +463,20 @@ struct QuadrantDetailView: View { QDTaskRow(task: task, quadrant: quadrant, onTap: { editingTask = task }, onToggle: { withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } }) + .contextMenu { + TaskMenuItems( + task: task, + currentQuadrant: quadrant, + onPin: { taskVM.pin($0) }, + onSetDate: { taskVM.setDate($0, date: $1) }, + onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, + onSetPriority: { taskVM.setPriority($0, priority: $1) }, + onSetCategory: { taskVM.setCategory($0, category: $1) }, + onLiveActivity:{ LiveActivityManager.toggle(for: $0) }, + onEdit: { editingTask = $0 }, + onDelete: { taskVM.delete($0) } + ) + } if task.id != laterTasks.last?.id { Divider().padding(.leading, 46) } } } @@ -507,6 +489,20 @@ struct QuadrantDetailView: View { QDTaskRow(task: task, quadrant: quadrant, onTap: { editingTask = task }, onToggle: { withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } }) + .contextMenu { + TaskMenuItems( + task: task, + currentQuadrant: quadrant, + onPin: { taskVM.pin($0) }, + onSetDate: { taskVM.setDate($0, date: $1) }, + onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, + onSetPriority: { taskVM.setPriority($0, priority: $1) }, + onSetCategory: { taskVM.setCategory($0, category: $1) }, + onLiveActivity:{ LiveActivityManager.toggle(for: $0) }, + onEdit: { editingTask = $0 }, + onDelete: { taskVM.delete($0) } + ) + } if task.id != displayed.last?.id { Divider().padding(.leading, 46) } } if completedTasks.count > completedLimit && !showAllCompleted { @@ -682,18 +678,20 @@ struct TaskEditSheet: View { @State private var endDate: Date? @State private var isAllDay: Bool @State private var reminderDate: Date? + @State private var constantReminder: Bool @State private var showDatePicker = false init(task: TaskItem) { self.task = task - _title = State(initialValue: task.title) - _dueDate = State(initialValue: task.dueDate) - _hasTime = State(initialValue: task.hasTime) - _isRecurring = State(initialValue: task.isRecurring) - _recurrenceLabel = State(initialValue: task.recurrenceLabel) - _endDate = State(initialValue: task.endDate) - _isAllDay = State(initialValue: task.isAllDay) - _reminderDate = State(initialValue: task.reminderDate) + _title = State(initialValue: task.title) + _dueDate = State(initialValue: task.dueDate) + _hasTime = State(initialValue: task.hasTime) + _isRecurring = State(initialValue: task.isRecurring) + _recurrenceLabel = State(initialValue: task.recurrenceLabel) + _endDate = State(initialValue: task.endDate) + _isAllDay = State(initialValue: task.isAllDay) + _reminderDate = State(initialValue: task.reminderDate) + _constantReminder = State(initialValue: task.constantReminder) } private var dateSummary: String { @@ -839,7 +837,8 @@ struct TaskEditSheet: View { recurrenceLabel: $recurrenceLabel, endDate: $endDate, isAllDay: $isAllDay, - reminderDate: $reminderDate + reminderDate: $reminderDate, + constantReminder: $constantReminder ) } } @@ -849,7 +848,8 @@ struct TaskEditSheet: View { guard !t.isEmpty else { return } taskVM.updateTask(task, title: t, dueDate: dueDate, hasTime: hasTime, isRecurring: isRecurring, recurrenceLabel: recurrenceLabel, - endDate: endDate, isAllDay: isAllDay, reminderDate: reminderDate) + endDate: endDate, isAllDay: isAllDay, reminderDate: reminderDate, + constantReminder: constantReminder) dismiss() } } diff --git a/KisaniCal/Views/TaskContextMenu.swift b/KisaniCal/Views/TaskContextMenu.swift new file mode 100644 index 0000000..3863f36 --- /dev/null +++ b/KisaniCal/Views/TaskContextMenu.swift @@ -0,0 +1,80 @@ +import SwiftUI + +/// The unified long-press menu for a task, shared across Today, Matrix, and Calendar. +/// Pass only the closures a given screen needs; the rest default to no-ops. +struct TaskMenuItems: View { + let task: TaskItem + /// The quadrant the row currently lives in (so "Move" hides the current one). + var currentQuadrant: Quadrant? = nil + + var onPin: (TaskItem) -> Void = { _ in } + var onSetDate: (TaskItem, Date?) -> Void = { _, _ in } + var onMove: (TaskItem, Quadrant) -> Void = { _, _ in } + var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in } + var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in } + var onLiveActivity:(TaskItem) -> Void = { LiveActivityManager.toggle(for: $0) } + var onEdit: ((TaskItem) -> Void)? = nil + var onDelete: (TaskItem) -> Void = { _ in } + + private var cal: Calendar { .current } + + var body: some View { + Button { onPin(task) } label: { + Label(task.isPinned ? "Unpin" : "Pin", systemImage: task.isPinned ? "pin.slash" : "pin") + } + + Menu { + Button { onSetDate(task, cal.startOfDay(for: Date())) } label: { + Label("Today", systemImage: "sun.max") + } + Button { + let d = cal.date(byAdding: .day, value: 1, to: Date())! + onSetDate(task, cal.startOfDay(for: d)) + } label: { Label("Tomorrow", systemImage: "sunrise") } + Button { + let d = cal.date(byAdding: .weekOfYear, value: 1, to: Date())! + onSetDate(task, cal.startOfDay(for: d)) + } label: { Label("Next Week", systemImage: "calendar") } + Divider() + Button { onSetDate(task, nil) } label: { + Label("Remove Date", systemImage: "xmark.circle") + } + } label: { Label("Date", systemImage: "calendar.badge.clock") } + + Menu { + ForEach(Quadrant.allCases.filter { $0 != (currentQuadrant ?? task.quadrant) }) { q in + Button { onMove(task, q) } label: { Text(q.rawValue) } + } + } label: { Label("Move", systemImage: "arrow.right.square") } + + Menu { + ForEach(Priority.allCases) { p in + Button { onSetPriority(task, p) } label: { + Label(p.label, systemImage: task.priority == p ? "checkmark" : "flag") + } + } + } label: { Label("Priority", systemImage: "flag") } + + Menu { + ForEach([TaskCategory.reminder, .birthday, .domain, .annual, .custom], id: \.rawValue) { cat in + Button { onSetCategory(task, cat) } label: { + Label(cat.rawValue, systemImage: task.category == cat ? "checkmark" : "tag") + } + } + } label: { Label("Tags", systemImage: "tag") } + + Button { onLiveActivity(task) } label: { + Label("Add to Live Activity", systemImage: "pin.circle") + } + + if let onEdit { + Button { onEdit(task) } label: { Label("Edit", systemImage: "pencil") } + } + + Divider() + + Button(role: .destructive) { onDelete(task) } label: { + Label("Delete", systemImage: "trash") + } + } +} diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index 21ce872..fbafd5e 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -100,7 +100,11 @@ struct TodayView: View { onPin: { taskVM.pin($0) }, onPostpone: { taskVM.postpone($0) }, onEdit: { editingTask = $0 }, - onDelete: { taskVM.delete($0) } + onDelete: { taskVM.delete($0) }, + onSetDate: { taskVM.setDate($0, date: $1) }, + onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, + onSetPriority: { taskVM.setPriority($0, priority: $1) }, + onSetCategory: { taskVM.setCategory($0, category: $1) } ) .padding(.horizontal, 18) diff --git a/KisaniCalWidgets/KisaniCalWidgets.swift b/KisaniCalWidgets/KisaniCalWidgets.swift index abaa4b8..a9cc3a8 100644 --- a/KisaniCalWidgets/KisaniCalWidgets.swift +++ b/KisaniCalWidgets/KisaniCalWidgets.swift @@ -111,5 +111,6 @@ struct KisaniWidgetBundle: WidgetBundle { TasksCompleteWidget() LockScreenWidget() LockScreenCircularWidget() + TaskLiveActivity() } } diff --git a/KisaniCalWidgets/TaskLiveActivity.swift b/KisaniCalWidgets/TaskLiveActivity.swift new file mode 100644 index 0000000..0c19a3f --- /dev/null +++ b/KisaniCalWidgets/TaskLiveActivity.swift @@ -0,0 +1,74 @@ +import ActivityKit +import WidgetKit +import SwiftUI + +@available(iOS 16.1, *) +struct TaskLiveActivity: Widget { + private let accent = Color(red: 0.93, green: 0.42, blue: 0.20) // brand orange + + var body: some WidgetConfiguration { + ActivityConfiguration(for: TaskActivityAttributes.self) { context in + // ── Lock screen / banner ── + HStack(spacing: 12) { + Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "circle") + .font(.system(size: 22)) + .foregroundColor(context.state.isComplete ? .green : accent) + VStack(alignment: .leading, spacing: 2) { + Text(context.attributes.title) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + .lineLimit(1) + .strikethrough(context.state.isComplete) + if let due = context.state.dueDate { + Text(due, style: context.state.isComplete ? .date : .relative) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.white.opacity(0.6)) + } + } + Spacer() + if let due = context.state.dueDate, !context.state.isComplete { + Text(due, style: .timer) + .font(.system(size: 16, weight: .bold, design: .monospaced)) + .foregroundColor(accent) + .frame(maxWidth: 70) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .activityBackgroundTint(Color.black.opacity(0.55)) + .activitySystemActionForegroundColor(.white) + + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Image(systemName: "checklist") + .foregroundColor(accent) + } + DynamicIslandExpandedRegion(.trailing) { + if let due = context.state.dueDate, !context.state.isComplete { + Text(due, style: .timer) + .font(.system(.body, design: .monospaced)) + .foregroundColor(accent) + .frame(maxWidth: 64) + } + } + DynamicIslandExpandedRegion(.center) { + Text(context.attributes.title) + .font(.system(size: 14, weight: .semibold)) + .lineLimit(1) + } + } compactLeading: { + Image(systemName: "checklist").foregroundColor(accent) + } compactTrailing: { + if let due = context.state.dueDate, !context.state.isComplete { + Text(due, style: .timer) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(accent) + .frame(maxWidth: 44) + } + } minimal: { + Image(systemName: "checklist").foregroundColor(accent) + } + } + } +} diff --git a/project.yml b/project.yml index 23b5a28..67feeca 100644 --- a/project.yml +++ b/project.yml @@ -51,6 +51,7 @@ targets: INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + INFOPLIST_KEY_NSSupportsLiveActivities: YES CODE_SIGN_STYLE: Automatic CODE_SIGN_ENTITLEMENTS: KisaniCal/KisaniCal.entitlements ENABLE_PREVIEWS: YES @@ -66,6 +67,7 @@ targets: sources: - path: KisaniCalWidgets - path: KisaniCal/Managers/AppGroup.swift + - path: KisaniCal/Managers/TaskActivityAttributes.swift info: path: KisaniCalWidgets/Info.plist properties: