Merge develop -> main: full session (KC-51 through KC-71) #28

Merged
kutesir merged 64 commits from develop into main 2026-07-12 22:57:14 +00:00
Showing only changes of commit 372da6df5a - Show all commits

View File

@@ -1004,27 +1004,27 @@ struct CalendarView: View {
ForEach(cvTimeItems(date)) { item in ForEach(cvTimeItems(date)) { item in
let top = max(0, CGFloat(item.startMin - cvStartH * 60) / 60.0 * cvHourH) let top = max(0, CGFloat(item.startMin - cvStartH * 60) / 60.0 * cvHourH)
let dur = max(CGFloat(item.endMin - item.startMin) / 60.0 * cvHourH, 28) let dur = max(CGFloat(item.endMin - item.startMin) / 60.0 * cvHourH, 28)
RoundedRectangle(cornerRadius: 4) CVTimeBlock(
.fill(item.color.opacity(0.28)) item: item, top: top, height: dur, hourH: cvHourH,
.overlay(alignment: .leading) { draggable: item.taskID != nil && !item.recurring,
Rectangle().fill(item.color).frame(width: 3) onReschedule: { newStartMin in
.clipShape(RoundedRectangle(cornerRadius: 2)) 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) .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 // MARK: - Shared helpers
private var cvWeekStrip: some View { private var cvWeekStrip: some View {
@@ -1084,7 +1084,9 @@ struct CalendarView: View {
for task in taskVM.occurrences(on: date) where !task.isComplete && task.hasTime { for task in taskVM.occurrences(on: date) where !task.isComplete && task.hasTime {
guard let d = task.dueDate else { continue } guard let d = task.dueDate else { continue }
let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d) 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) : [] { for ev in calStore.isAuthorized ? calStore.events(for: date) : [] {
guard let s = ev.startDate, let e = ev.endDate, !ev.isAllDay else { continue } guard let s = ev.startDate, let e = ev.endDate, !ev.isAllDay else { continue }
@@ -1107,13 +1109,65 @@ struct CVTimeItem: Identifiable {
let color: Color let color: Color
let startMin: Int let startMin: Int
let endMin: 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) { init(title: String, color: Color, startMin: Int, endMin: Int,
self.id = "\(title)-\(startMin)" taskID: UUID? = nil, recurring: Bool = false) {
self.title = title self.id = taskID?.uuidString ?? "\(title)-\(startMin)"
self.color = color self.title = title
self.startMin = startMin self.color = color
self.endMin = endMin 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
}
} }
} }