From 87c999c6768f02d7a0b4a696698d17cd2f3bf801 Mon Sep 17 00:00:00 2001 From: kutesir Date: Mon, 15 Jun 2026 19:53:14 +0300 Subject: [PATCH] =?UTF-8?q?Today=20menu=20features,=20Matrix=20Move=20trim?= =?UTF-8?q?,=20widget=20auto-advance,=20Wenza=20logo=20(KC-23=E2=80=9326)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Today ⋯ menu: implemented the placeholder actions. - Background: 5 selectable themes (Default/Warm/Cool/Mono/Paper), persisted. - Group & Sort: group by Date (default)/Priority/Category/Quadrant and sort by Smart/Due/Priority/Title; non-date groupings render via UpcomingSection. - Select: multi-select sheet with Select All/Deselect All and bulk Complete/Priority/Move/Delete. New isolated file TodayMenuFeatures.swift; added TaskViewModel.allActiveRows. - Matrix "Move" submenu now lists only the other three quadrants (excludes the current one) using clear matrix labels. - Event Countdown (Bars) widget auto-advances: each timeline entry re-resolves "nearest to finish" as of its own time and refreshes at the moment the current event expires, so it rolls to the next event on expiry/completion. - Splash wordmark rebranded to "wenza." (regular-weight typewriter + orange dot). - ISSUES.md: logged KC-23 (Pick Date + time-based reminders), KC-24 (Wenza rebrand), KC-25 (Move trim), KC-26 (widget auto-advance). Co-Authored-By: Claude Opus 4.8 --- KisaniCal.xcodeproj/project.pbxproj | 26 +- .../xcshareddata/xcschemes/KisaniCal.xcscheme | 16 +- KisaniCal/ISSUES.md | 120 ++++++- KisaniCal/Models/TaskItem.swift | 4 + KisaniCal/Views/SplashView.swift | 7 +- KisaniCal/Views/TaskContextMenu.swift | 5 +- KisaniCal/Views/TodayMenuFeatures.swift | 323 ++++++++++++++++++ KisaniCal/Views/TodayView.swift | 61 +++- KisaniCalWidgets/EventCountdownWidget.swift | 61 ++-- 9 files changed, 565 insertions(+), 58 deletions(-) create mode 100644 KisaniCal/Views/TodayMenuFeatures.swift diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index a65c20d..a751ba7 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 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 */; }; + 83EA218392952885C97144D1 /* TodayMenuFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */; }; 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 */; }; @@ -80,7 +81,7 @@ 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; 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 = ""; }; @@ -104,7 +105,7 @@ 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; 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 = ""; }; @@ -114,6 +115,7 @@ C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = ""; }; D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = ""; }; DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = ""; }; + ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayMenuFeatures.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 = ""; }; @@ -169,6 +171,7 @@ 72308FEE0226F45414C04DDD /* SettingsView.swift */, BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */, 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */, + ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */, C786EBC7DF879D64EB28165E /* TodayView.swift */, FA3D5289C5F93484E22DEB63 /* TutorialView.swift */, 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */, @@ -289,7 +292,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 2620; + LastUpgradeCheck = 1500; TargetAttributes = { 28B43516AD88946E21D9BFE0 = { DevelopmentTeam = K8BLMMR883; @@ -364,6 +367,7 @@ 32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */, EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */, 096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */, + 83EA218392952885C97144D1 /* TodayMenuFeatures.swift in Sources */, C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */, 3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */, 8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */, @@ -469,7 +473,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; @@ -484,7 +487,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; @@ -500,11 +502,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; @@ -551,7 +549,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; @@ -573,7 +570,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; @@ -589,11 +585,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/ISSUES.md b/KisaniCal/ISSUES.md index f046f00..3ae9187 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -751,8 +751,126 @@ Files: `TaskContextMenu.swift`, `TaskItem.swift`, `TodayView.swift`, ### How to test 1. Long-press a task in Today → Postpone → 15 Minutes; reminder/due time moves. 2. Repeat from Matrix grid, Matrix detail, and Calendar day timeline. -3. Open Move from the same menu → all four quadrants are visible, current one checked. +3. Open Move from the same menu → only the other three quadrants are listed (see KC-25). 4. Trigger a task reminder notification on device → verify 15/30/60/120 minute lock-screen actions appear. 5. Tap a lock-screen snooze action → task reminder updates and notification re-delivers after the selected interval. + +--- + +## KC-23 — "Pick Date" everywhere + sensible time-based reminders + +**Status:** In Progress (implemented & build-verified, pending device QA) +**Reported by:** User +**Area:** Tasks / Matrix / Calendar / Reminders + +### Problem +The Date submenu offered "Pick Date" only in the Today view — the Matrix grid +cards and the Calendar reschedule/move menu were missing it. Separately, the +reminder lead-time options were day/week based ("2 days early", "1 week early"), +which is unrealistic for timed events; the user wanted the standard time-based +set. + +### Fix +- "Pick Date" (and Edit) now appear in the Matrix grid cards and the Calendar day + timeline menu — both open the shared `TaskEditSheet` via a new `editingTask` + sheet (`QuadrantCard` gained an `onEdit`; Calendar passes `onEdit` into the + task menu). +- Reworked the reminder picker (`TaskDatePickerSheet`, shared by every entry + point) from day-offsets to **minutes before the event time**: None, On time, + 5 min, 30 min, 1 hour, 1 day, plus a Minutes/Hours/Days custom wheel. Existing + absolute `reminderDate`s migrate to the nearest minute offset on open. + +Files: `MatrixView.swift`, `CalendarView.swift`, `TodayView.swift`. + +### How to test +1. Long-press a task in Matrix and in Calendar → Date → "Pick Date" is present. +2. Create/edit a task → reminder options read On time / 5 min / 30 min / 1 hour / + 1 day / Custom, relative to the event time. +3. Custom → Minutes/Hours/Days toggle + count wheel commits the right offset. + +--- + +## KC-24 — App rebrand to "Wenza" + +**Status:** In Progress (display name + copy done; App Store name set in ASC) +**Reported by:** User +**Area:** Branding + +### Problem +The app is being rebranded from "Kisani Cal" to **Wenza**. + +### Fix +- `CFBundleDisplayName` → "Wenza" (app) and "Wenza Widgets" (widget) in + `project.yml`; permission prompts and in-app copy (sidebar, Auth, Onboarding, + Settings, tutorial, widget description) updated to Wenza. +- **Deliberately unchanged** to preserve App Store identity and user data: bundle + ID `com.kutesir.KisaniCal`, App Group `group.com.kutesir.KisaniCal`, + `kisani.tasks.v2.*` storage keys, team ID, and internal Xcode target/project + names. +- App Store listing name set separately in App Store Connect (ships with the + next version). + +Files: `project.yml`, `ContentView.swift`, `TutorialManager.swift`, `AuthView.swift`, +`OnboardingView.swift`, `SettingsView.swift`, `KisaniCalWidgets.swift`. + +### How to test +1. Build & run → home-screen label and all in-app titles read "Wenza". +2. Confirm tasks/widget data survive (App Group + keys unchanged). + +--- + +## KC-25 — Matrix "Move" lists only the other quadrants + +**Status:** In Progress (implemented & build-verified, pending device QA) +**Reported by:** User +**Area:** Tasks / Matrix + +### Problem +The Move submenu listed all four quadrants with a checkmark on the current one, +so the quadrant a task already sat in appeared as a no-op option. + +### Fix +- `TaskMenuItems` Move submenu now excludes the task's current quadrant and lists + only the other three, using the clear matrix labels (e.g. a task in Urgent & + Important shows Not Urgent & Important, Urgent & Unimportant, Not Urgent & + Unimportant). Shared across Today, Matrix, and Calendar. + +Files: `TaskContextMenu.swift`. + +### How to test +1. Long-press a task in any quadrant → Move shows only the three other quadrants. +2. Pick one → task moves (priority/urgency updated per the TickTick model). + +--- + +## KC-26 — Bars widget: auto-select advances to the next event + +**Status:** In Progress (implemented & build-verified, pending device QA) +**Reported by:** User +**Area:** Widgets + +### Problem +With "Upcoming queue" on + "Nearest to finish", the Event Countdown (Bars) widget +stayed stuck on an event after it expired (e.g. a passed deadline) instead of +rolling to the next-nearest. The selection was frozen: the timeline computed the +chosen event once and reused it for all 24 pre-rendered entries, only refreshing +every 12 hours. + +### Fix +- Each timeline entry now re-resolves the nearest-to-finish event *as of that + entry's own time* (`upcomingEvents(asOf:)` filters `dueDate > entryDate`), so + the moment the current event expires the next entry rolls to the next one. +- The timeline refreshes the instant the current selection expires + (`currentSelectionExpiry`), not 12 hours later, giving the next event a fresh + full pre-render window. +- Completing/closing a task already reloads widget timelines, and the recompute + drops it and picks the next. Tracked-event and manual-pick paths unchanged. + +Files: `EventCountdownWidget.swift`. + +### How to test +1. Queue on + Nearest to finish; let the nearest event's due time pass → widget + rolls to the next-nearest without manual action. +2. Complete the nearest event → widget advances to the next. diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index 4d1416e..bd051c7 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -222,6 +222,10 @@ class TaskViewModel: ObservableObject { return out } + /// All incomplete single-occurrence rows (dated and undated) — for Group & Sort + /// and multi-select, which regroup the same task set the date sections draw from. + var allActiveRows: [TaskItem] { activeRows() } + /// Active rows whose due date falls in [today+lo, today+hi) days (hi nil = open). private func bucket(from lo: Int, to hi: Int?) -> [TaskItem] { let cal = Calendar.current; let base = cal.startOfDay(for: Date()) diff --git a/KisaniCal/Views/SplashView.swift b/KisaniCal/Views/SplashView.swift index 40ab2b6..714f797 100644 --- a/KisaniCal/Views/SplashView.swift +++ b/KisaniCal/Views/SplashView.swift @@ -20,14 +20,11 @@ struct SplashView: View { .scaleEffect(iconScale) .opacity(iconOpacity) - // kisaniCAL. — single line, orange dot + // wenza. — single line, orange dot HStack(spacing: 0) { - Text("kisani") + Text("wenza") .font(AppFonts.mono(22, weight: .regular)) .foregroundColor(AppColors.text(cs)) - Text("CAL") - .font(AppFonts.mono(22, weight: .bold)) - .foregroundColor(AppColors.text(cs)) Text(".") .font(AppFonts.mono(22, weight: .bold)) .foregroundColor(AppColors.accent) diff --git a/KisaniCal/Views/TaskContextMenu.swift b/KisaniCal/Views/TaskContextMenu.swift index 835036e..8341f7a 100644 --- a/KisaniCal/Views/TaskContextMenu.swift +++ b/KisaniCal/Views/TaskContextMenu.swift @@ -89,9 +89,10 @@ struct TaskMenuItems: View { } label: { Label("Postpone", systemImage: "clock.arrow.circlepath") } Menu { - ForEach(Quadrant.allCases) { q in + // Only the quadrants the task is NOT currently in — you can't move it to where it sits. + ForEach(Quadrant.allCases.filter { $0 != (currentQuadrant ?? task.quadrant) }) { q in Button { onMove(task, q) } label: { - Label(q.rawValue, systemImage: q == (currentQuadrant ?? task.quadrant) ? "checkmark" : "square") + Label(q.matrixLabel, systemImage: "arrow.right") } } } label: { Label("Move", systemImage: "arrow.right.square") } diff --git a/KisaniCal/Views/TodayMenuFeatures.swift b/KisaniCal/Views/TodayMenuFeatures.swift new file mode 100644 index 0000000..f5da73b --- /dev/null +++ b/KisaniCal/Views/TodayMenuFeatures.swift @@ -0,0 +1,323 @@ +import SwiftUI + +// MARK: - Today background themes + +enum TodayBackground { + static let presets: [(id: String, label: String)] = [ + ("default", "Default"), + ("warm", "Warm"), + ("cool", "Cool"), + ("mono", "Mono"), + ("paper", "Paper"), + ] + + /// The full-screen background for a given style. "default" is a flat surface; + /// the rest are subtle top-down tints that fade into the base background. + static func gradient(_ id: String, _ cs: ColorScheme) -> LinearGradient { + let base = AppColors.background(cs) + let top: Color + switch id { + case "warm": top = AppColors.accentSoft + case "cool": top = AppColors.blueSoft + case "mono": top = Color(.systemGray5) + case "paper": top = Color(red: 0.96, green: 0.94, blue: 0.89) + default: top = base + } + return LinearGradient(colors: [top, base], startPoint: .top, endPoint: .bottom) + } +} + +// MARK: - Group & Sort model + +enum TaskGrouping: String, CaseIterable, Identifiable { + case date, priority, category, quadrant + var id: String { rawValue } + var label: String { + switch self { + case .date: return "Date" + case .priority: return "Priority" + case .category: return "Category" + case .quadrant: return "Quadrant" + } + } +} + +enum TaskSort: String, CaseIterable, Identifiable { + case smart, due, priority, title + var id: String { rawValue } + var label: String { + switch self { + case .smart: return "Smart (pinned, then date)" + case .due: return "Due date" + case .priority: return "Priority" + case .title: return "Title" + } + } +} + +enum TaskOrganizer { + private static func priorityRank(_ p: Priority) -> Int { + switch p { case .high: return 0; case .medium: return 1; case .low: return 2; case .none: return 3 } + } + + static func sorted(_ tasks: [TaskItem], by sort: String) -> [TaskItem] { + switch sort { + case "due": + return tasks.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } + case "priority": + return tasks.sorted { priorityRank($0.priority) < priorityRank($1.priority) } + case "title": + return tasks.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + default: // smart + return tasks.sorted { + if $0.isPinned != $1.isPinned { return $0.isPinned } + return ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) + } + } + } + + /// Non-date groupings → titled sections, each internally sorted. ("date" is handled + /// by the existing day sections, so it isn't produced here.) + @MainActor + static func sections(_ tasks: [TaskItem], grouping: String, sort: String, + vm: TaskViewModel) -> [(title: String, tasks: [TaskItem])] { + let s = sorted(tasks, by: sort) + switch grouping { + case "priority": + return [Priority.high, .medium, .low, .none].compactMap { p in + let g = s.filter { $0.priority == p } + return g.isEmpty ? nil : (p.label, g) + } + case "category": + return [TaskCategory.birthday, .domain, .annual, .reminder, .custom].compactMap { c in + let g = s.filter { $0.category == c } + return g.isEmpty ? nil : (c.rawValue, g) + } + case "quadrant": + return [Quadrant.urgent, .schedule, .delegate_, .eliminate].compactMap { q in + let g = s.filter { vm.displayQuadrant($0) == q } + return g.isEmpty ? nil : (q.matrixLabel, g) + } + default: + return [("Tasks", s)] + } + } +} + +// MARK: - Background picker sheet + +struct BackgroundPickerSheet: View { + @Binding var selected: String + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var cs + + private let columns = [GridItem(.adaptive(minimum: 96), spacing: 14)] + + var body: some View { + NavigationStack { + ScrollView { + LazyVGrid(columns: columns, spacing: 14) { + ForEach(TodayBackground.presets, id: \.id) { preset in + Button { + withAnimation(KisaniSpring.snappy) { selected = preset.id } + } label: { + VStack(spacing: 8) { + RoundedRectangle(cornerRadius: 14) + .fill(TodayBackground.gradient(preset.id, cs)) + .frame(height: 80) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(selected == preset.id ? AppColors.accent : AppColors.border(cs), + lineWidth: selected == preset.id ? 2.5 : 1) + ) + .overlay(alignment: .topTrailing) { + if selected == preset.id { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(AppColors.accent) + .padding(6) + } + } + Text(preset.label) + .font(AppFonts.sans(12, weight: .medium)) + .foregroundColor(AppColors.text2(cs)) + } + } + .buttonStyle(.plain) + } + } + .padding(18) + } + .background(AppColors.background(cs).ignoresSafeArea()) + .navigationTitle("Background") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } +} + +// MARK: - Group & Sort sheet + +struct GroupSortSheet: View { + @Binding var grouping: String + @Binding var sort: String + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section("Group by") { + ForEach(TaskGrouping.allCases) { g in + Button { + withAnimation(KisaniSpring.micro) { grouping = g.rawValue } + } label: { + HStack { + Text(g.label).foregroundColor(.primary) + Spacer() + if grouping == g.rawValue { + Image(systemName: "checkmark").foregroundColor(AppColors.accent) + } + } + } + } + } + Section("Sort by") { + ForEach(TaskSort.allCases) { s in + Button { + withAnimation(KisaniSpring.micro) { sort = s.rawValue } + } label: { + HStack { + Text(s.label).foregroundColor(.primary) + Spacer() + if sort == s.rawValue { + Image(systemName: "checkmark").foregroundColor(AppColors.accent) + } + } + } + } + } + } + .navigationTitle("Group & Sort") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } +} + +// MARK: - Multi-select sheet + +struct SelectableTaskListSheet: View { + @EnvironmentObject var taskVM: TaskViewModel + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var cs + @State private var selection: Set = [] + + private let fmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "d MMM"; return f + }() + + private var rows: [TaskItem] { taskVM.allActiveRows } + private var selectedTasks: [TaskItem] { rows.filter { selection.contains($0.id) } } + + var body: some View { + NavigationStack { + List { + ForEach(rows) { task in + Button { toggle(task.id) } label: { + HStack(spacing: 10) { + Image(systemName: selection.contains(task.id) ? "checkmark.circle.fill" : "circle") + .font(.system(size: 18)) + .foregroundColor(selection.contains(task.id) ? AppColors.accent : AppColors.text3(cs)) + Text(task.title) + .font(AppFonts.sans(14)) + .foregroundColor(AppColors.text(cs)) + .lineLimit(1) + Spacer() + if let d = task.dueDate { + Text(fmt.string(from: d)) + .font(AppFonts.mono(10)) + .foregroundColor(AppColors.text3(cs)) + } + } + } + .buttonStyle(.plain) + } + } + .listStyle(.plain) + .navigationTitle(selection.isEmpty ? "Select" : "\(selection.count) Selected") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(selection.count == rows.count && !rows.isEmpty ? "Deselect All" : "Select All") { toggleAll() } + .disabled(rows.isEmpty) + } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .safeAreaInset(edge: .bottom) { + if !selection.isEmpty { bulkBar } + } + } + } + + private var bulkBar: some View { + HStack(spacing: 18) { + bulkButton("Complete", "checkmark.circle") { + selectedTasks.forEach { taskVM.toggle($0) } + selection.removeAll() + } + Menu { + ForEach(Priority.allCases) { p in + Button(p.label) { selectedTasks.forEach { taskVM.setPriority($0, priority: p) } } + } + } label: { bulkLabel("Priority", "flag") } + + Menu { + ForEach(Quadrant.allCases) { q in + Button(q.matrixLabel) { selectedTasks.forEach { taskVM.moveToQuadrant(taskID: $0.id, quadrant: q) } } + } + } label: { bulkLabel("Move", "arrow.right.square") } + + bulkButton("Delete", "trash", role: .destructive) { + selectedTasks.forEach { taskVM.delete($0) } + selection.removeAll() + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(.ultraThinMaterial) + .overlay(Rectangle().frame(height: 1).foregroundColor(AppColors.border(cs)), alignment: .top) + } + + private func bulkButton(_ title: String, _ icon: String, + role: ButtonRole? = nil, action: @escaping () -> Void) -> some View { + Button(role: role, action: action) { bulkLabel(title, icon, destructive: role == .destructive) } + } + + private func bulkLabel(_ title: String, _ icon: String, destructive: Bool = false) -> some View { + VStack(spacing: 4) { + Image(systemName: icon).font(.system(size: 18)) + Text(title).font(AppFonts.sans(10, weight: .medium)) + } + .foregroundColor(destructive ? .red : AppColors.accent) + .frame(maxWidth: .infinity) + } + + private func toggle(_ id: UUID) { + if selection.contains(id) { selection.remove(id) } else { selection.insert(id) } + } + + private func toggleAll() { + if selection.count == rows.count { selection.removeAll() } + else { selection = Set(rows.map { $0.id }) } + } +} diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index a49cd95..23890e6 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -11,6 +11,12 @@ struct TodayView: View { @State private var showWorkoutSession = false @State private var editingTask: TaskItem? = nil @AppStorage("todayHideCompleted") private var hideCompleted = false + @AppStorage("todayBackground") private var bgStyle = "default" + @AppStorage("todayGroupMode") private var groupMode = "date" + @AppStorage("todaySortMode") private var sortMode = "smart" + @State private var showBackgroundSheet = false + @State private var showGroupSortSheet = false + @State private var showSelectSheet = false @ObservedObject private var tm = TutorialManager.shared @ObservedObject private var hk = HealthKitManager.shared @@ -27,6 +33,29 @@ struct TodayView: View { } } + // Group & Sort: non-date groupings rendered as titled sections (reuses UpcomingSection). + @ViewBuilder private var groupedTaskSections: some View { + let sections = TaskOrganizer.sections(taskVM.allActiveRows, grouping: groupMode, + sort: sortMode, vm: taskVM) + ForEach(Array(sections.enumerated()), id: \.offset) { _, section in + UpcomingSection( + title: section.title, + tasks: section.tasks, + onToggle: { taskVM.toggle($0) }, + onPostpone: { taskVM.postpone($0) }, + onPin: { taskVM.pin($0) }, + onEdit: { editingTask = $0 }, + onDelete: { taskVM.delete($0) }, + onSetDate: { taskVM.setDate($0, date: $1) }, + onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, + onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, + onSetPriority: { taskVM.setPriority($0, priority: $1) }, + onSetCategory: { taskVM.setCategory($0, category: $1) }, + onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) } + ) + } + } + var body: some View { ZStack(alignment: .bottomTrailing) { ScrollView(showsIndicators: false) { @@ -36,7 +65,7 @@ struct TodayView: View { // ── Header ── HStack { Menu { - Button { } label: { Label("Background", systemImage: "paintbrush") } + Button { showBackgroundSheet = true } label: { Label("Background", systemImage: "paintbrush") } Button { withAnimation(KisaniSpring.snappy) { hideCompleted.toggle() } } label: { @@ -44,8 +73,8 @@ struct TodayView: View { systemImage: hideCompleted ? "checkmark.circle" : "checkmark.circle.badge.xmark") } Divider() - Button { } label: { Label("Group & Sort", systemImage: "arrow.up.arrow.down") } - Button { } label: { Label("Select", systemImage: "checkmark.circle") } + Button { showGroupSortSheet = true } label: { Label("Group & Sort", systemImage: "arrow.up.arrow.down") } + Button { showSelectSheet = true } label: { Label("Select", systemImage: "checkmark.circle") } } label: { Image(systemName: "line.3.horizontal") .font(.system(size: 13, weight: .medium)) @@ -81,6 +110,7 @@ struct TodayView: View { .padding(.bottom, 10) } + if groupMode == "date" { // ── Today timeline: active → completed (green) ── let activeTasks = taskVM.todayTasks let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks @@ -197,6 +227,10 @@ struct TodayView: View { ) } + } else { + groupedTaskSections + } + // ── Completed (all, collapsible) ── if !taskVM.completedTasks.isEmpty { CompletedSection( @@ -207,9 +241,7 @@ struct TodayView: View { .padding(.top, 10) } - let hasAnything = !taskVM.overdueTasks.isEmpty || !taskVM.todayTasks.isEmpty - || !taskVM.tomorrowTasks.isEmpty || !taskVM.next7DaysTasks.isEmpty - || !taskVM.laterTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty + let hasAnything = !taskVM.allActiveRows.isEmpty || !taskVM.completedTodayTasks.isEmpty || !todayCalEvents.isEmpty || workoutVM.program(for: Date()) != nil if !hasAnything { @@ -250,13 +282,28 @@ struct TodayView: View { .zIndex(10) } } - .background(AppColors.background(cs).ignoresSafeArea()) + .background(TodayBackground.gradient(bgStyle, cs).ignoresSafeArea()) .sheet(isPresented: $showAddTask) { AddTaskSheet() .environmentObject(taskVM) .presentationDetents([.height(150)]) .presentationDragIndicator(.visible) } + .sheet(isPresented: $showBackgroundSheet) { + BackgroundPickerSheet(selected: $bgStyle) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $showGroupSortSheet) { + GroupSortSheet(grouping: $groupMode, sort: $sortMode) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $showSelectSheet) { + SelectableTaskListSheet() + .environmentObject(taskVM) + .presentationDragIndicator(.visible) + } .sheet(isPresented: $showWorkoutSession) { WorkoutSessionSheet() .environmentObject(workoutVM) diff --git a/KisaniCalWidgets/EventCountdownWidget.swift b/KisaniCalWidgets/EventCountdownWidget.swift index d5a1207..e115d5e 100644 --- a/KisaniCalWidgets/EventCountdownWidget.swift +++ b/KisaniCalWidgets/EventCountdownWidget.swift @@ -209,29 +209,46 @@ struct EventProvider: AppIntentTimelineProvider { } func timeline(for configuration: EventConfigIntent, in context: Context) async -> Timeline { - let base = entry(for: configuration) - // Live progress: pre-render an entry every 30 minutes for the next 12 hours - // so the bar and time-left label keep moving between refreshes. - let entries: [EventEntry] = (0..<24).map { step in - EventEntry(date: Date().addingTimeInterval(Double(step) * 1800), - eventName: base.eventName, - start: base.start, - target: base.target, - unitMode: base.unitMode) + let now = Date() + let step: TimeInterval = 1800 // 30 minutes + let count = 24 // ~12-hour pre-render window + // Re-resolve the selection at EACH step's own time, so the moment the nearest + // event expires the next entry rolls to the next-nearest automatically. + let entries: [EventEntry] = (0.. now, expiry < windowEnd { + next = expiry.addingTimeInterval(1) } - let next = Date().addingTimeInterval(24 * 1800) return Timeline(entries: entries, policy: .after(next)) } - /// Upcoming events (incomplete tasks with a future due date), nearest first. - private func upcomingEvents() -> [EventEntity] { + /// Upcoming events (incomplete tasks with a due date still in the future *as of `now`*), + /// nearest first. Passing `now` lets future timeline steps drop just-expired events. + private func upcomingEvents(asOf now: Date) -> [EventEntity] { loadWidgetTasks() - .filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > Date() } + .filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > now } .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } .map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate, isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) } } + /// Due date of the event the auto-select queue is currently showing (nil if not in + /// queue mode, an event is tracked, or there's nothing upcoming). + private func currentSelectionExpiry(for config: EventConfigIntent, asOf now: Date) -> Date? { + guard config.upcomingQueue, + UserDefaults.kisani.string(forKey: "kisani.widget.trackedEvent") == nil else { return nil } + let upcoming = upcomingEvents(asOf: now) + let idx = max(0, config.autoSelect.rawValue - 1) + guard idx < upcoming.count else { return nil } + return upcoming[idx].dueDate + } + /// Resolve the progress window (start → target) for any event. Recurring events /// (Mode B) span the current occurrence period: previous → next occurrence — /// so a birthday 5 days away reads ~98% full. One-off events (Mode A) count @@ -245,7 +262,7 @@ struct EventProvider: AppIntentTimelineProvider { return (Self.storedAnchor(key: anchorKey, target: due), due) } - private func entry(for config: EventConfigIntent) -> EventEntry { + private func entry(for config: EventConfigIntent, asOf now: Date = Date()) -> EventEntry { let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey) // An event tracked from the app ("Track Countdown") bypasses the widget @@ -257,35 +274,35 @@ struct EventProvider: AppIntentTimelineProvider { let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date let s = resolveSpan(due: due, isRecurring: task.isRecurring, label: task.recurrenceLabel, anchorKey: trackedId, explicitStart: since) - return EventEntry(date: .now, eventName: task.title, start: s.start, target: s.target, unitMode: mode) + return EventEntry(date: now, eventName: task.title, start: s.start, target: s.target, unitMode: mode) } // ── Configured (no tracked event) ── if config.upcomingQueue { - let upcoming = upcomingEvents() + let upcoming = upcomingEvents(asOf: now) let idx = max(0, config.autoSelect.rawValue - 1) guard idx < upcoming.count, let due = upcoming[idx].dueDate else { - return EventEntry(date: .now, eventName: "No upcoming event", - start: Date(), target: Date().addingTimeInterval(86400), unitMode: mode) + return EventEntry(date: now, eventName: "No upcoming event", + start: now, target: now.addingTimeInterval(86400), unitMode: mode) } let ev = upcoming[idx] let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel, anchorKey: ev.id, explicitStart: nil) - return EventEntry(date: .now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode) + return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode) } if let ev = config.event, let due = ev.dueDate { let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel, anchorKey: ev.id, explicitStart: config.startDate) - return EventEntry(date: .now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode) + return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode) } // Fully custom event (no task behind it). let name = config.eventName.isEmpty ? "My Event" : config.eventName let target = config.targetDate - ?? Calendar.current.startOfDay(for: Date()).addingTimeInterval(2 * 86400) + ?? Calendar.current.startOfDay(for: now).addingTimeInterval(2 * 86400) let anchorKey = "custom.\(name).\(Int(target.timeIntervalSince1970))" let start = (config.startDate.map { $0 < target ? $0 : nil } ?? nil) ?? Self.storedAnchor(key: anchorKey, target: target) - return EventEntry(date: .now, eventName: name, start: start, target: target, unitMode: mode) + return EventEntry(date: now, eventName: name, start: start, target: target, unitMode: mode) } /// First-seen date for a tracked event, persisted in the App Group so progress