From f96f09ec29e1f714e091594d5965c0516a830511 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Mon, 1 Jun 2026 13:14:14 +0300 Subject: [PATCH] feat: calendar settings, matrix drill-down, FAB fix, in-app add calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Calendar: redesign connect sheet with Local Calendars grouped view (Default/Other/Subscribed), per-calendar Show/Hide toggle, master enabled toggle, Do Not Disturb toggle; Add Calendar now in-app with Google/Outlook/iCloud/CalDAV/Exchange/Holidays/URL flows instead of opening iOS Calendar app - Calendar: fix toggle icon (calendar.day.timeline.left / list.bullet.below.rectangle), maintain full month view when switching to task list mode - Matrix: quadrant header tap drills into detail view (Overdue/Later/Completed sections), task tap opens edit sheet; fix navigationDestination iOS 16 compat - Tasks: functional reminder picker in date sheet with inline time wheel; birthday NLP detection sets yearly recurrence; dynamic repeat option labels; quadrant badge picker replaces priority flags - Notifications: fix actor isolation crash in mark-complete handler; per-task reminder scheduling uses reminderDate field; evening check-in notification - FAB: fix + button hidden behind custom tab bar in TodayView, CalendarView, MatrixView — move background out of ZStack children into .background() modifier - TaskItem: add reminderDate, recurrenceLabel, endDate, isAllDay fields; Quadrant adds roman/matrixLabel/color/softColor; TaskViewModel adds updateTask/pin/setDate/setCategory Co-Authored-By: Kutesir --- KisaniCal/Managers/AuthManager.swift | 6 + KisaniCal/Managers/NotificationManager.swift | 31 +- KisaniCal/Models/TaskItem.swift | 59 +- KisaniCal/Views/CalendarView.swift | 664 ++++++++++++--- KisaniCal/Views/MatrixView.swift | 843 +++++++++++++++++-- KisaniCal/Views/TodayView.swift | 208 +++-- 6 files changed, 1542 insertions(+), 269 deletions(-) diff --git a/KisaniCal/Managers/AuthManager.swift b/KisaniCal/Managers/AuthManager.swift index 6ea7aa6..59de4ff 100644 --- a/KisaniCal/Managers/AuthManager.swift +++ b/KisaniCal/Managers/AuthManager.swift @@ -22,10 +22,14 @@ final class AuthManager: NSObject, ObservableObject { private let keychainUser = "kisani.auth.user.v1" private let keychainEmail = "kisani.auth.apple.email." + nonisolated static let activeUserIdKey = "kisani.activeUserId" override init() { super.init() currentUser = loadUser() + if let uid = currentUser?.id { + UserDefaults.standard.set(uid, forKey: Self.activeUserIdKey) + } } var isAuthenticated: Bool { currentUser != nil } @@ -71,6 +75,7 @@ final class AuthManager: NSObject, ObservableObject { func signOut() { currentUser = nil + UserDefaults.standard.removeObject(forKey: Self.activeUserIdKey) deleteKeychain(key: keychainUser) } @@ -78,6 +83,7 @@ final class AuthManager: NSObject, ObservableObject { private func persist(_ user: AppUser) { currentUser = user + UserDefaults.standard.set(user.id, forKey: Self.activeUserIdKey) if let data = try? JSONEncoder().encode(user) { saveKeychain(key: keychainUser, data: data) } diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift index 930bc0f..b1424fd 100644 --- a/KisaniCal/Managers/NotificationManager.swift +++ b/KisaniCal/Managers/NotificationManager.swift @@ -37,9 +37,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable } // MARK: - Complete task directly in UserDefaults (called from background) - private func markTaskComplete(taskId: String) { - let uid = AuthManager.shared.currentUser?.id ?? "shared" - let key = "kisani.tasks.v2.\(uid)" + private func markTaskComplete(taskId: String, userId: String) { + let key = "kisani.tasks.v2.\(userId)" guard let data = UserDefaults.standard.data(forKey: key), var tasks = try? JSONDecoder().decode([TaskItem].self, from: data), let uuid = UUID(uuidString: taskId), @@ -171,15 +170,20 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable let cal = Calendar.current for task in snapshot where !task.isComplete { - guard let due = task.dueDate else { continue } - + // reminderDate wins if set; otherwise fall back to due time or 9am let fireDate: Date - if task.hasTime { - fireDate = due + if let reminder = task.reminderDate { + fireDate = reminder + } else if let due = task.dueDate { + if task.hasTime { + fireDate = due + } else { + var c = cal.dateComponents([.year, .month, .day], from: due) + c.hour = 9; c.minute = 0 + fireDate = cal.date(from: c) ?? due + } } else { - var c = cal.dateComponents([.year, .month, .day], from: due) - c.hour = 9; c.minute = 0 - fireDate = cal.date(from: c) ?? due + continue } guard fireDate > now else { continue } @@ -241,7 +245,8 @@ extension NotificationManager: UNUserNotificationCenterDelegate { didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { - let info = response.notification.request.content.userInfo + let userId = UserDefaults.standard.string(forKey: AuthManager.activeUserIdKey) ?? "shared" + let info = response.notification.request.content.userInfo let deeplink = info["deeplink"] as? String let taskId = info["taskId"] as? String @@ -249,11 +254,11 @@ extension NotificationManager: UNUserNotificationCenterDelegate { case Self.actionMarkId: // Swiped action: mark complete, don't open app - if let taskId { markTaskComplete(taskId: taskId) } + if let taskId { markTaskComplete(taskId: taskId, userId: userId) } default: // Tapped notification body - if let taskId { markTaskComplete(taskId: taskId) } + if let taskId { markTaskComplete(taskId: taskId, userId: userId) } Task { @MainActor in switch deeplink { case "workout": ShortcutHandler.shared.handle(QuickAction.workout.rawValue) diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index b8f8bda..11aa980 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -12,10 +12,12 @@ struct TaskItem: Identifiable, Codable, Equatable { var category: TaskCategory = .reminder var taskColor: TaskColor = .orange var isRecurring: Bool = false + var recurrenceLabel: String? = nil var isPinned: Bool = false var endDate: Date? = nil var isAllDay: Bool = false var priority: Priority = .none + var reminderDate: Date? = nil } enum Priority: String, Codable, CaseIterable, Identifiable { @@ -50,6 +52,42 @@ enum Quadrant: String, CaseIterable, Identifiable, Codable { case schedule = "Schedule It" case delegate_ = "Delegate" case eliminate = "Eliminate" + + var roman: String { + switch self { + case .urgent: return "I" + case .schedule: return "II" + case .delegate_: return "III" + case .eliminate: return "IV" + } + } + + var matrixLabel: String { + switch self { + case .urgent: return "Urgent & Important" + case .schedule: return "Not Urgent & Important" + case .delegate_: return "Urgent & Unimportant" + case .eliminate: return "Not Urgent & Unimportant" + } + } + + var color: Color { + switch self { + case .urgent: return AppColors.accent + case .schedule: return AppColors.yellow + case .delegate_: return AppColors.blue + case .eliminate: return AppColors.green + } + } + + var softColor: Color { + switch self { + case .urgent: return AppColors.accentSoft + case .schedule: return AppColors.yellowSoft + case .delegate_: return AppColors.blueSoft + case .eliminate: return AppColors.greenSoft + } + } } enum TaskCategory: String, Codable { @@ -189,13 +227,30 @@ class TaskViewModel: ObservableObject { func addTask(title: String, dueDate: Date?, hasTime: Bool = false, quadrant: Quadrant = .urgent, category: TaskCategory = .reminder, taskColor: TaskColor = .orange, isRecurring: Bool = false, + recurrenceLabel: String? = nil, endDate: Date? = nil, isAllDay: Bool = false, - priority: Priority = .none) { + priority: Priority = .none, reminderDate: Date? = nil) { tasks.append(TaskItem(title: title, dueDate: dueDate, hasTime: hasTime, quadrant: quadrant, category: category, taskColor: taskColor, isRecurring: isRecurring, + recurrenceLabel: recurrenceLabel, endDate: endDate, isAllDay: isAllDay, - priority: priority)) + priority: priority, reminderDate: reminderDate)) + save() + } + + func updateTask(_ task: TaskItem, title: String, dueDate: Date?, hasTime: Bool, + isRecurring: Bool, recurrenceLabel: String?, + endDate: Date?, isAllDay: Bool, reminderDate: Date?) { + guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } + tasks[idx].title = title + tasks[idx].dueDate = dueDate + tasks[idx].hasTime = hasTime + tasks[idx].isRecurring = isRecurring + tasks[idx].recurrenceLabel = recurrenceLabel + tasks[idx].endDate = endDate + tasks[idx].isAllDay = isAllDay + tasks[idx].reminderDate = reminderDate save() } diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift index 7989202..3bcfea1 100644 --- a/KisaniCal/Views/CalendarView.swift +++ b/KisaniCal/Views/CalendarView.swift @@ -11,20 +11,43 @@ final class CalendarStore: ObservableObject { private var changeToken: NSObjectProtocol? private var fetchedMonth: Date? + @Published var localCalendars: [EKCalendar] = [] + @Published var hiddenCalendarIDs: Set = [] + @Published var localCalendarsEnabled: Bool = true + @Published var doNotDisturb: Bool = false + + private let hiddenIDsKey = "kisani.cal.hiddenIDs" + private let calEnabledKey = "kisani.cal.localEnabled" + private let dndKey = "kisani.cal.dnd" + init() { authStatus = EKEventStore.authorizationStatus(for: .event) + hiddenCalendarIDs = Set(UserDefaults.standard.stringArray(forKey: "kisani.cal.hiddenIDs") ?? []) + localCalendarsEnabled = UserDefaults.standard.object(forKey: "kisani.cal.localEnabled") as? Bool ?? true + doNotDisturb = UserDefaults.standard.bool(forKey: "kisani.cal.dnd") changeToken = NotificationCenter.default.addObserver( forName: .EKEventStoreChanged, object: ekStore, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in - guard let self, let m = self.fetchedMonth else { return } - self.fetchMonth(containing: m) + guard let self else { return } + // Re-read auth status — may have changed since init (e.g. granted from onboarding) + self.authStatus = EKEventStore.authorizationStatus(for: .event) + guard self.isAuthorized else { return } + self.fetchMonth(containing: self.fetchedMonth ?? Date()) } } } + // Call when app becomes active so permission granted via Settings is picked up + func refreshStatus() { + let current = EKEventStore.authorizationStatus(for: .event) + guard current != authStatus else { return } + authStatus = current + if isAuthorized { fetchMonth(containing: fetchedMonth ?? Date()) } + } + deinit { if let t = changeToken { NotificationCenter.default.removeObserver(t) } } @@ -36,14 +59,14 @@ final class CalendarStore: ObservableObject { ekStore.requestFullAccessToEvents { [weak self] granted, _ in Task { @MainActor [weak self] in self?.authStatus = granted ? .fullAccess : .denied - if granted { self?.fetchMonth(containing: Date()) } + if granted { self?.fetchMonth(containing: Date()); self?.loadLocalCalendars() } } } } else { ekStore.requestAccess(to: .event) { [weak self] granted, _ in Task { @MainActor [weak self] in self?.authStatus = granted ? .authorized : .denied - if granted { self?.fetchMonth(containing: Date()) } + if granted { self?.fetchMonth(containing: Date()); self?.loadLocalCalendars() } } } } @@ -55,12 +78,15 @@ final class CalendarStore: ObservableObject { guard isAuthorized else { return } let cal = Calendar.current guard let interval = cal.dateInterval(of: .month, for: date) else { return } - // Extend a week each side to cover leading/trailing grid overflow days let start = cal.date(byAdding: .weekOfYear, value: -1, to: interval.start) ?? interval.start let end = cal.date(byAdding: .weekOfYear, value: 1, to: interval.end) ?? interval.end - let pred = ekStore.predicateForEvents(withStart: start, end: end, calendars: nil) fetchedMonth = date - monthEvents = ekStore.events(matching: pred) + guard localCalendarsEnabled else { monthEvents = []; return } + let allCals = ekStore.calendars(for: .event) + let visibleCals = allCals.filter { !hiddenCalendarIDs.contains($0.calendarIdentifier) } + guard !visibleCals.isEmpty else { monthEvents = []; return } + let pred = ekStore.predicateForEvents(withStart: start, end: end, calendars: visibleCals) + monthEvents = ekStore.events(matching: pred) .sorted { ($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture) } } @@ -81,6 +107,64 @@ final class CalendarStore: ObservableObject { } return authStatus == .authorized } + + // MARK: - Local Calendar Management + + func loadLocalCalendars() { + guard isAuthorized else { return } + localCalendars = ekStore.calendars(for: .event) + } + + func isCalendarVisible(_ cal: EKCalendar) -> Bool { + !hiddenCalendarIDs.contains(cal.calendarIdentifier) + } + + func toggleCalendarVisibility(_ cal: EKCalendar) { + if hiddenCalendarIDs.contains(cal.calendarIdentifier) { + hiddenCalendarIDs.remove(cal.calendarIdentifier) + } else { + hiddenCalendarIDs.insert(cal.calendarIdentifier) + } + saveCalPrefs() + if let m = fetchedMonth { fetchMonth(containing: m) } + } + + func setLocalCalendarsEnabled(_ enabled: Bool) { + localCalendarsEnabled = enabled + saveCalPrefs() + if let m = fetchedMonth { fetchMonth(containing: m) } + } + + func setDoNotDisturb(_ on: Bool) { + doNotDisturb = on + UserDefaults.standard.set(on, forKey: dndKey) + } + + private func saveCalPrefs() { + UserDefaults.standard.set(Array(hiddenCalendarIDs), forKey: hiddenIDsKey) + UserDefaults.standard.set(localCalendarsEnabled, forKey: calEnabledKey) + } + + func groupedCalendars() -> [CalendarGroup] { + guard isAuthorized else { return [] } + let all = ekStore.calendars(for: .event) + let defaultID = ekStore.defaultCalendarForNewEvents?.calendarIdentifier + let defaultCals = all.filter { $0.calendarIdentifier == defaultID } + let subscribed = all.filter { $0.type == .subscription } + let other = all.filter { $0.calendarIdentifier != defaultID && $0.type != .subscription } + var groups: [CalendarGroup] = [] + if !defaultCals.isEmpty { groups.append(.init(id: "default", title: "Local Calendar (Default)", calendars: defaultCals)) } + if !other.isEmpty { groups.append(.init(id: "other", title: "Local Calendar (Other)", calendars: other)) } + if !subscribed.isEmpty { groups.append(.init(id: "subscribed", title: "Local Calendar (Subscribed Calendars)", calendars: subscribed)) } + return groups + } +} + +// MARK: - Calendar Group +struct CalendarGroup: Identifiable { + let id: String + let title: String + let calendars: [EKCalendar] } // MARK: - Calendar View Mode @@ -107,6 +191,7 @@ enum CalendarViewMode: String, CaseIterable { // MARK: - Calendar View struct CalendarView: View { @Environment(\.colorScheme) var cs + @Environment(\.scenePhase) private var scenePhase @EnvironmentObject var taskVM: TaskViewModel @EnvironmentObject var workoutVM: WorkoutViewModel @StateObject private var calStore = CalendarStore() @@ -126,8 +211,6 @@ struct CalendarView: View { var body: some View { ZStack(alignment: .bottomTrailing) { - AppColors.background(cs).ignoresSafeArea() - VStack(spacing: 0) { // ── Header ── HStack(spacing: 0) { @@ -163,8 +246,8 @@ struct CalendarView: View { Button { withAnimation(KisaniSpring.snappy) { calTaskListMode.toggle() } } label: { - Image(systemName: calTaskListMode ? "calendar" : "slider.horizontal.3") - .font(.system(size: 14, weight: calTaskListMode ? .semibold : .medium)) + Image(systemName: calTaskListMode ? "calendar.day.timeline.left" : "list.bullet.below.rectangle") + .font(.system(size: 15, weight: .medium)) .foregroundColor(calTaskListMode ? AppColors.accent : AppColors.text(cs)) .frame(width: 46, height: 36) .contentShape(Rectangle()) @@ -200,18 +283,18 @@ struct CalendarView: View { } .padding(.horizontal, 18).padding(.top, 8) + calendarContent + if calTaskListMode { - cvWeekStrip.padding(.top, 12).padding(.bottom, 6) Divider().background(AppColors.border(cs)).padding(.horizontal, 16) calDayTaskListView - } else { - calendarContent } } FAB { showAddTask = true } .padding(.trailing, 20).padding(.bottom, 10) } + .background(AppColors.background(cs).ignoresSafeArea()) .sheet(isPresented: $showAddTask) { AddTaskSheet(prefilledDate: selectedDate) .environmentObject(taskVM) @@ -220,7 +303,7 @@ struct CalendarView: View { } .sheet(isPresented: $showCalendarConnect) { CalendarConnectSheet(calStore: calStore) - .presentationDetents([.height(340)]) + .presentationDetents([.large]) .presentationDragIndicator(.visible) } .sheet(isPresented: $showWorkoutSession) { @@ -230,11 +313,15 @@ struct CalendarView: View { .presentationDragIndicator(.visible) } .onAppear { + calStore.refreshStatus() if calStore.isAuthorized { calStore.fetchMonth(containing: displayMonth) } } .onChange(of: displayMonth) { newMonth in if calStore.isAuthorized { calStore.fetchMonth(containing: newMonth) } } + .onChange(of: scenePhase) { phase in + if phase == .active { calStore.refreshStatus() } + } } private var monthTitle: String { @@ -803,118 +890,237 @@ struct CalendarConnectSheet: View { @ObservedObject var calStore: CalendarStore @Environment(\.colorScheme) var cs @Environment(\.dismiss) var dismiss - @Environment(\.openURL) var openURL - @State private var webCalURL = "" - @FocusState private var webCalFocused: Bool var body: some View { - VStack(spacing: 0) { - HStack { - Text("Calendars").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs)) - Spacer() - Button("Done") { dismiss() }.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent).buttonStyle(.plain) + NavigationStack { + ScrollView { + VStack(spacing: 20) { + // Connect prompt if not yet authorized + if !calStore.isAuthorized { + HStack(spacing: 14) { + Image(systemName: "calendar") + .font(.system(size: 16)) + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background(AppColors.accent) + .clipShape(RoundedRectangle(cornerRadius: 9)) + VStack(alignment: .leading, spacing: 2) { + Text("iPhone Calendar").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) + Text("Tap to connect and show events").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) + } + Spacer() + Button("Connect") { calStore.requestAccess() } + .font(AppFonts.mono(9.5, weight: .bold)) + .foregroundColor(AppColors.accent) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(AppColors.accentSoft) + .clipShape(Capsule()) + .buttonStyle(.plain) + } + .padding(.horizontal, 16).padding(.vertical, 14) + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1)) + .padding(.horizontal, 20) + } + + // Main settings card + VStack(spacing: 0) { + // Local Calendars row + NavigationLink(destination: LocalCalendarsView(calStore: calStore)) { + HStack { + Text("Local Calendars") + .font(AppFonts.sans(14)) + .foregroundColor(AppColors.text(cs)) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(AppColors.text3(cs)) + } + .padding(.horizontal, 16).padding(.vertical, 15) + } + + Divider().background(AppColors.border(cs)).padding(.leading, 16) + + // Add Calendar row + NavigationLink(destination: AddCalendarView()) { + HStack(spacing: 6) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(AppColors.blue) + Text("Add Calendar") + .font(AppFonts.sans(14)) + .foregroundColor(AppColors.blue) + Spacer() + } + .padding(.horizontal, 16).padding(.vertical, 15) + } + + Divider().background(AppColors.border(cs)).padding(.leading, 16) + + // Do Not Disturb toggle + HStack { + Text("Do Not Disturb") + .font(AppFonts.sans(14)) + .foregroundColor(AppColors.text(cs)) + Spacer() + Toggle("", isOn: Binding( + get: { calStore.doNotDisturb }, + set: { calStore.setDoNotDisturb($0) } + )) + .tint(AppColors.accent) + .labelsHidden() + } + .padding(.horizontal, 16).padding(.vertical, 10) + } + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1)) + .padding(.horizontal, 20) + + Spacer(minLength: 32) + } + .padding(.top, 16) } - .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14) - - Divider().background(AppColors.border(cs)) - - VStack(spacing: 0) { - // Local Calendar row - HStack(spacing: 14) { - Image(systemName: "calendar") - .font(.system(size: 16)) - .foregroundColor(.white) - .frame(width: 36, height: 36) - .background(AppColors.accent) - .clipShape(RoundedRectangle(cornerRadius: 9)) - - VStack(alignment: .leading, spacing: 2) { - Text("iPhone Calendar").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) - Text(calStore.isAuthorized ? "Connected — events shown in day view" : "Tap to connect") - .font(AppFonts.sans(11)).foregroundColor(calStore.isAuthorized ? AppColors.green : AppColors.text3(cs)) - } - Spacer() - if calStore.isAuthorized { - Image(systemName: "checkmark.circle.fill").foregroundColor(AppColors.green).font(.system(size: 18)) - } else { - Button("Connect") { - calStore.requestAccess() - } - .font(AppFonts.mono(9.5, weight: .bold)) + .background(AppColors.background(cs).ignoresSafeArea()) + .navigationTitle("Calendar") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + .font(AppFonts.sans(13, weight: .semibold)) .foregroundColor(AppColors.accent) - .padding(.horizontal, 10).padding(.vertical, 5) - .background(AppColors.accentSoft) - .clipShape(Capsule()) - .buttonStyle(.plain) - } } - .padding(.horizontal, 16).padding(.vertical, 12) + } + } + } +} - Divider().background(AppColors.border(cs)).padding(.leading, 66) +// MARK: - Local Calendars View +struct LocalCalendarsView: View { + @ObservedObject var calStore: CalendarStore + @Environment(\.colorScheme) var cs - // WebCal row - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 14) { - Image(systemName: "globe") - .font(.system(size: 16)) - .foregroundColor(.white) - .frame(width: 36, height: 36) - .background(AppColors.blue) - .clipShape(RoundedRectangle(cornerRadius: 9)) - VStack(alignment: .leading, spacing: 2) { - Text("Web Calendar (WebCal)").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) - Text("Subscribe to an iCal / .ics feed").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) - } - Spacer() - } - .padding(.horizontal, 16).padding(.top, 12) - - HStack(spacing: 8) { - TextField("webcal:// or https://…", text: $webCalURL) - .font(AppFonts.sans(12)).foregroundColor(AppColors.text(cs)) - .focused($webCalFocused).padding(10) - .background(AppColors.surface2(cs)) - .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) - .overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(webCalFocused ? AppColors.accent : AppColors.border(cs), lineWidth: 1)) - .autocorrectionDisabled().textInputAutocapitalization(.never) - - Button { - var urlString = webCalURL.trimmingCharacters(in: .whitespaces) - if urlString.hasPrefix("https://") { urlString = urlString.replacingOccurrences(of: "https://", with: "webcal://") } - if let url = URL(string: urlString) { openURL(url) } - } label: { - Text("Subscribe").font(AppFonts.sans(12, weight: .semibold)).foregroundColor(.white) - .padding(.horizontal, 12).padding(.vertical, 10) - .background(!webCalURL.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.blue : AppColors.borderHi(cs)) - .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) - } - .buttonStyle(.plain).disabled(webCalURL.trimmingCharacters(in: .whitespaces).isEmpty) - } - .padding(.horizontal, 16).padding(.bottom, 12) - } - - Divider().background(AppColors.border(cs)).padding(.leading, 66) - - // Google Calendar row - HStack(spacing: 14) { - Image(systemName: "g.circle.fill") - .font(.system(size: 18)) - .foregroundColor(.white) - .frame(width: 36, height: 36) - .background(Color(red: 0.26, green: 0.52, blue: 0.96)) - .clipShape(RoundedRectangle(cornerRadius: 9)) - VStack(alignment: .leading, spacing: 2) { - Text("Google Calendar").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) - Text("Open in Google Calendar app").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) - } + var body: some View { + ScrollView { + VStack(spacing: 20) { + // Master enable toggle + HStack { + Text("Show Calendar Events") + .font(AppFonts.sans(14)) + .foregroundColor(AppColors.text(cs)) Spacer() - Image(systemName: "arrow.up.right").font(.system(size: 11)).foregroundColor(AppColors.text3(cs)) + Toggle("", isOn: Binding( + get: { calStore.localCalendarsEnabled }, + set: { calStore.setLocalCalendarsEnabled($0) } + )) + .tint(AppColors.accent) + .labelsHidden() } .padding(.horizontal, 16).padding(.vertical, 12) - .contentShape(Rectangle()) - .onTapGesture { - if let url = URL(string: "googlegmail://") { - openURL(url) + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1)) + .padding(.horizontal, 20) + + if calStore.localCalendarsEnabled { + let groups = calStore.groupedCalendars() + if groups.isEmpty { + Text("No calendars found") + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text3(cs)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 8) + } else { + ForEach(groups) { group in + VStack(alignment: .leading, spacing: 8) { + Text(group.title) + .font(AppFonts.sans(11, weight: .semibold)) + .foregroundColor(AppColors.text3(cs)) + .padding(.horizontal, 20) + + VStack(spacing: 0) { + ForEach(Array(group.calendars.enumerated()), id: \.element.calendarIdentifier) { idx, cal in + HStack(spacing: 12) { + Circle() + .fill(Color(cgColor: cal.cgColor)) + .frame(width: 12, height: 12) + Text(cal.title) + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text(cs)) + Spacer() + Button(calStore.isCalendarVisible(cal) ? "Hide" : "Show") { + calStore.toggleCalendarVisibility(cal) + } + .font(AppFonts.sans(12, weight: .semibold)) + .foregroundColor(calStore.isCalendarVisible(cal) ? AppColors.text3(cs) : AppColors.accent) + .buttonStyle(.plain) + } + .padding(.horizontal, 16).padding(.vertical, 13) + + if idx < group.calendars.count - 1 { + Divider().background(AppColors.border(cs)).padding(.leading, 40) + } + } + } + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1)) + .padding(.horizontal, 20) + } + } + } + } + + Spacer(minLength: 32) + } + .padding(.top, 16) + } + .background(AppColors.background(cs).ignoresSafeArea()) + .navigationTitle("Local Calendars") + .navigationBarTitleDisplayMode(.inline) + .onAppear { calStore.loadLocalCalendars() } + } +} + +// MARK: - Add Calendar + +struct CalAddItem: Identifiable { + let id: String + let title: String + let description: String? + let sfIcon: String + let iconBG: Color +} + +struct AddCalendarView: View { + @Environment(\.colorScheme) var cs + + private let items: [CalAddItem] = [ + CalAddItem(id: "google", title: "Google", description: nil, + sfIcon: "globe", iconBG: Color(red: 0.26, green: 0.52, blue: 0.96)), + CalAddItem(id: "outlook", title: "Outlook", description: nil, + sfIcon: "envelope.fill", iconBG: Color(red: 0.0, green: 0.47, blue: 0.85)), + CalAddItem(id: "icloud", title: "iCloud", description: nil, + sfIcon: "icloud.fill", iconBG: Color(red: 0.0, green: 0.48, blue: 1.0)), + CalAddItem(id: "caldav", title: "CalDAV", description: nil, + sfIcon: "server.rack", iconBG: Color(red: 0.95, green: 0.55, blue: 0.1)), + CalAddItem(id: "exchange", title: "Exchange", description: nil, + sfIcon: "envelope.badge.fill", iconBG: Color(red: 0.0, green: 0.47, blue: 0.72)), + CalAddItem(id: "holidays", title: "Holidays", description: nil, + sfIcon: "calendar.badge.plus", iconBG: AppColors.green), + CalAddItem(id: "url", title: "URL", + description: "Support adding calendars with URL subscriptions in iCal(.ics) format.", + sfIcon: "link.circle.fill", iconBG: AppColors.blue), + ] + + var body: some View { + ScrollView { + VStack(spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in + itemRow(item) + if idx < items.count - 1 { + Divider().background(AppColors.border(cs)).padding(.leading, 66) } } } @@ -922,10 +1128,230 @@ struct CalendarConnectSheet: View { .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1)) .padding(.horizontal, 20).padding(.top, 16) - - Spacer() + Spacer(minLength: 32) } .background(AppColors.background(cs).ignoresSafeArea()) + .navigationTitle("Add Calendar") + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private func itemRow(_ item: CalAddItem) -> some View { + if item.id == "url" { + NavigationLink(destination: AddCalendarURLView()) { rowContent(item) } + } else if item.id == "holidays" { + NavigationLink(destination: AddCalendarHolidaysView()) { rowContent(item) } + } else { + NavigationLink(destination: AddCalendarAccountView(item: item)) { rowContent(item) } + } + } + + private func rowContent(_ item: CalAddItem) -> some View { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(item.iconBG) + .frame(width: 36, height: 36) + Image(systemName: item.sfIcon) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.white) + } + VStack(alignment: .leading, spacing: 3) { + Text(item.title).font(AppFonts.sans(14)).foregroundColor(AppColors.text(cs)) + if let desc = item.description { + Text(desc).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).lineLimit(2) + } + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(AppColors.text3(cs)) + } + .padding(.horizontal, 16) + .padding(.vertical, item.description != nil ? 10 : 13) + } +} + +// MARK: - Add Calendar Account View +struct AddCalendarAccountView: View { + let item: CalAddItem + @Environment(\.colorScheme) var cs + @Environment(\.openURL) var openURL + + private var instructions: String { + switch item.id { + case "google": + return "In iOS Settings, go to Apps → Calendar → Accounts → Add Account → Google, then sign in with your Google account." + case "outlook": + return "In iOS Settings, go to Apps → Calendar → Accounts → Add Account → Outlook, then sign in with your Microsoft account." + case "icloud": + return "Tap your name at the top of iOS Settings and sign in to iCloud, then enable Calendars." + case "caldav": + return "In iOS Settings, go to Apps → Calendar → Accounts → Add Account → Other → Add CalDAV Account, then enter your server details." + case "exchange": + return "In iOS Settings, go to Apps → Calendar → Accounts → Add Account → Microsoft Exchange, then enter your work email and sign in." + default: + return "Open iOS Settings and navigate to Apps → Calendar → Accounts to add a new account." + } + } + + var body: some View { + ScrollView { + VStack(spacing: 24) { + VStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 20) + .fill(item.iconBG) + .frame(width: 72, height: 72) + Image(systemName: item.sfIcon) + .font(.system(size: 30)) + .foregroundColor(.white) + } + Text(item.title) + .font(AppFonts.sans(20, weight: .bold)) + .foregroundColor(AppColors.text(cs)) + } + .padding(.top, 32) + + Text(instructions) + .font(AppFonts.sans(14)) + .foregroundColor(AppColors.text3(cs)) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + Button { + if let url = URL(string: "app-settings:") { openURL(url) } + } label: { + Text("Open Settings") + .font(AppFonts.sans(14, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity).frame(height: 48) + .background(item.iconBG) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + } + .buttonStyle(.plain) + .padding(.horizontal, 32) + + Spacer(minLength: 32) + } + } + .background(AppColors.background(cs).ignoresSafeArea()) + .navigationTitle(item.title) + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Add Calendar URL View +struct AddCalendarURLView: View { + @Environment(\.colorScheme) var cs + @Environment(\.openURL) var openURL + @State private var urlText = "" + @FocusState private var focused: Bool + + var body: some View { + ScrollView { + VStack(spacing: 20) { + Text("Subscribe to an iCal or .ics calendar feed by entering its webcal or https URL.") + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text3(cs)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20).padding(.top, 4) + + HStack { + Text("URL") + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text3(cs)) + .frame(width: 52, alignment: .leading) + TextField("webcal:// or https://…", text: $urlText) + .font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + .focused($focused) + .autocorrectionDisabled().textInputAutocapitalization(.never) + } + .padding(.horizontal, 16).padding(.vertical, 14) + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(focused ? AppColors.accent : AppColors.border(cs), lineWidth: 1)) + .padding(.horizontal, 20) + + Button { + var s = urlText.trimmingCharacters(in: .whitespaces) + if s.hasPrefix("https://") { s = s.replacingOccurrences(of: "https://", with: "webcal://") } + if let url = URL(string: s) { openURL(url) } + } label: { + Text("Subscribe") + .font(AppFonts.sans(14, weight: .semibold)).foregroundColor(.white) + .frame(maxWidth: .infinity).frame(height: 48) + .background(!urlText.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.blue : AppColors.borderHi(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + } + .buttonStyle(.plain) + .disabled(urlText.trimmingCharacters(in: .whitespaces).isEmpty) + .padding(.horizontal, 20) + } + .padding(.top, 16) + } + .background(AppColors.background(cs).ignoresSafeArea()) + .navigationTitle("URL Subscription") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Add Calendar Holidays View +struct AddCalendarHolidaysView: View { + @Environment(\.colorScheme) var cs + @Environment(\.openURL) var openURL + + private struct HolidayRegion: Identifiable { + let id = UUID() + let name: String + let webcalURL: String + } + + private let regions: [HolidayRegion] = [ + .init(name: "United States", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/us_en.ics"), + .init(name: "United Kingdom", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/gb_en.ics"), + .init(name: "Canada", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/ca_en.ics"), + .init(name: "Australia", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/au_en.ics"), + .init(name: "Kenya", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/ke_en.ics"), + .init(name: "Nigeria", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/ng_en.ics"), + .init(name: "South Africa", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/za_en.ics"), + .init(name: "India", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/in_en.ics"), + .init(name: "Germany", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/de_de.ics"), + .init(name: "France", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/fr_fr.ics"), + .init(name: "Japan", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/jp_ja.ics"), + ] + + var body: some View { + ScrollView { + VStack(spacing: 0) { + ForEach(Array(regions.enumerated()), id: \.element.id) { idx, region in + Button { + if let url = URL(string: region.webcalURL) { openURL(url) } + } label: { + HStack { + Text(region.name).font(AppFonts.sans(14)).foregroundColor(AppColors.text(cs)) + Spacer() + Image(systemName: "plus.circle") + .font(.system(size: 18)) + .foregroundColor(AppColors.accent) + } + .padding(.horizontal, 16).padding(.vertical, 13) + } + .buttonStyle(.plain) + if idx < regions.count - 1 { + Divider().background(AppColors.border(cs)).padding(.leading, 16) + } + } + } + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1)) + .padding(.horizontal, 20).padding(.top, 16) + Spacer(minLength: 32) + } + .background(AppColors.background(cs).ignoresSafeArea()) + .navigationTitle("Holidays") + .navigationBarTitleDisplayMode(.inline) } } diff --git a/KisaniCal/Views/MatrixView.swift b/KisaniCal/Views/MatrixView.swift index 89ac46c..e503964 100644 --- a/KisaniCal/Views/MatrixView.swift +++ b/KisaniCal/Views/MatrixView.swift @@ -3,54 +3,127 @@ import SwiftUI struct MatrixView: View { @Environment(\.colorScheme) var cs @EnvironmentObject var taskVM: TaskViewModel - @State private var showAddTask = false + @State private var showAddTask = false + @State private var dropTarget: Quadrant? = nil + @State private var hideCompleted = false + @State private var showUserGuide = false + @State private var drillQuadrant: Quadrant? = nil + @State private var showDrill = false var body: some View { + NavigationStack { ZStack(alignment: .bottomTrailing) { - AppColors.background(cs).ignoresSafeArea() - VStack(spacing: 0) { // ── Header ── HStack { Text("Eisenhower Matrix") - .font(AppFonts.sans(15, weight: .semibold)) + .font(AppFonts.sans(17, weight: .bold)) .foregroundColor(AppColors.text(cs)) Spacer() - IButton(icon: "ellipsis") + Menu { + Button { + // future edit mode + } label: { + Label("Edit", systemImage: "pencil") + } + Button { + withAnimation(KisaniSpring.snappy) { hideCompleted.toggle() } + } label: { + Label(hideCompleted ? "Show Completed" : "Hide Completed", + systemImage: hideCompleted ? "checkmark.circle" : "checkmark.circle.badge.xmark") + } + Button { showUserGuide = true } label: { + Label("User Guide", systemImage: "questionmark.circle") + } + } label: { + Image(systemName: "ellipsis") + .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, 18) .padding(.top, 12) .padding(.bottom, 10) - // ── 2×2 Grid — fixed equal quadrants ── - VStack(spacing: 7) { - HStack(spacing: 7) { + // ── 2×2 Grid ── + VStack(spacing: 8) { + HStack(spacing: 8) { QuadrantCard( - roman: "I", label: "Urgent & Important", + quadrant: .urgent, + roman: "I", label: "Urgent & Important", badgeBg: AppColors.accentSoft, badgeColor: AppColors.accent, - tasks: taskVM.tasks(for: .urgent) - ) { taskVM.toggle($0) } - + tasks: taskVM.tasks(for: .urgent).filter { !hideCompleted || !$0.isComplete }, + isDropTarget: dropTarget == .urgent, + onToggle: { taskVM.toggle($0) }, + onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .urgent); dropTarget = nil } }, + onDropEnter: { dropTarget = .urgent }, + onDropExit: { dropTarget = nil }, + onPin: { taskVM.pin($0) }, + onSetDate: { taskVM.setDate($0, date: $1) }, + onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, + onSetCategory: { taskVM.setCategory($0, category: $1) }, + onDelete: { taskVM.delete($0) }, + onTapHeader: { drillQuadrant = .urgent; showDrill = true } + ) QuadrantCard( - roman: "II", label: "Not Urgent & Important", + quadrant: .schedule, + roman: "II", label: "Not Urgent & Important", badgeBg: AppColors.yellowSoft, badgeColor: AppColors.yellow, - tasks: taskVM.tasks(for: .schedule) - ) { taskVM.toggle($0) } + tasks: taskVM.tasks(for: .schedule).filter { !hideCompleted || !$0.isComplete }, + isDropTarget: dropTarget == .schedule, + onToggle: { taskVM.toggle($0) }, + onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .schedule); dropTarget = nil } }, + onDropEnter: { dropTarget = .schedule }, + onDropExit: { dropTarget = nil }, + onPin: { taskVM.pin($0) }, + onSetDate: { taskVM.setDate($0, date: $1) }, + onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, + onSetCategory: { taskVM.setCategory($0, category: $1) }, + onDelete: { taskVM.delete($0) }, + onTapHeader: { drillQuadrant = .schedule; showDrill = true } + ) } .frame(maxHeight: .infinity) - HStack(spacing: 7) { + HStack(spacing: 8) { QuadrantCard( + quadrant: .delegate_, roman: "III", label: "Urgent & Unimportant", badgeBg: AppColors.blueSoft, badgeColor: AppColors.blue, - tasks: taskVM.tasks(for: .delegate_) - ) { taskVM.toggle($0) } - + tasks: taskVM.tasks(for: .delegate_).filter { !hideCompleted || !$0.isComplete }, + isDropTarget: dropTarget == .delegate_, + onToggle: { taskVM.toggle($0) }, + onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .delegate_); dropTarget = nil } }, + onDropEnter: { dropTarget = .delegate_ }, + onDropExit: { dropTarget = nil }, + onPin: { taskVM.pin($0) }, + onSetDate: { taskVM.setDate($0, date: $1) }, + onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, + onSetCategory: { taskVM.setCategory($0, category: $1) }, + onDelete: { taskVM.delete($0) }, + onTapHeader: { drillQuadrant = .delegate_; showDrill = true } + ) QuadrantCard( - roman: "IV", label: "Not Urgent & Unimportant", + quadrant: .eliminate, + roman: "IV", label: "Not Urgent & Unimportant", badgeBg: AppColors.greenSoft, badgeColor: AppColors.green, - tasks: taskVM.tasks(for: .eliminate) - ) { taskVM.toggle($0) } + tasks: taskVM.tasks(for: .eliminate).filter { !hideCompleted || !$0.isComplete }, + isDropTarget: dropTarget == .eliminate, + onToggle: { taskVM.toggle($0) }, + onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .eliminate); dropTarget = nil } }, + onDropEnter: { dropTarget = .eliminate }, + onDropExit: { dropTarget = nil }, + onPin: { taskVM.pin($0) }, + onSetDate: { taskVM.setDate($0, date: $1) }, + onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, + onSetCategory: { taskVM.setCategory($0, category: $1) }, + onDelete: { taskVM.delete($0) }, + onTapHeader: { drillQuadrant = .eliminate; showDrill = true } + ) } .frame(maxHeight: .infinity) } @@ -64,125 +137,296 @@ struct MatrixView: View { .padding(.trailing, 20) .padding(.bottom, 10) } + .background(AppColors.background(cs).ignoresSafeArea()) .sheet(isPresented: $showAddTask) { AddTaskSheet() .environmentObject(taskVM) .presentationDetents([.height(150)]) .presentationDragIndicator(.visible) } + .sheet(isPresented: $showUserGuide) { + MatrixUserGuideSheet() + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + .navigationDestination(isPresented: $showDrill) { + if let q = drillQuadrant { + QuadrantDetailView(quadrant: q) + .environmentObject(taskVM) + } + } + } // NavigationStack } } // MARK: - Quadrant Card + struct QuadrantCard: View { @Environment(\.colorScheme) private var cs + let quadrant: Quadrant let roman: String let label: String let badgeBg: Color let badgeColor: Color let tasks: [TaskItem] + let isDropTarget: Bool let onToggle: (TaskItem) -> Void + let onDrop: (UUID) -> Void + let onDropEnter: () -> Void + let onDropExit: () -> Void + var onPin: ((TaskItem) -> Void)? = nil + var onSetDate: ((TaskItem, Date?) -> Void)? = nil + var onMove: ((TaskItem, Quadrant) -> Void)? = nil + var onSetCategory: ((TaskItem, TaskCategory) -> Void)? = nil + var onDelete: ((TaskItem) -> Void)? = nil + var onTapHeader: (() -> Void)? = nil private let shortFmt: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "d MMM" - return f + let f = DateFormatter(); f.dateFormat = "d MMM"; return f }() - private let shortFmtYear: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "d/MM/yyyy" - return f + let f = DateFormatter(); f.dateFormat = "d/MM/yyyy"; return f }() - private func dateLabel(_ date: Date) -> String { - let cal = Calendar.current - if cal.isDateInThisYear(date) { - return shortFmt.string(from: date) - } - return shortFmtYear.string(from: date) + Calendar.current.isDateInThisYear(date) ? shortFmt.string(from: date) : shortFmtYear.string(from: date) } var body: some View { VStack(alignment: .leading, spacing: 0) { // ── Header badge ── - HStack(spacing: 4) { - Text(roman) - .font(AppFonts.mono(7.5, weight: .heavy)) - .foregroundColor(.white) - .frame(width: 13, height: 13) - .background(badgeColor) - .clipShape(Circle()) - Text(label.uppercased()) - .font(AppFonts.mono(8, weight: .heavy)) - .foregroundColor(badgeColor) - .lineLimit(1) - .minimumScaleFactor(0.65) + HStack(spacing: 0) { + HStack(spacing: 5) { + Text(roman) + .font(AppFonts.mono(8, weight: .heavy)) + .foregroundColor(.white) + .frame(width: 15, height: 15) + .background(badgeColor) + .clipShape(Circle()) + Text(label.uppercased()) + .font(AppFonts.mono(8.5, weight: .bold)) + .foregroundColor(badgeColor) + .lineLimit(1) + .minimumScaleFactor(0.65) + if onTapHeader != nil { + Image(systemName: "chevron.right") + .font(.system(size: 7, weight: .bold)) + .foregroundColor(badgeColor.opacity(0.7)) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(badgeBg) + .clipShape(Capsule()) + .contentShape(Capsule()) + .onTapGesture { onTapHeader?() } + Spacer() } - .padding(.horizontal, 7) - .padding(.vertical, 3) - .background(badgeBg) - .cornerRadius(20) - .padding(.bottom, 7) + .padding(.bottom, 8) - // ── Scrollable task list ── + // ── Task list ── ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { Color.clear.frame(height: 0).trackScrollForTabBar() ForEach(tasks) { task in - HStack(alignment: .top, spacing: 6) { - RoundedRectangle(cornerRadius: 3) - .stroke( - task.isComplete ? AppColors.border(cs) : badgeColor, - lineWidth: 1.5 - ) - .background( - task.isComplete - ? RoundedRectangle(cornerRadius: 3) - .fill(AppColors.border(cs).opacity(0.5)) - : nil - ) - .overlay( - task.isComplete - ? Image(systemName: "checkmark") - .font(.system(size: 6.5, weight: .bold)) - .foregroundColor(AppColors.text3(cs)) - : nil - ) - .frame(width: 13, height: 13) - .padding(.top, 1.5) - .contentShape(Rectangle().size(CGSize(width: 28, height: 28))) - .onTapGesture { - withAnimation(KisaniSpring.bounce) { onToggle(task) } + HStack(alignment: .top, spacing: 8) { + // Circle checkbox + ZStack { + Circle() + .fill(task.isComplete ? badgeColor.opacity(0.15) : badgeBg) + .frame(width: 16, height: 16) + Circle() + .stroke(task.isComplete ? badgeColor.opacity(0.4) : badgeColor, lineWidth: 1.5) + .frame(width: 16, height: 16) + if task.isComplete { + Image(systemName: "checkmark") + .font(.system(size: 7, weight: .bold)) + .foregroundColor(badgeColor) } + } + .padding(.top, 1) + .onTapGesture { + withAnimation(KisaniSpring.bounce) { onToggle(task) } + } - VStack(alignment: .leading, spacing: 1) { - Text(task.title) - .font(AppFonts.sans(10.5)) - .foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs)) - .strikethrough(task.isComplete, color: AppColors.text3(cs)) - .lineLimit(2) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + if task.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 7, weight: .bold)) + .foregroundColor(badgeColor.opacity(0.7)) + } + Text(task.title) + .font(AppFonts.sans(12, weight: .medium)) + .foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs)) + .strikethrough(task.isComplete, color: AppColors.text3(cs)) + .lineLimit(2) + } if let d = task.dueDate { Text(dateLabel(d)) - .font(AppFonts.mono(8.5)) + .font(AppFonts.mono(9.5)) .foregroundColor(task.isComplete ? AppColors.text3(cs) : badgeColor) } } } - .opacity(task.isComplete ? 0.38 : 1) - .padding(.vertical, 3.5) + .opacity(task.isComplete ? 0.4 : 1) + .padding(.vertical, 4) + .padding(.leading, 2) + .draggable(task.id.uuidString) + .contextMenu { + Button { + withAnimation(KisaniSpring.snappy) { onPin?(task) } + } label: { + Label(task.isPinned ? "Unpin" : "Pin", + systemImage: task.isPinned ? "pin.slash" : "pin") + } + + Menu { + Button { + onSetDate?(task, Calendar.current.startOfDay(for: Date())) + } label: { Label("Today", systemImage: "sun.max") } + Button { + let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date())! + onSetDate?(task, Calendar.current.startOfDay(for: tomorrow)) + } label: { Label("Tomorrow", systemImage: "sunrise") } + Button { + let nextWeek = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: Date())! + onSetDate?(task, Calendar.current.startOfDay(for: nextWeek)) + } label: { Label("Next Week", systemImage: "calendar") } + Divider() + Button { + onSetDate?(task, nil) + } label: { Label("Remove Date", systemImage: "xmark.circle") } + } label: { + Label("Date", systemImage: "calendar.badge.clock") + } + + Menu { + ForEach(Quadrant.allCases.filter { $0 != quadrant }) { q in + Button { + withAnimation(KisaniSpring.snappy) { onMove?(task, q) } + } label: { + Text(q.rawValue) + } + } + } label: { + Label("Move", systemImage: "arrow.right.square") + } + + Menu { + ForEach([TaskCategory.reminder, .birthday, .domain, .annual, .custom], id: \.rawValue) { cat in + Button { + onSetCategory?(task, cat) + } label: { + Label(cat.rawValue, systemImage: task.category == cat ? "checkmark" : "tag") + } + } + } label: { + Label("Tags", systemImage: "tag") + } + + Button { } label: { + Label("Add to Live Activity", systemImage: "pin.circle") + } + + Divider() + + Button(role: .destructive) { + withAnimation(KisaniSpring.snappy) { onDelete?(task) } + } label: { + Label("Delete", systemImage: "trash") + } + } } } } } - .padding(10) + .padding(.horizontal, 12) + .padding(.vertical, 10) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(AppColors.surface(cs)) .clipShape(RoundedRectangle(cornerRadius: AppRadius.medium)) .overlay( RoundedRectangle(cornerRadius: AppRadius.medium) - .stroke(AppColors.border(cs), lineWidth: 1) + .stroke(isDropTarget ? badgeColor : AppColors.border(cs), + lineWidth: isDropTarget ? 2 : 1) ) + .scaleEffect(isDropTarget ? 1.02 : 1) + .animation(KisaniSpring.micro, value: isDropTarget) + .dropDestination(for: String.self) { items, _ in + guard let idString = items.first, + let id = UUID(uuidString: idString) else { return false } + onDrop(id) + return true + } isTargeted: { targeted in + if targeted { onDropEnter() } else { onDropExit() } + } + } +} + +// MARK: - User Guide Sheet + +private struct MatrixUserGuideSheet: View { + @Environment(\.colorScheme) private var cs + @Environment(\.dismiss) private var dismiss + + private let quadrants: [(String, String, Color, Color, String)] = [ + ("I", "Urgent & Important", AppColors.accent, AppColors.accentSoft, "Do it now. Deadlines, crises, important tasks with time pressure."), + ("II", "Not Urgent & Important", AppColors.yellow, AppColors.yellowSoft, "Schedule it. Planning, growth, and goals that matter long-term."), + ("III", "Urgent & Unimportant", AppColors.blue, AppColors.blueSoft, "Delegate it. Interruptions and requests that others can handle."), + ("IV", "Not Urgent & Unimportant", AppColors.green, AppColors.greenSoft, "Eliminate it. Time-wasters and low-value activities.") + ] + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("User Guide") + .font(AppFonts.sans(17, weight: .bold)) + .foregroundColor(AppColors.text(cs)) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(AppColors.text2(cs)) + .frame(width: 28, height: 28) + .background(AppColors.surface2(cs)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 16) + + VStack(spacing: 10) { + ForEach(quadrants, id: \.0) { roman, label, color, bg, desc in + HStack(alignment: .top, spacing: 12) { + Text(roman) + .font(AppFonts.mono(9, weight: .heavy)) + .foregroundColor(.white) + .frame(width: 20, height: 20) + .background(color) + .clipShape(Circle()) + VStack(alignment: .leading, spacing: 3) { + Text(label) + .font(AppFonts.sans(13, weight: .semibold)) + .foregroundColor(AppColors.text(cs)) + Text(desc) + .font(AppFonts.sans(12)) + .foregroundColor(AppColors.text3(cs)) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(bg) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + } + } + .padding(.horizontal, 20) + + Spacer() + } + .background(AppColors.background(cs).ignoresSafeArea()) } } @@ -191,3 +435,426 @@ private extension Calendar { component(.year, from: date) == component(.year, from: Date()) } } + +// MARK: - Quadrant Detail View + +struct QuadrantDetailView: View { + let quadrant: Quadrant + @EnvironmentObject var taskVM: TaskViewModel + @Environment(\.colorScheme) var cs + @State private var showAddTask = false + @State private var editingTask: TaskItem? = nil + @State private var overdueExpanded = true + @State private var laterExpanded = true + @State private var completedExpanded = true + @State private var showAllCompleted = false + private let completedLimit = 5 + + private var overdueTasks: [TaskItem] { + let startOfDay = Calendar.current.startOfDay(for: Date()) + return taskVM.tasks + .filter { $0.quadrant == quadrant && !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay } + .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } + } + private var laterTasks: [TaskItem] { + let startOfDay = Calendar.current.startOfDay(for: Date()) + return taskVM.tasks + .filter { $0.quadrant == quadrant && !$0.isComplete && ($0.dueDate ?? .distantFuture) >= startOfDay } + .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } + } + private var completedTasks: [TaskItem] { + taskVM.tasks + .filter { $0.quadrant == quadrant && $0.isComplete } + .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } + } + private var allEmpty: Bool { overdueTasks.isEmpty && laterTasks.isEmpty && completedTasks.isEmpty } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + ScrollView(showsIndicators: false) { + VStack(spacing: 12) { + if !overdueTasks.isEmpty { + QDSectionCard(title: "Overdue", count: overdueTasks.count, + expanded: $overdueExpanded, titleColor: .red) { + ForEach(overdueTasks) { task in + QDTaskRow(task: task, quadrant: quadrant, + onTap: { editingTask = task }, + onToggle: { withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } }) + if task.id != overdueTasks.last?.id { Divider().padding(.leading, 46) } + } + } + } + if !laterTasks.isEmpty { + QDSectionCard(title: "Later", count: laterTasks.count, + expanded: $laterExpanded, titleColor: AppColors.text(cs)) { + ForEach(laterTasks) { task in + QDTaskRow(task: task, quadrant: quadrant, + onTap: { editingTask = task }, + onToggle: { withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } }) + if task.id != laterTasks.last?.id { Divider().padding(.leading, 46) } + } + } + } + if !completedTasks.isEmpty { + let displayed = showAllCompleted ? completedTasks : Array(completedTasks.prefix(completedLimit)) + QDSectionCard(title: "Completed", count: completedTasks.count, + expanded: $completedExpanded, titleColor: AppColors.text2(cs)) { + ForEach(displayed) { task in + QDTaskRow(task: task, quadrant: quadrant, + onTap: { editingTask = task }, + onToggle: { withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } }) + if task.id != displayed.last?.id { Divider().padding(.leading, 46) } + } + if completedTasks.count > completedLimit && !showAllCompleted { + Button { withAnimation(KisaniSpring.snappy) { showAllCompleted = true } } label: { + Text("View More") + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text3(cs)) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .buttonStyle(.plain) + } + } + } + if allEmpty { + VStack(spacing: 8) { + Text("No tasks") + .font(AppFonts.sans(15, weight: .semibold)) + .foregroundColor(AppColors.text2(cs)) + Text("Tap + to add a task to this quadrant") + .font(AppFonts.sans(12)) + .foregroundColor(AppColors.text3(cs)) + } + .frame(maxWidth: .infinity) + .padding(.top, 80) + } + Spacer(minLength: 100) + } + .padding(.horizontal, 16) + .padding(.top, 16) + } + + FAB { showAddTask = true } + .padding(.trailing, 20) + .padding(.bottom, 10) + } + .background(AppColors.background(cs).ignoresSafeArea()) + .navigationTitle(quadrant.matrixLabel) + .navigationBarTitleDisplayMode(.large) + .sheet(isPresented: $showAddTask) { + AddTaskSheet(prefilledQuadrant: quadrant) + .environmentObject(taskVM) + .presentationDetents([.height(150)]) + .presentationDragIndicator(.visible) + } + .sheet(item: $editingTask) { task in + TaskEditSheet(task: task) + .environmentObject(taskVM) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + } +} + +// MARK: - Section Card + +private struct QDSectionCard: View { + @Environment(\.colorScheme) var cs + let title: String + let count: Int + @Binding var expanded: Bool + let titleColor: Color + @ViewBuilder let content: Content + + var body: some View { + VStack(spacing: 0) { + Button { withAnimation(KisaniSpring.snappy) { expanded.toggle() } } label: { + HStack { + Text(title) + .font(AppFonts.sans(15, weight: .semibold)) + .foregroundColor(titleColor) + Spacer() + Text("\(count)") + .font(AppFonts.mono(12)) + .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, 16).padding(.vertical, 14) + } + .buttonStyle(.plain) + if expanded { + Divider().padding(.leading, 16) + content.padding(.horizontal, 12).padding(.bottom, 6) + } + } + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(AppColors.border(cs), lineWidth: 1)) + } +} + +// MARK: - Task Row in Detail + +private struct QDTaskRow: View { + @Environment(\.colorScheme) var cs + let task: TaskItem + let quadrant: Quadrant + let onTap: () -> Void + let onToggle: () -> Void + + private var isOverdue: Bool { + !task.isComplete && (task.dueDate ?? .distantFuture) < Calendar.current.startOfDay(for: Date()) + } + + var body: some View { + HStack(spacing: 12) { + Button(action: onToggle) { + RoundedRectangle(cornerRadius: 5) + .stroke(task.isComplete ? quadrant.color.opacity(0.3) : quadrant.color, lineWidth: 1.5) + .frame(width: 22, height: 22) + .overlay { + if task.isComplete { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(quadrant.color.opacity(0.5)) + } + } + } + .buttonStyle(.plain) + + Button(action: onTap) { + HStack(alignment: .top) { + Text(task.title) + .font(AppFonts.sans(14, weight: task.isComplete ? .regular : .medium)) + .foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs)) + .strikethrough(task.isComplete, color: AppColors.text3(cs)) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .trailing, spacing: 3) { + if let d = task.dueDate { + Text(dateStr(d)) + .font(AppFonts.mono(11)) + .foregroundColor(isOverdue ? .red : (task.isComplete ? AppColors.text3(cs) : quadrant.color)) + } + HStack(spacing: 4) { + if task.reminderDate != nil { + Image(systemName: "alarm") + .font(.system(size: 9)) + .foregroundColor(AppColors.text3(cs)) + } + if task.isRecurring { + Image(systemName: "arrow.clockwise") + .font(.system(size: 9)) + .foregroundColor(AppColors.text3(cs)) + } + } + } + } + } + .buttonStyle(.plain) + } + .padding(.vertical, 10) + .opacity(task.isComplete ? 0.55 : 1) + } + + private func dateStr(_ d: Date) -> String { + let f = DateFormatter() + f.dateFormat = Calendar.current.isDateInThisYear(d) ? "MMM d" : "dd/MM/yyyy" + return f.string(from: d) + } +} + +// MARK: - Task Edit Sheet + +struct TaskEditSheet: View { + @EnvironmentObject var taskVM: TaskViewModel + @Environment(\.colorScheme) var cs + @Environment(\.dismiss) var dismiss + + let task: TaskItem + + @State private var title: String + @State private var dueDate: Date? + @State private var hasTime: Bool + @State private var isRecurring: Bool + @State private var recurrenceLabel: String? + @State private var endDate: Date? + @State private var isAllDay: Bool + @State private var reminderDate: Date? + @State private var showDatePicker = false + + init(task: TaskItem) { + self.task = task + _title = State(initialValue: task.title) + _dueDate = State(initialValue: task.dueDate) + _hasTime = State(initialValue: task.hasTime) + _isRecurring = State(initialValue: task.isRecurring) + _recurrenceLabel = State(initialValue: task.recurrenceLabel) + _endDate = State(initialValue: task.endDate) + _isAllDay = State(initialValue: task.isAllDay) + _reminderDate = State(initialValue: task.reminderDate) + } + + private var dateSummary: String { + guard let d = dueDate 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" : "EEE, 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) + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // ── Quadrant label ── + HStack { + HStack(spacing: 5) { + Text(task.quadrant.roman) + .font(AppFonts.mono(8, weight: .heavy)) + .foregroundColor(.white) + .frame(width: 14, height: 14) + .background(task.quadrant.color) + .clipShape(Circle()) + Text(task.quadrant.matrixLabel.uppercased()) + .font(AppFonts.mono(8, weight: .bold)) + .foregroundColor(task.quadrant.color) + } + .padding(.horizontal, 8).padding(.vertical, 4) + .background(task.quadrant.softColor) + .clipShape(Capsule()) + Spacer() + } + .padding(.horizontal, 20).padding(.top, 16).padding(.bottom, 12) + + // ── Date / reminder / recurrence row ── + if dueDate != nil || reminderDate != nil || isRecurring { + Button { showDatePicker = true } label: { + HStack(spacing: 8) { + Text(dateSummary) + .font(AppFonts.sans(13, weight: .semibold)) + .foregroundColor(task.quadrant.color) + if reminderDate != nil { + Image(systemName: "alarm") + .font(.system(size: 12)) + .foregroundColor(task.quadrant.color) + } + if isRecurring { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12)) + .foregroundColor(task.quadrant.color) + if let lbl = recurrenceLabel { + Text(lbl) + .font(AppFonts.sans(12)) + .foregroundColor(AppColors.text3(cs)) + } + } + Spacer() + } + .padding(.horizontal, 20).padding(.bottom, 8) + } + .buttonStyle(.plain) + } + + Divider().padding(.horizontal, 20) + + // ── Title ── + TextField("Task title", text: $title, axis: .vertical) + .font(AppFonts.sans(22, weight: .bold)) + .foregroundColor(AppColors.text(cs)) + .padding(.horizontal, 20) + .padding(.top, 16) + .lineLimit(1...5) + + Spacer() + + // ── Bottom toolbar ── + Divider() + HStack(spacing: 20) { + Button { showDatePicker = true } label: { + Image(systemName: dueDate != nil ? "calendar.badge.checkmark" : "calendar") + .font(.system(size: 18)) + .foregroundColor(dueDate != nil ? task.quadrant.color : AppColors.text3(cs)) + } + .buttonStyle(.plain) + + Button { showDatePicker = true } label: { + Image(systemName: "alarm") + .font(.system(size: 18)) + .foregroundColor(reminderDate != nil ? task.quadrant.color : AppColors.text3(cs)) + } + .buttonStyle(.plain) + + Button { showDatePicker = true } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 18)) + .foregroundColor(isRecurring ? task.quadrant.color : AppColors.text3(cs)) + } + .buttonStyle(.plain) + + Spacer() + + Button(action: save) { + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background(title.trimmingCharacters(in: .whitespaces).isEmpty ? Color.gray : task.quadrant.color) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled(title.trimmingCharacters(in: .whitespaces).isEmpty) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(AppColors.surface(cs).ignoresSafeArea()) + } + .background(AppColors.surface(cs).ignoresSafeArea()) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(AppColors.text2(cs)) + .frame(width: 28, height: 28) + .background(AppColors.surface2(cs)) + .clipShape(Circle()) + } + } + } + } + .sheet(isPresented: $showDatePicker) { + TaskDatePickerSheet( + selectedDate: $dueDate, + hasTime: $hasTime, + isRecurring: $isRecurring, + recurrenceLabel: $recurrenceLabel, + endDate: $endDate, + isAllDay: $isAllDay, + reminderDate: $reminderDate + ) + } + } + + private func save() { + let t = title.trimmingCharacters(in: .whitespaces) + guard !t.isEmpty else { return } + taskVM.updateTask(task, title: t, dueDate: dueDate, hasTime: hasTime, + isRecurring: isRecurring, recurrenceLabel: recurrenceLabel, + endDate: endDate, isAllDay: isAllDay, reminderDate: reminderDate) + dismiss() + } +} diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index efc0dfc..d9cffee 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -3,6 +3,7 @@ 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() @@ -22,8 +23,6 @@ struct TodayView: View { var body: some View { ZStack(alignment: .bottomTrailing) { - AppColors.background(cs).ignoresSafeArea() - ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { Color.clear.frame(height: 0).trackScrollForTabBar() @@ -69,13 +68,24 @@ struct TodayView: View { .padding(.horizontal, 18) .padding(.bottom, 8) - // ── Today timeline: active → completed (green) → overdue (accent) ── + // ── Overdue card (prominent, above timeline) ── + if !taskVM.overdueTasks.isEmpty { + OverdueCard( + tasks: taskVM.overdueTasks, + onPostpone: { taskVM.postponeAllOverdue() }, + onToggle: { task in withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } } + ) + .padding(.horizontal, 18) + .padding(.bottom, 8) + } + + // ── Today timeline: active → completed (green) ── let activeTasks = taskVM.todayTasks let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks DayTimelineView( date: Date(), tasks: activeTasks + completedTasks, - overdueTasks: taskVM.overdueTasks, + overdueTasks: [], calEvents: todayCalEvents, workout: workoutVM.program(for: Date()), onWorkoutTap: { @@ -131,6 +141,7 @@ struct TodayView: View { .padding(.trailing, 20) .padding(.bottom, 10) } + .background(AppColors.background(cs).ignoresSafeArea()) .sheet(isPresented: $showAddTask) { AddTaskSheet() .environmentObject(taskVM) @@ -144,8 +155,12 @@ struct TodayView: View { .presentationDragIndicator(.visible) } .onAppear { + calStore.refreshStatus() calStore.fetchMonth(containing: Date()) } + .onChange(of: scenePhase) { phase in + if phase == .active { calStore.refreshStatus() } + } } } @@ -1015,13 +1030,6 @@ struct TaskRowView: View { Spacer() - if task.priority != .none { - Image(systemName: "flag.fill") - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(task.priority.color) - .padding(.trailing, 4) - } - TagChip( text: task.category.rawValue, color: task.taskColor.color(), @@ -1140,6 +1148,7 @@ struct NLParsed { var recurrenceLabel: String? = nil var endDate: Date? = nil var isAllDay: Bool = false + var isBirthday: Bool = false var hasDate: Bool { date != nil } var dateLabel: String { @@ -1175,6 +1184,15 @@ struct NLTaskParser { 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 @@ -1466,7 +1484,7 @@ struct AddTaskSheet: View { @State private var parsed = NLParsed() @State private var selectedQuadrant: Quadrant @State private var selectedCategory: TaskCategory = .reminder - @State private var selectedPriority: Priority = .none + @State private var reminderDate: Date? = nil @State private var showDatePicker = false var prefilledDate: Date? @@ -1496,7 +1514,10 @@ struct AddTaskSheet: View { .onChange(of: rawText) { _ in var p = NLTaskParser.parse(rawText) if p.date == nil { p.date = Calendar.current.startOfDay(for: Date()) } - withAnimation(KisaniSpring.micro) { parsed = p } + withAnimation(KisaniSpring.micro) { + parsed = p + if p.isBirthday { selectedCategory = .birthday } + } } .padding(.horizontal, 16) .padding(.top, 14) @@ -1540,22 +1561,27 @@ struct AddTaskSheet: View { Spacer(minLength: 4) - // Priority + // Quadrant Menu { - ForEach(Priority.allCases) { p in - Button { withAnimation(KisaniSpring.micro) { selectedPriority = p } } label: { + ForEach(Quadrant.allCases) { q in + Button { withAnimation(KisaniSpring.micro) { selectedQuadrant = q } } label: { Label { - Text(p.label) + Text(q.matrixLabel) } icon: { - Image(uiImage: priorityFlagImage(p)) + Image(uiImage: quadrantBadgeImage(q)) } } } } label: { - Image(systemName: selectedPriority == .none ? "flag" : "flag.fill") - .font(.system(size: 16)) - .foregroundColor(selectedPriority == .none ? AppColors.text3(cs) : selectedPriority.color) - .frame(width: 42, height: 44) + HStack(spacing: 3) { + Text(selectedQuadrant.roman) + .font(AppFonts.mono(8, weight: .heavy)) + .foregroundColor(.white) + .frame(width: 14, height: 14) + .background(selectedQuadrant.color) + .clipShape(Circle()) + } + .frame(width: 42, height: 44) } .buttonStyle(.plain) @@ -1620,7 +1646,8 @@ struct AddTaskSheet: View { isRecurring: $parsed.isRecurring, recurrenceLabel: $parsed.recurrenceLabel, endDate: $parsed.endDate, - isAllDay: $parsed.isAllDay + isAllDay: $parsed.isAllDay, + reminderDate: $reminderDate ) } } @@ -1629,24 +1656,32 @@ struct AddTaskSheet: View { guard canAdd else { return } taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime, quadrant: selectedQuadrant, category: selectedCategory, - isRecurring: parsed.isRecurring, + isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel, endDate: parsed.endDate, isAllDay: parsed.isAllDay, - priority: selectedPriority) + reminderDate: reminderDate) dismiss() } - private func priorityFlagImage(_ p: Priority) -> UIImage { - let name: String + private func quadrantBadgeImage(_ q: Quadrant) -> UIImage { let uiColor: UIColor - switch p { - case .high: name = "flag.fill"; uiColor = .systemRed - case .medium: name = "flag.fill"; uiColor = .systemYellow - case .low: name = "flag.fill"; uiColor = .systemBlue - case .none: name = "flag"; uiColor = .systemGray3 + switch q { + case .urgent: uiColor = UIColor(AppColors.accent) + case .schedule: uiColor = UIColor(AppColors.yellow) + case .delegate_: uiColor = UIColor(AppColors.blue) + case .eliminate: uiColor = UIColor(AppColors.green) } - let cfg = UIImage.SymbolConfiguration(pointSize: 17, weight: .medium) - return (UIImage(systemName: name, withConfiguration: cfg) ?? UIImage()) - .withTintColor(uiColor, renderingMode: .alwaysOriginal) + let size = CGSize(width: 20, height: 20) + let renderer = UIGraphicsImageRenderer(size: size) + let img = renderer.image { ctx in + uiColor.setFill() + UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).fill() + let label = q.roman + let font = UIFont.monospacedSystemFont(ofSize: 8, weight: .heavy) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] + let ts = label.size(withAttributes: attrs) + label.draw(at: CGPoint(x: (size.width - ts.width) / 2, y: (size.height - ts.height) / 2), withAttributes: attrs) + } + return img.withRenderingMode(.alwaysOriginal) } } @@ -1659,6 +1694,7 @@ struct TaskDatePickerSheet: View { @Binding var recurrenceLabel: String? @Binding var endDate: Date? @Binding var isAllDay: Bool + @Binding var reminderDate: Date? @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var cs @@ -1667,23 +1703,28 @@ struct TaskDatePickerSheet: View { @State private var localEndDate: Date @State private var localTime: Date? @State private var showTimePicker = false + @State private var localReminder: Date? + @State private var showReminderPicker = false @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) { + endDate: Binding, isAllDay: Binding, + reminderDate: Binding = .constant(nil)) { _selectedDate = selectedDate _hasTime = hasTime _isRecurring = isRecurring _recurrenceLabel = recurrenceLabel _endDate = endDate _isAllDay = isAllDay + _reminderDate = reminderDate let start = selectedDate.wrappedValue ?? Calendar.current.startOfDay(for: Date()) _localDate = State(initialValue: start) _localEndDate = State(initialValue: endDate.wrappedValue ?? start) _localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil) + _localReminder = State(initialValue: reminderDate.wrappedValue) _localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None") _localIsAllDay = State(initialValue: isAllDay.wrappedValue) _pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0) @@ -1702,7 +1743,8 @@ struct TaskDatePickerSheet: View { } Button { - selectedDate = nil; hasTime = false; endDate = nil; dismiss() + selectedDate = nil; hasTime = false; endDate = nil + reminderDate = nil; dismiss() } label: { Text("Clear") .font(AppFonts.sans(16, weight: .medium)) @@ -1762,7 +1804,7 @@ struct TaskDatePickerSheet: View { @ViewBuilder private var optionsCard: some View { VStack(spacing: 0) { - Button { withAnimation(KisaniSpring.micro) { showTimePicker.toggle() } } label: { + Button { withAnimation(KisaniSpring.micro) { showTimePicker.toggle(); if showTimePicker { showReminderPicker = false } } } label: { pickerRow(icon: "clock", label: "Time", value: localTime != nil ? timeFmt.string(from: localTime!) : "None") } @@ -1778,17 +1820,46 @@ struct TaskDatePickerSheet: View { } Divider().padding(.leading, 54) - pickerRow(icon: "alarm", label: "Reminder", value: "None") + + Button { withAnimation(KisaniSpring.micro) { showReminderPicker.toggle(); if showReminderPicker { showTimePicker = false } } } label: { + pickerRow(icon: "alarm", label: "Reminder", + value: localReminder != nil ? timeFmt.string(from: localReminder!) : "None", + active: localReminder != nil) + } + .buttonStyle(.plain) + + if showReminderPicker { + DatePicker("", selection: Binding( + get: { localReminder ?? Calendar.current.date(bySettingHour: 9, minute: 0, second: 0, of: localDate) ?? localDate }, + set: { localReminder = $0 } + ), displayedComponents: .hourAndMinute) + .datePickerStyle(.wheel).labelsHidden() + .transition(.opacity.combined(with: .move(edge: .top))) + + Button { + withAnimation(KisaniSpring.micro) { + localReminder = nil + showReminderPicker = false + } + } label: { + Text("Clear Reminder") + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.accent) + } + .padding(.bottom, 8) + } + 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" : "") + 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: localRepeat) + pickerRow(icon: "arrow.clockwise", label: "Repeat", + value: repeatDisplayValue, active: localRepeat != "None") } .buttonStyle(.plain) } @@ -1870,21 +1941,54 @@ struct TaskDatePickerSheet: View { } @ViewBuilder - private func pickerRow(icon: String, label: String, value: String) -> some View { + 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(.secondary).frame(width: 24) + .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(.secondary) + .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 } @@ -1919,13 +2023,23 @@ struct TaskDatePickerSheet: View { selectedDate = final endDate = nil isAllDay = false - isRecurring = localRepeat != "None" + isRecurring = localRepeat != "None" recurrenceLabel = localRepeat != "None" ? localRepeat : nil + // Combine reminder time with the selected date + if let r = localReminder { + let rc = Calendar.current.dateComponents([.hour, .minute], from: r) + reminderDate = Calendar.current.date(bySettingHour: rc.hour ?? 9, + minute: rc.minute ?? 0, + second: 0, of: localDate) + } else { + reminderDate = nil + } } else { selectedDate = Calendar.current.startOfDay(for: localDate) endDate = Calendar.current.startOfDay(for: localEndDate) isAllDay = localIsAllDay hasTime = false + reminderDate = nil } } }