Today menu features, Matrix Move trim, widget auto-advance, Wenza logo (KC-23–26)

- 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 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-06-15 19:53:14 +03:00
parent 81813530c8
commit 87c999c676
9 changed files with 565 additions and 58 deletions

View File

@@ -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 = "<group>"; };
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = "<group>"; };
0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = "<group>"; };
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = "<group>"; };
@@ -104,7 +105,7 @@
9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = "<group>"; };
92824ED40ECD41EFD4F78BEC /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; sourceTree = "<group>"; };
93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
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 = "<group>"; };
AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = "<group>"; };
BC5E179A9189B0A8C3F856F6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@@ -114,6 +115,7 @@
C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = "<group>"; };
DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayMenuFeatures.swift; sourceTree = "<group>"; };
FA3D5289C5F93484E22DEB63 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = "<group>"; };
FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCountdownWidget.swift; sourceTree = "<group>"; };
FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskActivityAttributes.swift; sourceTree = "<group>"; };
@@ -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;

View File

@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.3">
LastUpgradeVersion = "1500"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -26,7 +27,8 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
@@ -38,6 +40,8 @@
</MacroExpansion>
<Testables>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -59,6 +63,8 @@
ReferencedContainer = "container:KisaniCal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -76,6 +82,8 @@
ReferencedContainer = "container:KisaniCal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">

View File

@@ -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.

View File

@@ -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())

View File

@@ -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)

View File

@@ -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") }

View File

@@ -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<UUID> = []
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 }) }
}
}

View File

@@ -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)

View File

@@ -209,29 +209,46 @@ struct EventProvider: AppIntentTimelineProvider {
}
func timeline(for configuration: EventConfigIntent, in context: Context) async -> Timeline<EventEntry> {
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..<count).map { i in
entry(for: configuration, asOf: now.addingTimeInterval(Double(i) * step))
}
// Refresh at the window end, or right when the current selection expires
// (whichever is sooner) so the next event gets a fresh full pre-render.
let windowEnd = now.addingTimeInterval(Double(count) * step)
var next = windowEnd
if let expiry = currentSelectionExpiry(for: configuration, asOf: now),
expiry > 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