From a92e9d4c09892d6d1bdc1c0063858b1877dda53d Mon Sep 17 00:00:00 2001 From: kutesir Date: Sat, 6 Jun 2026 19:29:25 +0300 Subject: [PATCH] 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 --- KisaniCal/Views/TodayView.swift | 411 +++++++++++++++++++++++++++----- 1 file changed, 350 insertions(+), 61 deletions(-) diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index 48735a9..21ce872 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -20,7 +20,10 @@ struct TodayView: View { private var todayCalEvents: [EKEvent] { 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) } - // ── 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) ── let activeTasks = taskVM.todayTasks let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks @@ -116,6 +104,21 @@ struct TodayView: View { ) .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 ── let next3 = taskVM.next3DaysTasks if !next3.isEmpty { @@ -1635,6 +1638,7 @@ struct AddTaskSheet: View { @State private var selectedQuadrant: Quadrant @State private var selectedCategory: TaskCategory = .reminder @State private var reminderDate: Date? = nil + @State private var constantReminder = false @State private var showDatePicker = false var prefilledDate: Date? @@ -1797,7 +1801,8 @@ struct AddTaskSheet: View { recurrenceLabel: $parsed.recurrenceLabel, endDate: $parsed.endDate, isAllDay: $parsed.isAllDay, - reminderDate: $reminderDate + reminderDate: $reminderDate, + constantReminder: $constantReminder ) } } @@ -1808,7 +1813,7 @@ struct AddTaskSheet: View { quadrant: selectedQuadrant, category: selectedCategory, isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel, endDate: parsed.endDate, isAllDay: parsed.isAllDay, - reminderDate: reminderDate) + reminderDate: reminderDate, constantReminder: constantReminder) dismiss() } @@ -1845,6 +1850,7 @@ struct TaskDatePickerSheet: View { @Binding var endDate: Date? @Binding var isAllDay: Bool @Binding var reminderDate: Date? + @Binding var constantReminder: Bool @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var cs @@ -1853,8 +1859,17 @@ struct TaskDatePickerSheet: View { @State private var localEndDate: Date @State private var localTime: Date? @State private var showTimePicker = false - @State private var localReminder: Date? @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 localIsAllDay: Bool @State private var editingEnd = false @@ -1862,22 +1877,51 @@ struct TaskDatePickerSheet: View { init(selectedDate: Binding, hasTime: Binding, isRecurring: Binding, recurrenceLabel: Binding, endDate: Binding, isAllDay: Binding, - reminderDate: Binding = .constant(nil)) { - _selectedDate = selectedDate - _hasTime = hasTime - _isRecurring = isRecurring - _recurrenceLabel = recurrenceLabel - _endDate = endDate - _isAllDay = isAllDay - _reminderDate = reminderDate - let start = selectedDate.wrappedValue ?? Calendar.current.startOfDay(for: Date()) + reminderDate: Binding = .constant(nil), + constantReminder: Binding = .constant(false)) { + _selectedDate = selectedDate + _hasTime = hasTime + _isRecurring = isRecurring + _recurrenceLabel = recurrenceLabel + _endDate = endDate + _isAllDay = isAllDay + _reminderDate = reminderDate + _constantReminder = constantReminder + let cal = Calendar.current + let start = selectedDate.wrappedValue ?? cal.startOfDay(for: Date()) _localDate = State(initialValue: start) _localEndDate = State(initialValue: endDate.wrappedValue ?? start) _localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil) - _localReminder = State(initialValue: reminderDate.wrappedValue) _localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None") _localIsAllDay = State(initialValue: isAllDay.wrappedValue) _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 { @@ -1936,6 +1980,9 @@ struct TaskDatePickerSheet: View { } } } + .overlay { + if showCustomReminder { customReminderModal } + } } @ViewBuilder @@ -1973,30 +2020,14 @@ struct TaskDatePickerSheet: View { Button { withAnimation(KisaniSpring.micro) { showReminderPicker.toggle(); if showReminderPicker { showTimePicker = false } } } label: { pickerRow(icon: "alarm", label: "Reminder", - value: localReminder != nil ? timeFmt.string(from: localReminder!) : "None", - active: localReminder != nil) + value: reminderSummary, + active: localReminderOffset != nil) } .buttonStyle(.plain) if showReminderPicker { - DatePicker("", selection: Binding( - get: { localReminder ?? Calendar.current.date(bySettingHour: 9, minute: 0, second: 0, of: localDate) ?? localDate }, - 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) + reminderOptions + .transition(.opacity.combined(with: .move(edge: .top))) } Divider().padding(.leading, 54) @@ -2018,6 +2049,264 @@ struct TaskDatePickerSheet: View { .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 private var durationContent: some View { HStack(alignment: .top, spacing: 0) { @@ -2175,21 +2464,21 @@ struct TaskDatePickerSheet: View { isAllDay = false isRecurring = localRepeat != "None" recurrenceLabel = localRepeat != "None" ? localRepeat : nil - // Combine reminder time with the selected date - if let r = localReminder { - let rc = Calendar.current.dateComponents([.hour, .minute], from: r) - reminderDate = Calendar.current.date(bySettingHour: rc.hour ?? 9, - minute: rc.minute ?? 0, - second: 0, of: localDate) + // Reminder is a lead-time offset from the task date at the chosen time-of-day. + if let off = localReminderOffset { + reminderDate = computedReminderDate(offset: off) + constantReminder = localConstant } else { - reminderDate = nil + reminderDate = nil + constantReminder = false } } else { - selectedDate = Calendar.current.startOfDay(for: localDate) - endDate = Calendar.current.startOfDay(for: localEndDate) - isAllDay = localIsAllDay - hasTime = false - reminderDate = nil + selectedDate = Calendar.current.startOfDay(for: localDate) + endDate = Calendar.current.startOfDay(for: localEndDate) + isAllDay = localIsAllDay + hasTime = false + reminderDate = nil + constantReminder = false } } }