feat: calendar settings, matrix drill-down, FAB fix, in-app add calendar

- 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 <tqwyy79vzn@privaterelay.appleid.com>
This commit is contained in:
Robin Kutesa
2026-06-01 13:14:14 +03:00
parent 8bf4d91053
commit f96f09ec29
6 changed files with 1542 additions and 269 deletions

View File

@@ -22,10 +22,14 @@ final class AuthManager: NSObject, ObservableObject {
private let keychainUser = "kisani.auth.user.v1" private let keychainUser = "kisani.auth.user.v1"
private let keychainEmail = "kisani.auth.apple.email." private let keychainEmail = "kisani.auth.apple.email."
nonisolated static let activeUserIdKey = "kisani.activeUserId"
override init() { override init() {
super.init() super.init()
currentUser = loadUser() currentUser = loadUser()
if let uid = currentUser?.id {
UserDefaults.standard.set(uid, forKey: Self.activeUserIdKey)
}
} }
var isAuthenticated: Bool { currentUser != nil } var isAuthenticated: Bool { currentUser != nil }
@@ -71,6 +75,7 @@ final class AuthManager: NSObject, ObservableObject {
func signOut() { func signOut() {
currentUser = nil currentUser = nil
UserDefaults.standard.removeObject(forKey: Self.activeUserIdKey)
deleteKeychain(key: keychainUser) deleteKeychain(key: keychainUser)
} }
@@ -78,6 +83,7 @@ final class AuthManager: NSObject, ObservableObject {
private func persist(_ user: AppUser) { private func persist(_ user: AppUser) {
currentUser = user currentUser = user
UserDefaults.standard.set(user.id, forKey: Self.activeUserIdKey)
if let data = try? JSONEncoder().encode(user) { if let data = try? JSONEncoder().encode(user) {
saveKeychain(key: keychainUser, data: data) saveKeychain(key: keychainUser, data: data)
} }

View File

@@ -37,9 +37,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
} }
// MARK: - Complete task directly in UserDefaults (called from background) // MARK: - Complete task directly in UserDefaults (called from background)
private func markTaskComplete(taskId: String) { private func markTaskComplete(taskId: String, userId: String) {
let uid = AuthManager.shared.currentUser?.id ?? "shared" let key = "kisani.tasks.v2.\(userId)"
let key = "kisani.tasks.v2.\(uid)"
guard let data = UserDefaults.standard.data(forKey: key), guard let data = UserDefaults.standard.data(forKey: key),
var tasks = try? JSONDecoder().decode([TaskItem].self, from: data), var tasks = try? JSONDecoder().decode([TaskItem].self, from: data),
let uuid = UUID(uuidString: taskId), let uuid = UUID(uuidString: taskId),
@@ -171,9 +170,11 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
let cal = Calendar.current let cal = Calendar.current
for task in snapshot where !task.isComplete { 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 let fireDate: Date
if let reminder = task.reminderDate {
fireDate = reminder
} else if let due = task.dueDate {
if task.hasTime { if task.hasTime {
fireDate = due fireDate = due
} else { } else {
@@ -181,6 +182,9 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
c.hour = 9; c.minute = 0 c.hour = 9; c.minute = 0
fireDate = cal.date(from: c) ?? due fireDate = cal.date(from: c) ?? due
} }
} else {
continue
}
guard fireDate > now else { continue } guard fireDate > now else { continue }
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
@@ -241,6 +245,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
didReceive response: UNNotificationResponse, didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void withCompletionHandler completionHandler: @escaping () -> Void
) { ) {
let userId = UserDefaults.standard.string(forKey: AuthManager.activeUserIdKey) ?? "shared"
let info = response.notification.request.content.userInfo let info = response.notification.request.content.userInfo
let deeplink = info["deeplink"] as? String let deeplink = info["deeplink"] as? String
let taskId = info["taskId"] as? String let taskId = info["taskId"] as? String
@@ -249,11 +254,11 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
case Self.actionMarkId: case Self.actionMarkId:
// Swiped action: mark complete, don't open app // Swiped action: mark complete, don't open app
if let taskId { markTaskComplete(taskId: taskId) } if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
default: default:
// Tapped notification body // Tapped notification body
if let taskId { markTaskComplete(taskId: taskId) } if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
Task { @MainActor in Task { @MainActor in
switch deeplink { switch deeplink {
case "workout": ShortcutHandler.shared.handle(QuickAction.workout.rawValue) case "workout": ShortcutHandler.shared.handle(QuickAction.workout.rawValue)

View File

@@ -12,10 +12,12 @@ struct TaskItem: Identifiable, Codable, Equatable {
var category: TaskCategory = .reminder var category: TaskCategory = .reminder
var taskColor: TaskColor = .orange var taskColor: TaskColor = .orange
var isRecurring: Bool = false var isRecurring: Bool = false
var recurrenceLabel: String? = nil
var isPinned: Bool = false var isPinned: Bool = false
var endDate: Date? = nil var endDate: Date? = nil
var isAllDay: Bool = false var isAllDay: Bool = false
var priority: Priority = .none var priority: Priority = .none
var reminderDate: Date? = nil
} }
enum Priority: String, Codable, CaseIterable, Identifiable { enum Priority: String, Codable, CaseIterable, Identifiable {
@@ -50,6 +52,42 @@ enum Quadrant: String, CaseIterable, Identifiable, Codable {
case schedule = "Schedule It" case schedule = "Schedule It"
case delegate_ = "Delegate" case delegate_ = "Delegate"
case eliminate = "Eliminate" 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 { enum TaskCategory: String, Codable {
@@ -189,13 +227,30 @@ class TaskViewModel: ObservableObject {
func addTask(title: String, dueDate: Date?, hasTime: Bool = false, func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder, quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
taskColor: TaskColor = .orange, isRecurring: Bool = false, taskColor: TaskColor = .orange, isRecurring: Bool = false,
recurrenceLabel: String? = nil,
endDate: Date? = nil, isAllDay: Bool = false, endDate: Date? = nil, isAllDay: Bool = false,
priority: Priority = .none) { priority: Priority = .none, reminderDate: Date? = nil) {
tasks.append(TaskItem(title: title, dueDate: dueDate, hasTime: hasTime, tasks.append(TaskItem(title: title, dueDate: dueDate, hasTime: hasTime,
quadrant: quadrant, category: category, quadrant: quadrant, category: category,
taskColor: taskColor, isRecurring: isRecurring, taskColor: taskColor, isRecurring: isRecurring,
recurrenceLabel: recurrenceLabel,
endDate: endDate, isAllDay: isAllDay, 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() save()
} }

View File

@@ -11,20 +11,43 @@ final class CalendarStore: ObservableObject {
private var changeToken: NSObjectProtocol? private var changeToken: NSObjectProtocol?
private var fetchedMonth: Date? private var fetchedMonth: Date?
@Published var localCalendars: [EKCalendar] = []
@Published var hiddenCalendarIDs: Set<String> = []
@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() { init() {
authStatus = EKEventStore.authorizationStatus(for: .event) 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( changeToken = NotificationCenter.default.addObserver(
forName: .EKEventStoreChanged, forName: .EKEventStoreChanged,
object: ekStore, object: ekStore,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self, let m = self.fetchedMonth else { return } guard let self else { return }
self.fetchMonth(containing: m) // 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 { deinit {
if let t = changeToken { NotificationCenter.default.removeObserver(t) } if let t = changeToken { NotificationCenter.default.removeObserver(t) }
} }
@@ -36,14 +59,14 @@ final class CalendarStore: ObservableObject {
ekStore.requestFullAccessToEvents { [weak self] granted, _ in ekStore.requestFullAccessToEvents { [weak self] granted, _ in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
self?.authStatus = granted ? .fullAccess : .denied self?.authStatus = granted ? .fullAccess : .denied
if granted { self?.fetchMonth(containing: Date()) } if granted { self?.fetchMonth(containing: Date()); self?.loadLocalCalendars() }
} }
} }
} else { } else {
ekStore.requestAccess(to: .event) { [weak self] granted, _ in ekStore.requestAccess(to: .event) { [weak self] granted, _ in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
self?.authStatus = granted ? .authorized : .denied self?.authStatus = granted ? .authorized : .denied
if granted { self?.fetchMonth(containing: Date()) } if granted { self?.fetchMonth(containing: Date()); self?.loadLocalCalendars() }
} }
} }
} }
@@ -55,11 +78,14 @@ final class CalendarStore: ObservableObject {
guard isAuthorized else { return } guard isAuthorized else { return }
let cal = Calendar.current let cal = Calendar.current
guard let interval = cal.dateInterval(of: .month, for: date) else { return } 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 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 end = cal.date(byAdding: .weekOfYear, value: 1, to: interval.end) ?? interval.end
let pred = ekStore.predicateForEvents(withStart: start, end: end, calendars: nil)
fetchedMonth = date fetchedMonth = date
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) monthEvents = ekStore.events(matching: pred)
.sorted { ($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture) } .sorted { ($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture) }
} }
@@ -81,6 +107,64 @@ final class CalendarStore: ObservableObject {
} }
return authStatus == .authorized 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 // MARK: - Calendar View Mode
@@ -107,6 +191,7 @@ enum CalendarViewMode: String, CaseIterable {
// MARK: - Calendar View // MARK: - Calendar View
struct CalendarView: View { struct CalendarView: View {
@Environment(\.colorScheme) var cs @Environment(\.colorScheme) var cs
@Environment(\.scenePhase) private var scenePhase
@EnvironmentObject var taskVM: TaskViewModel @EnvironmentObject var taskVM: TaskViewModel
@EnvironmentObject var workoutVM: WorkoutViewModel @EnvironmentObject var workoutVM: WorkoutViewModel
@StateObject private var calStore = CalendarStore() @StateObject private var calStore = CalendarStore()
@@ -126,8 +211,6 @@ struct CalendarView: View {
var body: some View { var body: some View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
AppColors.background(cs).ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
// Header // Header
HStack(spacing: 0) { HStack(spacing: 0) {
@@ -163,8 +246,8 @@ struct CalendarView: View {
Button { Button {
withAnimation(KisaniSpring.snappy) { calTaskListMode.toggle() } withAnimation(KisaniSpring.snappy) { calTaskListMode.toggle() }
} label: { } label: {
Image(systemName: calTaskListMode ? "calendar" : "slider.horizontal.3") Image(systemName: calTaskListMode ? "calendar.day.timeline.left" : "list.bullet.below.rectangle")
.font(.system(size: 14, weight: calTaskListMode ? .semibold : .medium)) .font(.system(size: 15, weight: .medium))
.foregroundColor(calTaskListMode ? AppColors.accent : AppColors.text(cs)) .foregroundColor(calTaskListMode ? AppColors.accent : AppColors.text(cs))
.frame(width: 46, height: 36) .frame(width: 46, height: 36)
.contentShape(Rectangle()) .contentShape(Rectangle())
@@ -200,18 +283,18 @@ struct CalendarView: View {
} }
.padding(.horizontal, 18).padding(.top, 8) .padding(.horizontal, 18).padding(.top, 8)
calendarContent
if calTaskListMode { if calTaskListMode {
cvWeekStrip.padding(.top, 12).padding(.bottom, 6)
Divider().background(AppColors.border(cs)).padding(.horizontal, 16) Divider().background(AppColors.border(cs)).padding(.horizontal, 16)
calDayTaskListView calDayTaskListView
} else {
calendarContent
} }
} }
FAB { showAddTask = true } FAB { showAddTask = true }
.padding(.trailing, 20).padding(.bottom, 10) .padding(.trailing, 20).padding(.bottom, 10)
} }
.background(AppColors.background(cs).ignoresSafeArea())
.sheet(isPresented: $showAddTask) { .sheet(isPresented: $showAddTask) {
AddTaskSheet(prefilledDate: selectedDate) AddTaskSheet(prefilledDate: selectedDate)
.environmentObject(taskVM) .environmentObject(taskVM)
@@ -220,7 +303,7 @@ struct CalendarView: View {
} }
.sheet(isPresented: $showCalendarConnect) { .sheet(isPresented: $showCalendarConnect) {
CalendarConnectSheet(calStore: calStore) CalendarConnectSheet(calStore: calStore)
.presentationDetents([.height(340)]) .presentationDetents([.large])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
} }
.sheet(isPresented: $showWorkoutSession) { .sheet(isPresented: $showWorkoutSession) {
@@ -230,11 +313,15 @@ struct CalendarView: View {
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
} }
.onAppear { .onAppear {
calStore.refreshStatus()
if calStore.isAuthorized { calStore.fetchMonth(containing: displayMonth) } if calStore.isAuthorized { calStore.fetchMonth(containing: displayMonth) }
} }
.onChange(of: displayMonth) { newMonth in .onChange(of: displayMonth) { newMonth in
if calStore.isAuthorized { calStore.fetchMonth(containing: newMonth) } if calStore.isAuthorized { calStore.fetchMonth(containing: newMonth) }
} }
.onChange(of: scenePhase) { phase in
if phase == .active { calStore.refreshStatus() }
}
} }
private var monthTitle: String { private var monthTitle: String {
@@ -803,23 +890,13 @@ struct CalendarConnectSheet: View {
@ObservedObject var calStore: CalendarStore @ObservedObject var calStore: CalendarStore
@Environment(\.colorScheme) var cs @Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@Environment(\.openURL) var openURL
@State private var webCalURL = ""
@FocusState private var webCalFocused: Bool
var body: some View { var body: some View {
VStack(spacing: 0) { NavigationStack {
HStack { ScrollView {
Text("Calendars").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs)) VStack(spacing: 20) {
Spacer() // Connect prompt if not yet authorized
Button("Done") { dismiss() }.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent).buttonStyle(.plain) if !calStore.isAuthorized {
}
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
Divider().background(AppColors.border(cs))
VStack(spacing: 0) {
// Local Calendar row
HStack(spacing: 14) { HStack(spacing: 14) {
Image(systemName: "calendar") Image(systemName: "calendar")
.font(.system(size: 16)) .font(.system(size: 16))
@@ -827,19 +904,12 @@ struct CalendarConnectSheet: View {
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
.background(AppColors.accent) .background(AppColors.accent)
.clipShape(RoundedRectangle(cornerRadius: 9)) .clipShape(RoundedRectangle(cornerRadius: 9))
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("iPhone Calendar").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) 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") Text("Tap to connect and show events").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
.font(AppFonts.sans(11)).foregroundColor(calStore.isAuthorized ? AppColors.green : AppColors.text3(cs))
} }
Spacer() Spacer()
if calStore.isAuthorized { Button("Connect") { calStore.requestAccess() }
Image(systemName: "checkmark.circle.fill").foregroundColor(AppColors.green).font(.system(size: 18))
} else {
Button("Connect") {
calStore.requestAccess()
}
.font(AppFonts.mono(9.5, weight: .bold)) .font(AppFonts.mono(9.5, weight: .bold))
.foregroundColor(AppColors.accent) .foregroundColor(AppColors.accent)
.padding(.horizontal, 10).padding(.vertical, 5) .padding(.horizontal, 10).padding(.vertical, 5)
@@ -847,74 +917,210 @@ struct CalendarConnectSheet: View {
.clipShape(Capsule()) .clipShape(Capsule())
.buttonStyle(.plain) .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)
}
.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)
}
}
}
}
}
// MARK: - Local Calendars View
struct LocalCalendarsView: View {
@ObservedObject var calStore: CalendarStore
@Environment(\.colorScheme) var 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()
Toggle("", isOn: Binding(
get: { calStore.localCalendarsEnabled },
set: { calStore.setLocalCalendarsEnabled($0) }
))
.tint(AppColors.accent)
.labelsHidden()
} }
.padding(.horizontal, 16).padding(.vertical, 12) .padding(.horizontal, 16).padding(.vertical, 12)
.background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
.padding(.horizontal, 20)
Divider().background(AppColors.border(cs)).padding(.leading, 66) if calStore.localCalendarsEnabled {
let groups = calStore.groupedCalendars()
// WebCal row 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) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 14) { Text(group.title)
Image(systemName: "globe") .font(AppFonts.sans(11, weight: .semibold))
.font(.system(size: 16)) .foregroundColor(AppColors.text3(cs))
.foregroundColor(.white) .padding(.horizontal, 20)
.frame(width: 36, height: 36)
.background(AppColors.blue) VStack(spacing: 0) {
.clipShape(RoundedRectangle(cornerRadius: 9)) ForEach(Array(group.calendars.enumerated()), id: \.element.calendarIdentifier) { idx, cal in
VStack(alignment: .leading, spacing: 2) { HStack(spacing: 12) {
Text("Web Calendar (WebCal)").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) Circle()
Text("Subscribe to an iCal / .ics feed").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) .fill(Color(cgColor: cal.cgColor))
} .frame(width: 12, height: 12)
Text(cal.title)
.font(AppFonts.sans(13))
.foregroundColor(AppColors.text(cs))
Spacer() Spacer()
Button(calStore.isCalendarVisible(cal) ? "Hide" : "Show") {
calStore.toggleCalendarVisibility(cal)
} }
.padding(.horizontal, 16).padding(.top, 12) .font(AppFonts.sans(12, weight: .semibold))
.foregroundColor(calStore.isCalendarVisible(cal) ? AppColors.text3(cs) : AppColors.accent)
.buttonStyle(.plain)
}
.padding(.horizontal, 16).padding(.vertical, 13)
HStack(spacing: 8) { if idx < group.calendars.count - 1 {
TextField("webcal:// or https://…", text: $webCalURL) Divider().background(AppColors.border(cs)).padding(.leading, 40)
.font(AppFonts.sans(12)).foregroundColor(AppColors.text(cs)) }
.focused($webCalFocused).padding(10) }
.background(AppColors.surface2(cs)) }
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) .background(AppColors.surface(cs))
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(webCalFocused ? AppColors.accent : AppColors.border(cs), lineWidth: 1)) .clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.autocorrectionDisabled().textInputAutocapitalization(.never) .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
.padding(.horizontal, 20)
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)
} }
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) 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))
}
Spacer()
Image(systemName: "arrow.up.right").font(.system(size: 11)).foregroundColor(AppColors.text3(cs))
}
.padding(.horizontal, 16).padding(.vertical, 12)
.contentShape(Rectangle())
.onTapGesture {
if let url = URL(string: "googlegmail://") {
openURL(url)
} }
} }
} }
@@ -922,10 +1128,230 @@ struct CalendarConnectSheet: View {
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) .clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1)) .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
.padding(.horizontal, 20).padding(.top, 16) .padding(.horizontal, 20).padding(.top, 16)
Spacer(minLength: 32)
Spacer()
} }
.background(AppColors.background(cs).ignoresSafeArea()) .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)
} }
} }

