From 372da6df5a705dc92b0bfa26bdd5c418eb6c60c2 Mon Sep 17 00:00:00 2001 From: kutesir Date: Tue, 7 Jul 2026 00:35:36 +0300 Subject: [PATCH] Calendar timeline: drag a task block to reschedule its time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- KisaniCal/Views/CalendarView.swift | 98 +++++++++++++++++++++++------- 1 file changed, 76 insertions(+), 22 deletions(-) diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift index 1ff4523..24ad8b0 100644 --- a/KisaniCal/Views/CalendarView.swift +++ b/KisaniCal/Views/CalendarView.swift @@ -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)" - self.title = title - self.color = color - self.startMin = startMin - self.endMin = endMin + 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 + } } }