import SwiftUI import EventKit struct TodayView: View { @Environment(\.colorScheme) var cs @Environment(\.scenePhase) private var scenePhase @EnvironmentObject var taskVM: TaskViewModel @EnvironmentObject var workoutVM: WorkoutViewModel @StateObject private var calStore = CalendarStore.shared @State private var showAddTask = false @State private var showWorkoutSession = false @State private var editingTask: TaskItem? = nil @AppStorage("todayHideCompleted") private var hideCompleted = false @ObservedObject private var tm = TutorialManager.shared @ObservedObject private var hk = HealthKitManager.shared private let dateFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "EEEE, MMM d"; return f }() private var todayCalEvents: [EKEvent] { calStore.monthEvents.filter { // 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) } } var body: some View { ZStack(alignment: .bottomTrailing) { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { Color.clear.frame(height: 0).trackScrollForTabBar() // ── Header ── HStack { Menu { Button { } label: { Label("Background", systemImage: "paintbrush") } Button { withAnimation(KisaniSpring.snappy) { hideCompleted.toggle() } } label: { Label(hideCompleted ? "Show Completed" : "Hide Completed", systemImage: hideCompleted ? "checkmark.circle" : "checkmark.circle.badge.xmark") } Divider() Button { } label: { Label("Group & Sort", systemImage: "arrow.up.arrow.down") } Button { } label: { Label("Select", systemImage: "checkmark.circle") } } label: { Image(systemName: "line.3.horizontal") .font(.system(size: 13, weight: .medium)) .foregroundColor(AppColors.text2(cs)) .frame(width: 34, height: 34) .background(AppColors.surface2(cs)) .clipShape(Circle()) .overlay(Circle().stroke(AppColors.border(cs), lineWidth: 1)) } Spacer() } .padding(.horizontal, 18) .padding(.top, 8) // ── Title ── Text("Today") .font(AppFonts.sans(34, weight: .bold)) .tracking(-0.8) .foregroundColor(AppColors.text(cs)) .padding(.horizontal, 18) .padding(.top, 6) Text(dateFmt.string(from: Date())) .font(AppFonts.mono(10)) .foregroundColor(AppColors.text3(cs).opacity(0.7)) .padding(.horizontal, 18) .padding(.bottom, 8) // ── Health stats strip ── if hk.authorized { TodayHealthStrip(hk: hk, workoutVM: workoutVM) .padding(.horizontal, 18) .padding(.bottom, 10) } // ── Today timeline: active → completed (green) ── let activeTasks = taskVM.todayTasks let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks DayTimelineView( date: Date(), tasks: activeTasks + completedTasks, overdueTasks: [], calEvents: todayCalEvents, workout: workoutVM.program(for: Date()), workoutDone: workoutVM.isWorkoutComplete(on: Date()), workoutMissed: workoutVM.isWorkoutMissed(on: Date()), workoutCompensation: workoutVM.isCompensation(on: Date()), onWorkoutDone: { workoutVM.markWorkoutDone(on: Date()) }, onWorkoutMissed: { workoutVM.markWorkoutMissed(on: Date()) }, onWorkoutCompensate: { workoutVM.markWorkoutMissed(on: Date()) workoutVM.promptCompensation(forMissed: Date()) }, onWorkoutTap: { if let w = workoutVM.program(for: Date()), workoutVM.activeProgramId != w.id { workoutVM.switchProgram(to: w.id) } showWorkoutSession = true }, onToggle: { task in withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } }, onPin: { taskVM.pin($0) }, onPostpone: { taskVM.postpone($0) }, onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) } ) .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) }, onSetDate: { taskVM.setDate($0, date: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) } ) .padding(.horizontal, 18) .padding(.top, 10) } // ── Tomorrow ── if !taskVM.tomorrowTasks.isEmpty { UpcomingSection( title: "Tomorrow", tasks: taskVM.tomorrowTasks, onToggle: { taskVM.toggle($0) }, onPostpone: { taskVM.postpone($0) }, onPin: { taskVM.pin($0) }, onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) } ) } // ── Next 7 Days (2–7 days out) ── if !taskVM.next7DaysTasks.isEmpty { UpcomingSection( title: "Next 7 Days", tasks: taskVM.next7DaysTasks, onToggle: { taskVM.toggle($0) }, onPostpone: { taskVM.postpone($0) }, onPin: { taskVM.pin($0) }, onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) } ) } // ── Later (beyond 7 days) ── if !taskVM.laterTasks.isEmpty { UpcomingSection( title: "Later", tasks: taskVM.laterTasks, onToggle: { taskVM.toggle($0) }, onPostpone: { taskVM.postpone($0) }, onPin: { taskVM.pin($0) }, onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onSetPriority: { taskVM.setPriority($0, priority: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) } ) } // ── Completed (all, collapsible) ── if !taskVM.completedTasks.isEmpty { CompletedSection( tasks: taskVM.completedTasks, onUndo: { taskVM.toggle($0) } ) .padding(.horizontal, 18) .padding(.top, 10) } let hasAnything = !taskVM.overdueTasks.isEmpty || !taskVM.todayTasks.isEmpty || !taskVM.tomorrowTasks.isEmpty || !taskVM.next7DaysTasks.isEmpty || !taskVM.laterTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty || !todayCalEvents.isEmpty || workoutVM.program(for: Date()) != nil if !hasAnything { VStack(spacing: 6) { Text("All caught up") .font(AppFonts.sans(15, weight: .semibold)) .foregroundColor(AppColors.text2(cs)) Text("Nothing on your plate — tap + to start") .font(AppFonts.sans(12)) .foregroundColor(AppColors.text3(cs)) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.top, 60) } Spacer(minLength: 100) } } FAB { showAddTask = true } .tutorialAnchor(0) .padding(.trailing, 20) .padding(.bottom, 10) if tm.todayIsActive { VStack { Spacer() InViewTutorialCard( tips: tm.todayTips, step: $tm.todayStep, onAdvance: { tm.advanceToday() }, onSkip: { tm.skipToday() } ) .padding(.bottom, 84) } .transition(.opacity) .zIndex(10) } } .background(AppColors.background(cs).ignoresSafeArea()) .sheet(isPresented: $showAddTask) { AddTaskSheet() .environmentObject(taskVM) .presentationDetents([.height(150)]) .presentationDragIndicator(.visible) } .sheet(isPresented: $showWorkoutSession) { WorkoutSessionSheet() .environmentObject(workoutVM) .presentationDetents([.large]) .presentationDragIndicator(.visible) } .sheet(item: $editingTask) { task in TaskEditSheet(task: task) .environmentObject(taskVM) .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) } .onAppear { calStore.refreshStatus() calStore.fetchMonth(containing: Date()) if hk.authorized { Task { await hk.refresh() } } } .onChange(of: scenePhase) { phase in if phase == .active { calStore.refreshStatus() if hk.authorized { Task { await hk.refresh() } } } } } } // MARK: - Unified Today Timeline private struct TodayTLEntry: Identifiable { let id = UUID() let timeLabel: String let ampm: String? let title: String let subtitle: String? let color: Color let isComplete: Bool let isAllDay: Bool let task: TaskItem } struct UnifiedTodayTimelineView: View { @Environment(\.colorScheme) private var cs @AppStorage("todayHideCompleted") private var hideCompleted = false let overdueTasks: [TaskItem] let todayTasks: [TaskItem] let completedTodayTasks:[TaskItem] let next3DaysTasks: [(date: Date, tasks: [TaskItem])] let upcomingTasks: [TaskItem] let onToggle: (TaskItem) -> Void let onPostpone: (TaskItem) -> Void let onPostponeAll: () -> Void private static let shortFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "MMM d"; return f }() private static let timeFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "h:mm"; return f }() private static let ampmFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "a"; return f }() private static let dayFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "EEE, MMM d"; return f }() private let cal = Calendar.current private func entry(for task: TaskItem, dateAsLabel: Bool = false) -> TodayTLEntry { let timeLabel: String; let ampm: String? if task.isComplete, let done = task.completedAt { timeLabel = Self.timeFmt.string(from: done) ampm = Self.ampmFmt.string(from: done) } else if dateAsLabel, let d = task.dueDate { timeLabel = Self.shortFmt.string(from: d); ampm = nil } else if let d = task.dueDate, task.hasTime { timeLabel = Self.timeFmt.string(from: d) ampm = Self.ampmFmt.string(from: d) } else { timeLabel = "all day"; ampm = nil } return TodayTLEntry( timeLabel: timeLabel, ampm: ampm, title: task.title, subtitle: task.isRecurring ? "Annual" : nil, color: task.quadrant.color, isComplete: task.isComplete, isAllDay: task.isComplete ? false : !task.hasTime, task: task ) } private func tlRows(_ entries: [TodayTLEntry], postpnable: Bool = false) -> some View { ForEach(Array(entries.enumerated()), id: \.element.id) { i, e in TodayTLRow(entry: e, isFirst: i == 0, isLast: i == entries.count - 1, cs: cs) { onToggle(e.task) } .contextMenu { if postpnable { Button { onPostpone(e.task) } label: { Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath") } } } .padding(.horizontal, 18) } } var body: some View { VStack(alignment: .leading, spacing: 0) { // ── OVERDUE ── if !overdueTasks.isEmpty { TLSectionHeader(label: "OVERDUE", color: AppColors.accent, action: "POSTPONE ALL", onAction: onPostponeAll) .padding(.horizontal, 18) tlRows(overdueTasks.map { entry(for: $0, dateAsLabel: true) }, postpnable: true) } // ── TODAY ── let showToday = !todayTasks.isEmpty || (!completedTodayTasks.isEmpty && !hideCompleted) if showToday { TLSectionHeader(label: "TODAY", color: AppColors.blue) .padding(.horizontal, 18) // Active today tasks tlRows(todayTasks.map { entry(for: $0) }) // Completed today — right here, inside Today if !completedTodayTasks.isEmpty && !hideCompleted { tlRows(completedTodayTasks.map { entry(for: $0) }) } } // ── NEXT 3 DAYS (each day labelled) ── ForEach(next3DaysTasks, id: \.date) { day, tasks in let label = cal.isDateInTomorrow(day) ? "TOMORROW" : Self.dayFmt.string(from: day).uppercased() TLSectionHeader(label: label, color: AppColors.text2(cs)) .padding(.horizontal, 18) tlRows(tasks.map { entry(for: $0) }) } // ── UPCOMING (4+ days — flat, date in time column) ── if !upcomingTasks.isEmpty { TLSectionHeader(label: "UPCOMING", color: AppColors.text3(cs)) .padding(.horizontal, 18) tlRows(upcomingTasks.map { entry(for: $0, dateAsLabel: true) }, postpnable: true) } } .padding(.top, 8) } } private struct TLSectionHeader: View { @Environment(\.colorScheme) private var cs let label: String let color: Color var action: String? = nil var onAction: (() -> Void)? = nil var body: some View { HStack { Text(label) .font(AppFonts.mono(9.5, weight: .bold)) .foregroundColor(color) if let action, let onAction { Spacer() Button(action: onAction) { Text(action) .font(AppFonts.mono(8.5, weight: .bold)) .foregroundColor(color) .padding(.horizontal, 8).padding(.vertical, 3) .background(color.opacity(0.12)) .clipShape(Capsule()) } .buttonStyle(.plain) } } .padding(.top, 18) .padding(.bottom, 6) } } private struct TodayTLRow: View { let entry: TodayTLEntry let isFirst: Bool let isLast: Bool let cs: ColorScheme let onToggle: () -> Void @AppStorage("todayShowDetails") private var showDetails = true private let circleD: CGFloat = 18 private let timeW: CGFloat = 46 private let trackW: CGFloat = 26 private let topGap: CGFloat = 10 var body: some View { HStack(alignment: .top, spacing: 0) { // ── Time column ── VStack(alignment: .trailing, spacing: 0) { Text(entry.timeLabel) .font(AppFonts.mono(entry.isAllDay ? 8.5 : 11, weight: entry.isAllDay ? .medium : .bold)) .foregroundColor(entry.isAllDay ? AppColors.text3(cs) : AppColors.text(cs)) .lineLimit(1) .minimumScaleFactor(0.7) if let ap = entry.ampm { Text(ap) .font(AppFonts.mono(7, weight: .medium)) .foregroundColor(AppColors.text3(cs)) } } .frame(width: timeW, alignment: .trailing) .padding(.top, topGap) // ── Vertical track ── VStack(spacing: 0) { Rectangle() .fill(isFirst ? Color.clear : AppColors.border(cs)) .frame(width: 1, height: topGap) .frame(maxWidth: trackW) // Marker ZStack { if entry.isComplete { Circle() .fill(entry.color.opacity(0.14)) .frame(width: circleD, height: circleD) Image(systemName: "checkmark") .font(.system(size: 7.5, weight: .bold)) .foregroundColor(entry.color) } else if entry.isAllDay { Circle() .strokeBorder(entry.color, lineWidth: 1.5) .frame(width: circleD, height: circleD) } else { Circle() .fill(entry.color) .frame(width: 9, height: 9) } } .frame(width: circleD, height: circleD) Rectangle() .fill(isLast ? Color.clear : AppColors.border(cs)) .frame(minWidth: 1, maxWidth: 1, maxHeight: .infinity) .frame(maxWidth: trackW) } .frame(width: trackW) // ── Event card ── HStack(alignment: .center, spacing: 8) { VStack(alignment: .leading, spacing: 3) { Text(entry.title) .font(AppFonts.sans(13, weight: .medium)) .foregroundColor(entry.isComplete ? AppColors.text3(cs) : AppColors.text(cs)) .strikethrough(entry.isComplete, color: AppColors.text3(cs)) .lineLimit(2) if showDetails, let sub = entry.subtitle { Text(sub) .font(AppFonts.mono(9.5)) .foregroundColor(AppColors.text3(cs)) } } Spacer(minLength: 0) TagChip( text: entry.task.category.rawValue, color: entry.color, bg: entry.task.quadrant.softColor ) Button(action: onToggle) { Image(systemName: entry.isComplete ? "checkmark.circle.fill" : "circle") .font(.system(size: 20)) .foregroundColor(entry.isComplete ? entry.color : AppColors.text3(cs)) } .buttonStyle(.plain) } .padding(.horizontal, 13).padding(.vertical, 11) .background(AppColors.surface(cs)) .clipShape(RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(AppColors.border(cs), lineWidth: 1)) .padding(.leading, 8).padding(.bottom, 8) .frame(maxWidth: .infinity, alignment: .leading) } } } // MARK: - Overdue Card struct OverdueCard: View { @Environment(\.colorScheme) private var cs let tasks: [TaskItem] let onPostpone: () -> 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 } var onSetDate: (TaskItem, Date?) -> Void = { _, _ in } var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in } var onMove: (TaskItem, Quadrant) -> Void = { _, _ in } var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in } var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in } var onResetMatrixUrgency: (TaskItem) -> Void = { _ in } @State private var expanded = true var body: some View { VStack(spacing: 0) { // ── Header ── HStack { Text("OVERDUE") .font(AppFonts.mono(9.5, weight: .bold)) .foregroundColor(AppColors.accent) Spacer() HStack(spacing: 7) { Button(action: onPostpone) { Text("POSTPONE") .font(AppFonts.mono(9, weight: .bold)) .foregroundColor(AppColors.accent) .padding(.horizontal, 8).padding(.vertical, 3) .background(AppColors.accentSoft) .cornerRadius(5) } .buttonStyle(.plain) Text("\(tasks.count)") .font(AppFonts.mono(9.5)) .foregroundColor(AppColors.text3(cs)) Image(systemName: "chevron.down") .font(.system(size: 9, weight: .semibold)) .foregroundColor(AppColors.text3(cs)) .rotationEffect(.degrees(expanded ? 0 : -90)) .animation(KisaniSpring.micro, value: expanded) } } .padding(.horizontal, 13).padding(.vertical, 10) .contentShape(Rectangle()) .onTapGesture { withAnimation(KisaniSpring.snappy) { expanded.toggle() } } if expanded { Divider().background(AppColors.accent.opacity(0.10)) ForEach(tasks) { task in HStack(spacing: 10) { Button { withAnimation(KisaniSpring.bounce) { onToggle(task) } } label: { Circle() .stroke(AppColors.red, lineWidth: 1.5) .frame(width: 20, height: 20) .frame(width: 36, height: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) Text(task.title) .font(AppFonts.sans(12.5, weight: .medium)) .foregroundColor(AppColors.red) .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 { Text(d, style: .date) .font(AppFonts.mono(10)) .foregroundColor(AppColors.red.opacity(0.7)) } } .padding(.horizontal, 10) .contentShape(Rectangle()) .contextMenu { taskMenu(task) } } } } .background(AppColors.red.opacity(0.06)) .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) .overlay(RoundedRectangle(cornerRadius: AppRadius.large) .stroke(AppColors.red.opacity(0.18), lineWidth: 1)) } @ViewBuilder private func taskMenu(_ task: TaskItem) -> some View { Button { withAnimation(KisaniSpring.bounce) { onToggle(task) } } label: { Label("Mark Complete", systemImage: "checkmark.circle") } Button { withAnimation(KisaniSpring.snappy) { onPostponeTask(task) } } label: { Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath") } Divider() TaskMenuItems( task: task, onPin: onPin, onSetDate: onSetDate, onPostponeMinutes: onPostponeMinutes, onMove: onMove, onSetPriority: onSetPriority, onSetCategory: onSetCategory, onResetMatrixUrgency: onResetMatrixUrgency, onLiveActivity: { LiveActivityManager.toggle(for: $0) }, onEdit: onEdit, onDelete: onDelete ) } } // MARK: - Workout Today Banner struct WorkoutTodayBanner: View { @Environment(\.colorScheme) private var cs let program: WorkoutProgram let vm: WorkoutViewModel @Binding var isExpanded: Bool let onTap: () -> Void private var isActiveProgram: Bool { vm.activeProgramId == program.id } private var isDone: Bool { isActiveProgram && vm.progress >= 1 && vm.totalSets > 0 } private var accent: Color { isDone ? AppColors.green : AppColors.blue } private var setsText: String { guard isActiveProgram && vm.totalSets > 0 else { return "\(program.totalExercises) exercises" } return "\(vm.doneSets)/\(vm.totalSets) sets" } var body: some View { VStack(alignment: .leading, spacing: 0) { // ── Main row — progress ring leads ── HStack(spacing: 13) { // Progress ring (or idle ring when not active) ZStack { Circle() .stroke(AppColors.borderHi(cs), lineWidth: 3) .frame(width: 44, height: 44) if isActiveProgram && vm.totalSets > 0 { Circle() .trim(from: 0, to: CGFloat(min(vm.progress, 1))) .stroke(accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) .frame(width: 44, height: 44) .rotationEffect(.degrees(-90)) .animation(KisaniSpring.entrance, value: vm.progress) } if isDone { Image(systemName: "checkmark") .font(.system(size: 11, weight: .semibold)) .foregroundColor(accent) } else if isActiveProgram && vm.totalSets > 0 { Text("\(Int(vm.progress * 100))%") .font(AppFonts.mono(9, weight: .bold)) .foregroundColor(accent) } else { Image(systemName: "dumbbell") .font(.system(size: 13, weight: .regular)) .foregroundColor(AppColors.text3(cs)) } } VStack(alignment: .leading, spacing: 3) { HStack(spacing: 5) { Text(isDone ? "DONE" : "TODAY") .font(AppFonts.mono(8, weight: .bold)) .foregroundColor(accent) Text("·") .font(AppFonts.mono(8)) .foregroundColor(AppColors.text3(cs)) Text(setsText) .font(AppFonts.mono(8)) .foregroundColor(AppColors.text3(cs)) } Text(program.name) .font(AppFonts.sans(14, weight: .semibold)) .foregroundColor(AppColors.text(cs)) Text("\(program.sections.count) section\(program.sections.count == 1 ? "" : "s") · \(program.totalExercises) exercise\(program.totalExercises == 1 ? "" : "s")") .font(AppFonts.mono(9)) .foregroundColor(AppColors.text3(cs)) } Spacer() Image(systemName: "chevron.right") .font(.system(size: 10, weight: .semibold)) .foregroundColor(AppColors.text3(cs)) } .padding(.horizontal, 13).padding(.vertical, 12) .contentShape(Rectangle()) .onTapGesture { onTap() } // ── Expand toggle ── if !program.sections.isEmpty { Button { withAnimation(KisaniSpring.snappy) { isExpanded.toggle() } } label: { HStack(spacing: 4) { Image(systemName: isExpanded ? "chevron.up" : "chevron.down") .font(.system(size: 8, weight: .bold)) Text(isExpanded ? "Less" : "Show \(program.sections.count) sections") .font(AppFonts.mono(8.5, weight: .bold)) } .foregroundColor(accent.opacity(0.55)) .frame(maxWidth: .infinity) .padding(.vertical, 7) } .buttonStyle(.plain) // ── Section breakdown (expanded only) ── if isExpanded { Divider().background(accent.opacity(0.10)) VStack(alignment: .leading, spacing: 6) { ForEach(program.sections) { section in HStack(alignment: .top, spacing: 8) { Text(section.name.uppercased()) .font(AppFonts.mono(8.5, weight: .bold)) .foregroundColor(accent.opacity(0.8)) .frame(width: 72, alignment: .leading) Text(section.exercises.isEmpty ? "No exercises" : section.exercises.map(\.name).joined(separator: " · ")) .font(AppFonts.sans(10.5)) .foregroundColor(AppColors.text2(cs)) .lineLimit(1) Spacer() } } } .padding(.horizontal, 13).padding(.top, 8).padding(.bottom, 12) .transition(.opacity.combined(with: .move(edge: .top))) } } } .background(AppColors.surface(cs)) .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1)) .animation(KisaniSpring.entrance, value: isDone) } } // MARK: - Workout Session Sheet struct WorkoutSessionSheet: View { @EnvironmentObject var vm: WorkoutViewModel @Environment(\.colorScheme) var cs @Environment(\.dismiss) var dismiss var body: some View { VStack(spacing: 0) { // ── Header ── HStack(spacing: 12) { WorkoutSectionBar(count: vm.activeSections.count) .frame(width: 44, height: 44) VStack(alignment: .leading, spacing: 2) { Text(vm.workoutTitle) .font(AppFonts.sans(17, weight: .bold)) .foregroundColor(AppColors.text(cs)) Text(vm.totalSets > 0 ? "\(vm.doneSets) of \(vm.totalSets) sets done" : "No exercises added yet") .font(AppFonts.mono(10)) .foregroundColor(AppColors.text3(cs)) } Spacer() Button("Done") { dismiss() } .font(AppFonts.sans(13, weight: .semibold)) .foregroundColor(AppColors.accent) .buttonStyle(.plain) } .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14) // ── Dot Grid Progress ── VStack(spacing: 8) { HStack { Text("PROGRESS") .font(AppFonts.mono(8.5, weight: .bold)) .foregroundColor(AppColors.text3(cs)) Spacer() Text(vm.totalSets > 0 ? "\(Int(vm.progress * 100))%" : "0%") .font(AppFonts.mono(10, weight: .bold)) .foregroundColor(vm.progress >= 1 ? AppColors.green : AppColors.text2(cs)) } DotGridProgress(progress: vm.totalSets > 0 ? vm.progress : 0, cs: cs) } .padding(.horizontal, 20).padding(.bottom, 14) // ── Completion Banner ── if vm.progress >= 1 && vm.totalSets > 0 { HStack(spacing: 8) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 14)).foregroundColor(AppColors.green) Text("All sets done") .font(AppFonts.mono(11, weight: .bold)) .foregroundColor(AppColors.green) Spacer() } .padding(.horizontal, 20).padding(.vertical, 10) .background(AppColors.green.opacity(0.08)) } Divider().background(AppColors.border(cs)) // ── Exercise List ── List { ForEach($vm.activeSections) { $section in Section { if section.isExpanded { if section.exercises.isEmpty { Text("No exercises in this section") .font(AppFonts.sans(11)) .foregroundColor(AppColors.text3(cs)) .frame(maxWidth: .infinity).padding(.vertical, 12) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } else { ForEach($section.exercises) { $ex in ExerciseCard(exercise: $ex, sectionId: section.id) .environmentObject(vm) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 3, leading: 14, bottom: 3, trailing: 14)) } } } } header: { SessionSectionHeader(section: $section) .environmentObject(vm) } } Color.clear.frame(height: 30) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } .listStyle(.plain) .scrollContentBackground(.hidden) .scrollDismissesKeyboard(.immediately) .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer() Button("Done") { UIApplication.shared.sendAction( #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil ) } .font(AppFonts.sans(13, weight: .semibold)) .foregroundColor(AppColors.accent) } } } .background(AppColors.background(cs).ignoresSafeArea()) } } // MARK: - Session Section Header (expand/collapse + done count only) private struct SessionSectionHeader: View { @Binding var section: WorkoutSection @EnvironmentObject var vm: WorkoutViewModel @Environment(\.colorScheme) var cs private var doneSets: Int { section.exercises.reduce(0) { $0 + $1.doneSets } } private var totalSets: Int { section.exercises.reduce(0) { $0 + $1.totalSets } } private var isComplete: Bool { totalSets > 0 && doneSets == totalSets } var body: some View { HStack(spacing: 8) { Button { withAnimation(KisaniSpring.snappy) { vm.toggleSectionExpanded(section.id) } } label: { Image(systemName: "chevron.right") .font(.system(size: 9, weight: .bold)) .foregroundColor(AppColors.text3(cs)) .rotationEffect(.degrees(section.isExpanded ? 90 : 0)) .frame(width: 18) } .buttonStyle(.plain) Text(section.name.uppercased()) .font(AppFonts.mono(9.5, weight: .bold)) .foregroundColor(AppColors.text2(cs)) .textCase(nil) if totalSets > 0 { HStack(spacing: 3) { if isComplete { Image(systemName: "checkmark") .font(.system(size: 7, weight: .bold)) .foregroundColor(AppColors.green) } Text("\(doneSets)/\(totalSets)") .font(AppFonts.mono(8.5, weight: .bold)) .foregroundColor(isComplete ? AppColors.green : AppColors.text3(cs)) } .padding(.horizontal, 6).padding(.vertical, 2) .background(isComplete ? AppColors.greenSoft : AppColors.surface2(cs)) .clipShape(Capsule()) } Spacer() } .padding(.horizontal, 14).padding(.vertical, 6) .background(AppColors.background(cs)) } } // MARK: - Upcoming Section struct UpcomingSection: View { @Environment(\.colorScheme) private var cs var title: String = "Upcoming" let tasks: [TaskItem] let onToggle: (TaskItem) -> Void let onPostpone: (TaskItem) -> Void var onPin: (TaskItem) -> Void = { _ in } var onEdit: (TaskItem) -> Void = { _ in } let onDelete: (TaskItem) -> Void var onSetDate: (TaskItem, Date?) -> Void = { _, _ in } var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in } var onMove: (TaskItem, Quadrant) -> Void = { _, _ in } var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in } var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in } var onResetMatrixUrgency: (TaskItem) -> Void = { _ in } @State private var collapsed = false @State private var showAll = false private let pageSize = 5 private var visible: [TaskItem] { showAll ? tasks : Array(tasks.prefix(pageSize)) } var body: some View { VStack(alignment: .leading, spacing: 0) { // ── Header (tappable to collapse) ── HStack(spacing: 6) { Text(title) .font(AppFonts.sans(10.5, weight: .semibold)) .foregroundColor(AppColors.text3(cs)) Text("\(tasks.count)") .font(AppFonts.mono(9, weight: .semibold)) .foregroundColor(AppColors.text3(cs).opacity(0.5)) .padding(.horizontal, 6).padding(.vertical, 2) .background(AppColors.surface2(cs)) .clipShape(Capsule()) Spacer() Image(systemName: "chevron.down") .font(.system(size: 10, weight: .semibold)) .foregroundColor(AppColors.text3(cs).opacity(0.6)) .rotationEffect(.degrees(collapsed ? -90 : 0)) .animation(KisaniSpring.micro, value: collapsed) } .padding(.horizontal, 18) .padding(.top, 14) .padding(.bottom, collapsed ? 2 : 8) .contentShape(Rectangle()) .onTapGesture { withAnimation(KisaniSpring.snappy) { collapsed.toggle() if !collapsed { showAll = false } } } if !collapsed { // Task rows ForEach(visible) { task in TaskRowView(task: task) { withAnimation(KisaniSpring.snappy) { onToggle(task) } } .contextMenu { Button { withAnimation(KisaniSpring.snappy) { onPostpone(task) } } label: { Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath") } Divider() TaskMenuItems( task: task, onPin: onPin, onSetDate: onSetDate, onPostponeMinutes: onPostponeMinutes, onMove: onMove, onSetPriority: onSetPriority, onSetCategory: onSetCategory, onResetMatrixUrgency: onResetMatrixUrgency, onLiveActivity: { LiveActivityManager.toggle(for: $0) }, onEdit: onEdit, onDelete: { task in withAnimation(KisaniSpring.snappy) { onDelete(task) } } ) } .padding(.horizontal, 18) .padding(.bottom, 5) } // Show more / collapse overflow if tasks.count > pageSize { Button { withAnimation(KisaniSpring.snappy) { showAll.toggle() } } label: { HStack(spacing: 5) { Text(showAll ? "Show less" : "\(tasks.count - pageSize) more") .font(AppFonts.mono(9.5, weight: .semibold)) .foregroundColor(AppColors.accent) Image(systemName: showAll ? "chevron.up" : "chevron.down") .font(.system(size: 8, weight: .bold)) .foregroundColor(AppColors.accent) } .padding(.horizontal, 18) .padding(.top, 2) .padding(.bottom, 4) } .buttonStyle(.plain) } } } } } // MARK: - Completed Today Section struct CompletedTodaySection: View { @Environment(\.colorScheme) private var cs let tasks: [TaskItem] let onUndo: (TaskItem) -> Void @State private var expanded = true private let timeFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "h:mm a"; return f }() var body: some View { VStack(spacing: 0) { // Header HStack { HStack(spacing: 6) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 11)) .foregroundColor(AppColors.green) Text("COMPLETED TODAY") .font(AppFonts.mono(9.5, weight: .bold)) .foregroundColor(AppColors.green) } Spacer() HStack(spacing: 6) { Text("\(tasks.count)") .font(AppFonts.mono(9.5)) .foregroundColor(AppColors.text3(cs)) Image(systemName: "chevron.down") .font(.system(size: 9, weight: .semibold)) .foregroundColor(AppColors.text3(cs)) .rotationEffect(.degrees(expanded ? 0 : -90)) .animation(KisaniSpring.micro, value: expanded) } } .padding(.horizontal, 13).padding(.vertical, 10) .contentShape(Rectangle()) .onTapGesture { withAnimation(KisaniSpring.snappy) { expanded.toggle() } } if expanded { Divider().background(AppColors.green.opacity(0.15)) ForEach(tasks) { task in HStack(spacing: 10) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 18)) .foregroundColor(AppColors.green) .frame(width: 36, height: 44) VStack(alignment: .leading, spacing: 1) { Text(task.title) .font(AppFonts.sans(12, weight: .medium)) .foregroundColor(AppColors.text3(cs)) .strikethrough(true, color: AppColors.text3(cs)) if let t = task.completedAt { Text("Done at \(timeFmt.string(from: t))") .font(AppFonts.mono(9.5)) .foregroundColor(AppColors.text3(cs).opacity(0.6)) } } Spacer() Button { withAnimation(KisaniSpring.snappy) { onUndo(task) } } label: { Text("UNDO") .font(AppFonts.mono(8.5, weight: .bold)) .foregroundColor(AppColors.text3(cs)) .padding(.horizontal, 7).padding(.vertical, 3) .background(AppColors.surface2(cs)) .cornerRadius(5) .overlay(RoundedRectangle(cornerRadius: 5).stroke(AppColors.border(cs), lineWidth: 1)) } .buttonStyle(.plain) .padding(.trailing, 4) } .padding(.horizontal, 10) } .padding(.bottom, 4) } } .background(AppColors.green.opacity(0.06)) .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) .overlay(RoundedRectangle(cornerRadius: AppRadius.large) .stroke(AppColors.green.opacity(0.18), lineWidth: 1)) } } // MARK: - Completed Section (all completed, collapsible archive) struct CompletedSection: View { @Environment(\.colorScheme) private var cs let tasks: [TaskItem] let onUndo: (TaskItem) -> Void @State private var expanded = false private let dateFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "MMM d"; return f }() var body: some View { VStack(spacing: 0) { // Header HStack { Text("Completed") .font(AppFonts.sans(15, weight: .bold)) .foregroundColor(AppColors.text2(cs)) Spacer() Text("\(tasks.count)") .font(AppFonts.mono(11)) .foregroundColor(AppColors.text3(cs)) Image(systemName: "chevron.down") .font(.system(size: 11, weight: .semibold)) .foregroundColor(AppColors.text3(cs)) .rotationEffect(.degrees(expanded ? 0 : -90)) .animation(KisaniSpring.micro, value: expanded) } .padding(.horizontal, 14).padding(.vertical, 12) .contentShape(Rectangle()) .onTapGesture { withAnimation(KisaniSpring.snappy) { expanded.toggle() } } if expanded { ForEach(tasks) { task in HStack(spacing: 12) { Button { withAnimation(KisaniSpring.snappy) { onUndo(task) } } label: { Image(systemName: "checkmark.square.fill") .font(.system(size: 22)) .foregroundColor(AppColors.text3(cs).opacity(0.6)) } .buttonStyle(.plain) Text(task.title) .font(AppFonts.sans(14)) .foregroundColor(AppColors.text3(cs)) .strikethrough(true, color: AppColors.text3(cs).opacity(0.7)) .lineLimit(1) Spacer(minLength: 8) if let d = task.completedAt { Text(dateFmt.string(from: d)) .font(AppFonts.sans(13)) .foregroundColor(AppColors.text3(cs)) } } .padding(.horizontal, 14).padding(.vertical, 10) } .padding(.bottom, 4) } } .background(AppColors.surface(cs)) .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1)) } } // MARK: - Task Row struct TaskRowView: View { @Environment(\.colorScheme) private var cs @AppStorage("todayShowDetails") private var showDetails = true let task: TaskItem let onToggle: () -> Void private let dueFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "MMM d"; return f }() var body: some View { HStack(spacing: 10) { Button(action: onToggle) { ZStack { Circle() .fill(task.quadrant.softColor) .frame(width: 22, height: 22) Circle() .stroke(task.quadrant.color, lineWidth: 2) .frame(width: 22, height: 22) } .frame(width: 36, height: 44) .padding(.leading, 4) .contentShape(Circle()) } .buttonStyle(PressButtonStyle(scale: 0.82)) VStack(alignment: .leading, spacing: 2) { Text(task.title) .font(AppFonts.sans(13)) .foregroundColor(AppColors.text(cs)) if let d = task.dueDate, showDetails { HStack(spacing: 4) { Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceLabel ?? "Repeat")" : "")") .font(AppFonts.mono(9.5)) .foregroundColor(AppColors.text3(cs)) Text("·") .font(AppFonts.mono(9.5)) .foregroundColor(AppColors.text3(cs).opacity(0.4)) Text(countdownLabel(to: d)) .font(AppFonts.mono(9.5, weight: .semibold)) .foregroundColor(countdownColor(to: d)) } } } Spacer() // ⏰ reminder · 🔁 recurring indicators HStack(spacing: 5) { if task.reminderDate != nil || task.constantReminder { Image(systemName: "alarm") .font(.system(size: 11)) .foregroundColor(AppColors.text3(cs)) } if task.isRecurring { Image(systemName: "repeat") .font(.system(size: 11)) .foregroundColor(AppColors.text3(cs)) } } TagChip( text: task.category.rawValue, color: task.quadrant.color, bg: task.quadrant.softColor ) .padding(.trailing, 9) } .frame(minHeight: 48) .background(AppColors.surface(cs)) .overlay(alignment: .leading) { Rectangle() .fill(task.quadrant.color) .frame(width: 2.5) } .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) .overlay(RoundedRectangle(cornerRadius: AppRadius.small) .stroke(AppColors.border(cs), lineWidth: 1)) } private func countdownLabel(to date: Date) -> String { let diff = date.timeIntervalSince(Date()) if diff < 0 { return "overdue" } let secs = Int(diff) let mins = secs / 60 let hours = mins / 60 let days = hours / 24 let weeks = days / 7 let months = days / 30 if hours < 1 { return "in \(max(1, mins)) min\(mins == 1 ? "" : "s")" } if hours < 24 { return "in \(hours) hr\(hours == 1 ? "" : "s")" } if days < 7 { return "in \(days) day\(days == 1 ? "" : "s")" } if weeks < 5 { return "in \(weeks) wk\(weeks == 1 ? "" : "s")" } return "in \(months) mo\(months == 1 ? "" : "s")" } private func countdownColor(to date: Date) -> Color { let diff = date.timeIntervalSince(Date()) if diff < 0 { return AppColors.accent } if diff < 3_600 { return AppColors.accent } if diff < 86_400 { return AppColors.yellow } if diff < 3 * 86_400 { return AppColors.text2(cs) } return AppColors.text3(cs) } } // MARK: - Next 3 Days Section struct Next3DaysSection: View { @Environment(\.colorScheme) private var cs let tasksByDay: [(date: Date, tasks: [TaskItem])] let onToggle: (TaskItem) -> Void @AppStorage("next3Collapsed") private var collapsed = false private let dayFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "EEEE, MMM d"; return f }() var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 6) { Text("Next 3 Days") .font(AppFonts.sans(10.5, weight: .semibold)) .foregroundColor(AppColors.text3(cs)) let total = tasksByDay.reduce(0) { $0 + $1.tasks.count } Text("\(total)") .font(AppFonts.mono(9, weight: .semibold)) .foregroundColor(AppColors.text3(cs).opacity(0.5)) .padding(.horizontal, 6).padding(.vertical, 2) .background(AppColors.surface2(cs)) .clipShape(Capsule()) Spacer() Image(systemName: "chevron.down") .font(.system(size: 10, weight: .semibold)) .foregroundColor(AppColors.text3(cs).opacity(0.6)) .rotationEffect(.degrees(collapsed ? -90 : 0)) .animation(KisaniSpring.micro, value: collapsed) } .padding(.horizontal, 18) .padding(.top, 14) .padding(.bottom, collapsed ? 2 : 8) .contentShape(Rectangle()) .onTapGesture { withAnimation(KisaniSpring.snappy) { collapsed.toggle() } } if !collapsed { ForEach(tasksByDay, id: \.date) { day, tasks in Text(dayFmt.string(from: day)) .font(AppFonts.mono(9.5, weight: .bold)) .foregroundColor(AppColors.accent) .padding(.horizontal, 18) .padding(.top, 8) .padding(.bottom, 4) ForEach(tasks) { task in TaskRowView(task: task) { withAnimation(KisaniSpring.snappy) { onToggle(task) } } .padding(.horizontal, 18) .padding(.bottom, 5) } } } } } } // MARK: - NL Task Parser struct NLParsed { var date: Date? var hasTime: Bool = false var tokenRanges: [NSRange] = [] var isRecurring: Bool = false var recurrenceLabel: String? = nil var recurrenceEnd: Date? = nil var endDate: Date? = nil var isAllDay: Bool = false var isBirthday: Bool = false var hasDate: Bool { date != nil } var dateLabel: String { guard let d = date else { return "No Date" } let cal = Calendar.current let fmt = DateFormatter() if hasTime { fmt.dateFormat = cal.isDateInToday(d) ? "'Today,' HH:mm" : cal.isDateInTomorrow(d) ? "'Tomorrow,' HH:mm" : "MMM d, HH:mm" } else { if cal.isDateInToday(d) { return "Today" } if cal.isDateInTomorrow(d) { return "Tomorrow" } fmt.dateFormat = "EEE, MMM d" } return fmt.string(from: d) } } struct NLTaskParser { static func parse(_ input: String) -> NLParsed { var result = NLParsed() guard !input.isEmpty else { return result } let lower = input.lowercased() let cal = Calendar.current var comps = cal.dateComponents([.year, .month, .day], from: Date()) var rawRanges: [NSRange] = [] var foundDay = false // ── Intent phrases (stripped from title) ────────────────────────── for pat in ["\\bremind\\s+me(?:\\s+(?:to|of|about))?", "\\bremember(?:\\s+(?:to|about))?"] { if let r = match(pat, in: lower) { rawRanges.append(r) } } // ── Birthday detection ───────────────────────────────────────────── if match("\\bbirthday\\b", in: lower) != nil { result.isBirthday = true if !result.isRecurring { result.isRecurring = true result.recurrenceLabel = "Yearly" } } // ── Recurrence ────────────────────────────────────────────────────── if let (r, label) = parseRecurrence(in: lower) { result.isRecurring = true result.recurrenceLabel = label rawRanges.append(r) } // ── Absolute month + day ──────────────────────────────────────────── if let (r, month, day) = parseMonthDay(in: lower) { var testComps = DateComponents() testComps.year = cal.component(.year, from: Date()) testComps.month = month testComps.day = day let year: Int if let candidate = cal.date(from: testComps), candidate < cal.startOfDay(for: Date()) { year = cal.component(.year, from: Date()) + 1 } else { year = cal.component(.year, from: Date()) } comps.year = year; comps.month = month; comps.day = day rawRanges.append(r); foundDay = true } // ── Day keywords ──────────────────────────────────────────────────── if !foundDay { let dayTable: [(String, Int)] = [ ("\\btoday\\b", 0), ("\\btom+[ao]r+ow\\b", 1), ("\\bmon(day)?\\b", daysUntil(2)), ("\\btue(sday)?\\b", daysUntil(3)), ("\\bwed(nesday)?\\b", daysUntil(4)), ("\\bthu(rsday)?\\b", daysUntil(5)), ("\\bfri(day)?\\b", daysUntil(6)), ("\\bsat(urday)?\\b", daysUntil(7)), ("\\bsun(day)?\\b", daysUntil(1)), ] if let (r, offset) = parseRelativeOffset(in: lower) { let d = cal.date(byAdding: .day, value: offset, to: Date()) ?? Date() comps = cal.dateComponents([.year, .month, .day], from: d) rawRanges.append(r); foundDay = true } if !foundDay { for (pat, offset) in dayTable { if let r = match(pat, in: lower) { let d = cal.date(byAdding: .day, value: offset, to: Date()) ?? Date() comps = cal.dateComponents([.year, .month, .day], from: d) rawRanges.append(r); foundDay = true; break } } } } // ── Time patterns ──────────────────────────────────────────────────── let timePatterns = [ "at\\s+(\\d{4})\\b", "at\\s+(\\d{1,2}):(\\d{2})\\s*(am|pm)?", "at\\s+(\\d{1,2})\\s*(am|pm)", "\\b(\\d{1,2}):(\\d{2})\\s*(am|pm)\\b", "\\b(\\d{1,2})\\s*(am|pm)\\b", ] var foundTime = false for pat in timePatterns { if let (r, h, m) = parseTime(pat, in: lower) { comps.hour = h; comps.minute = m; comps.second = 0 rawRanges.append(r); foundTime = true; break } } if foundDay || foundTime { result.date = cal.date(from: comps) result.hasTime = foundTime } result.tokenRanges = expand(rawRanges, in: lower) return result } static func cleanTitle(_ text: String, ranges: [NSRange]) -> String { var s = text as NSString for r in ranges.sorted(by: { $0.location > $1.location }) where r.location + r.length <= s.length { s = s.replacingCharacters(in: r, with: "") as NSString } return (s as String).trimmingCharacters(in: .whitespaces) .replacingOccurrences(of: " ", with: " ") } // MARK: - Month + Day private static let monthAlts = "jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?" private static func parseMonthDay(in text: String) -> (NSRange, Int, Int)? { let m = monthAlts let o = "(?:st|nd|rd|th)?" // (pattern, dayGroupIndex, monthGroupIndex) let pats: [(String, Int, Int)] = [ ("\\bon\\s+(?:the\\s+)?(\\d{1,2})\(o)\\s+of\\s+(\(m))\\b", 1, 2), ("\\bon\\s+(\(m))\\s+(\\d{1,2})\(o)\\b", 2, 1), ("\\bon\\s+(\\d{1,2})\(o)\\s+(\(m))\\b", 1, 2), ("\\b(\(m))\\s+(\\d{1,2})\(o)\\b", 2, 1), ("\\b(\\d{1,2})(?:st|nd|rd|th)\\s+(\(m))\\b", 1, 2), ] for (pat, dayIdx, moIdx) in pats { guard let rx = try? NSRegularExpression(pattern: pat, options: .caseInsensitive), let hit = rx.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), hit.numberOfRanges > max(dayIdx, moIdx), let dr = Range(hit.range(at: dayIdx), in: text), let mr = Range(hit.range(at: moIdx), in: text), let day = Int(String(text[dr])), let month = resolveMonth(String(text[mr])), day >= 1 && day <= 31 else { continue } return (hit.range, month, day) } return nil } private static func resolveMonth(_ s: String) -> Int? { let map: [String: Int] = [ "january":1,"jan":1,"february":2,"feb":2,"march":3,"mar":3, "april":4,"apr":4,"may":5,"june":6,"jun":6,"july":7,"jul":7, "august":8,"aug":8,"september":9,"sep":9,"sept":9, "october":10,"oct":10,"november":11,"nov":11,"december":12,"dec":12, ] return map[s.lowercased()] } // MARK: - Recurrence private static func parseRecurrence(in text: String) -> (NSRange, String)? { let pats: [(String, String)] = [ ("\\bevery\\s+year\\b", "Yearly"), ("\\bannually\\b", "Yearly"), ("\\byearly\\b", "Yearly"), ("\\bevery\\s+month\\b", "Monthly"), ("\\bmonthly\\b", "Monthly"), ("\\bevery\\s+week\\b", "Weekly"), ("\\bweekly\\b", "Weekly"), ("\\bevery\\s+day\\b", "Daily"), ("\\bdaily\\b", "Daily"), ] for (pat, label) in pats { if let r = match(pat, in: text) { return (r, label) } } return nil } // MARK: - Helpers private static func parseRelativeOffset(in text: String) -> (NSRange, Int)? { let wordNums = ["a": 1, "an": 1, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10] if let r = match("\\bnext\\s+week\\b", in: text) { return (r, 7) } for (unit, mult) in [("days?", 1), ("weeks?", 7)] { let pat = "\\bin\\s+(\\d+|a|an|one|two|three|four|five|six|seven|eight|nine|ten)\\s+\(unit)\\b" guard let rx = try? NSRegularExpression(pattern: pat), let m = rx.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), m.numberOfRanges >= 2, let nr = Range(m.range(at: 1), in: text) else { continue } let ns = String(text[nr]) return (m.range, (Int(ns) ?? wordNums[ns] ?? 1) * mult) } return nil } private static func daysUntil(_ target: Int) -> Int { let today = Calendar.current.component(.weekday, from: Date()) let d = target - today return d <= 0 ? d + 7 : d } private static func match(_ pattern: String, in text: String) -> NSRange? { guard let rx = try? NSRegularExpression(pattern: pattern) else { return nil } return rx.firstMatch(in: text, range: NSRange(text.startIndex..., in: text))?.range } private static func parseTime(_ pattern: String, in text: String) -> (NSRange, Int, Int)? { guard let rx = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), let m = rx.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) else { return nil } let full = (text as NSString).substring(with: m.range).lowercased() var h = 0, mn = 0 if m.numberOfRanges >= 2, let r1 = Range(m.range(at: 1), in: text) { let s1 = String(text[r1]) if s1.count == 4 { h = Int(s1.prefix(2)) ?? 0; mn = Int(s1.suffix(2)) ?? 0 } else { h = Int(s1) ?? 0 } } if m.numberOfRanges >= 3, m.range(at: 2).location != NSNotFound, let r2 = Range(m.range(at: 2), in: text) { mn = Int(String(text[r2])) ?? mn } let isPM = full.contains("pm") && h < 12 let isAM = full.contains("am") && h == 12 if isPM { h += 12 }; if isAM { h = 0 } guard h >= 0 && h < 24 && mn >= 0 && mn < 60 else { return nil } return (m.range, h, mn) } private static func expand(_ ranges: [NSRange], in text: String) -> [NSRange] { let ns = text as NSString return ranges.map { r in var loc = r.location var len = r.length if loc > 0 && ns.character(at: loc - 1) == 32 { loc -= 1; len += 1 } else if loc + len < ns.length && ns.character(at: loc + len) == 32 { len += 1 } return NSRange(location: loc, length: len) } } } // MARK: - NL Task Field struct NLTaskField: UIViewRepresentable { @Binding var text: String let placeholder: String var highlightRanges: [NSRange] var onReturn: (() -> Void)? = nil func makeUIView(context: Context) -> UITextView { let tv = UITextView() tv.delegate = context.coordinator tv.isScrollEnabled = false tv.backgroundColor = .clear tv.textContainerInset = .zero tv.textContainer.lineFragmentPadding = 0 tv.font = UIFont.systemFont(ofSize: 18) tv.textColor = .placeholderText tv.text = placeholder tv.autocorrectionType = .yes tv.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) return tv } func updateUIView(_ tv: UITextView, context: Context) { guard context.coordinator.isEditing else { if text.isEmpty { tv.text = placeholder tv.textColor = .placeholderText } return } let sel = tv.selectedRange let attr = NSMutableAttributedString(string: text) let full = NSRange(location: 0, length: attr.length) attr.addAttributes([.font: UIFont.systemFont(ofSize: 18), .foregroundColor: UIColor.label], range: full) for r in highlightRanges where r.location + r.length <= attr.length { attr.addAttributes([ .backgroundColor: UIColor.systemBlue.withAlphaComponent(0.13), .foregroundColor: UIColor.systemBlue, ], range: r) } tv.attributedText = attr tv.selectedRange = sel } func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, UITextViewDelegate { var parent: NLTaskField var isEditing = false init(_ p: NLTaskField) { parent = p } func textViewDidBeginEditing(_ tv: UITextView) { isEditing = true if tv.textColor == .placeholderText { tv.text = ""; tv.textColor = .label } } func textViewDidEndEditing(_ tv: UITextView) { isEditing = false if tv.text.isEmpty { tv.text = parent.placeholder; tv.textColor = .placeholderText } } func textViewDidChange(_ tv: UITextView) { parent.text = tv.text } } } // MARK: - Add Task Sheet struct AddTaskSheet: View { @EnvironmentObject var taskVM: TaskViewModel @Environment(\.colorScheme) var cs @Environment(\.dismiss) var dismiss @State private var rawText = "" @State private var parsed = NLParsed() @State private var selectedPriority: Priority = .none @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? var prefilledQuadrant: Quadrant init(prefilledDate: Date? = nil, prefilledQuadrant: Quadrant = .urgent) { self.prefilledDate = prefilledDate self.prefilledQuadrant = prefilledQuadrant // Adding from a Matrix quadrant: seed the Priority that lands the task there. let importantRow = (prefilledQuadrant == .urgent || prefilledQuadrant == .schedule) _selectedPriority = State(initialValue: importantRow ? .high : .none) var p = NLParsed() p.date = prefilledDate _parsed = State(initialValue: p) } private var cleanTitle: String { NLTaskParser.cleanTitle(rawText, ranges: parsed.tokenRanges) } private var canAdd: Bool { !cleanTitle.trimmingCharacters(in: .whitespaces).isEmpty } var body: some View { VStack(spacing: 0) { // ── Text input ────────────────────────────────────────────────── NLTaskField( text: $rawText, placeholder: "What would you like to do?", highlightRanges: parsed.tokenRanges ) .frame(minHeight: 36, maxHeight: 72) .onChange(of: rawText) { _ in var p = NLTaskParser.parse(rawText) if p.date == nil { p.date = prefilledDate } withAnimation(KisaniSpring.micro) { parsed = p if p.isBirthday { selectedCategory = .birthday } } } .padding(.horizontal, 16) .padding(.top, 14) .padding(.bottom, 10) // ── Toolbar ───────────────────────────────────────────────────── Divider().background(AppColors.border(cs)) HStack(spacing: 0) { // Date chip Button { showDatePicker = true } label: { HStack(spacing: 5) { Image(systemName: "calendar") .font(.system(size: 13, weight: .semibold)) Text(parsed.hasDate ? parsed.dateLabel : "No Date") .font(AppFonts.sans(13, weight: .semibold)) } .foregroundColor(parsed.hasDate ? AppColors.accent : AppColors.text3(cs)) .padding(.horizontal, 10).padding(.vertical, 6) .background(parsed.hasDate ? AppColors.accentSoft : Color.clear) .clipShape(Capsule()) } .buttonStyle(.plain) .animation(KisaniSpring.micro, value: parsed.hasDate) if parsed.isRecurring { HStack(spacing: 4) { Image(systemName: "arrow.clockwise") .font(.system(size: 11, weight: .semibold)) Text(parsed.recurrenceLabel ?? "Recurring") .font(AppFonts.sans(13, weight: .semibold)) } .foregroundColor(AppColors.accent) .padding(.horizontal, 10).padding(.vertical, 6) .background(AppColors.accentSoft) .clipShape(Capsule()) .transition(.scale(scale: 0.8).combined(with: .opacity)) .padding(.leading, 6) .animation(KisaniSpring.micro, value: parsed.isRecurring) } Spacer(minLength: 4) // Priority — drives Matrix placement (importance axis), TickTick-style Menu { ForEach(Priority.allCases) { p in Button { withAnimation(KisaniSpring.micro) { selectedPriority = p } } label: { Label(p.label, systemImage: selectedPriority == p ? "checkmark" : "flag") } } } label: { Image(systemName: selectedPriority == .none ? "flag" : "flag.fill") .font(.system(size: 15)) .foregroundColor(selectedPriority == .none ? AppColors.text3(cs) : selectedPriority.color) .frame(width: 42, height: 44) } .buttonStyle(.plain) // Category Menu { ForEach([TaskCategory.reminder, .birthday, .domain, .annual, .custom], id: \.rawValue) { cat in Button { withAnimation(KisaniSpring.micro) { selectedCategory = cat } } label: { Label(cat.rawValue, systemImage: selectedCategory == cat ? "checkmark" : "") } } } label: { Image(systemName: selectedCategory != .reminder ? "tag.fill" : "tag") .font(.system(size: 15)) .foregroundColor(selectedCategory != .reminder ? AppColors.accent : AppColors.text3(cs)) .frame(width: 42, height: 44) } .buttonStyle(.plain) Image(systemName: "arrow.right.square") .font(.system(size: 15)) .foregroundColor(AppColors.text3(cs)) .frame(width: 42, height: 44) Image(systemName: "ellipsis") .font(.system(size: 15)) .foregroundColor(AppColors.text3(cs)) .frame(width: 42, height: 44) Spacer() // Submit / mic ZStack { if canAdd { Button { submit() } label: { Image(systemName: "arrow.up") .font(.system(size: 14, weight: .bold)) .foregroundColor(.white) .frame(width: 34, height: 34) .background(AppColors.accent) .clipShape(Circle()) } .buttonStyle(PressButtonStyle(scale: 0.90)) .transition(.scale(scale: 0.5).combined(with: .opacity)) } else { Image(systemName: "mic") .font(.system(size: 18)) .foregroundColor(AppColors.text3(cs)) .frame(width: 42, height: 44) .transition(.scale(scale: 0.5).combined(with: .opacity)) } } .animation(KisaniSpring.micro, value: canAdd) } .padding(.horizontal, 12) .padding(.vertical, 4) } .background(AppColors.surface(cs).ignoresSafeArea()) .sheet(isPresented: $showDatePicker) { TaskDatePickerSheet( selectedDate: $parsed.date, hasTime: $parsed.hasTime, isRecurring: $parsed.isRecurring, recurrenceLabel: $parsed.recurrenceLabel, endDate: $parsed.endDate, isAllDay: $parsed.isAllDay, reminderDate: $reminderDate, constantReminder: $constantReminder, recurrenceEnd: $parsed.recurrenceEnd ) } } private func submit() { guard canAdd else { return } taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime, quadrant: prefilledQuadrant, category: selectedCategory, isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel, endDate: parsed.endDate, isAllDay: parsed.isAllDay, priority: selectedPriority, reminderDate: reminderDate, constantReminder: constantReminder, recurrenceEnd: parsed.recurrenceEnd) dismiss() } } // MARK: - Task Date Picker Sheet struct TaskDatePickerSheet: View { @Binding var selectedDate: Date? @Binding var hasTime: Bool @Binding var isRecurring: Bool @Binding var recurrenceLabel: String? @Binding var recurrenceEnd: Date? @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 @State private var pickerMode: Int // 0 = Date, 1 = Duration @State private var localDate: Date @State private var localEndDate: Date @State private var localTime: Date? @State private var showTimePicker = false @State private var showReminderPicker = false // Reminder lead-time, expressed as MINUTES before the task time (nil = no reminder). @State private var localReminderOffset: Int? @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 customUnit: Int // 0 = minutes, 1 = hours, 2 = days @State private var localRepeat: String @State private var localRecurrenceEnd: Date? @State private var showRecurrenceEndPicker = false @State private var localIsAllDay: Bool @State private var editingEnd = false init(selectedDate: Binding, hasTime: Binding, isRecurring: Binding, recurrenceLabel: Binding, endDate: Binding, isAllDay: Binding, reminderDate: Binding = .constant(nil), constantReminder: Binding = .constant(false), recurrenceEnd: Binding = .constant(nil)) { _selectedDate = selectedDate _hasTime = hasTime _isRecurring = isRecurring _recurrenceLabel = recurrenceLabel _recurrenceEnd = recurrenceEnd _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) _localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None") _localRecurrenceEnd = State(initialValue: recurrenceEnd.wrappedValue) _localIsAllDay = State(initialValue: isAllDay.wrappedValue) _pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0) _localConstant = State(initialValue: constantReminder.wrappedValue) // Derive the lead-time (minutes before the event time) from any existing reminder. if let r = reminderDate.wrappedValue { let base: Date = hasTime.wrappedValue ? (selectedDate.wrappedValue ?? start) : (cal.date(bySettingHour: 9, minute: 0, second: 0, of: start) ?? start) let mins = max(0, cal.dateComponents([.minute], from: r, to: base).minute ?? 0) _localReminderOffset = State(initialValue: mins) if mins > 0 && mins % 1440 == 0 { _customUnit = State(initialValue: 2); _customNumber = State(initialValue: mins / 1440) } else if mins > 0 && mins % 60 == 0 { _customUnit = State(initialValue: 1); _customNumber = State(initialValue: mins / 60) } else { _customUnit = State(initialValue: 0); _customNumber = State(initialValue: max(1, mins)) } } else { _localReminderOffset = State(initialValue: nil) _customUnit = State(initialValue: 1) // default Custom draft: 1 hour _customNumber = State(initialValue: 1) } } var body: some View { NavigationStack { ScrollView { VStack(spacing: 0) { if pickerMode == 0 { CalendarGridView(selectedDate: $localDate) .padding(.horizontal, 20).padding(.top, 12).padding(.bottom, 20) optionsCard } else { durationContent } Button { selectedDate = nil; hasTime = false; endDate = nil reminderDate = nil; dismiss() } label: { Text("Clear") .font(AppFonts.sans(16, weight: .medium)) .foregroundColor(AppColors.accent) } .padding(.top, 24).padding(.bottom, 12) } } .background(Color(uiColor: cs == .dark ? .systemBackground : .systemGroupedBackground).ignoresSafeArea()) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button { dismiss() } label: { Image(systemName: "xmark") .font(.system(size: 13, weight: .semibold)) .foregroundColor(.primary) .frame(width: 34, height: 34) .background(Color(uiColor: .systemGray5)) .clipShape(Circle()) } } ToolbarItem(placement: .principal) { HStack(spacing: 2) { modeButton("Date", tag: 0) modeButton("Duration", tag: 1) } .background(Color(uiColor: .systemGray6)) .clipShape(Capsule()) } ToolbarItem(placement: .confirmationAction) { Button { commit(); dismiss() } label: { Image(systemName: "checkmark") .font(.system(size: 14, weight: .bold)) .foregroundColor(.white) .frame(width: 36, height: 36) .background(AppColors.accent) .clipShape(Circle()) } } } } .overlay { if showCustomReminder { customReminderModal } } } @ViewBuilder private func modeButton(_ label: String, tag: Int) -> some View { Button { withAnimation(KisaniSpring.micro) { pickerMode = tag } } label: { Text(label) .font(AppFonts.sans(14, weight: pickerMode == tag ? .semibold : .regular)) .foregroundColor(pickerMode == tag ? .primary : .secondary) .padding(.horizontal, 18).padding(.vertical, 7) .background(pickerMode == tag ? Color(uiColor: .systemGray4) : Color.clear) .clipShape(Capsule()) } .buttonStyle(.plain) } @ViewBuilder private var optionsCard: some View { VStack(spacing: 0) { Button { withAnimation(KisaniSpring.micro) { showTimePicker.toggle(); if showTimePicker { showReminderPicker = false } } } label: { pickerRow(icon: "clock", label: "Time", value: localTime != nil ? timeFmt.string(from: localTime!) : "None") } .buttonStyle(.plain) if showTimePicker { DatePicker("", selection: Binding( get: { localTime ?? localDate }, set: { localTime = $0 } ), displayedComponents: .hourAndMinute) .datePickerStyle(.wheel).labelsHidden() .transition(.opacity.combined(with: .move(edge: .top))) } Divider().padding(.leading, 54) Button { withAnimation(KisaniSpring.micro) { showReminderPicker.toggle(); if showReminderPicker { showTimePicker = false } } } label: { pickerRow(icon: "alarm", label: "Reminder", value: reminderSummary, active: localReminderOffset != nil) } .buttonStyle(.plain) if showReminderPicker { reminderOptions .transition(.opacity.combined(with: .move(edge: .top))) } Divider().padding(.leading, 54) Menu { ForEach(repeatOptions, id: \.key) { opt in Button { withAnimation(KisaniSpring.micro) { localRepeat = opt.key } } label: { Label(opt.display, systemImage: localRepeat == opt.key ? "checkmark" : "") } } } label: { pickerRow(icon: "arrow.clockwise", label: "Repeat", value: repeatDisplayValue, active: localRepeat != "None") } .buttonStyle(.plain) // ── Repeat until (only when a recurrence is set) ── if localRepeat != "None" { Divider().padding(.leading, 54) Button { withAnimation(KisaniSpring.micro) { showRecurrenceEndPicker.toggle() } } label: { pickerRow(icon: "calendar.badge.exclamationmark", label: "Repeat until", value: localRecurrenceEnd.map { repeatEndFmt.string(from: $0) } ?? "Forever", active: localRecurrenceEnd != nil) } .buttonStyle(.plain) if showRecurrenceEndPicker { DatePicker("", selection: Binding( get: { localRecurrenceEnd ?? localDate }, set: { localRecurrenceEnd = $0 } ), in: localDate..., displayedComponents: .date) .datePickerStyle(.graphical).labelsHidden() .padding(.horizontal, 8) if localRecurrenceEnd != nil { Button { withAnimation(KisaniSpring.micro) { localRecurrenceEnd = nil; showRecurrenceEndPicker = false } } label: { Text("Clear end date").font(AppFonts.sans(13)).foregroundColor(AppColors.accent) } .frame(maxWidth: .infinity).padding(.bottom, 8) } } } } .background(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 14)) .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 } // Offsets are MINUTES before the event time. private var reminderPresets: [ReminderPreset] { [ ReminderPreset(offset: nil, label: "None"), ReminderPreset(offset: 0, label: "On time"), ReminderPreset(offset: 5, label: "5 minutes early"), ReminderPreset(offset: 30, label: "30 minutes early"), ReminderPreset(offset: 60, label: "1 hour early"), ReminderPreset(offset: 1440, label: "1 day early"), ] } @ViewBuilder private var reminderOptions: some View { VStack(spacing: 0) { ForEach(reminderPresets) { preset in Button { withAnimation(KisaniSpring.micro) { localReminderOffset = preset.offset if preset.offset == nil { localConstant = false } showCustomReminder = false } } label: { HStack(spacing: 8) { Text(preset.label) .font(AppFonts.sans(15)) .foregroundColor(preset.offset == nil ? AppColors.accent : .primary) 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) // Minutes-per-unit for the Custom wheel: minutes / hours / days. private let customUnitMinutes = [1, 60, 1440] private let customUnitNames = ["minute", "hour", "day"] private func seedCustomDraft() { guard let off = localReminderOffset, off > 0 else { return } if off % 1440 == 0 { customUnit = 2; customNumber = off / 1440 } else if off % 60 == 0 { customUnit = 1; customNumber = off / 60 } else { customUnit = 0; customNumber = off } } private var customOffsetMinutes: Int { max(1, customNumber) * customUnitMinutes[customUnit] } private func commitCustomReminder() { localReminderOffset = customOffsetMinutes } // Label for a given count under the current unit. private func customLeadLabel(_ n: Int) -> String { let unit = customUnitNames[customUnit] return "\(n) \(unit)\(n == 1 ? "" : "s") early" } private var customMaxCount: Int { [59, 23, 30][customUnit] } // Reminder date implied by the current *draft* wheel selection. private var draftReminderDate: Date? { Calendar.current.date(byAdding: .minute, value: -customOffsetMinutes, to: reminderBaseDate()) } 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) { // Minutes / Hours / Days segmented toggle HStack(spacing: 2) { customModeButton("Minutes", unit: 0) customModeButton("Hours", unit: 1) customModeButton("Days", unit: 2) } .padding(3) .background(Color(uiColor: .tertiarySystemFill)) .clipShape(Capsule()) .padding(.top, 20) // Single lead-time wheel (relative to the event time) Picker("", selection: $customNumber) { ForEach(1...customMaxCount, id: \.self) { n in Text(customLeadLabel(n)).tag(n) } } .pickerStyle(.wheel) .frame(maxWidth: .infinity) .frame(height: 180) .clipped() .onChange(of: customUnit) { _ in if customNumber > customMaxCount { customNumber = customMaxCount } if customNumber < 1 { customNumber = 1 } } // 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, unit: Int) -> some View { Button { withAnimation(KisaniSpring.micro) { customUnit = unit if customNumber > customMaxCount { customNumber = customMaxCount } if customNumber < 1 { customNumber = 1 } } } label: { Text(label) .font(AppFonts.sans(15, weight: customUnit == unit ? .semibold : .regular)) .foregroundColor(customUnit == unit ? .primary : .secondary) .frame(maxWidth: .infinity).frame(height: 36) .background(customUnit == unit ? 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() } // The event datetime the reminder counts back from: the task's time, or 9 AM if undated. private func reminderBaseDate() -> Date { let cal = Calendar.current if let t = localTime { let tc = cal.dateComponents([.hour, .minute], from: t) return cal.date(bySettingHour: tc.hour ?? 9, minute: tc.minute ?? 0, second: 0, of: localDate) ?? localDate } return cal.date(bySettingHour: 9, minute: 0, second: 0, of: localDate) ?? localDate } private func computedReminderDate(offset: Int) -> Date? { Calendar.current.date(byAdding: .minute, value: -offset, to: reminderBaseDate()) } private var reminderSummary: String { guard let off = localReminderOffset else { return "None" } if off == 0 { return "On time" } if off % 1440 == 0 { let d = off / 1440; return "\(d) day\(d == 1 ? "" : "s") early" } if off % 60 == 0 { let h = off / 60; return "\(h) hour\(h == 1 ? "" : "s") early" } return "\(off) minute\(off == 1 ? "" : "s") early" } @ViewBuilder private var durationContent: some View { HStack(alignment: .top, spacing: 0) { Button { withAnimation(KisaniSpring.micro) { editingEnd = false } } label: { VStack(alignment: .leading, spacing: 5) { Text("Start") .font(AppFonts.sans(13)).foregroundColor(.secondary) Text(dayStr(localDate)) .font(AppFonts.sans(17, weight: .semibold)) .foregroundColor(editingEnd ? .secondary : AppColors.accent) Text(relStr(localDate)) .font(AppFonts.sans(13)).foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 24).padding(.vertical, 16) } .buttonStyle(.plain) Rectangle() .fill(Color(uiColor: .separator)) .frame(width: 0.5).padding(.vertical, 12) Button { withAnimation(KisaniSpring.micro) { editingEnd = true } } label: { VStack(alignment: .leading, spacing: 5) { Text("End") .font(AppFonts.sans(13)).foregroundColor(.secondary) Text(dayStr(localEndDate)) .font(AppFonts.sans(17, weight: .semibold)) .foregroundColor(editingEnd ? AppColors.accent : .secondary) Text(durationStr) .font(AppFonts.sans(13)).foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 24).padding(.vertical, 16) } .buttonStyle(.plain) } .background(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 14)) .padding(.horizontal, 16) .padding(.top, 12) CalendarGridView(selectedDate: Binding( get: { editingEnd ? localEndDate : localDate }, set: { v in if editingEnd { localEndDate = max(v, localDate) } else { localDate = v if localEndDate < v { localEndDate = v } } } )) .padding(.horizontal, 20).padding(.top, 12).padding(.bottom, 20) VStack(spacing: 0) { HStack(spacing: 14) { Image(systemName: "sun.max") .font(.system(size: 18)).foregroundColor(.secondary).frame(width: 24) Text("All Day") .font(AppFonts.sans(16)).foregroundColor(.primary) Spacer() Toggle("", isOn: $localIsAllDay) .tint(AppColors.accent).labelsHidden() } .padding(.horizontal, 16).frame(height: 52) } .background(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 14)) .padding(.horizontal, 16) } @ViewBuilder private func pickerRow(icon: String, label: String, value: String, active: Bool = false) -> some View { HStack(spacing: 14) { Image(systemName: icon) .font(.system(size: 18)) .foregroundColor(active ? AppColors.accent : .secondary) .frame(width: 24) Text(label) .font(AppFonts.sans(16)).foregroundColor(.primary) Spacer() Text(value) .font(AppFonts.sans(15)) .foregroundColor(active ? AppColors.accent : .secondary) Image(systemName: "chevron.up.chevron.down") .font(.system(size: 11)).foregroundColor(.secondary) } .padding(.horizontal, 16).frame(height: 52) } private struct RepeatOption { let key: String; let display: String } private var repeatOptions: [RepeatOption] { let cal = Calendar.current let wdFmt = DateFormatter(); wdFmt.dateFormat = "EEEE" let mdFmt = DateFormatter(); mdFmt.dateFormat = "MMM d" let weekdayName = wdFmt.string(from: localDate) let dayOfMonth = cal.component(.day, from: localDate) let ordinal: String switch dayOfMonth { case 1: ordinal = "1st" case 2: ordinal = "2nd" case 3: ordinal = "3rd" default: ordinal = "\(dayOfMonth)th" } let monthDay = mdFmt.string(from: localDate) return [ RepeatOption(key: "None", display: "None"), RepeatOption(key: "Daily", display: "Daily"), RepeatOption(key: "Weekly", display: "Weekly (\(weekdayName))"), RepeatOption(key: "Monthly", display: "Monthly (the \(ordinal) day)"), RepeatOption(key: "Yearly", display: "Yearly (\(monthDay))"), RepeatOption(key: "Every Weekday", display: "Every Weekday (Mon–Fri)"), ] } private var repeatDisplayValue: String { repeatOptions.first(where: { $0.key == localRepeat })?.display ?? localRepeat } private var timeFmt: DateFormatter { let f = DateFormatter(); f.timeStyle = .short; return f } private func dayStr(_ d: Date) -> String { let f = DateFormatter(); f.dateFormat = "MMM d, EEE"; return f.string(from: d) } private func relStr(_ d: Date) -> String { if Calendar.current.isDateInToday(d) { return "Today" } if Calendar.current.isDateInTomorrow(d) { return "Tomorrow" } if Calendar.current.isDateInYesterday(d) { return "Yesterday" } return "" } private var durationStr: String { let n = (Calendar.current.dateComponents([.day], from: localDate, to: localEndDate).day ?? 0) + 1 return "Duration: \(n) day\(n == 1 ? "" : "s")" } private let repeatEndFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "MMM d, yyyy"; return f }() private func commit() { if pickerMode == 0 { var final = Calendar.current.startOfDay(for: localDate) if let t = localTime { let c = Calendar.current.dateComponents([.hour, .minute], from: t) final = Calendar.current.date(bySettingHour: c.hour ?? 0, minute: c.minute ?? 0, second: 0, of: localDate) ?? final hasTime = true } else { hasTime = false } selectedDate = final endDate = nil isAllDay = false isRecurring = localRepeat != "None" recurrenceLabel = localRepeat != "None" ? localRepeat : nil recurrenceEnd = localRepeat != "None" ? localRecurrenceEnd : nil // 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 constantReminder = false } } else { selectedDate = Calendar.current.startOfDay(for: localDate) endDate = Calendar.current.startOfDay(for: localEndDate) isAllDay = localIsAllDay hasTime = false reminderDate = nil constantReminder = false } } } // MARK: - Calendar Grid View struct CalendarGridView: View { @Binding var selectedDate: Date @State private var displayMonth: Date @Environment(\.colorScheme) private var cs private let cal = Calendar.current private let dow = ["S", "M", "T", "W", "T", "F", "S"] init(selectedDate: Binding) { _selectedDate = selectedDate let c = Calendar.current.dateComponents([.year, .month], from: selectedDate.wrappedValue) _displayMonth = State(initialValue: Calendar.current.date(from: c) ?? selectedDate.wrappedValue) } var body: some View { VStack(spacing: 10) { HStack { Button { shift(-1) } label: { Image(systemName: "chevron.left") .font(.system(size: 14, weight: .semibold)) .foregroundColor(.primary) .frame(width: 36, height: 36) } Spacer() Text(monthLabel) .font(.system(size: 22, weight: .bold)) Spacer() Button { shift(1) } label: { Image(systemName: "chevron.right") .font(.system(size: 14, weight: .semibold)) .foregroundColor(.primary) .frame(width: 36, height: 36) } } HStack(spacing: 0) { ForEach(dow, id: \.self) { d in Text(d) .font(.system(size: 13, weight: .medium)) .foregroundColor(.secondary) .frame(maxWidth: .infinity) } } LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 4) { ForEach(Array(days.enumerated()), id: \.offset) { _, day in if let day = day { let isSelected = cal.isDate(day, inSameDayAs: selectedDate) let isToday = cal.isDateInToday(day) Button { selectedDate = day } label: { ZStack { if isSelected { Circle().fill(AppColors.accent).frame(width: 38, height: 38) } Text("\(cal.component(.day, from: day))") .font(.system(size: 16, weight: isSelected ? .bold : .regular)) .foregroundColor(isSelected ? .white : isToday ? AppColors.accent : .primary) } .frame(height: 42) } .buttonStyle(.plain) } else { Color.clear.frame(height: 42) } } } } } private var monthLabel: String { let f = DateFormatter(); f.dateFormat = "MMMM yyyy"; return f.string(from: displayMonth) } private func shift(_ d: Int) { displayMonth = cal.date(byAdding: .month, value: d, to: displayMonth) ?? displayMonth } private var days: [Date?] { guard let range = cal.range(of: .day, in: .month, for: displayMonth), let first = cal.date(from: cal.dateComponents([.year, .month], from: displayMonth)) else { return [] } let offset = cal.component(.weekday, from: first) - 1 var result: [Date?] = Array(repeating: nil, count: offset) for i in 0.. some View { HStack(spacing: 5) { Image(systemName: icon) .font(.system(size: 11, weight: .semibold)) .foregroundColor(color) VStack(alignment: .leading, spacing: 0) { Text(value) .font(AppFonts.mono(12, weight: .bold)) .foregroundColor(AppColors.text(cs)) Text(label) .font(AppFonts.mono(8)) .foregroundColor(AppColors.text3(cs)) } } .padding(.horizontal, 10).padding(.vertical, 7) .frame(maxWidth: .infinity) .background(AppColors.surface(cs)) .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) .overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(AppColors.border(cs), lineWidth: 1)) } private var stepsLabel: String { hk.stepsToday >= 1000 ? String(format: "%.1fk", Double(hk.stepsToday) / 1000) : "\(hk.stepsToday)" } private var calsLabel: String { "\(hk.activeCaloriesToday)" } private var hrLabel: String { hk.restingHeartRate > 0 ? "\(hk.restingHeartRate)" : "--" } }