feat: overdue tasks in red with quick postpone + task context menu

- Overdue card: red task titles, per-row "+1d" postpone, tappable complete.
- Task rows (overdue + day timeline) gain a press menu: Complete, Postpone,
  Pin/Unpin, Edit, Share, Delete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-03 13:51:33 +03:00
parent 646cdf6a9b
commit 324ddf9b0a
2 changed files with 104 additions and 26 deletions

View File

@@ -1454,6 +1454,10 @@ struct DayTimelineView: View {
let workout: WorkoutProgram? let workout: WorkoutProgram?
var onWorkoutTap: (() -> Void)? = nil var onWorkoutTap: (() -> Void)? = nil
var onToggle: ((TaskItem) -> Void)? = nil var onToggle: ((TaskItem) -> Void)? = nil
var onPin: ((TaskItem) -> Void)? = nil
var onPostpone: ((TaskItem) -> Void)? = nil
var onEdit: ((TaskItem) -> Void)? = nil
var onDelete: ((TaskItem) -> Void)? = nil
private let cal = Calendar.current private let cal = Calendar.current
private static let overdueFmt: DateFormatter = { private static let overdueFmt: DateFormatter = {
@@ -1573,7 +1577,7 @@ struct DayTimelineView: View {
} else { } else {
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.element.id) { i, entry in ForEach(Array(entries.enumerated()), id: \.element.id) { i, entry in
DayTLRow( let row = DayTLRow(
entry: entry, entry: entry,
isFirst: i == 0, isFirst: i == 0,
isLast: i == entries.count - 1, isLast: i == entries.count - 1,
@@ -1581,10 +1585,40 @@ struct DayTimelineView: View {
onWorkoutTap: entry.workoutRef != nil ? onWorkoutTap : nil, onWorkoutTap: entry.workoutRef != nil ? onWorkoutTap : nil,
onToggle: entry.taskRef.map { t in { onToggle?(t) } } onToggle: entry.taskRef.map { t in { onToggle?(t) } }
) )
if let t = entry.taskRef {
row.contextMenu { taskContextMenu(t) }
} else {
row
}
} }
} }
} }
} }
@ViewBuilder
private func taskContextMenu(_ task: TaskItem) -> some View {
Button { onToggle?(task) } label: {
Label(task.isComplete ? "Mark Incomplete" : "Mark Complete", systemImage: "checkmark.circle")
}
if let onPostpone {
Button { onPostpone(task) } label: {
Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
}
}
if let onPin {
Button { onPin(task) } label: {
Label(task.isPinned ? "Unpin" : "Pin", systemImage: task.isPinned ? "pin.slash" : "pin")
}
}
if let onEdit {
Button { onEdit(task) } label: { Label("Edit", systemImage: "pencil") }
}
ShareLink(item: task.title) { Label("Share", systemImage: "square.and.arrow.up") }
if let onDelete {
Divider()
Button(role: .destructive) { onDelete(task) } label: { Label("Delete", systemImage: "trash") }
}
}
} }
private struct DayTLRow: View { private struct DayTLRow: View {

View File

@@ -9,6 +9,7 @@ struct TodayView: View {
@StateObject private var calStore = CalendarStore() @StateObject private var calStore = CalendarStore()
@State private var showAddTask = false @State private var showAddTask = false
@State private var showWorkoutSession = false @State private var showWorkoutSession = false
@State private var editingTask: TaskItem? = nil
@AppStorage("todayHideCompleted") private var hideCompleted = false @AppStorage("todayHideCompleted") private var hideCompleted = false
@ObservedObject private var tm = TutorialManager.shared @ObservedObject private var tm = TutorialManager.shared
@ObservedObject private var hk = HealthKitManager.shared @ObservedObject private var hk = HealthKitManager.shared
@@ -82,7 +83,11 @@ struct TodayView: View {
OverdueCard( OverdueCard(
tasks: taskVM.overdueTasks, tasks: taskVM.overdueTasks,
onPostpone: { taskVM.postponeAllOverdue() }, onPostpone: { taskVM.postponeAllOverdue() },
onToggle: { task in withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } } onToggle: { task in withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } },
onPostponeTask: { taskVM.postpone($0) },
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) }
) )
.padding(.horizontal, 18) .padding(.horizontal, 18)
.padding(.bottom, 8) .padding(.bottom, 8)
@@ -103,7 +108,11 @@ struct TodayView: View {
} }
showWorkoutSession = true showWorkoutSession = true
}, },
onToggle: { task in withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } } onToggle: { task in withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } },
onPin: { taskVM.pin($0) },
onPostpone: { taskVM.postpone($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) }
) )
.padding(.horizontal, 18) .padding(.horizontal, 18)
@@ -188,6 +197,12 @@ struct TodayView: View {
.presentationDetents([.large]) .presentationDetents([.large])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
} }
.sheet(item: $editingTask) { task in
TaskEditSheet(task: task)
.environmentObject(taskVM)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
.onAppear { .onAppear {
calStore.refreshStatus() calStore.refreshStatus()
calStore.fetchMonth(containing: Date()) calStore.fetchMonth(containing: Date())
@@ -459,6 +474,10 @@ struct OverdueCard: View {
let tasks: [TaskItem] let tasks: [TaskItem]
let onPostpone: () -> Void let onPostpone: () -> Void
let onToggle: (TaskItem) -> Void let onToggle: (TaskItem) -> Void
var onPostponeTask: (TaskItem) -> Void = { _ in }
var onPin: (TaskItem) -> Void = { _ in }
var onEdit: (TaskItem) -> Void = { _ in }
var onDelete: (TaskItem) -> Void = { _ in }
@State private var expanded = true @State private var expanded = true
@@ -500,46 +519,71 @@ struct OverdueCard: View {
ForEach(tasks) { task in ForEach(tasks) { task in
HStack(spacing: 10) { HStack(spacing: 10) {
ZStack { Button { withAnimation(KisaniSpring.bounce) { onToggle(task) } } label: {
Circle() Circle()
.stroke(AppColors.accent, lineWidth: 1.5) .stroke(AppColors.red, lineWidth: 1.5)
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
.frame(width: 36, height: 44)
.contentShape(Rectangle())
} }
.frame(width: 36, height: 44) .buttonStyle(.plain)
.contentShape(Rectangle())
Text(task.title) Text(task.title)
.font(AppFonts.sans(12.5)) .font(AppFonts.sans(12.5, weight: .medium))
.foregroundColor(AppColors.text(cs)) .foregroundColor(AppColors.red)
Spacer() .lineLimit(1)
Spacer(minLength: 6)
// Small inline postpone
Button { withAnimation(KisaniSpring.snappy) { onPostponeTask(task) } } label: {
Text("+1d")
.font(AppFonts.mono(9, weight: .bold))
.foregroundColor(AppColors.red)
.padding(.horizontal, 7).padding(.vertical, 3)
.background(AppColors.redSoft)
.cornerRadius(5)
}
.buttonStyle(.plain)
if let d = task.dueDate { if let d = task.dueDate {
Text(d, style: .date) Text(d, style: .date)
.font(AppFonts.mono(10)) .font(AppFonts.mono(10))
.foregroundColor(AppColors.accent) .foregroundColor(AppColors.red.opacity(0.7))
} }
} }
.padding(.horizontal, 10) .padding(.horizontal, 10)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { onToggle(task) } .contextMenu { taskMenu(task) }
.contextMenu {
Button {
withAnimation { onToggle(task) }
} label: {
Label("Mark Complete", systemImage: "checkmark.circle")
}
Button {
// postpone this single task via the viewmodel accessed through onToggle pattern
} label: {
Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
}
}
} }
} }
} }
.background(AppColors.accent.opacity(0.07)) .background(AppColors.red.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) .clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large) .overlay(RoundedRectangle(cornerRadius: AppRadius.large)
.stroke(AppColors.accent.opacity(0.18), lineWidth: 1)) .stroke(AppColors.red.opacity(0.18), lineWidth: 1))
}
@ViewBuilder
private func taskMenu(_ task: TaskItem) -> some View {
Button { withAnimation { onToggle(task) } } label: {
Label("Mark Complete", systemImage: "checkmark.circle")
}
Button { onPostponeTask(task) } label: {
Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
}
Button { onPin(task) } label: {
Label(task.isPinned ? "Unpin" : "Pin", systemImage: task.isPinned ? "pin.slash" : "pin")
}
Button { onEdit(task) } label: {
Label("Edit", systemImage: "pencil")
}
ShareLink(item: task.title) {
Label("Share", systemImage: "square.and.arrow.up")
}
Divider()
Button(role: .destructive) { onDelete(task) } label: {
Label("Delete", systemImage: "trash")
}
} }
} }