Calendar: force timeline fill (dead space) + long-press menu on blocks
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
KC-49: week/3-day dead space — the outer .frame only allowed fill; force the timeline ScrollView greedy (.frame maxHeight .infinity) and pin each timeline root VStack top, so the header pins to the top instead of floating mid-screen. KC-50: timeline blocks now have a long-press context menu — Complete, Snooze (15m/1h/tomorrow), and the shared TaskMenuItems (reschedule/move/priority/ category/edit/delete). Events get no menu. CVTimeBlock takes @ViewBuilder menu. Logs KC-49 (supersedes KC-48) and KC-50 in ISSUES.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1407,3 +1407,47 @@ Pinned the top-level content `VStack` with
|
||||
`.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)`.
|
||||
|
||||
Files: `CalendarView.swift`.
|
||||
|
||||
---
|
||||
|
||||
## KC-49 — Week/3-day dead space persisted after KC-48
|
||||
|
||||
**Status:** Fixed (not build-verified) — supersedes KC-48
|
||||
**Reported by:** User ("DEAD SPACE STILL EXISTS")
|
||||
**Area:** Calendar / Layout
|
||||
|
||||
### Root cause (refined)
|
||||
Pinning only the outer container (`.frame(maxHeight: .infinity)`, KC-48) merely
|
||||
*allows* fill — it doesn't *force* it. The timeline `ScrollView` returned its
|
||||
content size instead of expanding, so the week/3-day root stayed short and the
|
||||
`.bottomTrailing` ZStack floated it down, leaving the day header stranded mid-
|
||||
screen. Day view happened to fill, so it never showed.
|
||||
|
||||
### Fix
|
||||
Force the timeline `ScrollView` to be greedy with
|
||||
`.frame(maxWidth: .infinity, maxHeight: .infinity)`, and pin each timeline
|
||||
view's root `VStack` to fill + top-align. Applied to `weekView` and
|
||||
`threeDayView`.
|
||||
|
||||
Files: `CalendarView.swift`.
|
||||
|
||||
---
|
||||
|
||||
## KC-50 — Long-press on a timeline event has no actions
|
||||
|
||||
**Status:** Implemented (not build-verified)
|
||||
**Reported by:** User
|
||||
**Area:** Calendar / Tasks
|
||||
|
||||
### Description
|
||||
Long-pressing a task block on the day/week/3-day timeline did nothing — it
|
||||
should offer the same actions as elsewhere (Complete, Snooze, reschedule, etc.).
|
||||
|
||||
### Change
|
||||
`CVTimeBlock` now takes `@ViewBuilder menu` content and shows it via
|
||||
`.contextMenu`. `cvBlockMenu(for:)` builds it for task-backed items: a Complete
|
||||
toggle, a Snooze submenu (15 min / 1 hr / tomorrow), then the shared
|
||||
`TaskMenuItems` (pin, reschedule, move, priority, category, edit, delete).
|
||||
Events (no `taskID`) get no menu.
|
||||
|
||||
Files: `CalendarView.swift`.
|
||||
|
||||
@@ -828,8 +828,10 @@ struct CalendarView: View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
cvTimeGrid(days: days).padding(.bottom, 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
|
||||
// Grid layout: the 7 days as cards (2 columns), each listing its schedule.
|
||||
@@ -909,7 +911,9 @@ struct CalendarView: View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
cvTimeGrid(days: days).padding(.bottom, 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
|
||||
// MARK: - Day
|
||||
@@ -1043,13 +1047,45 @@ struct CalendarView: View {
|
||||
draggable: item.taskID != nil && !item.recurring,
|
||||
onReschedule: { newStartMin in
|
||||
rescheduleTask(id: item.taskID, on: date, toStartMin: newStartMin)
|
||||
}
|
||||
},
|
||||
menu: { cvBlockMenu(for: item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
/// Long-press menu for a timeline block. Task items get the full shared menu
|
||||
/// (Complete / Snooze / date / move / edit / delete); events get nothing.
|
||||
@ViewBuilder
|
||||
private func cvBlockMenu(for item: CVTimeItem) -> some View {
|
||||
if let id = item.taskID, let task = taskVM.tasks.first(where: { $0.id == id }) {
|
||||
Button {
|
||||
withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) }
|
||||
} label: {
|
||||
Label(task.isComplete ? "Mark Incomplete" : "Complete", systemImage: "checkmark.circle")
|
||||
}
|
||||
Menu {
|
||||
Button { taskVM.postpone(task, minutes: 15) } label: { Label("15 minutes", systemImage: "clock") }
|
||||
Button { taskVM.postpone(task, minutes: 60) } label: { Label("1 hour", systemImage: "clock") }
|
||||
Button { taskVM.postpone(task, days: 1) } label: { Label("Tomorrow", systemImage: "sun.max") }
|
||||
} label: {
|
||||
Label("Snooze", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
TaskMenuItems(
|
||||
task: task,
|
||||
onPin: { taskVM.pin($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) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a timed task to a new start minute on the same day (drag-to-reschedule).
|
||||
private func rescheduleTask(id: UUID?, on date: Date, toStartMin newStartMin: Int) {
|
||||
guard let id, let task = taskVM.tasks.first(where: { $0.id == id }) else { return }
|
||||
@@ -1161,13 +1197,14 @@ struct CVTimeItem: Identifiable {
|
||||
// A single timed block on the week/day timeline. Task-backed blocks can be
|
||||
// dragged vertically to reschedule (snapped to 15-minute steps); events and
|
||||
// recurring tasks render fixed.
|
||||
private struct CVTimeBlock: View {
|
||||
private struct CVTimeBlock<MenuContent: View>: View {
|
||||
let item: CVTimeItem
|
||||
let top: CGFloat
|
||||
let height: CGFloat
|
||||
let hourH: CGFloat
|
||||
let draggable: Bool
|
||||
let onReschedule: (Int) -> Void
|
||||
@ViewBuilder let menu: () -> MenuContent
|
||||
@GestureState private var dragY: CGFloat = 0
|
||||
|
||||
private var block: some View {
|
||||
@@ -1190,18 +1227,21 @@ private struct CVTimeBlock: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if draggable {
|
||||
block.gesture(
|
||||
DragGesture(minimumDistance: 6)
|
||||
.updating($dragY) { value, state, _ in state = value.translation.height }
|
||||
.onEnded { value in
|
||||
let deltaMin = Int((Double(value.translation.height) / Double(hourH) * 60).rounded() / 15) * 15
|
||||
onReschedule(item.startMin + deltaMin)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
block
|
||||
Group {
|
||||
if draggable {
|
||||
block.gesture(
|
||||
DragGesture(minimumDistance: 6)
|
||||
.updating($dragY) { value, state, _ in state = value.translation.height }
|
||||
.onEnded { value in
|
||||
let deltaMin = Int((Double(value.translation.height) / Double(hourH) * 60).rounded() / 15) * 15
|
||||
onReschedule(item.startMin + deltaMin)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
block
|
||||
}
|
||||
}
|
||||
.contextMenu { menu() } // empty for events → no menu; task actions otherwise
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user