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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,9 +170,11 @@ 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 let reminder = task.reminderDate {
|
||||
fireDate = reminder
|
||||
} else if let due = task.dueDate {
|
||||
if task.hasTime {
|
||||
fireDate = due
|
||||
} else {
|
||||
@@ -181,6 +182,9 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
c.hour = 9; c.minute = 0
|
||||
fireDate = cal.date(from: c) ?? due
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
guard fireDate > now else { continue }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
@@ -241,6 +245,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -11,20 +11,43 @@ final class CalendarStore: ObservableObject {
|
||||
private var changeToken: NSObjectProtocol?
|
||||
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() {
|
||||
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,11 +78,14 @@ 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
|
||||
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,23 +890,13 @@ 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)
|
||||
}
|
||||
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
|
||||
|
||||
Divider().background(AppColors.border(cs))
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Local Calendar row
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Connect prompt if not yet authorized
|
||||
if !calStore.isAuthorized {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "calendar")
|
||||
.font(.system(size: 16))
|
||||
@@ -827,19 +904,12 @@ struct CalendarConnectSheet: View {
|
||||
.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))
|
||||
Text("Tap to connect and show events").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
Spacer()
|
||||
if calStore.isAuthorized {
|
||||
Image(systemName: "checkmark.circle.fill").foregroundColor(AppColors.green).font(.system(size: 18))
|
||||
} else {
|
||||
Button("Connect") {
|
||||
calStore.requestAccess()
|
||||
}
|
||||
Button("Connect") { calStore.requestAccess() }
|
||||
.font(AppFonts.mono(9.5, weight: .bold))
|
||||
.foregroundColor(AppColors.accent)
|
||||
.padding(.horizontal, 10).padding(.vertical, 5)
|
||||
@@ -847,74 +917,210 @@ struct CalendarConnectSheet: View {
|
||||
.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)
|
||||
}
|
||||
.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)
|
||||
.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)
|
||||
|
||||
// WebCal row
|
||||
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) {
|
||||
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))
|
||||
}
|
||||
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)
|
||||
}
|
||||
.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) {
|
||||
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))
|
||||
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)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
|
||||
// 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))
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,53 +4,126 @@ struct MatrixView: View {
|
||||
@Environment(\.colorScheme) var cs
|
||||
@EnvironmentObject var taskVM: TaskViewModel
|
||||
@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(
|
||||
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(
|
||||
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(
|
||||
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) {
|
||||
HStack(spacing: 0) {
|
||||
HStack(spacing: 5) {
|
||||
Text(roman)
|
||||
.font(AppFonts.mono(7.5, weight: .heavy))
|
||||
.font(AppFonts.mono(8, weight: .heavy))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 13, height: 13)
|
||||
.frame(width: 15, height: 15)
|
||||
.background(badgeColor)
|
||||
.clipShape(Circle())
|
||||
Text(label.uppercased())
|
||||
.font(AppFonts.mono(8, weight: .heavy))
|
||||
.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, 7)
|
||||
.padding(.vertical, 3)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(badgeBg)
|
||||
.cornerRadius(20)
|
||||
.padding(.bottom, 7)
|
||||
.clipShape(Capsule())
|
||||
.contentShape(Capsule())
|
||||
.onTapGesture { onTapHeader?() }
|
||||
Spacer()
|
||||
}
|
||||
.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)))
|
||||
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) {
|
||||
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(10.5))
|
||||
.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<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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,21 +1561,26 @@ 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)
|
||||
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<Date?>, hasTime: Binding<Bool>,
|
||||
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
|
||||
_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
|
||||
}
|
||||
@@ -1921,11 +2025,21 @@ struct TaskDatePickerSheet: View {
|
||||
isAllDay = false
|
||||
isRecurring = localRepeat != "None"
|
||||
recurrenceLabel = localRepeat != "None" ? localRepeat : nil
|
||||
// Combine reminder time with the selected date
|
||||
if let r = localReminder {
|
||||
let rc = Calendar.current.dateComponents([.hour, .minute], from: r)
|
||||
reminderDate = Calendar.current.date(bySettingHour: rc.hour ?? 9,
|
||||
minute: rc.minute ?? 0,
|
||||
second: 0, of: localDate)
|
||||
} else {
|
||||
reminderDate = nil
|
||||
}
|
||||
} else {
|
||||
selectedDate = Calendar.current.startOfDay(for: localDate)
|
||||
endDate = Calendar.current.startOfDay(for: localEndDate)
|
||||
isAllDay = localIsAllDay
|
||||
hasTime = false
|
||||
reminderDate = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user