diff --git a/KisaniCal/ContentView.swift b/KisaniCal/ContentView.swift index 8af19d9..6709cea 100644 --- a/KisaniCal/ContentView.swift +++ b/KisaniCal/ContentView.swift @@ -77,6 +77,7 @@ struct ContentView: View { .preferredColorScheme(preferredScheme) .fullScreenCover(isPresented: $showOnboarding) { OnboardingView(isPresented: $showOnboarding) + .environmentObject(workoutVM) .onDisappear { hasOnboarded = true } } .onAppear { @@ -101,6 +102,8 @@ struct ContentView: View { showQuickAddTask = true case .today: withAnimation(KisaniSpring.micro) { selectedTab = 0 } + case .next3: + withAnimation(KisaniSpring.micro) { selectedTab = 0 } case .calendar: withAnimation(KisaniSpring.micro) { selectedTab = 1 } case .workout: @@ -165,11 +168,8 @@ struct ContentView: View { selection: $selectedTab, calIcon: calIconImage, showMatrix: showMatrixTab, - showWorkout: showWorkoutTab, - isCollapsed: tabState.isCollapsed + showWorkout: showWorkoutTab ) - .padding(.horizontal, 24) - .padding(.bottom, 8) } .onAppear { UITabBar.appearance().isHidden = true } } @@ -202,12 +202,10 @@ struct ContentView: View { // MARK: - Custom Tab Bar -// Adds a transparent bottom safe-area inset equal to the floating tab bar height, -// so scroll content and FABs inside each tab clear the bar. private extension View { func tabBarInset() -> some View { safeAreaInset(edge: .bottom, spacing: 0) { - Color.clear.frame(height: 62) + Color.clear.frame(height: 64) } } } @@ -217,7 +215,6 @@ struct KisaniTabBar: View { let calIcon: Image var showMatrix: Bool var showWorkout: Bool - var isCollapsed: Bool = false @Environment(\.colorScheme) private var cs private struct Entry: Identifiable { @@ -244,33 +241,35 @@ struct KisaniTabBar: View { } label: { ZStack { if selection == entry.id { - RoundedRectangle(cornerRadius: 14) - .fill(AppColors.surface2(cs)) - .frame(width: 52, height: 44) - .transition(.opacity.combined(with: .scale(scale: 0.9))) + RoundedRectangle(cornerRadius: 16) + .fill(Color(uiColor: cs == .dark ? .tertiarySystemFill : .systemGray5)) + .frame(width: 62, height: 42) + .transition(.opacity.combined(with: .scale(scale: 0.88))) } + if let img = entry.custom { img - .font(.system(size: isCollapsed ? 17 : 20)) - .foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text(cs)) + .font(.system(size: 22)) + .foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text3(cs)) } else if let icon = entry.sfIcon { Image(systemName: icon) - .font(.system(size: isCollapsed ? 15 : 17, weight: selection == entry.id ? .semibold : .regular)) - .foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text(cs)) + .font(.system(size: 22, weight: selection == entry.id ? .semibold : .regular)) + .foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text3(cs)) } } - .frame(maxWidth: .infinity).frame(height: isCollapsed ? 38 : 50) + .frame(maxWidth: .infinity) + .frame(height: 52) } - .buttonStyle(PressButtonStyle(scale: 0.88)) + .buttonStyle(PressButtonStyle(scale: 0.90)) } } - .padding(.horizontal, 10) + .padding(.horizontal, 8) .background( RoundedRectangle(cornerRadius: 28) - .fill(AppColors.surface(cs)) - .shadow(color: .black.opacity(isCollapsed ? 0.06 : 0.10), radius: 20, x: 0, y: 6) - .shadow(color: .black.opacity(0.04), radius: 4, x: 0, y: 1) + .fill(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground)) + .shadow(color: .black.opacity(cs == .dark ? 0.40 : 0.10), radius: 20, x: 0, y: 6) ) - .animation(KisaniSpring.snappy, value: isCollapsed) + .padding(.horizontal, 20) + .padding(.bottom, 10) } } diff --git a/KisaniCal/KisaniCal.entitlements b/KisaniCal/KisaniCal.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/KisaniCal/KisaniCal.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index f49fbac..646facf 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -12,6 +12,9 @@ struct TaskItem: Identifiable, Codable, Equatable { var category: TaskCategory = .reminder var taskColor: TaskColor = .orange var isRecurring: Bool = false + var isPinned: Bool = false + var endDate: Date? = nil + var isAllDay: Bool = false } enum Quadrant: String, CaseIterable, Identifiable, Codable { @@ -84,12 +87,26 @@ class TaskViewModel: ObservableObject { .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } } var upcomingTasks: [TaskItem] { - let tomorrow = Calendar.current.date(byAdding: .day, value: 1, - to: Calendar.current.startOfDay(for: Date()))! + let cal = Calendar.current + let fourDays = cal.date(byAdding: .day, value: 4, to: cal.startOfDay(for: Date()))! return tasks - .filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= tomorrow } + .filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= fourDays } .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } } + var next3DaysTasks: [(date: Date, tasks: [TaskItem])] { + let cal = Calendar.current + let today = cal.startOfDay(for: Date()) + return (1...3).compactMap { offset -> (Date, [TaskItem])? in + guard let day = cal.date(byAdding: .day, value: offset, to: today), + let nextDay = cal.date(byAdding: .day, value: 1, to: day) else { return nil } + let dayTasks = tasks.filter { + !$0.isComplete && + ($0.dueDate ?? .distantFuture) >= day && + ($0.dueDate ?? .distantFuture) < nextDay + }.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } + return dayTasks.isEmpty ? nil : (day, dayTasks) + } + } var completedTodayTasks: [TaskItem] { let startOfDay = Calendar.current.startOfDay(for: Date()) return tasks @@ -104,15 +121,42 @@ class TaskViewModel: ObservableObject { save() } func tasks(for quadrant: Quadrant) -> [TaskItem] { - tasks.filter { $0.quadrant == quadrant } + tasks + .filter { $0.quadrant == quadrant } + .sorted { $0.isPinned && !$1.isPinned } + } + + func pin(_ task: TaskItem) { + guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } + tasks[idx].isPinned.toggle() + save() + } + + func setDate(_ task: TaskItem, date: Date?) { + guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } + tasks[idx].dueDate = date + save() + } + + func setCategory(_ task: TaskItem, category: TaskCategory) { + guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } + tasks[idx].category = category + save() + } + func moveToQuadrant(taskID: UUID, quadrant: Quadrant) { + guard let idx = tasks.firstIndex(where: { $0.id == taskID }) else { return } + tasks[idx].quadrant = quadrant + save() } func addTask(title: String, dueDate: Date?, hasTime: Bool = false, quadrant: Quadrant = .urgent, category: TaskCategory = .reminder, - taskColor: TaskColor = .orange, isRecurring: Bool = false) { + taskColor: TaskColor = .orange, isRecurring: Bool = false, + endDate: Date? = nil, isAllDay: Bool = false) { tasks.append(TaskItem(title: title, dueDate: dueDate, hasTime: hasTime, quadrant: quadrant, category: category, - taskColor: taskColor, isRecurring: isRecurring)) + taskColor: taskColor, isRecurring: isRecurring, + endDate: endDate, isAllDay: isAllDay)) save() } diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift index 0a428f2..7989202 100644 --- a/KisaniCal/Views/CalendarView.swift +++ b/KisaniCal/Views/CalendarView.swift @@ -18,8 +18,10 @@ final class CalendarStore: ObservableObject { object: ekStore, queue: .main ) { [weak self] _ in - guard let self, let m = self.fetchedMonth else { return } - self.fetchMonth(containing: m) + Task { @MainActor [weak self] in + guard let self, let m = self.fetchedMonth else { return } + self.fetchMonth(containing: m) + } } } @@ -974,6 +976,7 @@ private struct DayTLEntry: Identifiable { var isComplete: Bool var isAllDay: Bool var sortKey: Int // < 0 = all-day/special; else minute-of-day + var sortGroup: Int = 0 // 0 = active, 1 = completed, 2 = overdue var taskRef: TaskItem? var workoutRef: WorkoutProgram? } @@ -981,26 +984,31 @@ private struct DayTLEntry: Identifiable { struct DayTimelineView: View { @Environment(\.colorScheme) private var cs - let date: Date - let tasks: [TaskItem] - let calEvents: [EKEvent] - let workout: WorkoutProgram? - var onWorkoutTap: (() -> Void)? = nil - var onToggle: ((TaskItem) -> Void)? = nil + let date: Date + let tasks: [TaskItem] + var overdueTasks: [TaskItem] = [] + let calEvents: [EKEvent] + let workout: WorkoutProgram? + var onWorkoutTap: (() -> Void)? = nil + var onToggle: ((TaskItem) -> Void)? = nil private let cal = Calendar.current + private static let overdueFmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "MMM d"; return f + }() private var entries: [DayTLEntry] { var out: [DayTLEntry] = [] // All-day tasks (no specific time) for t in tasks where !t.hasTime { + let grp = t.isComplete ? 1 : 0 out.append(DayTLEntry( timeLabel: "All Day", title: t.title, - color: t.taskColor.color(), + color: t.isComplete ? AppColors.green : t.taskColor.color(), isComplete: t.isComplete, - isAllDay: true, sortKey: -1, taskRef: t + isAllDay: true, sortKey: -1, sortGroup: grp, taskRef: t )) } @@ -1010,11 +1018,11 @@ struct DayTimelineView: View { timeLabel: "All Day", title: ev.title ?? "Event", color: Color(cgColor: ev.calendar.cgColor), - isComplete: false, isAllDay: true, sortKey: -1 + isComplete: false, isAllDay: true, sortKey: -1, sortGroup: 0 )) } - // Workout + // Workout — always pinned first (sortGroup -1) if let w = workout { let done = w.doneSets == w.totalSets && w.totalSets > 0 out.append(DayTLEntry( @@ -1022,7 +1030,7 @@ struct DayTimelineView: View { title: w.name, subtitle: "\(w.totalExercises) exercise\(w.totalExercises == 1 ? "" : "s")", color: AppColors.blue, - isComplete: done, isAllDay: true, sortKey: -2, workoutRef: w + isComplete: done, isAllDay: true, sortKey: -2, sortGroup: -1, workoutRef: w )) } @@ -1032,11 +1040,12 @@ struct DayTimelineView: View { let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d) let hd = h == 0 ? 12 : (h > 12 ? h - 12 : h) let ms = m == 0 ? "" : ":\(String(format: "%02d", m))" + let grp = t.isComplete ? 1 : 0 out.append(DayTLEntry( timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM", - title: t.title, color: t.taskColor.color(), + title: t.title, color: t.isComplete ? AppColors.green : t.taskColor.color(), isComplete: t.isComplete, isAllDay: false, - sortKey: h * 60 + m, taskRef: t + sortKey: h * 60 + m, sortGroup: grp, taskRef: t )) } @@ -1057,11 +1066,26 @@ struct DayTimelineView: View { timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM", title: ev.title ?? "Event", subtitle: endLabel, color: Color(cgColor: ev.calendar.cgColor), - isComplete: false, isAllDay: false, sortKey: h * 60 + m + isComplete: false, isAllDay: false, sortKey: h * 60 + m, sortGroup: 0 + )) + } + + // Overdue tasks — shown last with accent color and their date as label + for t in overdueTasks { + let label = t.dueDate.map { + cal.isDateInYesterday($0) ? "Yest." : Self.overdueFmt.string(from: $0) + } ?? "Overdue" + out.append(DayTLEntry( + timeLabel: label, + title: t.title, + color: AppColors.accent, + isComplete: false, isAllDay: true, + sortKey: -1, sortGroup: 2, taskRef: t )) } return out.sorted { + if $0.sortGroup != $1.sortGroup { return $0.sortGroup < $1.sortGroup } if $0.sortKey < 0 && $1.sortKey < 0 { return $0.sortKey > $1.sortKey } if $0.sortKey < 0 { return true } if $1.sortKey < 0 { return false } diff --git a/KisaniCal/Views/OnboardingView.swift b/KisaniCal/Views/OnboardingView.swift index 72ac46b..1a01690 100644 --- a/KisaniCal/Views/OnboardingView.swift +++ b/KisaniCal/Views/OnboardingView.swift @@ -1,147 +1,502 @@ import SwiftUI +import EventKit struct OnboardingView: View { @Environment(\.colorScheme) private var cs + @EnvironmentObject private var workoutVM: WorkoutViewModel @Binding var isPresented: Bool - @AppStorage("bodyWeightKg") private var bodyWeightKg: Double = 0 - @AppStorage("heightCm") private var heightCm: Double = 0 - @AppStorage("weightUnit") private var weightUnit: Int = 0 // 0 = kg, 1 = lbs + // Persisted calendar settings + @AppStorage("appearanceMode") private var appearanceMode: Int = 0 + @AppStorage("dayStartHour") private var dayStartHour: Int = 7 + @AppStorage("todayViewStyle") private var todayViewStyle: Int = 0 + // Persisted workout settings + @AppStorage("showWorkoutTab") private var showWorkoutTab: Bool = false + @AppStorage("bodyWeightKg") private var bodyWeightKg: Double = 0 + @AppStorage("heightCm") private var heightCm: Double = 0 + @AppStorage("weightUnit") private var weightUnit: Int = 0 + @AppStorage("preferredWorkoutTime") private var preferredWorkoutTime: Int = 0 - @State private var weightStr = "" - @State private var heightStr = "" + // Local (uncommitted) state + @State private var localAppearance: Int = 2 // dark by default + @State private var localDayStart: Int = 7 + @State private var localViewStyle: Int = 0 + + @State private var workoutEnabled = false + @State private var weightStr = "" + @State private var heightStr = "" + @State private var selectedDays: Set = [] + @State private var calAuthStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .event) @FocusState private var focusedField: Field? - fileprivate enum Field { case weight, height } + private let ekStore = EKEventStore() - private var canContinue: Bool { - (Double(weightStr) ?? 0) > 0 && (Double(heightStr) ?? 0) > 0 + private let timeSlots: [(Int, String, String)] = [ + (0, "Morning", "sunrise.fill"), + (1, "Midday", "sun.max.fill"), + (2, "Afternoon", "sun.min.fill"), + (3, "Evening", "sunset.fill") + ] + fileprivate enum Field { case weight, height } + private let weekdays: [(Int, String)] = [(2,"M"),(3,"T"),(4,"W"),(5,"T"),(6,"F"),(7,"S"),(1,"S")] + + private var calIsGranted: Bool { + if #available(iOS 17.0, *) { return calAuthStatus == .fullAccess } + return calAuthStatus == .authorized + } + + private var liveScheme: ColorScheme? { + switch localAppearance { + case 1: return .light + case 2: return .dark + default: return nil + } } var body: some View { ZStack { AppColors.background(cs).ignoresSafeArea() - VStack(spacing: 0) { - Spacer() - - // ── Brand mark ── - VStack(spacing: 14) { - ZStack { - RoundedRectangle(cornerRadius: 22) - .fill(AppColors.accentSoft) - .frame(width: 72, height: 72) - Image(systemName: "dumbbell.fill") - .font(.system(size: 32, weight: .semibold)) - .foregroundColor(AppColors.accent) - } - VStack(spacing: 6) { - Text("Welcome to Kisani") - .font(AppFonts.sans(26, weight: .bold)) - .foregroundColor(AppColors.text(cs)) - Text("Personalizes your warm-up and body stats") - .font(AppFonts.sans(14)) - .foregroundColor(AppColors.text3(cs)) - } - } - .padding(.bottom, 40) - - // ── Form card ── + ScrollView(showsIndicators: false) { VStack(spacing: 0) { - // Unit toggle - HStack(spacing: 0) { - ForEach([("kg", 0), ("lbs", 1)], id: \.1) { label, idx in - Button { - withAnimation(KisaniSpring.micro) { - // Convert existing weight string when toggling - if let val = Double(weightStr) { - let kg = weightUnit == 1 ? val / 2.20462 : val - weightUnit = idx - let display = weightUnit == 1 ? kg * 2.20462 : kg - weightStr = display.truncatingRemainder(dividingBy: 1) == 0 - ? "\(Int(display))" - : String(format: "%.1f", display) - } else { - weightUnit = idx + Spacer(minLength: 32) + + // ── Brand mark ── + VStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 22) + .fill(AppColors.accentSoft) + .frame(width: 72, height: 72) + Image(systemName: "checklist.unchecked") + .font(.system(size: 30, weight: .semibold)) + .foregroundColor(AppColors.accent) + } + VStack(spacing: 6) { + Text("Welcome to KisaniCal") + .font(AppFonts.sans(26, weight: .bold)) + .foregroundColor(AppColors.text(cs)) + Text("Tasks, calendar, and workouts\n— all in one place.") + .font(AppFonts.sans(14)) + .foregroundColor(AppColors.text3(cs)) + .multilineTextAlignment(.center) + } + } + .padding(.bottom, 36) + + // ══════════════════════════════ + // MARK: Calendar + // ══════════════════════════════ + OnboardingSectionHeader(title: "Calendar") + + // Calendar access + HStack(spacing: 12) { + Image(systemName: calIsGranted ? "calendar.badge.checkmark" : "calendar") + .font(.system(size: 15)) + .foregroundColor(calIsGranted ? AppColors.green : AppColors.accent) + .frame(width: 32, height: 32) + .background(calIsGranted ? AppColors.greenSoft : AppColors.accentSoft) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .animation(KisaniSpring.micro, value: calIsGranted) + + VStack(alignment: .leading, spacing: 2) { + Text("iPhone Calendar") + .font(AppFonts.sans(13, weight: .semibold)) + .foregroundColor(AppColors.text(cs)) + Text(calIsGranted + ? "Events will appear in your calendar view" + : "Show your events alongside tasks") + .font(AppFonts.sans(11)) + .foregroundColor(AppColors.text3(cs)) + } + + Spacer() + + Group { + if calIsGranted { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 22)) + .foregroundColor(AppColors.green) + .transition(.scale.combined(with: .opacity)) + } else if calAuthStatus == .denied { + Text("Denied") + .font(AppFonts.mono(10, weight: .bold)) + .foregroundColor(AppColors.accent) + .padding(.horizontal, 8).padding(.vertical, 4) + .background(AppColors.accentSoft) + .clipShape(Capsule()) + } else { + Button { requestCalendar() } label: { + Text("Enable") + .font(AppFonts.mono(10, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 14).padding(.vertical, 7) + .background(AppColors.accent) + .clipShape(Capsule()) + } + .buttonStyle(PressButtonStyle(scale: 0.92)) + } + } + .animation(KisaniSpring.micro, value: calAuthStatus.rawValue) + } + .padding(.horizontal, 14).padding(.vertical, 14) + .cardStyle() + .padding(.horizontal, 20) + .padding(.bottom, 10) + + // Appearance + Day Start + View Style card + VStack(spacing: 0) { + + // Appearance + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + Image(systemName: "paintbrush.fill") + .font(.system(size: 15)) + .foregroundColor(AppColors.accent) + .frame(width: 32, height: 32) + .background(AppColors.accentSoft) + .clipShape(RoundedRectangle(cornerRadius: 8)) + Text("Appearance") + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text(cs)) + } + HStack(spacing: 6) { + ForEach( + [(2, "Dark", "moon.fill"), + (1, "Light", "sun.max.fill"), + (0, "System", "iphone")] as [(Int, String, String)], + id: \.0 + ) { idx, label, icon in + Button { + withAnimation(KisaniSpring.micro) { localAppearance = idx } + } label: { + let on = localAppearance == idx + HStack(spacing: 5) { + Image(systemName: icon) + .font(.system(size: 11)) + Text(label) + .font(AppFonts.mono(9.5, weight: .bold)) + } + .foregroundColor(on ? AppColors.accent : AppColors.text3(cs)) + .frame(maxWidth: .infinity).frame(height: 36) + .background(on ? AppColors.accentSoft : AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke( + on ? AppColors.accent : AppColors.border(cs), + lineWidth: on ? 1.5 : 1)) + } + .buttonStyle(PressButtonStyle(scale: 0.93)) + } + } + } + .padding(.horizontal, 14).padding(.vertical, 12) + + Divider().background(AppColors.border(cs)).padding(.leading, 56) + + // Day Starts At + HStack(spacing: 12) { + Image(systemName: "clock.fill") + .font(.system(size: 15)) + .foregroundColor(AppColors.blue) + .frame(width: 32, height: 32) + .background(AppColors.blueSoft) + .clipShape(RoundedRectangle(cornerRadius: 8)) + Text("Day Starts At") + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text(cs)) + Spacer() + Menu { + ForEach(4...11, id: \.self) { hour in + Button { + withAnimation(KisaniSpring.micro) { localDayStart = hour } + } label: { + let h12 = hour > 12 ? hour - 12 : hour + let ap = hour < 12 ? "AM" : "PM" + Label( + "\(h12):00 \(ap)", + systemImage: localDayStart == hour ? "checkmark" : "" + ) } } } label: { - Text(label) - .font(AppFonts.mono(12, weight: .bold)) - .foregroundColor(weightUnit == idx ? AppColors.accent : AppColors.text3(cs)) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(weightUnit == idx ? AppColors.accentSoft : Color.clear) - .clipShape(RoundedRectangle(cornerRadius: 7)) + let h12 = localDayStart > 12 ? localDayStart - 12 : localDayStart + let ap = localDayStart < 12 ? "AM" : "PM" + HStack(spacing: 4) { + Text("\(h12):00 \(ap)") + .font(AppFonts.mono(12, weight: .semibold)) + .foregroundColor(AppColors.accent) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(AppColors.text3(cs)) + } + .padding(.horizontal, 10).padding(.vertical, 6) + .background(AppColors.accentSoft) + .clipShape(RoundedRectangle(cornerRadius: 7)) } - .buttonStyle(PressButtonStyle(scale: 0.96)) } - } - .padding(4) - .background(AppColors.surface2(cs)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(AppColors.border(cs), lineWidth: 1)) - .padding(.horizontal, 28) - .padding(.bottom, 16) + .padding(.horizontal, 14).padding(.vertical, 12) - // Fields - VStack(spacing: 0) { - OnboardingField( - icon: "scalemass.fill", - label: "Body Weight", - unit: weightUnit == 0 ? "kg" : "lbs", - placeholder: weightUnit == 0 ? "e.g. 80" : "e.g. 176", - text: $weightStr, - keyboard: .decimalPad, - focused: $focusedField, - tag: .weight - ) Divider().background(AppColors.border(cs)).padding(.leading, 56) - OnboardingField( - icon: "ruler.fill", - label: "Height", - unit: "cm", - placeholder: "e.g. 175", - text: $heightStr, - keyboard: .numberPad, - focused: $focusedField, - tag: .height - ) + + // Today View Style + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + Image(systemName: "list.bullet.below.rectangle") + .font(.system(size: 15)) + .foregroundColor(AppColors.accent) + .frame(width: 32, height: 32) + .background(AppColors.accentSoft) + .clipShape(RoundedRectangle(cornerRadius: 8)) + Text("Today View") + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text(cs)) + } + HStack(spacing: 6) { + ForEach( + [(0, "Timeline", "chart.bar.xaxis"), + (1, "Task List", "list.bullet")] as [(Int, String, String)], + id: \.0 + ) { idx, label, icon in + Button { + withAnimation(KisaniSpring.micro) { localViewStyle = idx } + } label: { + let on = localViewStyle == idx + HStack(spacing: 5) { + Image(systemName: icon) + .font(.system(size: 11)) + Text(label) + .font(AppFonts.mono(9.5, weight: .bold)) + } + .foregroundColor(on ? AppColors.accent : AppColors.text3(cs)) + .frame(maxWidth: .infinity).frame(height: 36) + .background(on ? AppColors.accentSoft : AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke( + on ? AppColors.accent : AppColors.border(cs), + lineWidth: on ? 1.5 : 1)) + } + .buttonStyle(PressButtonStyle(scale: 0.93)) + } + } + } + .padding(.horizontal, 14).padding(.vertical, 12) } .cardStyle() .padding(.horizontal, 20) - } + .padding(.bottom, 28) - // ── Hint ── - Text("Used for bodyweight exercises and warm-up loads.\nEdit anytime in Settings → Workout Settings.") - .font(AppFonts.sans(11)) - .foregroundColor(AppColors.text3(cs)) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - .padding(.top, 14) + // ══════════════════════════════ + // MARK: Workout + // ══════════════════════════════ + OnboardingSectionHeader(title: "Workout") - Spacer() + VStack(spacing: 0) { - // ── CTA ── - Button { commit() } label: { - HStack(spacing: 8) { - Text("Get Started") - .font(AppFonts.sans(15, weight: .bold)) - Image(systemName: "arrow.right") - .font(.system(size: 13, weight: .bold)) + // Activate toggle + HStack(spacing: 12) { + Image(systemName: "dumbbell.fill") + .font(.system(size: 15)) + .foregroundColor(AppColors.blue) + .frame(width: 32, height: 32) + .background(AppColors.blueSoft) + .clipShape(RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 2) { + Text("Workout Module") + .font(AppFonts.sans(13, weight: .semibold)) + .foregroundColor(AppColors.text(cs)) + Text("Track gym sessions and exercises") + .font(AppFonts.sans(11)) + .foregroundColor(AppColors.text3(cs)) + } + Spacer() + Toggle("", isOn: $workoutEnabled) + .labelsHidden() + .tint(AppColors.accent) + } + .padding(.horizontal, 14).padding(.vertical, 14) + + // Expanded workout details + if workoutEnabled { + Divider().background(AppColors.border(cs)) + + // kg / lbs toggle + HStack(spacing: 0) { + ForEach([("kg", 0), ("lbs", 1)], id: \.1) { label, idx in + Button { + withAnimation(KisaniSpring.micro) { + if let val = Double(weightStr) { + let kg = weightUnit == 1 ? val / 2.20462 : val + weightUnit = idx + let display = weightUnit == 1 ? kg * 2.20462 : kg + weightStr = display.truncatingRemainder(dividingBy: 1) == 0 + ? "\(Int(display))" + : String(format: "%.1f", display) + } else { + weightUnit = idx + } + } + } label: { + Text(label) + .font(AppFonts.mono(12, weight: .bold)) + .foregroundColor(weightUnit == idx ? AppColors.accent : AppColors.text3(cs)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(weightUnit == idx ? AppColors.accentSoft : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 7)) + } + .buttonStyle(PressButtonStyle(scale: 0.96)) + } + } + .padding(4) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(AppColors.border(cs), lineWidth: 1)) + .padding(.horizontal, 28) + .padding(.top, 12) + + OnboardingField( + icon: "scalemass.fill", + label: "Body Weight", + unit: weightUnit == 0 ? "kg" : "lbs", + placeholder: weightUnit == 0 ? "e.g. 80" : "e.g. 176", + text: $weightStr, + keyboard: .decimalPad, + focused: $focusedField, + tag: .weight + ) + Divider().background(AppColors.border(cs)).padding(.leading, 56) + OnboardingField( + icon: "ruler.fill", + label: "Height", + unit: "cm", + placeholder: "e.g. 175", + text: $heightStr, + keyboard: .numberPad, + focused: $focusedField, + tag: .height + ) + Divider().background(AppColors.border(cs)).padding(.leading, 56) + + // Workout days + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + Image(systemName: "calendar.badge.checkmark") + .font(.system(size: 15)) + .foregroundColor(AppColors.blue) + .frame(width: 32, height: 32) + .background(AppColors.blueSoft) + .clipShape(RoundedRectangle(cornerRadius: 8)) + Text("Workout Days") + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text(cs)) + Spacer() + if !selectedDays.isEmpty { + Text("\(selectedDays.count)d/wk") + .font(AppFonts.mono(9.5, weight: .bold)) + .foregroundColor(AppColors.blue) + } + } + HStack(spacing: 5) { + ForEach(weekdays, id: \.0) { wd, label in + Button { + withAnimation(KisaniSpring.micro) { + if selectedDays.contains(wd) { selectedDays.remove(wd) } + else { selectedDays.insert(wd) } + } + } label: { + let on = selectedDays.contains(wd) + Text(label) + .font(AppFonts.mono(10, weight: .bold)) + .foregroundColor(on ? AppColors.blue : AppColors.text3(cs)) + .frame(maxWidth: .infinity).frame(height: 32) + .background(on ? AppColors.blueSoft : AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .overlay(RoundedRectangle(cornerRadius: 7).stroke( + on ? AppColors.blue : AppColors.border(cs), + lineWidth: on ? 1.5 : 1)) + } + .buttonStyle(PressButtonStyle(scale: 0.92)) + } + } + } + .padding(.horizontal, 14).padding(.vertical, 12) + + Divider().background(AppColors.border(cs)).padding(.leading, 56) + + // Preferred workout time + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + Image(systemName: "clock.fill") + .font(.system(size: 15)) + .foregroundColor(AppColors.accent) + .frame(width: 32, height: 32) + .background(AppColors.accentSoft) + .clipShape(RoundedRectangle(cornerRadius: 8)) + Text("Preferred Time") + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text(cs)) + } + HStack(spacing: 5) { + ForEach(timeSlots, id: \.0) { idx, label, icon in + Button { + withAnimation(KisaniSpring.micro) { preferredWorkoutTime = idx } + } label: { + let on = preferredWorkoutTime == idx + VStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 13)) + .foregroundColor(on ? AppColors.accent : AppColors.text3(cs)) + Text(label) + .font(AppFonts.mono(9, weight: .bold)) + .foregroundColor(on ? AppColors.accent : AppColors.text3(cs)) + } + .frame(maxWidth: .infinity).frame(height: 46) + .background(on ? AppColors.accentSoft : AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 9)) + .overlay(RoundedRectangle(cornerRadius: 9).stroke( + on ? AppColors.accent : AppColors.border(cs), + lineWidth: on ? 1.5 : 1)) + } + .buttonStyle(PressButtonStyle(scale: 0.92)) + } + } + } + .padding(.horizontal, 14).padding(.vertical, 12) + } } - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(canContinue ? AppColors.accent : AppColors.borderHi(cs)) - .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + .cardStyle() + .padding(.horizontal, 20) + .animation(KisaniSpring.snappy, value: workoutEnabled) + + Text("All settings can be changed later in Settings.") + .font(AppFonts.sans(11)) + .foregroundColor(AppColors.text3(cs)) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + .padding(.top, 16) + + Spacer(minLength: 24) + + // ── Get Started ── + Button { commit() } label: { + HStack(spacing: 8) { + Text("Get Started") + .font(AppFonts.sans(15, weight: .bold)) + Image(systemName: "arrow.right") + .font(.system(size: 13, weight: .bold)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(AppColors.accent) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + } + .buttonStyle(PressButtonStyle()) + .padding(.horizontal, 20) + .padding(.bottom, 36) } - .buttonStyle(PressButtonStyle()) - .disabled(!canContinue) - .padding(.horizontal, 20) - .padding(.bottom, 36) } } + .preferredColorScheme(liveScheme) .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer() @@ -150,20 +505,73 @@ struct OnboardingView: View { .foregroundColor(AppColors.accent) } } - .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { focusedField = .weight } } + .onAppear { + calAuthStatus = EKEventStore.authorizationStatus(for: .event) + } + } + + // MARK: - Actions + + private func requestCalendar() { + if #available(iOS 17.0, *) { + ekStore.requestFullAccessToEvents { granted, _ in + Task { @MainActor in + withAnimation(KisaniSpring.snappy) { + self.calAuthStatus = granted ? .fullAccess : .denied + } + } + } + } else { + ekStore.requestAccess(to: .event) { granted, _ in + Task { @MainActor in + withAnimation(KisaniSpring.snappy) { + self.calAuthStatus = granted ? .authorized : .denied + } + } + } + } } private func commit() { - guard canContinue else { return } - let rawWeight = Double(weightStr) ?? 0 - // Always store in kg internally - bodyWeightKg = weightUnit == 1 ? rawWeight / 2.20462 : rawWeight - heightCm = Double(heightStr) ?? 0 + appearanceMode = localAppearance + dayStartHour = localDayStart + todayViewStyle = localViewStyle + showWorkoutTab = workoutEnabled + if workoutEnabled { + let rawWeight = Double(weightStr) ?? 0 + if rawWeight > 0 { + bodyWeightKg = weightUnit == 1 ? rawWeight / 2.20462 : rawWeight + } + if let h = Double(heightStr), h > 0 { heightCm = h } + if !selectedDays.isEmpty { workoutVM.setScheduleForDays(selectedDays) } + } withAnimation(KisaniSpring.exit) { isPresented = false } } } +// MARK: - Section Header + +private struct OnboardingSectionHeader: View { + @Environment(\.colorScheme) private var cs + let title: String + + var body: some View { + HStack(spacing: 10) { + Text(title.uppercased()) + .font(AppFonts.mono(9.5, weight: .bold)) + .foregroundColor(AppColors.text3(cs)) + Rectangle() + .fill(AppColors.border(cs)) + .frame(height: 1) + } + .padding(.horizontal, 20) + .padding(.top, 4) + .padding(.bottom, 10) + } +} + // MARK: - Field Row + private struct OnboardingField: View { @Environment(\.colorScheme) private var cs let icon: String diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index 13f59bb..f62d23c 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -1,17 +1,25 @@ import SwiftUI +import EventKit struct TodayView: View { @Environment(\.colorScheme) var cs @EnvironmentObject var taskVM: TaskViewModel @EnvironmentObject var workoutVM: WorkoutViewModel + @StateObject private var calStore = CalendarStore() @State private var showAddTask = false @State private var showWorkoutSession = false - @State private var workoutExpanded = false + @AppStorage("todayHideCompleted") private var hideCompleted = false private let dateFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "EEEE, MMM d"; return f }() + private var todayCalEvents: [EKEvent] { + calStore.monthEvents.filter { + Calendar.current.isDateInToday($0.startDate ?? .distantFuture) + } + } + var body: some View { ZStack(alignment: .bottomTrailing) { AppColors.background(cs).ignoresSafeArea() @@ -22,19 +30,27 @@ struct TodayView: View { // ── Header ── HStack { - IButton(icon: "line.3.horizontal") - Spacer() - HStack(spacing: 6) { - Text("💡").font(.system(size: 14)) - Text("···") - .font(AppFonts.sans(14)) + 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)) } - .padding(.horizontal, 12) - .padding(.vertical, 5) - .background(AppColors.surface2(cs)) - .clipShape(Capsule()) - .overlay(Capsule().stroke(AppColors.border(cs), lineWidth: 1)) + Spacer() } .padding(.horizontal, 18) .padding(.top, 8) @@ -51,86 +67,34 @@ struct TodayView: View { .font(AppFonts.mono(10)) .foregroundColor(AppColors.text3(cs).opacity(0.7)) .padding(.horizontal, 18) - .padding(.bottom, 12) + .padding(.bottom, 8) - // ── Overdue ── - if !taskVM.overdueTasks.isEmpty { - OverdueCard( - tasks: taskVM.overdueTasks, - onPostpone: { - withAnimation(KisaniSpring.snappy) { taskVM.postponeAllOverdue() } - }, - onToggle: { task in - withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) } + // ── Today timeline: active → completed (green) → overdue (accent) ── + let activeTasks = taskVM.todayTasks + let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks + DayTimelineView( + date: Date(), + tasks: activeTasks + completedTasks, + overdueTasks: taskVM.overdueTasks, + calEvents: todayCalEvents, + workout: workoutVM.program(for: Date()), + onWorkoutTap: { + if let w = workoutVM.program(for: Date()), workoutVM.activeProgramId != w.id { + workoutVM.switchProgram(to: w.id) } - ) - .padding(.horizontal, 18) + showWorkoutSession = true + }, + onToggle: { task in withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } } + ) + .padding(.horizontal, 18) + + // ── Next 3 Days ── + let next3 = taskVM.next3DaysTasks + if !next3.isEmpty { + Next3DaysSection(tasksByDay: next3, onToggle: { taskVM.toggle($0) }) } - // ── Today's Workout ── - if let workout = workoutVM.program(for: Date()) { - WorkoutTodayBanner( - program: workout, - vm: workoutVM, - isExpanded: $workoutExpanded, - onTap: { - if workoutVM.activeProgramId != workout.id { - workoutVM.switchProgram(to: workout.id) - } - showWorkoutSession = true - } - ) - .padding(.horizontal, 18) - .padding(.top, taskVM.overdueTasks.isEmpty ? 0 : 10) - .padding(.bottom, 4) - } - - // ── Today's Tasks ── - if !taskVM.todayTasks.isEmpty { - HStack(alignment: .firstTextBaseline) { - Text("Today") - .font(AppFonts.sans(11, weight: .semibold)) - .foregroundColor(AppColors.text2(cs)) - Spacer() - Text("\(taskVM.todayTasks.count) task\(taskVM.todayTasks.count == 1 ? "" : "s")") - .font(AppFonts.mono(9)) - .foregroundColor(AppColors.text3(cs)) - } - .padding(.horizontal, 18) - .padding(.top, 14) - .padding(.bottom, 6) - - ForEach(taskVM.todayTasks) { task in - TaskRowView(task: task) { - withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) } - } - .contextMenu { - Button { withAnimation { taskVM.postpone(task) } } label: { - Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath") - } - Divider() - Button(role: .destructive) { withAnimation { taskVM.delete(task) } } label: { - Label("Delete", systemImage: "trash") - } - } - .padding(.horizontal, 18) - .padding(.bottom, 5) - } - } - - // ── Completed Today ── - if !taskVM.completedTodayTasks.isEmpty { - CompletedTodaySection( - tasks: taskVM.completedTodayTasks, - onUndo: { task in - withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) } - } - ) - .padding(.horizontal, 18) - .padding(.top, 14) - } - - // ── Upcoming ── + // ── Upcoming (4+ days) ── if !taskVM.upcomingTasks.isEmpty { UpcomingSection( tasks: taskVM.upcomingTasks, @@ -142,6 +106,7 @@ struct TodayView: View { let hasAnything = !taskVM.overdueTasks.isEmpty || !taskVM.todayTasks.isEmpty || !taskVM.upcomingTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty + || !taskVM.next3DaysTasks.isEmpty || !todayCalEvents.isEmpty || workoutVM.program(for: Date()) != nil if !hasAnything { VStack(spacing: 6) { @@ -177,6 +142,260 @@ struct TodayView: View { .presentationDetents([.large]) .presentationDragIndicator(.visible) } + .onAppear { + calStore.fetchMonth(containing: Date()) + } + } +} + +// 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.taskColor.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.taskColor.soft() + ) + 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) + } } } @@ -228,7 +447,7 @@ struct OverdueCard: View { ForEach(tasks) { task in HStack(spacing: 10) { ZStack { - RoundedRectangle(cornerRadius: 5) + Circle() .stroke(AppColors.accent, lineWidth: 1.5) .frame(width: 20, height: 20) } @@ -749,6 +968,7 @@ struct CompletedTodaySection: View { // MARK: - Task Row struct TaskRowView: View { @Environment(\.colorScheme) private var cs + @AppStorage("todayShowDetails") private var showDetails = true let task: TaskItem let onToggle: () -> Void @@ -761,8 +981,11 @@ struct TaskRowView: View { HStack(spacing: 10) { ZStack { Circle() - .stroke(task.taskColor.color(), lineWidth: 1.5) - .frame(width: 18, height: 18) + .fill(task.taskColor.soft()) + .frame(width: 22, height: 22) + Circle() + .stroke(task.taskColor.color(), lineWidth: 2) + .frame(width: 22, height: 22) } .frame(width: 36, height: 44) .padding(.leading, 4) @@ -771,10 +994,18 @@ struct TaskRowView: View { Text(task.title) .font(AppFonts.sans(13)) .foregroundColor(AppColors.text(cs)) - if let d = task.dueDate { - Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · Annual" : "")") - .font(AppFonts.mono(9.5)) - .foregroundColor(AppColors.text3(cs)) + if let d = task.dueDate, showDetails { + HStack(spacing: 4) { + Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · Annual" : "")") + .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)) + } } } @@ -789,19 +1020,106 @@ struct TaskRowView: View { } .frame(minHeight: 48) .background(AppColors.surface(cs)) - .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) - .overlay(RoundedRectangle(cornerRadius: AppRadius.small) - .stroke(AppColors.border(cs), lineWidth: 1)) .overlay(alignment: .leading) { Rectangle() .fill(task.taskColor.color()) .frame(width: 2.5) - .clipShape(RoundedRectangle(cornerRadius: 2)) } + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.small) + .stroke(AppColors.border(cs), lineWidth: 1)) .contentShape(Rectangle()) } .buttonStyle(PressButtonStyle()) } + + 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 @@ -810,6 +1128,10 @@ struct NLParsed { var date: Date? var hasTime: Bool = false var tokenRanges: [NSRange] = [] + var isRecurring: Bool = false + var recurrenceLabel: String? = nil + var endDate: Date? = nil + var isAllDay: Bool = false var hasDate: Bool { date != nil } var dateLabel: String { @@ -839,42 +1161,72 @@ struct NLTaskParser { var rawRanges: [NSRange] = [] var foundDay = false - // ── Day keywords ─────────────────────────────────────────────────── - 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)), - ] - // ── Relative offsets ("in N days", "next week") ─────────────────── - 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) + // ── 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) } + } + + // ── 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 { - 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 + 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 ────────────────────────────────────────────────── + // ── Time patterns ──────────────────────────────────────────────────── let timePatterns = [ - "at\\s+(\\d{4})\\b", // at 1500 - "at\\s+(\\d{1,2}):(\\d{2})\\s*(am|pm)?", // at 15:00 / at 3:30pm - "at\\s+(\\d{1,2})\\s*(am|pm)", // at 3pm - "\\b(\\d{1,2}):(\\d{2})\\s*(am|pm)\\b", // 3:30pm - "\\b(\\d{1,2})\\s*(am|pm)\\b", // 3pm + "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 { @@ -885,10 +1237,10 @@ struct NLTaskParser { } if foundDay || foundTime { - result.date = cal.date(from: comps) + result.date = cal.date(from: comps) result.hasTime = foundTime - result.tokenRanges = expand(rawRanges, in: lower) } + result.tokenRanges = expand(rawRanges, in: lower) return result } @@ -901,6 +1253,68 @@ struct NLTaskParser { .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] @@ -937,7 +1351,7 @@ struct NLTaskParser { if m.numberOfRanges >= 2, let r1 = Range(m.range(at: 1), in: text) { let s1 = String(text[r1]) - if s1.count == 4 { // packed 1500 + if s1.count == 4 { h = Int(s1.prefix(2)) ?? 0; mn = Int(s1.suffix(2)) ?? 0 } else { h = Int(s1) ?? 0 @@ -954,14 +1368,17 @@ struct NLTaskParser { return (m.range, h, mn) } - // widen ranges to absorb leading space so clean-strip leaves no double spaces private static func expand(_ ranges: [NSRange], in text: String) -> [NSRange] { let ns = text as NSString return ranges.map { r in - if r.location > 0 && ns.character(at: r.location - 1) == 32 { - return NSRange(location: r.location - 1, length: r.length + 1) + 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 r + return NSRange(location: loc, length: len) } } } @@ -1041,6 +1458,7 @@ struct AddTaskSheet: View { @State private var parsed = NLParsed() @State private var selectedQuadrant: Quadrant @State private var selectedCategory: TaskCategory = .reminder + @State private var showDatePicker = false var prefilledDate: Date? var prefilledQuadrant: Quadrant @@ -1049,9 +1467,9 @@ struct AddTaskSheet: View { self.prefilledDate = prefilledDate self.prefilledQuadrant = prefilledQuadrant _selectedQuadrant = State(initialValue: prefilledQuadrant) - if let d = prefilledDate { - var p = NLParsed(); p.date = d; _parsed = State(initialValue: p) - } + var p = NLParsed() + p.date = prefilledDate ?? Calendar.current.startOfDay(for: Date()) + _parsed = State(initialValue: p) } private var cleanTitle: String { NLTaskParser.cleanTitle(rawText, ranges: parsed.tokenRanges) } @@ -1067,7 +1485,9 @@ struct AddTaskSheet: View { ) .frame(minHeight: 36, maxHeight: 72) .onChange(of: rawText) { _ in - withAnimation(KisaniSpring.micro) { parsed = NLTaskParser.parse(rawText) } + var p = NLTaskParser.parse(rawText) + if p.date == nil { p.date = Calendar.current.startOfDay(for: Date()) } + withAnimation(KisaniSpring.micro) { parsed = p } } .padding(.horizontal, 16) .padding(.top, 14) @@ -1078,7 +1498,7 @@ struct AddTaskSheet: View { HStack(spacing: 0) { // Date chip - Button { } label: { + Button { showDatePicker = true } label: { HStack(spacing: 5) { Image(systemName: "calendar") .font(.system(size: 13, weight: .semibold)) @@ -1093,6 +1513,22 @@ struct AddTaskSheet: View { .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 @@ -1164,16 +1600,401 @@ struct AddTaskSheet: View { .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 + ) + } } private func submit() { guard canAdd else { return } taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime, - quadrant: selectedQuadrant, category: selectedCategory) + quadrant: selectedQuadrant, category: selectedCategory, + isRecurring: parsed.isRecurring, + endDate: parsed.endDate, isAllDay: parsed.isAllDay) 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 endDate: Date? + @Binding var isAllDay: 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 localRepeat: String + @State private var localIsAllDay: Bool + @State private var editingEnd = false + + init(selectedDate: Binding, hasTime: Binding, + isRecurring: Binding, recurrenceLabel: Binding, + endDate: Binding, isAllDay: Binding) { + _selectedDate = selectedDate + _hasTime = hasTime + _isRecurring = isRecurring + _recurrenceLabel = recurrenceLabel + _endDate = endDate + _isAllDay = isAllDay + let start = selectedDate.wrappedValue ?? Calendar.current.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") + _localIsAllDay = State(initialValue: isAllDay.wrappedValue) + _pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0) + } + + 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; 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()) + } + } + } + } + } + + @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() } } 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) + pickerRow(icon: "alarm", label: "Reminder", value: "None") + Divider().padding(.leading, 54) + + Menu { + ForEach(["None", "Daily", "Weekly", "Monthly", "Yearly"], id: \.self) { opt in + Button { withAnimation(KisaniSpring.micro) { localRepeat = opt } } label: { + Label(opt, systemImage: localRepeat == opt ? "checkmark" : "") + } + } + } label: { + pickerRow(icon: "arrow.clockwise", label: "Repeat", value: localRepeat) + } + .buttonStyle(.plain) + } + .background(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .padding(.horizontal, 16) + } + + @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) -> some View { + HStack(spacing: 14) { + Image(systemName: icon) + .font(.system(size: 18)).foregroundColor(.secondary).frame(width: 24) + Text(label) + .font(AppFonts.sans(16)).foregroundColor(.primary) + Spacer() + Text(value) + .font(AppFonts.sans(15)).foregroundColor(.secondary) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11)).foregroundColor(.secondary) + } + .padding(.horizontal, 16).frame(height: 52) + } + + 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 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 + } else { + selectedDate = Calendar.current.startOfDay(for: localDate) + endDate = Calendar.current.startOfDay(for: localEndDate) + isAllDay = localIsAllDay + hasTime = 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"; 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..