View File

@@ -4,53 +4,126 @@ struct MatrixView: View {
@Environment(\.colorScheme) var cs @Environment(\.colorScheme) var cs
@EnvironmentObject var taskVM: TaskViewModel @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 { var body: some View {
NavigationStack {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
AppColors.background(cs).ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
// Header // Header
HStack { HStack {
Text("Eisenhower Matrix") Text("Eisenhower Matrix")
.font(AppFonts.sans(15, weight: .semibold)) .font(AppFonts.sans(17, weight: .bold))
.foregroundColor(AppColors.text(cs)) .foregroundColor(AppColors.text(cs))
Spacer() 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(.horizontal, 18)
.padding(.top, 12) .padding(.top, 12)
.padding(.bottom, 10) .padding(.bottom, 10)
// 2×2 Grid fixed equal quadrants // 2×2 Grid
VStack(spacing: 7) { VStack(spacing: 8) {
HStack(spacing: 7) { HStack(spacing: 8) {
QuadrantCard( QuadrantCard(
quadrant: .urgent,
roman: "I", label: "Urgent & Important", roman: "I", label: "Urgent & Important",
badgeBg: AppColors.accentSoft, badgeColor: AppColors.accent, badgeBg: AppColors.accentSoft, badgeColor: AppColors.accent,
tasks: taskVM.tasks(for: .urgent) tasks: taskVM.tasks(for: .urgent).filter { !hideCompleted || !$0.isComplete },
) { taskVM.toggle($0) } 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( QuadrantCard(
quadrant: .schedule,
roman: "II", label: "Not Urgent & Important", roman: "II", label: "Not Urgent & Important",
badgeBg: AppColors.yellowSoft, badgeColor: AppColors.yellow, badgeBg: AppColors.yellowSoft, badgeColor: AppColors.yellow,
tasks: taskVM.tasks(for: .schedule) tasks: taskVM.tasks(for: .schedule).filter { !hideCompleted || !$0.isComplete },
) { taskVM.toggle($0) } 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) .frame(maxHeight: .infinity)
HStack(spacing: 7) { HStack(spacing: 8) {
QuadrantCard( QuadrantCard(
quadrant: .delegate_,
roman: "III", label: "Urgent & Unimportant", roman: "III", label: "Urgent & Unimportant",
badgeBg: AppColors.blueSoft, badgeColor: AppColors.blue, badgeBg: AppColors.blueSoft, badgeColor: AppColors.blue,
tasks: taskVM.tasks(for: .delegate_) tasks: taskVM.tasks(for: .delegate_).filter { !hideCompleted || !$0.isComplete },
) { taskVM.toggle($0) } 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( QuadrantCard(
quadrant: .eliminate,
roman: "IV", label: "Not Urgent & Unimportant", roman: "IV", label: "Not Urgent & Unimportant",
badgeBg: AppColors.greenSoft, badgeColor: AppColors.green, badgeBg: AppColors.greenSoft, badgeColor: AppColors.green,
tasks: taskVM.tasks(for: .eliminate) tasks: taskVM.tasks(for: .eliminate).filter { !hideCompleted || !$0.isComplete },
) { taskVM.toggle($0) } 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) .frame(maxHeight: .infinity)
} }
@@ -64,125 +137,296 @@ struct MatrixView: View {
.padding(.trailing, 20) .padding(.trailing, 20)
.padding(.bottom, 10) .padding(.bottom, 10)
} }
.background(AppColors.background(cs).ignoresSafeArea())
.sheet(isPresented: $showAddTask) { .sheet(isPresented: $showAddTask) {
AddTaskSheet() AddTaskSheet()
.environmentObject(taskVM) .environmentObject(taskVM)
.presentationDetents([.height(150)]) .presentationDetents([.height(150)])
.presentationDragIndicator(.visible) .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 // MARK: - Quadrant Card
struct QuadrantCard: View { struct QuadrantCard: View {
@Environment(\.colorScheme) private var cs @Environment(\.colorScheme) private var cs
let quadrant: Quadrant
let roman: String let roman: String
let label: String let label: String
let badgeBg: Color let badgeBg: Color
let badgeColor: Color let badgeColor: Color
let tasks: [TaskItem] let tasks: [TaskItem]
let isDropTarget: Bool
let onToggle: (TaskItem) -> Void 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 = { private let shortFmt: DateFormatter = {
let f = DateFormatter() let f = DateFormatter(); f.dateFormat = "d MMM"; return f
f.dateFormat = "d MMM"
return f
}() }()
private let shortFmtYear: DateFormatter = { private let shortFmtYear: DateFormatter = {
let f = DateFormatter() let f = DateFormatter(); f.dateFormat = "d/MM/yyyy"; return f
f.dateFormat = "d/MM/yyyy"
return f
}() }()
private func dateLabel(_ date: Date) -> String { private func dateLabel(_ date: Date) -> String {
let cal = Calendar.current Calendar.current.isDateInThisYear(date) ? shortFmt.string(from: date) : shortFmtYear.string(from: date)
if cal.isDateInThisYear(date) {
return shortFmt.string(from: date)
}
return shortFmtYear.string(from: date)
} }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Header badge // Header badge
HStack(spacing: 4) { HStack(spacing: 0) {
HStack(spacing: 5) {
Text(roman) Text(roman)
.font(AppFonts.mono(7.5, weight: .heavy)) .font(AppFonts.mono(8, weight: .heavy))
.foregroundColor(.white) .foregroundColor(.white)
.frame(width: 13, height: 13) .frame(width: 15, height: 15)
.background(badgeColor) .background(badgeColor)
.clipShape(Circle()) .clipShape(Circle())
Text(label.uppercased()) Text(label.uppercased())
.font(AppFonts.mono(8, weight: .heavy)) .font(AppFonts.mono(8.5, weight: .bold))
.foregroundColor(badgeColor) .foregroundColor(badgeColor)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.65) .minimumScaleFactor(0.65)
if onTapHeader != nil {
Image(systemName: "chevron.right")
.font(.system(size: 7, weight: .bold))
.foregroundColor(badgeColor.opacity(0.7))
} }
.padding(.horizontal, 7) }
.padding(.vertical, 3) .padding(.horizontal, 8)
.padding(.vertical, 4)
.background(badgeBg) .background(badgeBg)
.cornerRadius(20) .clipShape(Capsule())
.padding(.bottom, 7) .contentShape(Capsule())
.onTapGesture { onTapHeader?() }
Spacer()
}
.padding(.bottom, 8)
// Scrollable task list // Task list
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Color.clear.frame(height: 0).trackScrollForTabBar() Color.clear.frame(height: 0).trackScrollForTabBar()
ForEach(tasks) { task in ForEach(tasks) { task in
HStack(alignment: .top, spacing: 6) { HStack(alignment: .top, spacing: 8) {
RoundedRectangle(cornerRadius: 3) // Circle checkbox
.stroke( ZStack {
task.isComplete ? AppColors.border(cs) : badgeColor, Circle()
lineWidth: 1.5 .fill(task.isComplete ? badgeColor.opacity(0.15) : badgeBg)
) .frame(width: 16, height: 16)
.background( Circle()
task.isComplete .stroke(task.isComplete ? badgeColor.opacity(0.4) : badgeColor, lineWidth: 1.5)
? RoundedRectangle(cornerRadius: 3) .frame(width: 16, height: 16)
.fill(AppColors.border(cs).opacity(0.5)) if task.isComplete {
: nil Image(systemName: "checkmark")
) .font(.system(size: 7, weight: .bold))
.overlay( .foregroundColor(badgeColor)
task.isComplete }
? Image(systemName: "checkmark") }
.font(.system(size: 6.5, weight: .bold)) .padding(.top, 1)
.foregroundColor(AppColors.text3(cs))
: nil
)
.frame(width: 13, height: 13)
.padding(.top, 1.5)
.contentShape(Rectangle().size(CGSize(width: 28, height: 28)))
.onTapGesture { .onTapGesture {
withAnimation(KisaniSpring.bounce) { onToggle(task) } withAnimation(KisaniSpring.bounce) { onToggle(task) }
} }
VStack(alignment: .leading, spacing: 1) { 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) Text(task.title)
.font(AppFonts.sans(10.5)) .font(AppFonts.sans(12, weight: .medium))
.foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs)) .foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
.strikethrough(task.isComplete, color: AppColors.text3(cs)) .strikethrough(task.isComplete, color: AppColors.text3(cs))
.lineLimit(2) .lineLimit(2)
}
if let d = task.dueDate { if let d = task.dueDate {
Text(dateLabel(d)) Text(dateLabel(d))
.font(AppFonts.mono(8.5)) .font(AppFonts.mono(9.5))
.foregroundColor(task.isComplete ? AppColors.text3(cs) : badgeColor) .foregroundColor(task.isComplete ? AppColors.text3(cs) : badgeColor)
} }
} }
} }
.opacity(task.isComplete ? 0.38 : 1) .opacity(task.isComplete ? 0.4 : 1)
.padding(.vertical, 3.5) .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) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(AppColors.surface(cs)) .background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.medium)) .clipShape(RoundedRectangle(cornerRadius: AppRadius.medium))
.overlay( .overlay(
RoundedRectangle(cornerRadius: AppRadius.medium) 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()) 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<Content: View>: 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()
}
}

View File

@@ -3,6 +3,7 @@ import EventKit
struct TodayView: View { struct TodayView: View {
@Environment(\.colorScheme) var cs @Environment(\.colorScheme) var cs
@Environment(\.scenePhase) private var scenePhase
@EnvironmentObject var taskVM: TaskViewModel @EnvironmentObject var taskVM: TaskViewModel
@EnvironmentObject var workoutVM: WorkoutViewModel @EnvironmentObject var workoutVM: WorkoutViewModel
@StateObject private var calStore = CalendarStore() @StateObject private var calStore = CalendarStore()
@@ -22,8 +23,6 @@ struct TodayView: View {
var body: some View { var body: some View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
AppColors.background(cs).ignoresSafeArea()
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Color.clear.frame(height: 0).trackScrollForTabBar() Color.clear.frame(height: 0).trackScrollForTabBar()
@@ -69,13 +68,24 @@ struct TodayView: View {
.padding(.horizontal, 18) .padding(.horizontal, 18)
.padding(.bottom, 8) .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 activeTasks = taskVM.todayTasks
let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks
DayTimelineView( DayTimelineView(
date: Date(), date: Date(),
tasks: activeTasks + completedTasks, tasks: activeTasks + completedTasks,
overdueTasks: taskVM.overdueTasks, overdueTasks: [],
calEvents: todayCalEvents, calEvents: todayCalEvents,
workout: workoutVM.program(for: Date()), workout: workoutVM.program(for: Date()),
onWorkoutTap: { onWorkoutTap: {
@@ -131,6 +141,7 @@ struct TodayView: View {
.padding(.trailing, 20) .padding(.trailing, 20)
.padding(.bottom, 10) .padding(.bottom, 10)
} }
.background(AppColors.background(cs).ignoresSafeArea())
.sheet(isPresented: $showAddTask) { .sheet(isPresented: $showAddTask) {
AddTaskSheet() AddTaskSheet()
.environmentObject(taskVM) .environmentObject(taskVM)
@@ -144,8 +155,12 @@ struct TodayView: View {
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
} }
.onAppear { .onAppear {
calStore.refreshStatus()
calStore.fetchMonth(containing: Date()) calStore.fetchMonth(containing: Date())
} }
.onChange(of: scenePhase) { phase in
if phase == .active { calStore.refreshStatus() }
}
} }
} }
@@ -1015,13 +1030,6 @@ struct TaskRowView: View {
Spacer() Spacer()
if task.priority != .none {
Image(systemName: "flag.fill")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(task.priority.color)
.padding(.trailing, 4)
}
TagChip( TagChip(
text: task.category.rawValue, text: task.category.rawValue,
color: task.taskColor.color(), color: task.taskColor.color(),
@@ -1140,6 +1148,7 @@ struct NLParsed {
var recurrenceLabel: String? = nil var recurrenceLabel: String? = nil
var endDate: Date? = nil var endDate: Date? = nil
var isAllDay: Bool = false var isAllDay: Bool = false
var isBirthday: Bool = false
var hasDate: Bool { date != nil } var hasDate: Bool { date != nil }
var dateLabel: String { var dateLabel: String {
@@ -1175,6 +1184,15 @@ struct NLTaskParser {
if let r = match(pat, in: lower) { rawRanges.append(r) } 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 // Recurrence
if let (r, label) = parseRecurrence(in: lower) { if let (r, label) = parseRecurrence(in: lower) {
result.isRecurring = true result.isRecurring = true
@@ -1466,7 +1484,7 @@ struct AddTaskSheet: View {
@State private var parsed = NLParsed() @State private var parsed = NLParsed()
@State private var selectedQuadrant: Quadrant @State private var selectedQuadrant: Quadrant
@State private var selectedCategory: TaskCategory = .reminder @State private var selectedCategory: TaskCategory = .reminder
@State private var selectedPriority: Priority = .none @State private var reminderDate: Date? = nil
@State private var showDatePicker = false @State private var showDatePicker = false
var prefilledDate: Date? var prefilledDate: Date?
@@ -1496,7 +1514,10 @@ struct AddTaskSheet: View {
.onChange(of: rawText) { _ in .onChange(of: rawText) { _ in
var p = NLTaskParser.parse(rawText) var p = NLTaskParser.parse(rawText)
if p.date == nil { p.date = Calendar.current.startOfDay(for: Date()) } 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(.horizontal, 16)
.padding(.top, 14) .padding(.top, 14)
@@ -1540,21 +1561,26 @@ struct AddTaskSheet: View {
Spacer(minLength: 4) Spacer(minLength: 4)
// Priority // Quadrant
Menu { Menu {
ForEach(Priority.allCases) { p in ForEach(Quadrant.allCases) { q in
Button { withAnimation(KisaniSpring.micro) { selectedPriority = p } } label: { Button { withAnimation(KisaniSpring.micro) { selectedQuadrant = q } } label: {
Label { Label {
Text(p.label) Text(q.matrixLabel)
} icon: { } icon: {
Image(uiImage: priorityFlagImage(p)) Image(uiImage: quadrantBadgeImage(q))
} }
} }
} }
} label: { } label: {
Image(systemName: selectedPriority == .none ? "flag" : "flag.fill") HStack(spacing: 3) {
.font(.system(size: 16)) Text(selectedQuadrant.roman)
.foregroundColor(selectedPriority == .none ? AppColors.text3(cs) : selectedPriority.color) .font(AppFonts.mono(8, weight: .heavy))
.foregroundColor(.white)
.frame(width: 14, height: 14)
.background(selectedQuadrant.color)
.clipShape(Circle())
}
.frame(width: 42, height: 44) .frame(width: 42, height: 44)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -1620,7 +1646,8 @@ struct AddTaskSheet: View {
isRecurring: $parsed.isRecurring, isRecurring: $parsed.isRecurring,
recurrenceLabel: $parsed.recurrenceLabel, recurrenceLabel: $parsed.recurrenceLabel,
endDate: $parsed.endDate, endDate: $parsed.endDate,
isAllDay: $parsed.isAllDay isAllDay: $parsed.isAllDay,
reminderDate: $reminderDate
) )
} }
} }
@@ -1629,24 +1656,32 @@ struct AddTaskSheet: View {
guard canAdd else { return } guard canAdd else { return }
taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime, taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
quadrant: selectedQuadrant, category: selectedCategory, quadrant: selectedQuadrant, category: selectedCategory,
isRecurring: parsed.isRecurring, isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
endDate: parsed.endDate, isAllDay: parsed.isAllDay, endDate: parsed.endDate, isAllDay: parsed.isAllDay,
priority: selectedPriority) reminderDate: reminderDate)
dismiss() dismiss()
} }
private func priorityFlagImage(_ p: Priority) -> UIImage { private func quadrantBadgeImage(_ q: Quadrant) -> UIImage {
let name: String
let uiColor: UIColor let uiColor: UIColor
switch p { switch q {
case .high: name = "flag.fill"; uiColor = .systemRed case .urgent: uiColor = UIColor(AppColors.accent)
case .medium: name = "flag.fill"; uiColor = .systemYellow case .schedule: uiColor = UIColor(AppColors.yellow)
case .low: name = "flag.fill"; uiColor = .systemBlue case .delegate_: uiColor = UIColor(AppColors.blue)
case .none: name = "flag"; uiColor = .systemGray3 case .eliminate: uiColor = UIColor(AppColors.green)
} }
let cfg = UIImage.SymbolConfiguration(pointSize: 17, weight: .medium) let size = CGSize(width: 20, height: 20)
return (UIImage(systemName: name, withConfiguration: cfg) ?? UIImage()) let renderer = UIGraphicsImageRenderer(size: size)
.withTintColor(uiColor, renderingMode: .alwaysOriginal) 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 recurrenceLabel: String?
@Binding var endDate: Date? @Binding var endDate: Date?
@Binding var isAllDay: Bool @Binding var isAllDay: Bool
@Binding var reminderDate: Date?
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var cs @Environment(\.colorScheme) private var cs
@@ -1667,23 +1703,28 @@ struct TaskDatePickerSheet: View {
@State private var localEndDate: Date @State private var localEndDate: Date
@State private var localTime: Date? @State private var localTime: Date?
@State private var showTimePicker = false @State private var showTimePicker = false
@State private var localReminder: Date?
@State private var showReminderPicker = false
@State private var localRepeat: String @State private var localRepeat: String
@State private var localIsAllDay: Bool @State private var localIsAllDay: Bool
@State private var editingEnd = false @State private var editingEnd = false
init(selectedDate: Binding<Date?>, hasTime: Binding<Bool>, init(selectedDate: Binding<Date?>, hasTime: Binding<Bool>,
isRecurring: Binding<Bool>, recurrenceLabel: Binding<String?>, isRecurring: Binding<Bool>, recurrenceLabel: Binding<String?>,
endDate: Binding<Date?>, isAllDay: Binding<Bool>) { endDate: Binding<Date?>, isAllDay: Binding<Bool>,
reminderDate: Binding<Date?> = .constant(nil)) {
_selectedDate = selectedDate _selectedDate = selectedDate
_hasTime = hasTime _hasTime = hasTime
_isRecurring = isRecurring _isRecurring = isRecurring
_recurrenceLabel = recurrenceLabel _recurrenceLabel = recurrenceLabel
_endDate = endDate _endDate = endDate
_isAllDay = isAllDay _isAllDay = isAllDay
_reminderDate = reminderDate
let start = selectedDate.wrappedValue ?? Calendar.current.startOfDay(for: Date()) let start = selectedDate.wrappedValue ?? Calendar.current.startOfDay(for: Date())
_localDate = State(initialValue: start) _localDate = State(initialValue: start)
_localEndDate = State(initialValue: endDate.wrappedValue ?? start) _localEndDate = State(initialValue: endDate.wrappedValue ?? start)
_localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil) _localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil)
_localReminder = State(initialValue: reminderDate.wrappedValue)
_localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None") _localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None")
_localIsAllDay = State(initialValue: isAllDay.wrappedValue) _localIsAllDay = State(initialValue: isAllDay.wrappedValue)
_pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0) _pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0)
@@ -1702,7 +1743,8 @@ struct TaskDatePickerSheet: View {
} }
Button { Button {
selectedDate = nil; hasTime = false; endDate = nil; dismiss() selectedDate = nil; hasTime = false; endDate = nil
reminderDate = nil; dismiss()
} label: { } label: {
Text("Clear") Text("Clear")
.font(AppFonts.sans(16, weight: .medium)) .font(AppFonts.sans(16, weight: .medium))
@@ -1762,7 +1804,7 @@ struct TaskDatePickerSheet: View {
@ViewBuilder @ViewBuilder
private var optionsCard: some View { private var optionsCard: some View {
VStack(spacing: 0) { 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", pickerRow(icon: "clock", label: "Time",
value: localTime != nil ? timeFmt.string(from: localTime!) : "None") value: localTime != nil ? timeFmt.string(from: localTime!) : "None")
} }
@@ -1778,17 +1820,46 @@ struct TaskDatePickerSheet: View {
} }
Divider().padding(.leading, 54) 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) Divider().padding(.leading, 54)
Menu { Menu {
ForEach(["None", "Daily", "Weekly", "Monthly", "Yearly"], id: \.self) { opt in ForEach(repeatOptions, id: \.key) { opt in
Button { withAnimation(KisaniSpring.micro) { localRepeat = opt } } label: { Button { withAnimation(KisaniSpring.micro) { localRepeat = opt.key } } label: {
Label(opt, systemImage: localRepeat == opt ? "checkmark" : "") Label(opt.display, systemImage: localRepeat == opt.key ? "checkmark" : "")
} }
} }
} label: { } label: {
pickerRow(icon: "arrow.clockwise", label: "Repeat", value: localRepeat) pickerRow(icon: "arrow.clockwise", label: "Repeat",
value: repeatDisplayValue, active: localRepeat != "None")
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -1870,21 +1941,54 @@ struct TaskDatePickerSheet: View {
} }
@ViewBuilder @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) { HStack(spacing: 14) {
Image(systemName: icon) 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) Text(label)
.font(AppFonts.sans(16)).foregroundColor(.primary) .font(AppFonts.sans(16)).foregroundColor(.primary)
Spacer() Spacer()
Text(value) Text(value)
.font(AppFonts.sans(15)).foregroundColor(.secondary) .font(AppFonts.sans(15))
.foregroundColor(active ? AppColors.accent : .secondary)
Image(systemName: "chevron.up.chevron.down") Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11)).foregroundColor(.secondary) .font(.system(size: 11)).foregroundColor(.secondary)
} }
.padding(.horizontal, 16).frame(height: 52) .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 (MonFri)"),
]
}
private var repeatDisplayValue: String {
repeatOptions.first(where: { $0.key == localRepeat })?.display ?? localRepeat
}
private var timeFmt: DateFormatter { private var timeFmt: DateFormatter {
let f = DateFormatter(); f.timeStyle = .short; return f let f = DateFormatter(); f.timeStyle = .short; return f
} }
@@ -1921,11 +2025,21 @@ struct TaskDatePickerSheet: View {
isAllDay = false isAllDay = false
isRecurring = localRepeat != "None" isRecurring = localRepeat != "None"
recurrenceLabel = localRepeat != "None" ? localRepeat : nil recurrenceLabel = localRepeat != "None" ? localRepeat : nil
// Combine reminder time with the selected date
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 { } else {
selectedDate = Calendar.current.startOfDay(for: localDate) selectedDate = Calendar.current.startOfDay(for: localDate)
endDate = Calendar.current.startOfDay(for: localEndDate) endDate = Calendar.current.startOfDay(for: localEndDate)
isAllDay = localIsAllDay isAllDay = localIsAllDay
hasTime = false hasTime = false
reminderDate = nil
} }
} }
} }