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

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