Calendar timeline: drag a task block to reschedule its time

The headline of the TickTick weekly timeline — task blocks are now draggable
vertically to change their time, snapped to 15-minute steps. Applies on the
week, 3-day, and day timelines.

- CVTimeItem carries taskID + recurring; task items are tagged, events are not
- CVTimeBlock: a draggable block (GestureState offset while dragging, commits
  on release); events and recurring tasks render fixed (non-draggable)
- rescheduleTask: clamps to the visible hour range and writes the new time via
  taskVM.setDate on the same day

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-07-07 00:35:36 +03:00
committed by kutesir
parent 7fdf50c898
commit 39f4c0fcfa

View File

@@ -1004,27 +1004,27 @@ struct CalendarView: View {
ForEach(cvTimeItems(date)) { item in
let top = max(0, CGFloat(item.startMin - cvStartH * 60) / 60.0 * cvHourH)
let dur = max(CGFloat(item.endMin - item.startMin) / 60.0 * cvHourH, 28)
RoundedRectangle(cornerRadius: 4)
.fill(item.color.opacity(0.28))
.overlay(alignment: .leading) {
Rectangle().fill(item.color).frame(width: 3)
.clipShape(RoundedRectangle(cornerRadius: 2))
CVTimeBlock(
item: item, top: top, height: dur, hourH: cvHourH,
draggable: item.taskID != nil && !item.recurring,
onReschedule: { newStartMin in
rescheduleTask(id: item.taskID, on: date, toStartMin: newStartMin)
}
.overlay(alignment: .topLeading) {
Text(item.title)
.font(AppFonts.sans(9, weight: .medium))
.foregroundColor(item.color)
.padding(.horizontal, 6).padding(.top, 4)
.lineLimit(2)
}
.frame(maxWidth: .infinity).frame(height: dur)
.padding(.trailing, 2)
.offset(y: top)
)
}
}
.frame(maxWidth: .infinity)
}
/// 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 }
let clamped = min(max(newStartMin, cvStartH * 60), cvEndH * 60 - 15)
let h = clamped / 60, m = clamped % 60
guard let newDate = cal.date(bySettingHour: h, minute: m, second: 0, of: date) else { return }
withAnimation(KisaniSpring.snappy) { taskVM.setDate(task, date: newDate) }
}
// MARK: - Shared helpers
private var cvWeekStrip: some View {
@@ -1084,7 +1084,9 @@ struct CalendarView: View {
for task in taskVM.occurrences(on: date) where !task.isComplete && task.hasTime {
guard let d = task.dueDate else { continue }
let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d)
items.append(CVTimeItem(title: task.title, color: task.quadrant.color, startMin: h * 60 + m, endMin: h * 60 + m + 30))
items.append(CVTimeItem(title: task.title, color: task.quadrant.color,
startMin: h * 60 + m, endMin: h * 60 + m + 30,
taskID: task.id, recurring: taskVM.recurs(task)))
}
for ev in calStore.isAuthorized ? calStore.events(for: date) : [] {
guard let s = ev.startDate, let e = ev.endDate, !ev.isAllDay else { continue }
@@ -1107,13 +1109,65 @@ struct CVTimeItem: Identifiable {
let color: Color
let startMin: Int
let endMin: Int
let taskID: UUID? // non-nil for task-backed items (draggable)
let recurring: Bool // recurring tasks aren't drag-rescheduled
init(title: String, color: Color, startMin: Int, endMin: Int) {
self.id = "\(title)-\(startMin)"
init(title: String, color: Color, startMin: Int, endMin: Int,
taskID: UUID? = nil, recurring: Bool = false) {
self.id = taskID?.uuidString ?? "\(title)-\(startMin)"
self.title = title
self.color = color
self.startMin = startMin
self.endMin = endMin
self.taskID = taskID
self.recurring = recurring
}
}
// 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 {
let item: CVTimeItem
let top: CGFloat
let height: CGFloat
let hourH: CGFloat
let draggable: Bool
let onReschedule: (Int) -> Void
@GestureState private var dragY: CGFloat = 0
private var block: some View {
RoundedRectangle(cornerRadius: 4)
.fill(item.color.opacity(dragY == 0 ? 0.28 : 0.45))
.overlay(alignment: .leading) {
Rectangle().fill(item.color).frame(width: 3)
.clipShape(RoundedRectangle(cornerRadius: 2))
}
.overlay(alignment: .topLeading) {
Text(item.title)
.font(AppFonts.sans(9, weight: .medium))
.foregroundColor(item.color)
.padding(.horizontal, 6).padding(.top, 4)
.lineLimit(2)
}
.frame(maxWidth: .infinity).frame(height: height)
.padding(.trailing, 2)
.offset(y: top + dragY)
}
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
}
}
}