Today view: reorder sections + keep RSS feeds out of tasks

- Move the Overdue card to sit directly below today's active tasks
  (above Next 3 Days) so your real tasks stay at the top.
- Filter subscription/RSS calendar feeds (F1, TV shows, etc.) out of the
  Today/Tasks timeline; they remain in the Calendar tab. Birthdays and
  personal calendars are unaffected.

Note: also carries prior in-progress TodayView edits that were already
in the working tree before this session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-06-06 19:29:25 +03:00
parent 800c7db76d
commit a92e9d4c09

View File

@@ -20,7 +20,10 @@ struct TodayView: View {
private var todayCalEvents: [EKEvent] { private var todayCalEvents: [EKEvent] {
calStore.monthEvents.filter { calStore.monthEvents.filter {
Calendar.current.isDateInToday($0.startDate ?? .distantFuture) // Subscribed/RSS feed calendars (F1 schedule, TV shows, etc.) belong in the
// Calendar tab only keep them out of the Today/Tasks timeline.
$0.calendar?.type != .subscription
&& Calendar.current.isDateInToday($0.startDate ?? .distantFuture)
} }
} }
@@ -78,21 +81,6 @@ struct TodayView: View {
.padding(.bottom, 10) .padding(.bottom, 10)
} }
// Overdue card (prominent, above timeline)
if !taskVM.overdueTasks.isEmpty {
OverdueCard(
tasks: taskVM.overdueTasks,
onPostpone: { taskVM.postponeAllOverdue() },
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(.bottom, 8)
}
// Today timeline: active completed (green) // Today timeline: active completed (green)
let activeTasks = taskVM.todayTasks let activeTasks = taskVM.todayTasks
let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks
@@ -116,6 +104,21 @@ struct TodayView: View {
) )
.padding(.horizontal, 18) .padding(.horizontal, 18)
// Overdue (below Today's tasks, above Next 3 Days)
if !taskVM.overdueTasks.isEmpty {
OverdueCard(
tasks: taskVM.overdueTasks,
onPostpone: { taskVM.postponeAllOverdue() },
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(.top, 10)
}
// Next 3 Days // Next 3 Days
let next3 = taskVM.next3DaysTasks let next3 = taskVM.next3DaysTasks
if !next3.isEmpty { if !next3.isEmpty {
@@ -1635,6 +1638,7 @@ struct AddTaskSheet: View {
@State private var selectedQuadrant: Quadrant @State private var selectedQuadrant: Quadrant
@State private var selectedCategory: TaskCategory = .reminder @State private var selectedCategory: TaskCategory = .reminder
@State private var reminderDate: Date? = nil @State private var reminderDate: Date? = nil
@State private var constantReminder = false
@State private var showDatePicker = false @State private var showDatePicker = false
var prefilledDate: Date? var prefilledDate: Date?
@@ -1797,7 +1801,8 @@ struct AddTaskSheet: View {
recurrenceLabel: $parsed.recurrenceLabel, recurrenceLabel: $parsed.recurrenceLabel,
endDate: $parsed.endDate, endDate: $parsed.endDate,
isAllDay: $parsed.isAllDay, isAllDay: $parsed.isAllDay,
reminderDate: $reminderDate reminderDate: $reminderDate,
constantReminder: $constantReminder
) )
} }
} }
@@ -1808,7 +1813,7 @@ struct AddTaskSheet: View {
quadrant: selectedQuadrant, category: selectedCategory, quadrant: selectedQuadrant, category: selectedCategory,
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel, isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
endDate: parsed.endDate, isAllDay: parsed.isAllDay, endDate: parsed.endDate, isAllDay: parsed.isAllDay,
reminderDate: reminderDate) reminderDate: reminderDate, constantReminder: constantReminder)
dismiss() dismiss()
} }
@@ -1845,6 +1850,7 @@ struct TaskDatePickerSheet: View {
@Binding var endDate: Date? @Binding var endDate: Date?
@Binding var isAllDay: Bool @Binding var isAllDay: Bool
@Binding var reminderDate: Date? @Binding var reminderDate: Date?
@Binding var constantReminder: Bool
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var cs @Environment(\.colorScheme) private var cs
@@ -1853,8 +1859,17 @@ struct TaskDatePickerSheet: View {
@State private var localEndDate: Date @State private var localEndDate: Date
@State private var localTime: Date? @State private var localTime: Date?
@State private var showTimePicker = false @State private var showTimePicker = false
@State private var localReminder: Date?
@State private var showReminderPicker = false @State private var showReminderPicker = false
// Reminder lead-time, expressed as days before the task date (nil = no reminder).
@State private var localReminderOffset: Int?
@State private var localReminderTime: Date
@State private var localConstant: Bool
// Custom wheel state (draft committed to the reminder only on "Add")
@State private var showCustomReminder = false
@State private var customNumber: Int
@State private var customUnitWeeks: Bool
@State private var customHour: Int
@State private var customMinute: Int
@State private var localRepeat: String @State private var localRepeat: String
@State private var localIsAllDay: Bool @State private var localIsAllDay: Bool
@State private var editingEnd = false @State private var editingEnd = false
@@ -1862,22 +1877,51 @@ struct TaskDatePickerSheet: View {
init(selectedDate: Binding<Date?>, hasTime: Binding<Bool>, init(selectedDate: Binding<Date?>, hasTime: Binding<Bool>,
isRecurring: Binding<Bool>, recurrenceLabel: Binding<String?>, isRecurring: Binding<Bool>, recurrenceLabel: Binding<String?>,
endDate: Binding<Date?>, isAllDay: Binding<Bool>, endDate: Binding<Date?>, isAllDay: Binding<Bool>,
reminderDate: Binding<Date?> = .constant(nil)) { reminderDate: Binding<Date?> = .constant(nil),
_selectedDate = selectedDate constantReminder: Binding<Bool> = .constant(false)) {
_hasTime = hasTime _selectedDate = selectedDate
_isRecurring = isRecurring _hasTime = hasTime
_recurrenceLabel = recurrenceLabel _isRecurring = isRecurring
_endDate = endDate _recurrenceLabel = recurrenceLabel
_isAllDay = isAllDay _endDate = endDate
_reminderDate = reminderDate _isAllDay = isAllDay
let start = selectedDate.wrappedValue ?? Calendar.current.startOfDay(for: Date()) _reminderDate = reminderDate
_constantReminder = constantReminder
let cal = Calendar.current
let start = selectedDate.wrappedValue ?? cal.startOfDay(for: Date())
_localDate = State(initialValue: start) _localDate = State(initialValue: start)
_localEndDate = State(initialValue: endDate.wrappedValue ?? start) _localEndDate = State(initialValue: endDate.wrappedValue ?? start)
_localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil) _localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil)
_localReminder = State(initialValue: reminderDate.wrappedValue)
_localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None") _localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None")
_localIsAllDay = State(initialValue: isAllDay.wrappedValue) _localIsAllDay = State(initialValue: isAllDay.wrappedValue)
_pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0) _pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0)
_localConstant = State(initialValue: constantReminder.wrappedValue)
// Derive lead-time + time-of-day from any existing absolute reminder date.
if let r = reminderDate.wrappedValue {
let days = cal.dateComponents([.day],
from: cal.startOfDay(for: r),
to: cal.startOfDay(for: start)).day ?? 0
let offset = max(0, days)
_localReminderOffset = State(initialValue: offset)
_localReminderTime = State(initialValue: r)
if offset > 0 && offset % 7 == 0 {
_customUnitWeeks = State(initialValue: true)
_customNumber = State(initialValue: offset / 7)
} else {
_customUnitWeeks = State(initialValue: false)
_customNumber = State(initialValue: offset)
}
_customHour = State(initialValue: cal.component(.hour, from: r))
_customMinute = State(initialValue: cal.component(.minute, from: r))
} else {
_localReminderOffset = State(initialValue: nil)
_localReminderTime = State(initialValue: cal.date(bySettingHour: 9, minute: 0, second: 0, of: start) ?? start)
_customUnitWeeks = State(initialValue: false)
_customNumber = State(initialValue: 0)
_customHour = State(initialValue: 9)
_customMinute = State(initialValue: 0)
}
} }
var body: some View { var body: some View {
@@ -1936,6 +1980,9 @@ struct TaskDatePickerSheet: View {
} }
} }
} }
.overlay {
if showCustomReminder { customReminderModal }
}
} }
@ViewBuilder @ViewBuilder
@@ -1973,30 +2020,14 @@ struct TaskDatePickerSheet: View {
Button { withAnimation(KisaniSpring.micro) { showReminderPicker.toggle(); if showReminderPicker { showTimePicker = false } } } label: { Button { withAnimation(KisaniSpring.micro) { showReminderPicker.toggle(); if showReminderPicker { showTimePicker = false } } } label: {
pickerRow(icon: "alarm", label: "Reminder", pickerRow(icon: "alarm", label: "Reminder",
value: localReminder != nil ? timeFmt.string(from: localReminder!) : "None", value: reminderSummary,
active: localReminder != nil) active: localReminderOffset != nil)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
if showReminderPicker { if showReminderPicker {
DatePicker("", selection: Binding( reminderOptions
get: { localReminder ?? Calendar.current.date(bySettingHour: 9, minute: 0, second: 0, of: localDate) ?? localDate }, .transition(.opacity.combined(with: .move(edge: .top)))
set: { localReminder = $0 }
), displayedComponents: .hourAndMinute)
.datePickerStyle(.wheel).labelsHidden()
.transition(.opacity.combined(with: .move(edge: .top)))
Button {
withAnimation(KisaniSpring.micro) {
localReminder = nil
showReminderPicker = false
}
} label: {
Text("Clear Reminder")
.font(AppFonts.sans(13))
.foregroundColor(AppColors.accent)
}
.padding(.bottom, 8)
} }
Divider().padding(.leading, 54) Divider().padding(.leading, 54)
@@ -2018,6 +2049,264 @@ struct TaskDatePickerSheet: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
// MARK: - Reminder lead-time picker
private struct ReminderPreset: Identifiable {
let id = UUID()
let offset: Int? // days before; nil = None
let label: String
}
private var reminderPresets: [ReminderPreset] {
[
ReminderPreset(offset: nil, label: "None"),
ReminderPreset(offset: 0, label: "On the day"),
ReminderPreset(offset: 1, label: "1 day early"),
ReminderPreset(offset: 2, label: "2 days early"),
ReminderPreset(offset: 3, label: "3 days early"),
ReminderPreset(offset: 7, label: "1 week early"),
]
}
@ViewBuilder
private var reminderOptions: some View {
VStack(spacing: 0) {
ForEach(reminderPresets) { preset in
Button {
withAnimation(KisaniSpring.micro) {
localReminderOffset = preset.offset
if let off = preset.offset {
if off > 0 && off % 7 == 0 { customUnitWeeks = true; customNumber = off / 7 }
else { customUnitWeeks = false; customNumber = off }
} else {
localConstant = false
}
showCustomReminder = false
}
} label: {
HStack(spacing: 8) {
Text(preset.label)
.font(AppFonts.sans(15))
.foregroundColor(preset.offset == nil ? AppColors.accent : .primary)
if preset.offset != nil {
Text("(\(timeFmt.string(from: localReminderTime)))")
.font(AppFonts.sans(13))
.foregroundColor(.secondary)
}
Spacer()
if !showCustomReminder && localReminderOffset == preset.offset {
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(AppColors.accent)
}
}
.padding(.horizontal, 16).frame(height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
// Custom opens the Day/Week wheel picker.
Button {
seedCustomDraft()
withAnimation(KisaniSpring.snappy) { showCustomReminder = true }
} label: {
HStack(spacing: 8) {
Text("Custom")
.font(AppFonts.sans(15)).foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.secondary)
}
.padding(.horizontal, 16).frame(height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// Subtle (non-red) note when the chosen reminder time is already past.
if reminderInPast {
Text("Selected reminder time has already passed.")
.font(AppFonts.sans(12))
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16).padding(.bottom, 8)
}
// Constant Reminder toggle intentionally hidden to be gated behind a
// paywall later. Model field + scheduler support remain in place.
}
.padding(.vertical, 4)
}
// MARK: - Custom reminder wheel (Day / Week)
private func seedCustomDraft() {
let cal = Calendar.current
if let off = localReminderOffset {
if off > 0 && off % 7 == 0 { customUnitWeeks = true; customNumber = off / 7 }
else { customUnitWeeks = false; customNumber = off }
}
customHour = cal.component(.hour, from: localReminderTime)
customMinute = cal.component(.minute, from: localReminderTime)
}
private func commitCustomReminder() {
let cal = Calendar.current
localReminderOffset = customUnitWeeks ? customNumber * 7 : customNumber
localReminderTime = cal.date(bySettingHour: customHour, minute: customMinute,
second: 0, of: localDate) ?? localReminderTime
}
// Label for a given count under the current Day/Week mode.
private func customLeadLabel(_ n: Int) -> String {
if n == 0 { return "On the day" }
let unit = customUnitWeeks ? "week" : "day"
return "\(n) \(unit)\(n == 1 ? "" : "s") early"
}
private var customMaxCount: Int { customUnitWeeks ? 8 : 60 }
// Reminder date implied by the current *draft* wheel selection.
private var draftReminderDate: Date? {
let cal = Calendar.current
let off = customUnitWeeks ? customNumber * 7 : customNumber
let base = cal.date(byAdding: .day, value: -off, to: localDate) ?? localDate
return cal.date(bySettingHour: customHour, minute: customMinute, second: 0, of: base)
}
private var draftReminderInPast: Bool {
(draftReminderDate ?? Date()) < Date()
}
@ViewBuilder
private var customReminderModal: some View {
ZStack {
// Dimmed scrim tap to cancel.
Color.black.opacity(0.45)
.ignoresSafeArea()
.onTapGesture { withAnimation(KisaniSpring.snappy) { showCustomReminder = false } }
VStack(spacing: 18) {
// Day / Week segmented toggle
HStack(spacing: 2) {
customModeButton("Day", weeks: false)
customModeButton("Week", weeks: true)
}
.padding(3)
.background(Color(uiColor: .tertiarySystemFill))
.clipShape(Capsule())
.padding(.top, 20)
// Three-column wheel: lead-time · hour · minute
HStack(spacing: 0) {
Picker("", selection: $customNumber) {
ForEach(0...customMaxCount, id: \.self) { n in
Text(customLeadLabel(n)).tag(n)
}
}
.pickerStyle(.wheel).frame(maxWidth: .infinity).clipped()
Picker("", selection: $customHour) {
ForEach(0...23, id: \.self) { h in
Text(String(format: "%02d", h)).tag(h)
}
}
.pickerStyle(.wheel).frame(width: 64).clipped()
Picker("", selection: $customMinute) {
ForEach(0...59, id: \.self) { m in
Text(String(format: "%02d", m)).tag(m)
}
}
.pickerStyle(.wheel).frame(width: 64).clipped()
}
.frame(height: 180)
.onChange(of: customUnitWeeks) { _ in
if customNumber > customMaxCount { customNumber = customMaxCount }
}
// Subtle (non-red) note never alarming.
if draftReminderInPast {
Text("Selected reminder time has already passed.")
.font(AppFonts.sans(13))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
// Cancel / Add
HStack(spacing: 12) {
Button {
withAnimation(KisaniSpring.snappy) { showCustomReminder = false }
} label: {
Text("Cancel")
.font(AppFonts.sans(16, weight: .medium))
.foregroundColor(.primary)
.frame(maxWidth: .infinity).frame(height: 52)
.background(Color(uiColor: .tertiarySystemFill))
.clipShape(RoundedRectangle(cornerRadius: 16))
}
Button {
commitCustomReminder()
withAnimation(KisaniSpring.snappy) { showCustomReminder = false }
} label: {
Text("Add")
.font(AppFonts.sans(16, weight: .semibold))
.foregroundColor(.primary)
.frame(maxWidth: .infinity).frame(height: 52)
.background(Color(uiColor: .tertiarySystemFill))
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
.padding(.horizontal, 20).padding(.bottom, 20)
}
.frame(maxWidth: 380)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28))
.overlay(RoundedRectangle(cornerRadius: 28).stroke(Color.white.opacity(0.08), lineWidth: 1))
.padding(.horizontal, 20)
.transition(.scale(scale: 0.92).combined(with: .opacity))
}
}
@ViewBuilder
private func customModeButton(_ label: String, weeks: Bool) -> some View {
Button {
withAnimation(KisaniSpring.micro) { customUnitWeeks = weeks }
} label: {
Text(label)
.font(AppFonts.sans(15, weight: customUnitWeeks == weeks ? .semibold : .regular))
.foregroundColor(customUnitWeeks == weeks ? .primary : .secondary)
.frame(width: 96, height: 36)
.background(customUnitWeeks == weeks ? Color(uiColor: .systemGray4) : Color.clear)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private var reminderInPast: Bool {
guard let off = localReminderOffset else { return false }
return computedReminderDate(offset: off) ?? Date() < Date()
}
private func computedReminderDate(offset: Int) -> Date? {
let cal = Calendar.current
let base = cal.date(byAdding: .day, value: -offset, to: localDate) ?? localDate
let tc = cal.dateComponents([.hour, .minute], from: localReminderTime)
return cal.date(bySettingHour: tc.hour ?? 9, minute: tc.minute ?? 0, second: 0, of: base)
}
private var reminderSummary: String {
guard let off = localReminderOffset else { return "None" }
let timeStr = timeFmt.string(from: localReminderTime)
if off == 0 { return "On the day · \(timeStr)" }
if off % 7 == 0 {
let w = off / 7
return "\(w) week\(w == 1 ? "" : "s") early · \(timeStr)"
}
return "\(off) day\(off == 1 ? "" : "s") early · \(timeStr)"
}
@ViewBuilder @ViewBuilder
private var durationContent: some View { private var durationContent: some View {
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
@@ -2175,21 +2464,21 @@ struct TaskDatePickerSheet: View {
isAllDay = false isAllDay = false
isRecurring = localRepeat != "None" isRecurring = localRepeat != "None"
recurrenceLabel = localRepeat != "None" ? localRepeat : nil recurrenceLabel = localRepeat != "None" ? localRepeat : nil
// Combine reminder time with the selected date // Reminder is a lead-time offset from the task date at the chosen time-of-day.
if let r = localReminder { if let off = localReminderOffset {
let rc = Calendar.current.dateComponents([.hour, .minute], from: r) reminderDate = computedReminderDate(offset: off)
reminderDate = Calendar.current.date(bySettingHour: rc.hour ?? 9, constantReminder = localConstant
minute: rc.minute ?? 0,
second: 0, of: localDate)
} else { } else {
reminderDate = nil reminderDate = nil
constantReminder = false
} }
} else { } else {
selectedDate = Calendar.current.startOfDay(for: localDate) selectedDate = Calendar.current.startOfDay(for: localDate)
endDate = Calendar.current.startOfDay(for: localEndDate) endDate = Calendar.current.startOfDay(for: localEndDate)
isAllDay = localIsAllDay isAllDay = localIsAllDay
hasTime = false hasTime = false
reminderDate = nil reminderDate = nil
constantReminder = false
} }
} }
} }