diff --git a/KisaniCal/ContentView.swift b/KisaniCal/ContentView.swift
index 8af19d9..6709cea 100644
--- a/KisaniCal/ContentView.swift
+++ b/KisaniCal/ContentView.swift
@@ -77,6 +77,7 @@ struct ContentView: View {
.preferredColorScheme(preferredScheme)
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView(isPresented: $showOnboarding)
+ .environmentObject(workoutVM)
.onDisappear { hasOnboarded = true }
}
.onAppear {
@@ -101,6 +102,8 @@ struct ContentView: View {
showQuickAddTask = true
case .today:
withAnimation(KisaniSpring.micro) { selectedTab = 0 }
+ case .next3:
+ withAnimation(KisaniSpring.micro) { selectedTab = 0 }
case .calendar:
withAnimation(KisaniSpring.micro) { selectedTab = 1 }
case .workout:
@@ -165,11 +168,8 @@ struct ContentView: View {
selection: $selectedTab,
calIcon: calIconImage,
showMatrix: showMatrixTab,
- showWorkout: showWorkoutTab,
- isCollapsed: tabState.isCollapsed
+ showWorkout: showWorkoutTab
)
- .padding(.horizontal, 24)
- .padding(.bottom, 8)
}
.onAppear { UITabBar.appearance().isHidden = true }
}
@@ -202,12 +202,10 @@ struct ContentView: View {
// MARK: - Custom Tab Bar
-// Adds a transparent bottom safe-area inset equal to the floating tab bar height,
-// so scroll content and FABs inside each tab clear the bar.
private extension View {
func tabBarInset() -> some View {
safeAreaInset(edge: .bottom, spacing: 0) {
- Color.clear.frame(height: 62)
+ Color.clear.frame(height: 64)
}
}
}
@@ -217,7 +215,6 @@ struct KisaniTabBar: View {
let calIcon: Image
var showMatrix: Bool
var showWorkout: Bool
- var isCollapsed: Bool = false
@Environment(\.colorScheme) private var cs
private struct Entry: Identifiable {
@@ -244,33 +241,35 @@ struct KisaniTabBar: View {
} label: {
ZStack {
if selection == entry.id {
- RoundedRectangle(cornerRadius: 14)
- .fill(AppColors.surface2(cs))
- .frame(width: 52, height: 44)
- .transition(.opacity.combined(with: .scale(scale: 0.9)))
+ RoundedRectangle(cornerRadius: 16)
+ .fill(Color(uiColor: cs == .dark ? .tertiarySystemFill : .systemGray5))
+ .frame(width: 62, height: 42)
+ .transition(.opacity.combined(with: .scale(scale: 0.88)))
}
+
if let img = entry.custom {
img
- .font(.system(size: isCollapsed ? 17 : 20))
- .foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text(cs))
+ .font(.system(size: 22))
+ .foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text3(cs))
} else if let icon = entry.sfIcon {
Image(systemName: icon)
- .font(.system(size: isCollapsed ? 15 : 17, weight: selection == entry.id ? .semibold : .regular))
- .foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text(cs))
+ .font(.system(size: 22, weight: selection == entry.id ? .semibold : .regular))
+ .foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text3(cs))
}
}
- .frame(maxWidth: .infinity).frame(height: isCollapsed ? 38 : 50)
+ .frame(maxWidth: .infinity)
+ .frame(height: 52)
}
- .buttonStyle(PressButtonStyle(scale: 0.88))
+ .buttonStyle(PressButtonStyle(scale: 0.90))
}
}
- .padding(.horizontal, 10)
+ .padding(.horizontal, 8)
.background(
RoundedRectangle(cornerRadius: 28)
- .fill(AppColors.surface(cs))
- .shadow(color: .black.opacity(isCollapsed ? 0.06 : 0.10), radius: 20, x: 0, y: 6)
- .shadow(color: .black.opacity(0.04), radius: 4, x: 0, y: 1)
+ .fill(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground))
+ .shadow(color: .black.opacity(cs == .dark ? 0.40 : 0.10), radius: 20, x: 0, y: 6)
)
- .animation(KisaniSpring.snappy, value: isCollapsed)
+ .padding(.horizontal, 20)
+ .padding(.bottom, 10)
}
}
diff --git a/KisaniCal/KisaniCal.entitlements b/KisaniCal/KisaniCal.entitlements
new file mode 100644
index 0000000..0c67376
--- /dev/null
+++ b/KisaniCal/KisaniCal.entitlements
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift
index f49fbac..646facf 100644
--- a/KisaniCal/Models/TaskItem.swift
+++ b/KisaniCal/Models/TaskItem.swift
@@ -12,6 +12,9 @@ struct TaskItem: Identifiable, Codable, Equatable {
var category: TaskCategory = .reminder
var taskColor: TaskColor = .orange
var isRecurring: Bool = false
+ var isPinned: Bool = false
+ var endDate: Date? = nil
+ var isAllDay: Bool = false
}
enum Quadrant: String, CaseIterable, Identifiable, Codable {
@@ -84,12 +87,26 @@ class TaskViewModel: ObservableObject {
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
}
var upcomingTasks: [TaskItem] {
- let tomorrow = Calendar.current.date(byAdding: .day, value: 1,
- to: Calendar.current.startOfDay(for: Date()))!
+ let cal = Calendar.current
+ let fourDays = cal.date(byAdding: .day, value: 4, to: cal.startOfDay(for: Date()))!
return tasks
- .filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= tomorrow }
+ .filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= fourDays }
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
}
+ var next3DaysTasks: [(date: Date, tasks: [TaskItem])] {
+ let cal = Calendar.current
+ let today = cal.startOfDay(for: Date())
+ return (1...3).compactMap { offset -> (Date, [TaskItem])? in
+ guard let day = cal.date(byAdding: .day, value: offset, to: today),
+ let nextDay = cal.date(byAdding: .day, value: 1, to: day) else { return nil }
+ let dayTasks = tasks.filter {
+ !$0.isComplete &&
+ ($0.dueDate ?? .distantFuture) >= day &&
+ ($0.dueDate ?? .distantFuture) < nextDay
+ }.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
+ return dayTasks.isEmpty ? nil : (day, dayTasks)
+ }
+ }
var completedTodayTasks: [TaskItem] {
let startOfDay = Calendar.current.startOfDay(for: Date())
return tasks
@@ -104,15 +121,42 @@ class TaskViewModel: ObservableObject {
save()
}
func tasks(for quadrant: Quadrant) -> [TaskItem] {
- tasks.filter { $0.quadrant == quadrant }
+ tasks
+ .filter { $0.quadrant == quadrant }
+ .sorted { $0.isPinned && !$1.isPinned }
+ }
+
+ func pin(_ task: TaskItem) {
+ guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
+ tasks[idx].isPinned.toggle()
+ save()
+ }
+
+ func setDate(_ task: TaskItem, date: Date?) {
+ guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
+ tasks[idx].dueDate = date
+ save()
+ }
+
+ func setCategory(_ task: TaskItem, category: TaskCategory) {
+ guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
+ tasks[idx].category = category
+ save()
+ }
+ func moveToQuadrant(taskID: UUID, quadrant: Quadrant) {
+ guard let idx = tasks.firstIndex(where: { $0.id == taskID }) else { return }
+ tasks[idx].quadrant = quadrant
+ save()
}
func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
- taskColor: TaskColor = .orange, isRecurring: Bool = false) {
+ taskColor: TaskColor = .orange, isRecurring: Bool = false,
+ endDate: Date? = nil, isAllDay: Bool = false) {
tasks.append(TaskItem(title: title, dueDate: dueDate, hasTime: hasTime,
quadrant: quadrant, category: category,
- taskColor: taskColor, isRecurring: isRecurring))
+ taskColor: taskColor, isRecurring: isRecurring,
+ endDate: endDate, isAllDay: isAllDay))
save()
}
diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift
index 0a428f2..7989202 100644
--- a/KisaniCal/Views/CalendarView.swift
+++ b/KisaniCal/Views/CalendarView.swift
@@ -18,8 +18,10 @@ final class CalendarStore: ObservableObject {
object: ekStore,
queue: .main
) { [weak self] _ in
- guard let self, let m = self.fetchedMonth else { return }
- self.fetchMonth(containing: m)
+ Task { @MainActor [weak self] in
+ guard let self, let m = self.fetchedMonth else { return }
+ self.fetchMonth(containing: m)
+ }
}
}
@@ -974,6 +976,7 @@ private struct DayTLEntry: Identifiable {
var isComplete: Bool
var isAllDay: Bool
var sortKey: Int // < 0 = all-day/special; else minute-of-day
+ var sortGroup: Int = 0 // 0 = active, 1 = completed, 2 = overdue
var taskRef: TaskItem?
var workoutRef: WorkoutProgram?
}
@@ -981,26 +984,31 @@ private struct DayTLEntry: Identifiable {
struct DayTimelineView: View {
@Environment(\.colorScheme) private var cs
- let date: Date
- let tasks: [TaskItem]
- let calEvents: [EKEvent]
- let workout: WorkoutProgram?
- var onWorkoutTap: (() -> Void)? = nil
- var onToggle: ((TaskItem) -> Void)? = nil
+ let date: Date
+ let tasks: [TaskItem]
+ var overdueTasks: [TaskItem] = []
+ let calEvents: [EKEvent]
+ let workout: WorkoutProgram?
+ var onWorkoutTap: (() -> Void)? = nil
+ var onToggle: ((TaskItem) -> Void)? = nil
private let cal = Calendar.current
+ private static let overdueFmt: DateFormatter = {
+ let f = DateFormatter(); f.dateFormat = "MMM d"; return f
+ }()
private var entries: [DayTLEntry] {
var out: [DayTLEntry] = []
// All-day tasks (no specific time)
for t in tasks where !t.hasTime {
+ let grp = t.isComplete ? 1 : 0
out.append(DayTLEntry(
timeLabel: "All Day",
title: t.title,
- color: t.taskColor.color(),
+ color: t.isComplete ? AppColors.green : t.taskColor.color(),
isComplete: t.isComplete,
- isAllDay: true, sortKey: -1, taskRef: t
+ isAllDay: true, sortKey: -1, sortGroup: grp, taskRef: t
))
}
@@ -1010,11 +1018,11 @@ struct DayTimelineView: View {
timeLabel: "All Day",
title: ev.title ?? "Event",
color: Color(cgColor: ev.calendar.cgColor),
- isComplete: false, isAllDay: true, sortKey: -1
+ isComplete: false, isAllDay: true, sortKey: -1, sortGroup: 0
))
}
- // Workout
+ // Workout — always pinned first (sortGroup -1)
if let w = workout {
let done = w.doneSets == w.totalSets && w.totalSets > 0
out.append(DayTLEntry(
@@ -1022,7 +1030,7 @@ struct DayTimelineView: View {
title: w.name,
subtitle: "\(w.totalExercises) exercise\(w.totalExercises == 1 ? "" : "s")",
color: AppColors.blue,
- isComplete: done, isAllDay: true, sortKey: -2, workoutRef: w
+ isComplete: done, isAllDay: true, sortKey: -2, sortGroup: -1, workoutRef: w
))
}
@@ -1032,11 +1040,12 @@ struct DayTimelineView: View {
let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d)
let hd = h == 0 ? 12 : (h > 12 ? h - 12 : h)
let ms = m == 0 ? "" : ":\(String(format: "%02d", m))"
+ let grp = t.isComplete ? 1 : 0
out.append(DayTLEntry(
timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM",
- title: t.title, color: t.taskColor.color(),
+ title: t.title, color: t.isComplete ? AppColors.green : t.taskColor.color(),
isComplete: t.isComplete, isAllDay: false,
- sortKey: h * 60 + m, taskRef: t
+ sortKey: h * 60 + m, sortGroup: grp, taskRef: t
))
}
@@ -1057,11 +1066,26 @@ struct DayTimelineView: View {
timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM",
title: ev.title ?? "Event", subtitle: endLabel,
color: Color(cgColor: ev.calendar.cgColor),
- isComplete: false, isAllDay: false, sortKey: h * 60 + m
+ isComplete: false, isAllDay: false, sortKey: h * 60 + m, sortGroup: 0
+ ))
+ }
+
+ // Overdue tasks — shown last with accent color and their date as label
+ for t in overdueTasks {
+ let label = t.dueDate.map {
+ cal.isDateInYesterday($0) ? "Yest." : Self.overdueFmt.string(from: $0)
+ } ?? "Overdue"
+ out.append(DayTLEntry(
+ timeLabel: label,
+ title: t.title,
+ color: AppColors.accent,
+ isComplete: false, isAllDay: true,
+ sortKey: -1, sortGroup: 2, taskRef: t
))
}
return out.sorted {
+ if $0.sortGroup != $1.sortGroup { return $0.sortGroup < $1.sortGroup }
if $0.sortKey < 0 && $1.sortKey < 0 { return $0.sortKey > $1.sortKey }
if $0.sortKey < 0 { return true }
if $1.sortKey < 0 { return false }
diff --git a/KisaniCal/Views/OnboardingView.swift b/KisaniCal/Views/OnboardingView.swift
index 72ac46b..1a01690 100644
--- a/KisaniCal/Views/OnboardingView.swift
+++ b/KisaniCal/Views/OnboardingView.swift
@@ -1,147 +1,502 @@
import SwiftUI
+import EventKit
struct OnboardingView: View {
@Environment(\.colorScheme) private var cs
+ @EnvironmentObject private var workoutVM: WorkoutViewModel
@Binding var isPresented: Bool
- @AppStorage("bodyWeightKg") private var bodyWeightKg: Double = 0
- @AppStorage("heightCm") private var heightCm: Double = 0
- @AppStorage("weightUnit") private var weightUnit: Int = 0 // 0 = kg, 1 = lbs
+ // Persisted calendar settings
+ @AppStorage("appearanceMode") private var appearanceMode: Int = 0
+ @AppStorage("dayStartHour") private var dayStartHour: Int = 7
+ @AppStorage("todayViewStyle") private var todayViewStyle: Int = 0
+ // Persisted workout settings
+ @AppStorage("showWorkoutTab") private var showWorkoutTab: Bool = false
+ @AppStorage("bodyWeightKg") private var bodyWeightKg: Double = 0
+ @AppStorage("heightCm") private var heightCm: Double = 0
+ @AppStorage("weightUnit") private var weightUnit: Int = 0
+ @AppStorage("preferredWorkoutTime") private var preferredWorkoutTime: Int = 0
- @State private var weightStr = ""
- @State private var heightStr = ""
+ // Local (uncommitted) state
+ @State private var localAppearance: Int = 2 // dark by default
+ @State private var localDayStart: Int = 7
+ @State private var localViewStyle: Int = 0
+
+ @State private var workoutEnabled = false
+ @State private var weightStr = ""
+ @State private var heightStr = ""
+ @State private var selectedDays: Set = []
+ @State private var calAuthStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .event)
@FocusState private var focusedField: Field?
- fileprivate enum Field { case weight, height }
+ private let ekStore = EKEventStore()
- private var canContinue: Bool {
- (Double(weightStr) ?? 0) > 0 && (Double(heightStr) ?? 0) > 0
+ private let timeSlots: [(Int, String, String)] = [
+ (0, "Morning", "sunrise.fill"),
+ (1, "Midday", "sun.max.fill"),
+ (2, "Afternoon", "sun.min.fill"),
+ (3, "Evening", "sunset.fill")
+ ]
+ fileprivate enum Field { case weight, height }
+ private let weekdays: [(Int, String)] = [(2,"M"),(3,"T"),(4,"W"),(5,"T"),(6,"F"),(7,"S"),(1,"S")]
+
+ private var calIsGranted: Bool {
+ if #available(iOS 17.0, *) { return calAuthStatus == .fullAccess }
+ return calAuthStatus == .authorized
+ }
+
+ private var liveScheme: ColorScheme? {
+ switch localAppearance {
+ case 1: return .light
+ case 2: return .dark
+ default: return nil
+ }
}
var body: some View {
ZStack {
AppColors.background(cs).ignoresSafeArea()
- VStack(spacing: 0) {
- Spacer()
-
- // ── Brand mark ──
- VStack(spacing: 14) {
- ZStack {
- RoundedRectangle(cornerRadius: 22)
- .fill(AppColors.accentSoft)
- .frame(width: 72, height: 72)
- Image(systemName: "dumbbell.fill")
- .font(.system(size: 32, weight: .semibold))
- .foregroundColor(AppColors.accent)
- }
- VStack(spacing: 6) {
- Text("Welcome to Kisani")
- .font(AppFonts.sans(26, weight: .bold))
- .foregroundColor(AppColors.text(cs))
- Text("Personalizes your warm-up and body stats")
- .font(AppFonts.sans(14))
- .foregroundColor(AppColors.text3(cs))
- }
- }
- .padding(.bottom, 40)
-
- // ── Form card ──
+ ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
- // Unit toggle
- HStack(spacing: 0) {
- ForEach([("kg", 0), ("lbs", 1)], id: \.1) { label, idx in
- Button {
- withAnimation(KisaniSpring.micro) {
- // Convert existing weight string when toggling
- if let val = Double(weightStr) {
- let kg = weightUnit == 1 ? val / 2.20462 : val
- weightUnit = idx
- let display = weightUnit == 1 ? kg * 2.20462 : kg
- weightStr = display.truncatingRemainder(dividingBy: 1) == 0
- ? "\(Int(display))"
- : String(format: "%.1f", display)
- } else {
- weightUnit = idx
+ Spacer(minLength: 32)
+
+ // ── Brand mark ──
+ VStack(spacing: 14) {
+ ZStack {
+ RoundedRectangle(cornerRadius: 22)
+ .fill(AppColors.accentSoft)
+ .frame(width: 72, height: 72)
+ Image(systemName: "checklist.unchecked")
+ .font(.system(size: 30, weight: .semibold))
+ .foregroundColor(AppColors.accent)
+ }
+ VStack(spacing: 6) {
+ Text("Welcome to KisaniCal")
+ .font(AppFonts.sans(26, weight: .bold))
+ .foregroundColor(AppColors.text(cs))
+ Text("Tasks, calendar, and workouts\n— all in one place.")
+ .font(AppFonts.sans(14))
+ .foregroundColor(AppColors.text3(cs))
+ .multilineTextAlignment(.center)
+ }
+ }
+ .padding(.bottom, 36)
+
+ // ══════════════════════════════
+ // MARK: Calendar
+ // ══════════════════════════════
+ OnboardingSectionHeader(title: "Calendar")
+
+ // Calendar access
+ HStack(spacing: 12) {
+ Image(systemName: calIsGranted ? "calendar.badge.checkmark" : "calendar")
+ .font(.system(size: 15))
+ .foregroundColor(calIsGranted ? AppColors.green : AppColors.accent)
+ .frame(width: 32, height: 32)
+ .background(calIsGranted ? AppColors.greenSoft : AppColors.accentSoft)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ .animation(KisaniSpring.micro, value: calIsGranted)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("iPhone Calendar")
+ .font(AppFonts.sans(13, weight: .semibold))
+ .foregroundColor(AppColors.text(cs))
+ Text(calIsGranted
+ ? "Events will appear in your calendar view"
+ : "Show your events alongside tasks")
+ .font(AppFonts.sans(11))
+ .foregroundColor(AppColors.text3(cs))
+ }
+
+ Spacer()
+
+ Group {
+ if calIsGranted {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 22))
+ .foregroundColor(AppColors.green)
+ .transition(.scale.combined(with: .opacity))
+ } else if calAuthStatus == .denied {
+ Text("Denied")
+ .font(AppFonts.mono(10, weight: .bold))
+ .foregroundColor(AppColors.accent)
+ .padding(.horizontal, 8).padding(.vertical, 4)
+ .background(AppColors.accentSoft)
+ .clipShape(Capsule())
+ } else {
+ Button { requestCalendar() } label: {
+ Text("Enable")
+ .font(AppFonts.mono(10, weight: .bold))
+ .foregroundColor(.white)
+ .padding(.horizontal, 14).padding(.vertical, 7)
+ .background(AppColors.accent)
+ .clipShape(Capsule())
+ }
+ .buttonStyle(PressButtonStyle(scale: 0.92))
+ }
+ }
+ .animation(KisaniSpring.micro, value: calAuthStatus.rawValue)
+ }
+ .padding(.horizontal, 14).padding(.vertical, 14)
+ .cardStyle()
+ .padding(.horizontal, 20)
+ .padding(.bottom, 10)
+
+ // Appearance + Day Start + View Style card
+ VStack(spacing: 0) {
+
+ // Appearance
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(spacing: 12) {
+ Image(systemName: "paintbrush.fill")
+ .font(.system(size: 15))
+ .foregroundColor(AppColors.accent)
+ .frame(width: 32, height: 32)
+ .background(AppColors.accentSoft)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ Text("Appearance")
+ .font(AppFonts.sans(13))
+ .foregroundColor(AppColors.text(cs))
+ }
+ HStack(spacing: 6) {
+ ForEach(
+ [(2, "Dark", "moon.fill"),
+ (1, "Light", "sun.max.fill"),
+ (0, "System", "iphone")] as [(Int, String, String)],
+ id: \.0
+ ) { idx, label, icon in
+ Button {
+ withAnimation(KisaniSpring.micro) { localAppearance = idx }
+ } label: {
+ let on = localAppearance == idx
+ HStack(spacing: 5) {
+ Image(systemName: icon)
+ .font(.system(size: 11))
+ Text(label)
+ .font(AppFonts.mono(9.5, weight: .bold))
+ }
+ .foregroundColor(on ? AppColors.accent : AppColors.text3(cs))
+ .frame(maxWidth: .infinity).frame(height: 36)
+ .background(on ? AppColors.accentSoft : AppColors.surface2(cs))
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ .overlay(RoundedRectangle(cornerRadius: 8).stroke(
+ on ? AppColors.accent : AppColors.border(cs),
+ lineWidth: on ? 1.5 : 1))
+ }
+ .buttonStyle(PressButtonStyle(scale: 0.93))
+ }
+ }
+ }
+ .padding(.horizontal, 14).padding(.vertical, 12)
+
+ Divider().background(AppColors.border(cs)).padding(.leading, 56)
+
+ // Day Starts At
+ HStack(spacing: 12) {
+ Image(systemName: "clock.fill")
+ .font(.system(size: 15))
+ .foregroundColor(AppColors.blue)
+ .frame(width: 32, height: 32)
+ .background(AppColors.blueSoft)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ Text("Day Starts At")
+ .font(AppFonts.sans(13))
+ .foregroundColor(AppColors.text(cs))
+ Spacer()
+ Menu {
+ ForEach(4...11, id: \.self) { hour in
+ Button {
+ withAnimation(KisaniSpring.micro) { localDayStart = hour }
+ } label: {
+ let h12 = hour > 12 ? hour - 12 : hour
+ let ap = hour < 12 ? "AM" : "PM"
+ Label(
+ "\(h12):00 \(ap)",
+ systemImage: localDayStart == hour ? "checkmark" : ""
+ )
}
}
} label: {
- Text(label)
- .font(AppFonts.mono(12, weight: .bold))
- .foregroundColor(weightUnit == idx ? AppColors.accent : AppColors.text3(cs))
- .frame(maxWidth: .infinity)
- .padding(.vertical, 8)
- .background(weightUnit == idx ? AppColors.accentSoft : Color.clear)
- .clipShape(RoundedRectangle(cornerRadius: 7))
+ let h12 = localDayStart > 12 ? localDayStart - 12 : localDayStart
+ let ap = localDayStart < 12 ? "AM" : "PM"
+ HStack(spacing: 4) {
+ Text("\(h12):00 \(ap)")
+ .font(AppFonts.mono(12, weight: .semibold))
+ .foregroundColor(AppColors.accent)
+ Image(systemName: "chevron.up.chevron.down")
+ .font(.system(size: 9, weight: .semibold))
+ .foregroundColor(AppColors.text3(cs))
+ }
+ .padding(.horizontal, 10).padding(.vertical, 6)
+ .background(AppColors.accentSoft)
+ .clipShape(RoundedRectangle(cornerRadius: 7))
}
- .buttonStyle(PressButtonStyle(scale: 0.96))
}
- }
- .padding(4)
- .background(AppColors.surface2(cs))
- .clipShape(RoundedRectangle(cornerRadius: 10))
- .overlay(RoundedRectangle(cornerRadius: 10).stroke(AppColors.border(cs), lineWidth: 1))
- .padding(.horizontal, 28)
- .padding(.bottom, 16)
+ .padding(.horizontal, 14).padding(.vertical, 12)
- // Fields
- VStack(spacing: 0) {
- OnboardingField(
- icon: "scalemass.fill",
- label: "Body Weight",
- unit: weightUnit == 0 ? "kg" : "lbs",
- placeholder: weightUnit == 0 ? "e.g. 80" : "e.g. 176",
- text: $weightStr,
- keyboard: .decimalPad,
- focused: $focusedField,
- tag: .weight
- )
Divider().background(AppColors.border(cs)).padding(.leading, 56)
- OnboardingField(
- icon: "ruler.fill",
- label: "Height",
- unit: "cm",
- placeholder: "e.g. 175",
- text: $heightStr,
- keyboard: .numberPad,
- focused: $focusedField,
- tag: .height
- )
+
+ // Today View Style
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(spacing: 12) {
+ Image(systemName: "list.bullet.below.rectangle")
+ .font(.system(size: 15))
+ .foregroundColor(AppColors.accent)
+ .frame(width: 32, height: 32)
+ .background(AppColors.accentSoft)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ Text("Today View")
+ .font(AppFonts.sans(13))
+ .foregroundColor(AppColors.text(cs))
+ }
+ HStack(spacing: 6) {
+ ForEach(
+ [(0, "Timeline", "chart.bar.xaxis"),
+ (1, "Task List", "list.bullet")] as [(Int, String, String)],
+ id: \.0
+ ) { idx, label, icon in
+ Button {
+ withAnimation(KisaniSpring.micro) { localViewStyle = idx }
+ } label: {
+ let on = localViewStyle == idx
+ HStack(spacing: 5) {
+ Image(systemName: icon)
+ .font(.system(size: 11))
+ Text(label)
+ .font(AppFonts.mono(9.5, weight: .bold))
+ }
+ .foregroundColor(on ? AppColors.accent : AppColors.text3(cs))
+ .frame(maxWidth: .infinity).frame(height: 36)
+ .background(on ? AppColors.accentSoft : AppColors.surface2(cs))
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ .overlay(RoundedRectangle(cornerRadius: 8).stroke(
+ on ? AppColors.accent : AppColors.border(cs),
+ lineWidth: on ? 1.5 : 1))
+ }
+ .buttonStyle(PressButtonStyle(scale: 0.93))
+ }
+ }
+ }
+ .padding(.horizontal, 14).padding(.vertical, 12)
}
.cardStyle()
.padding(.horizontal, 20)
- }
+ .padding(.bottom, 28)
- // ── Hint ──
- Text("Used for bodyweight exercises and warm-up loads.\nEdit anytime in Settings → Workout Settings.")
- .font(AppFonts.sans(11))
- .foregroundColor(AppColors.text3(cs))
- .multilineTextAlignment(.center)
- .padding(.horizontal, 32)
- .padding(.top, 14)
+ // ══════════════════════════════
+ // MARK: Workout
+ // ══════════════════════════════
+ OnboardingSectionHeader(title: "Workout")
- Spacer()
+ VStack(spacing: 0) {
- // ── CTA ──
- Button { commit() } label: {
- HStack(spacing: 8) {
- Text("Get Started")
- .font(AppFonts.sans(15, weight: .bold))
- Image(systemName: "arrow.right")
- .font(.system(size: 13, weight: .bold))
+ // Activate toggle
+ HStack(spacing: 12) {
+ Image(systemName: "dumbbell.fill")
+ .font(.system(size: 15))
+ .foregroundColor(AppColors.blue)
+ .frame(width: 32, height: 32)
+ .background(AppColors.blueSoft)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Workout Module")
+ .font(AppFonts.sans(13, weight: .semibold))
+ .foregroundColor(AppColors.text(cs))
+ Text("Track gym sessions and exercises")
+ .font(AppFonts.sans(11))
+ .foregroundColor(AppColors.text3(cs))
+ }
+ Spacer()
+ Toggle("", isOn: $workoutEnabled)
+ .labelsHidden()
+ .tint(AppColors.accent)
+ }
+ .padding(.horizontal, 14).padding(.vertical, 14)
+
+ // Expanded workout details
+ if workoutEnabled {
+ Divider().background(AppColors.border(cs))
+
+ // kg / lbs toggle
+ HStack(spacing: 0) {
+ ForEach([("kg", 0), ("lbs", 1)], id: \.1) { label, idx in
+ Button {
+ withAnimation(KisaniSpring.micro) {
+ if let val = Double(weightStr) {
+ let kg = weightUnit == 1 ? val / 2.20462 : val
+ weightUnit = idx
+ let display = weightUnit == 1 ? kg * 2.20462 : kg
+ weightStr = display.truncatingRemainder(dividingBy: 1) == 0
+ ? "\(Int(display))"
+ : String(format: "%.1f", display)
+ } else {
+ weightUnit = idx
+ }
+ }
+ } label: {
+ Text(label)
+ .font(AppFonts.mono(12, weight: .bold))
+ .foregroundColor(weightUnit == idx ? AppColors.accent : AppColors.text3(cs))
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ .background(weightUnit == idx ? AppColors.accentSoft : Color.clear)
+ .clipShape(RoundedRectangle(cornerRadius: 7))
+ }
+ .buttonStyle(PressButtonStyle(scale: 0.96))
+ }
+ }
+ .padding(4)
+ .background(AppColors.surface2(cs))
+ .clipShape(RoundedRectangle(cornerRadius: 10))
+ .overlay(RoundedRectangle(cornerRadius: 10).stroke(AppColors.border(cs), lineWidth: 1))
+ .padding(.horizontal, 28)
+ .padding(.top, 12)
+
+ OnboardingField(
+ icon: "scalemass.fill",
+ label: "Body Weight",
+ unit: weightUnit == 0 ? "kg" : "lbs",
+ placeholder: weightUnit == 0 ? "e.g. 80" : "e.g. 176",
+ text: $weightStr,
+ keyboard: .decimalPad,
+ focused: $focusedField,
+ tag: .weight
+ )
+ Divider().background(AppColors.border(cs)).padding(.leading, 56)
+ OnboardingField(
+ icon: "ruler.fill",
+ label: "Height",
+ unit: "cm",
+ placeholder: "e.g. 175",
+ text: $heightStr,
+ keyboard: .numberPad,
+ focused: $focusedField,
+ tag: .height
+ )
+ Divider().background(AppColors.border(cs)).padding(.leading, 56)
+
+ // Workout days
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(spacing: 12) {
+ Image(systemName: "calendar.badge.checkmark")
+ .font(.system(size: 15))
+ .foregroundColor(AppColors.blue)
+ .frame(width: 32, height: 32)
+ .background(AppColors.blueSoft)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ Text("Workout Days")
+ .font(AppFonts.sans(13))
+ .foregroundColor(AppColors.text(cs))
+ Spacer()
+ if !selectedDays.isEmpty {
+ Text("\(selectedDays.count)d/wk")
+ .font(AppFonts.mono(9.5, weight: .bold))
+ .foregroundColor(AppColors.blue)
+ }
+ }
+ HStack(spacing: 5) {
+ ForEach(weekdays, id: \.0) { wd, label in
+ Button {
+ withAnimation(KisaniSpring.micro) {
+ if selectedDays.contains(wd) { selectedDays.remove(wd) }
+ else { selectedDays.insert(wd) }
+ }
+ } label: {
+ let on = selectedDays.contains(wd)
+ Text(label)
+ .font(AppFonts.mono(10, weight: .bold))
+ .foregroundColor(on ? AppColors.blue : AppColors.text3(cs))
+ .frame(maxWidth: .infinity).frame(height: 32)
+ .background(on ? AppColors.blueSoft : AppColors.surface2(cs))
+ .clipShape(RoundedRectangle(cornerRadius: 7))
+ .overlay(RoundedRectangle(cornerRadius: 7).stroke(
+ on ? AppColors.blue : AppColors.border(cs),
+ lineWidth: on ? 1.5 : 1))
+ }
+ .buttonStyle(PressButtonStyle(scale: 0.92))
+ }
+ }
+ }
+ .padding(.horizontal, 14).padding(.vertical, 12)
+
+ Divider().background(AppColors.border(cs)).padding(.leading, 56)
+
+ // Preferred workout time
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(spacing: 12) {
+ Image(systemName: "clock.fill")
+ .font(.system(size: 15))
+ .foregroundColor(AppColors.accent)
+ .frame(width: 32, height: 32)
+ .background(AppColors.accentSoft)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ Text("Preferred Time")
+ .font(AppFonts.sans(13))
+ .foregroundColor(AppColors.text(cs))
+ }
+ HStack(spacing: 5) {
+ ForEach(timeSlots, id: \.0) { idx, label, icon in
+ Button {
+ withAnimation(KisaniSpring.micro) { preferredWorkoutTime = idx }
+ } label: {
+ let on = preferredWorkoutTime == idx
+ VStack(spacing: 4) {
+ Image(systemName: icon)
+ .font(.system(size: 13))
+ .foregroundColor(on ? AppColors.accent : AppColors.text3(cs))
+ Text(label)
+ .font(AppFonts.mono(9, weight: .bold))
+ .foregroundColor(on ? AppColors.accent : AppColors.text3(cs))
+ }
+ .frame(maxWidth: .infinity).frame(height: 46)
+ .background(on ? AppColors.accentSoft : AppColors.surface2(cs))
+ .clipShape(RoundedRectangle(cornerRadius: 9))
+ .overlay(RoundedRectangle(cornerRadius: 9).stroke(
+ on ? AppColors.accent : AppColors.border(cs),
+ lineWidth: on ? 1.5 : 1))
+ }
+ .buttonStyle(PressButtonStyle(scale: 0.92))
+ }
+ }
+ }
+ .padding(.horizontal, 14).padding(.vertical, 12)
+ }
}
- .foregroundColor(.white)
- .frame(maxWidth: .infinity)
- .padding(.vertical, 16)
- .background(canContinue ? AppColors.accent : AppColors.borderHi(cs))
- .clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
+ .cardStyle()
+ .padding(.horizontal, 20)
+ .animation(KisaniSpring.snappy, value: workoutEnabled)
+
+ Text("All settings can be changed later in Settings.")
+ .font(AppFonts.sans(11))
+ .foregroundColor(AppColors.text3(cs))
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 32)
+ .padding(.top, 16)
+
+ Spacer(minLength: 24)
+
+ // ── Get Started ──
+ Button { commit() } label: {
+ HStack(spacing: 8) {
+ Text("Get Started")
+ .font(AppFonts.sans(15, weight: .bold))
+ Image(systemName: "arrow.right")
+ .font(.system(size: 13, weight: .bold))
+ }
+ .foregroundColor(.white)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 16)
+ .background(AppColors.accent)
+ .clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
+ }
+ .buttonStyle(PressButtonStyle())
+ .padding(.horizontal, 20)
+ .padding(.bottom, 36)
}
- .buttonStyle(PressButtonStyle())
- .disabled(!canContinue)
- .padding(.horizontal, 20)
- .padding(.bottom, 36)
}
}
+ .preferredColorScheme(liveScheme)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
@@ -150,20 +505,73 @@ struct OnboardingView: View {
.foregroundColor(AppColors.accent)
}
}
- .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { focusedField = .weight } }
+ .onAppear {
+ calAuthStatus = EKEventStore.authorizationStatus(for: .event)
+ }
+ }
+
+ // MARK: - Actions
+
+ private func requestCalendar() {
+ if #available(iOS 17.0, *) {
+ ekStore.requestFullAccessToEvents { granted, _ in
+ Task { @MainActor in
+ withAnimation(KisaniSpring.snappy) {
+ self.calAuthStatus = granted ? .fullAccess : .denied
+ }
+ }
+ }
+ } else {
+ ekStore.requestAccess(to: .event) { granted, _ in
+ Task { @MainActor in
+ withAnimation(KisaniSpring.snappy) {
+ self.calAuthStatus = granted ? .authorized : .denied
+ }
+ }
+ }
+ }
}
private func commit() {
- guard canContinue else { return }
- let rawWeight = Double(weightStr) ?? 0
- // Always store in kg internally
- bodyWeightKg = weightUnit == 1 ? rawWeight / 2.20462 : rawWeight
- heightCm = Double(heightStr) ?? 0
+ appearanceMode = localAppearance
+ dayStartHour = localDayStart
+ todayViewStyle = localViewStyle
+ showWorkoutTab = workoutEnabled
+ if workoutEnabled {
+ let rawWeight = Double(weightStr) ?? 0
+ if rawWeight > 0 {
+ bodyWeightKg = weightUnit == 1 ? rawWeight / 2.20462 : rawWeight
+ }
+ if let h = Double(heightStr), h > 0 { heightCm = h }
+ if !selectedDays.isEmpty { workoutVM.setScheduleForDays(selectedDays) }
+ }
withAnimation(KisaniSpring.exit) { isPresented = false }
}
}
+// MARK: - Section Header
+
+private struct OnboardingSectionHeader: View {
+ @Environment(\.colorScheme) private var cs
+ let title: String
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Text(title.uppercased())
+ .font(AppFonts.mono(9.5, weight: .bold))
+ .foregroundColor(AppColors.text3(cs))
+ Rectangle()
+ .fill(AppColors.border(cs))
+ .frame(height: 1)
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 4)
+ .padding(.bottom, 10)
+ }
+}
+
// MARK: - Field Row
+
private struct OnboardingField: View {
@Environment(\.colorScheme) private var cs
let icon: String
diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift
index 13f59bb..f62d23c 100644
--- a/KisaniCal/Views/TodayView.swift
+++ b/KisaniCal/Views/TodayView.swift
@@ -1,17 +1,25 @@
import SwiftUI
+import EventKit
struct TodayView: View {
@Environment(\.colorScheme) var cs
@EnvironmentObject var taskVM: TaskViewModel
@EnvironmentObject var workoutVM: WorkoutViewModel
+ @StateObject private var calStore = CalendarStore()
@State private var showAddTask = false
@State private var showWorkoutSession = false
- @State private var workoutExpanded = false
+ @AppStorage("todayHideCompleted") private var hideCompleted = false
private let dateFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEEE, MMM d"; return f
}()
+ private var todayCalEvents: [EKEvent] {
+ calStore.monthEvents.filter {
+ Calendar.current.isDateInToday($0.startDate ?? .distantFuture)
+ }
+ }
+
var body: some View {
ZStack(alignment: .bottomTrailing) {
AppColors.background(cs).ignoresSafeArea()
@@ -22,19 +30,27 @@ struct TodayView: View {
// ── Header ──
HStack {
- IButton(icon: "line.3.horizontal")
- Spacer()
- HStack(spacing: 6) {
- Text("💡").font(.system(size: 14))
- Text("···")
- .font(AppFonts.sans(14))
+ Menu {
+ Button { } label: { Label("Background", systemImage: "paintbrush") }
+ Button {
+ withAnimation(KisaniSpring.snappy) { hideCompleted.toggle() }
+ } label: {
+ Label(hideCompleted ? "Show Completed" : "Hide Completed",
+ systemImage: hideCompleted ? "checkmark.circle" : "checkmark.circle.badge.xmark")
+ }
+ Divider()
+ Button { } label: { Label("Group & Sort", systemImage: "arrow.up.arrow.down") }
+ Button { } label: { Label("Select", systemImage: "checkmark.circle") }
+ } label: {
+ Image(systemName: "line.3.horizontal")
+ .font(.system(size: 13, weight: .medium))
.foregroundColor(AppColors.text2(cs))
+ .frame(width: 34, height: 34)
+ .background(AppColors.surface2(cs))
+ .clipShape(Circle())
+ .overlay(Circle().stroke(AppColors.border(cs), lineWidth: 1))
}
- .padding(.horizontal, 12)
- .padding(.vertical, 5)
- .background(AppColors.surface2(cs))
- .clipShape(Capsule())
- .overlay(Capsule().stroke(AppColors.border(cs), lineWidth: 1))
+ Spacer()
}
.padding(.horizontal, 18)
.padding(.top, 8)
@@ -51,86 +67,34 @@ struct TodayView: View {
.font(AppFonts.mono(10))
.foregroundColor(AppColors.text3(cs).opacity(0.7))
.padding(.horizontal, 18)
- .padding(.bottom, 12)
+ .padding(.bottom, 8)
- // ── Overdue ──
- if !taskVM.overdueTasks.isEmpty {
- OverdueCard(
- tasks: taskVM.overdueTasks,
- onPostpone: {
- withAnimation(KisaniSpring.snappy) { taskVM.postponeAllOverdue() }
- },
- onToggle: { task in
- withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) }
+ // ── Today timeline: active → completed (green) → overdue (accent) ──
+ let activeTasks = taskVM.todayTasks
+ let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks
+ DayTimelineView(
+ date: Date(),
+ tasks: activeTasks + completedTasks,
+ overdueTasks: taskVM.overdueTasks,
+ calEvents: todayCalEvents,
+ workout: workoutVM.program(for: Date()),
+ onWorkoutTap: {
+ if let w = workoutVM.program(for: Date()), workoutVM.activeProgramId != w.id {
+ workoutVM.switchProgram(to: w.id)
}
- )
- .padding(.horizontal, 18)
+ showWorkoutSession = true
+ },
+ onToggle: { task in withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } }
+ )
+ .padding(.horizontal, 18)
+
+ // ── Next 3 Days ──
+ let next3 = taskVM.next3DaysTasks
+ if !next3.isEmpty {
+ Next3DaysSection(tasksByDay: next3, onToggle: { taskVM.toggle($0) })
}
- // ── Today's Workout ──
- if let workout = workoutVM.program(for: Date()) {
- WorkoutTodayBanner(
- program: workout,
- vm: workoutVM,
- isExpanded: $workoutExpanded,
- onTap: {
- if workoutVM.activeProgramId != workout.id {
- workoutVM.switchProgram(to: workout.id)
- }
- showWorkoutSession = true
- }
- )
- .padding(.horizontal, 18)
- .padding(.top, taskVM.overdueTasks.isEmpty ? 0 : 10)
- .padding(.bottom, 4)
- }
-
- // ── Today's Tasks ──
- if !taskVM.todayTasks.isEmpty {
- HStack(alignment: .firstTextBaseline) {
- Text("Today")
- .font(AppFonts.sans(11, weight: .semibold))
- .foregroundColor(AppColors.text2(cs))
- Spacer()
- Text("\(taskVM.todayTasks.count) task\(taskVM.todayTasks.count == 1 ? "" : "s")")
- .font(AppFonts.mono(9))
- .foregroundColor(AppColors.text3(cs))
- }
- .padding(.horizontal, 18)
- .padding(.top, 14)
- .padding(.bottom, 6)
-
- ForEach(taskVM.todayTasks) { task in
- TaskRowView(task: task) {
- withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) }
- }
- .contextMenu {
- Button { withAnimation { taskVM.postpone(task) } } label: {
- Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
- }
- Divider()
- Button(role: .destructive) { withAnimation { taskVM.delete(task) } } label: {
- Label("Delete", systemImage: "trash")
- }
- }
- .padding(.horizontal, 18)
- .padding(.bottom, 5)
- }
- }
-
- // ── Completed Today ──
- if !taskVM.completedTodayTasks.isEmpty {
- CompletedTodaySection(
- tasks: taskVM.completedTodayTasks,
- onUndo: { task in
- withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) }
- }
- )
- .padding(.horizontal, 18)
- .padding(.top, 14)
- }
-
- // ── Upcoming ──
+ // ── Upcoming (4+ days) ──
if !taskVM.upcomingTasks.isEmpty {
UpcomingSection(
tasks: taskVM.upcomingTasks,
@@ -142,6 +106,7 @@ struct TodayView: View {
let hasAnything = !taskVM.overdueTasks.isEmpty || !taskVM.todayTasks.isEmpty
|| !taskVM.upcomingTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty
+ || !taskVM.next3DaysTasks.isEmpty || !todayCalEvents.isEmpty
|| workoutVM.program(for: Date()) != nil
if !hasAnything {
VStack(spacing: 6) {
@@ -177,6 +142,260 @@ struct TodayView: View {
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
+ .onAppear {
+ calStore.fetchMonth(containing: Date())
+ }
+ }
+}
+
+// MARK: - Unified Today Timeline
+
+private struct TodayTLEntry: Identifiable {
+ let id = UUID()
+ let timeLabel: String
+ let ampm: String?
+ let title: String
+ let subtitle: String?
+ let color: Color
+ let isComplete: Bool
+ let isAllDay: Bool
+ let task: TaskItem
+}
+
+struct UnifiedTodayTimelineView: View {
+ @Environment(\.colorScheme) private var cs
+ @AppStorage("todayHideCompleted") private var hideCompleted = false
+ let overdueTasks: [TaskItem]
+ let todayTasks: [TaskItem]
+ let completedTodayTasks:[TaskItem]
+ let next3DaysTasks: [(date: Date, tasks: [TaskItem])]
+ let upcomingTasks: [TaskItem]
+ let onToggle: (TaskItem) -> Void
+ let onPostpone: (TaskItem) -> Void
+ let onPostponeAll: () -> Void
+
+ private static let shortFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "MMM d"; return f }()
+ private static let timeFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "h:mm"; return f }()
+ private static let ampmFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "a"; return f }()
+ private static let dayFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "EEE, MMM d"; return f }()
+ private let cal = Calendar.current
+
+ private func entry(for task: TaskItem, dateAsLabel: Bool = false) -> TodayTLEntry {
+ let timeLabel: String; let ampm: String?
+ if task.isComplete, let done = task.completedAt {
+ timeLabel = Self.timeFmt.string(from: done)
+ ampm = Self.ampmFmt.string(from: done)
+ } else if dateAsLabel, let d = task.dueDate {
+ timeLabel = Self.shortFmt.string(from: d); ampm = nil
+ } else if let d = task.dueDate, task.hasTime {
+ timeLabel = Self.timeFmt.string(from: d)
+ ampm = Self.ampmFmt.string(from: d)
+ } else {
+ timeLabel = "all day"; ampm = nil
+ }
+ return TodayTLEntry(
+ timeLabel: timeLabel, ampm: ampm,
+ title: task.title,
+ subtitle: task.isRecurring ? "Annual" : nil,
+ color: task.taskColor.color(),
+ isComplete: task.isComplete,
+ isAllDay: task.isComplete ? false : !task.hasTime,
+ task: task
+ )
+ }
+
+ private func tlRows(_ entries: [TodayTLEntry], postpnable: Bool = false) -> some View {
+ ForEach(Array(entries.enumerated()), id: \.element.id) { i, e in
+ TodayTLRow(entry: e, isFirst: i == 0, isLast: i == entries.count - 1, cs: cs) {
+ onToggle(e.task)
+ }
+ .contextMenu {
+ if postpnable {
+ Button { onPostpone(e.task) } label: {
+ Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
+ }
+ }
+ }
+ .padding(.horizontal, 18)
+ }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+
+ // ── OVERDUE ──
+ if !overdueTasks.isEmpty {
+ TLSectionHeader(label: "OVERDUE", color: AppColors.accent,
+ action: "POSTPONE ALL", onAction: onPostponeAll)
+ .padding(.horizontal, 18)
+ tlRows(overdueTasks.map { entry(for: $0, dateAsLabel: true) }, postpnable: true)
+ }
+
+ // ── TODAY ──
+ let showToday = !todayTasks.isEmpty || (!completedTodayTasks.isEmpty && !hideCompleted)
+ if showToday {
+ TLSectionHeader(label: "TODAY", color: AppColors.blue)
+ .padding(.horizontal, 18)
+ // Active today tasks
+ tlRows(todayTasks.map { entry(for: $0) })
+ // Completed today — right here, inside Today
+ if !completedTodayTasks.isEmpty && !hideCompleted {
+ tlRows(completedTodayTasks.map { entry(for: $0) })
+ }
+ }
+
+ // ── NEXT 3 DAYS (each day labelled) ──
+ ForEach(next3DaysTasks, id: \.date) { day, tasks in
+ let label = cal.isDateInTomorrow(day)
+ ? "TOMORROW"
+ : Self.dayFmt.string(from: day).uppercased()
+ TLSectionHeader(label: label, color: AppColors.text2(cs))
+ .padding(.horizontal, 18)
+ tlRows(tasks.map { entry(for: $0) })
+ }
+
+ // ── UPCOMING (4+ days — flat, date in time column) ──
+ if !upcomingTasks.isEmpty {
+ TLSectionHeader(label: "UPCOMING", color: AppColors.text3(cs))
+ .padding(.horizontal, 18)
+ tlRows(upcomingTasks.map { entry(for: $0, dateAsLabel: true) }, postpnable: true)
+ }
+ }
+ .padding(.top, 8)
+ }
+}
+
+private struct TLSectionHeader: View {
+ @Environment(\.colorScheme) private var cs
+ let label: String
+ let color: Color
+ var action: String? = nil
+ var onAction: (() -> Void)? = nil
+
+ var body: some View {
+ HStack {
+ Text(label)
+ .font(AppFonts.mono(9.5, weight: .bold))
+ .foregroundColor(color)
+ if let action, let onAction {
+ Spacer()
+ Button(action: onAction) {
+ Text(action)
+ .font(AppFonts.mono(8.5, weight: .bold))
+ .foregroundColor(color)
+ .padding(.horizontal, 8).padding(.vertical, 3)
+ .background(color.opacity(0.12))
+ .clipShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.top, 18)
+ .padding(.bottom, 6)
+ }
+}
+
+private struct TodayTLRow: View {
+ let entry: TodayTLEntry
+ let isFirst: Bool
+ let isLast: Bool
+ let cs: ColorScheme
+ let onToggle: () -> Void
+
+ @AppStorage("todayShowDetails") private var showDetails = true
+
+ private let circleD: CGFloat = 18
+ private let timeW: CGFloat = 46
+ private let trackW: CGFloat = 26
+ private let topGap: CGFloat = 10
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 0) {
+
+ // ── Time column ──
+ VStack(alignment: .trailing, spacing: 0) {
+ Text(entry.timeLabel)
+ .font(AppFonts.mono(entry.isAllDay ? 8.5 : 11,
+ weight: entry.isAllDay ? .medium : .bold))
+ .foregroundColor(entry.isAllDay ? AppColors.text3(cs) : AppColors.text(cs))
+ .lineLimit(1)
+ .minimumScaleFactor(0.7)
+ if let ap = entry.ampm {
+ Text(ap)
+ .font(AppFonts.mono(7, weight: .medium))
+ .foregroundColor(AppColors.text3(cs))
+ }
+ }
+ .frame(width: timeW, alignment: .trailing)
+ .padding(.top, topGap)
+
+ // ── Vertical track ──
+ VStack(spacing: 0) {
+ Rectangle()
+ .fill(isFirst ? Color.clear : AppColors.border(cs))
+ .frame(width: 1, height: topGap)
+ .frame(maxWidth: trackW)
+ // Marker
+ ZStack {
+ if entry.isComplete {
+ Circle()
+ .fill(entry.color.opacity(0.14))
+ .frame(width: circleD, height: circleD)
+ Image(systemName: "checkmark")
+ .font(.system(size: 7.5, weight: .bold))
+ .foregroundColor(entry.color)
+ } else if entry.isAllDay {
+ Circle()
+ .strokeBorder(entry.color, lineWidth: 1.5)
+ .frame(width: circleD, height: circleD)
+ } else {
+ Circle()
+ .fill(entry.color)
+ .frame(width: 9, height: 9)
+ }
+ }
+ .frame(width: circleD, height: circleD)
+ Rectangle()
+ .fill(isLast ? Color.clear : AppColors.border(cs))
+ .frame(minWidth: 1, maxWidth: 1, maxHeight: .infinity)
+ .frame(maxWidth: trackW)
+ }
+ .frame(width: trackW)
+
+ // ── Event card ──
+ HStack(alignment: .center, spacing: 8) {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(entry.title)
+ .font(AppFonts.sans(13, weight: .medium))
+ .foregroundColor(entry.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
+ .strikethrough(entry.isComplete, color: AppColors.text3(cs))
+ .lineLimit(2)
+ if showDetails, let sub = entry.subtitle {
+ Text(sub)
+ .font(AppFonts.mono(9.5))
+ .foregroundColor(AppColors.text3(cs))
+ }
+ }
+ Spacer(minLength: 0)
+ TagChip(
+ text: entry.task.category.rawValue,
+ color: entry.color,
+ bg: entry.task.taskColor.soft()
+ )
+ Button(action: onToggle) {
+ Image(systemName: entry.isComplete ? "checkmark.circle.fill" : "circle")
+ .font(.system(size: 20))
+ .foregroundColor(entry.isComplete ? entry.color : AppColors.text3(cs))
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, 13).padding(.vertical, 11)
+ .background(AppColors.surface(cs))
+ .clipShape(RoundedRectangle(cornerRadius: 10))
+ .overlay(RoundedRectangle(cornerRadius: 10).stroke(AppColors.border(cs), lineWidth: 1))
+ .padding(.leading, 8).padding(.bottom, 8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
}
}
@@ -228,7 +447,7 @@ struct OverdueCard: View {
ForEach(tasks) { task in
HStack(spacing: 10) {
ZStack {
- RoundedRectangle(cornerRadius: 5)
+ Circle()
.stroke(AppColors.accent, lineWidth: 1.5)
.frame(width: 20, height: 20)
}
@@ -749,6 +968,7 @@ struct CompletedTodaySection: View {
// MARK: - Task Row
struct TaskRowView: View {
@Environment(\.colorScheme) private var cs
+ @AppStorage("todayShowDetails") private var showDetails = true
let task: TaskItem
let onToggle: () -> Void
@@ -761,8 +981,11 @@ struct TaskRowView: View {
HStack(spacing: 10) {
ZStack {
Circle()
- .stroke(task.taskColor.color(), lineWidth: 1.5)
- .frame(width: 18, height: 18)
+ .fill(task.taskColor.soft())
+ .frame(width: 22, height: 22)
+ Circle()
+ .stroke(task.taskColor.color(), lineWidth: 2)
+ .frame(width: 22, height: 22)
}
.frame(width: 36, height: 44)
.padding(.leading, 4)
@@ -771,10 +994,18 @@ struct TaskRowView: View {
Text(task.title)
.font(AppFonts.sans(13))
.foregroundColor(AppColors.text(cs))
- if let d = task.dueDate {
- Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · Annual" : "")")
- .font(AppFonts.mono(9.5))
- .foregroundColor(AppColors.text3(cs))
+ if let d = task.dueDate, showDetails {
+ HStack(spacing: 4) {
+ Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · Annual" : "")")
+ .font(AppFonts.mono(9.5))
+ .foregroundColor(AppColors.text3(cs))
+ Text("·")
+ .font(AppFonts.mono(9.5))
+ .foregroundColor(AppColors.text3(cs).opacity(0.4))
+ Text(countdownLabel(to: d))
+ .font(AppFonts.mono(9.5, weight: .semibold))
+ .foregroundColor(countdownColor(to: d))
+ }
}
}
@@ -789,19 +1020,106 @@ struct TaskRowView: View {
}
.frame(minHeight: 48)
.background(AppColors.surface(cs))
- .clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
- .overlay(RoundedRectangle(cornerRadius: AppRadius.small)
- .stroke(AppColors.border(cs), lineWidth: 1))
.overlay(alignment: .leading) {
Rectangle()
.fill(task.taskColor.color())
.frame(width: 2.5)
- .clipShape(RoundedRectangle(cornerRadius: 2))
}
+ .clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
+ .overlay(RoundedRectangle(cornerRadius: AppRadius.small)
+ .stroke(AppColors.border(cs), lineWidth: 1))
.contentShape(Rectangle())
}
.buttonStyle(PressButtonStyle())
}
+
+ private func countdownLabel(to date: Date) -> String {
+ let diff = date.timeIntervalSince(Date())
+ if diff < 0 { return "overdue" }
+ let secs = Int(diff)
+ let mins = secs / 60
+ let hours = mins / 60
+ let days = hours / 24
+ let weeks = days / 7
+ let months = days / 30
+ if hours < 1 { return "in \(max(1, mins)) min\(mins == 1 ? "" : "s")" }
+ if hours < 24 { return "in \(hours) hr\(hours == 1 ? "" : "s")" }
+ if days < 7 { return "in \(days) day\(days == 1 ? "" : "s")" }
+ if weeks < 5 { return "in \(weeks) wk\(weeks == 1 ? "" : "s")" }
+ return "in \(months) mo\(months == 1 ? "" : "s")"
+ }
+
+ private func countdownColor(to date: Date) -> Color {
+ let diff = date.timeIntervalSince(Date())
+ if diff < 0 { return AppColors.accent }
+ if diff < 3_600 { return AppColors.accent }
+ if diff < 86_400 { return AppColors.yellow }
+ if diff < 3 * 86_400 { return AppColors.text2(cs) }
+ return AppColors.text3(cs)
+ }
+}
+
+// MARK: - Next 3 Days Section
+
+struct Next3DaysSection: View {
+ @Environment(\.colorScheme) private var cs
+ let tasksByDay: [(date: Date, tasks: [TaskItem])]
+ let onToggle: (TaskItem) -> Void
+
+ @AppStorage("next3Collapsed") private var collapsed = false
+
+ private let dayFmt: DateFormatter = {
+ let f = DateFormatter(); f.dateFormat = "EEEE, MMM d"; return f
+ }()
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ HStack(spacing: 6) {
+ Text("Next 3 Days")
+ .font(AppFonts.sans(10.5, weight: .semibold))
+ .foregroundColor(AppColors.text3(cs))
+ let total = tasksByDay.reduce(0) { $0 + $1.tasks.count }
+ Text("\(total)")
+ .font(AppFonts.mono(9, weight: .semibold))
+ .foregroundColor(AppColors.text3(cs).opacity(0.5))
+ .padding(.horizontal, 6).padding(.vertical, 2)
+ .background(AppColors.surface2(cs))
+ .clipShape(Capsule())
+ Spacer()
+ Image(systemName: "chevron.down")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundColor(AppColors.text3(cs).opacity(0.6))
+ .rotationEffect(.degrees(collapsed ? -90 : 0))
+ .animation(KisaniSpring.micro, value: collapsed)
+ }
+ .padding(.horizontal, 18)
+ .padding(.top, 14)
+ .padding(.bottom, collapsed ? 2 : 8)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ withAnimation(KisaniSpring.snappy) { collapsed.toggle() }
+ }
+
+ if !collapsed {
+ ForEach(tasksByDay, id: \.date) { day, tasks in
+ Text(dayFmt.string(from: day))
+ .font(AppFonts.mono(9.5, weight: .bold))
+ .foregroundColor(AppColors.accent)
+ .padding(.horizontal, 18)
+ .padding(.top, 8)
+ .padding(.bottom, 4)
+
+ ForEach(tasks) { task in
+ TaskRowView(task: task) {
+ withAnimation(KisaniSpring.snappy) { onToggle(task) }
+ }
+ .padding(.horizontal, 18)
+ .padding(.bottom, 5)
+ }
+ }
+ }
+ }
+ }
}
// MARK: - NL Task Parser
@@ -810,6 +1128,10 @@ struct NLParsed {
var date: Date?
var hasTime: Bool = false
var tokenRanges: [NSRange] = []
+ var isRecurring: Bool = false
+ var recurrenceLabel: String? = nil
+ var endDate: Date? = nil
+ var isAllDay: Bool = false
var hasDate: Bool { date != nil }
var dateLabel: String {
@@ -839,42 +1161,72 @@ struct NLTaskParser {
var rawRanges: [NSRange] = []
var foundDay = false
- // ── Day keywords ───────────────────────────────────────────────────
- let dayTable: [(String, Int)] = [
- ("\\btoday\\b", 0),
- ("\\btom+[ao]r+ow\\b", 1),
- ("\\bmon(day)?\\b", daysUntil(2)),
- ("\\btue(sday)?\\b", daysUntil(3)),
- ("\\bwed(nesday)?\\b", daysUntil(4)),
- ("\\bthu(rsday)?\\b", daysUntil(5)),
- ("\\bfri(day)?\\b", daysUntil(6)),
- ("\\bsat(urday)?\\b", daysUntil(7)),
- ("\\bsun(day)?\\b", daysUntil(1)),
- ]
- // ── Relative offsets ("in N days", "next week") ───────────────────
- if let (r, offset) = parseRelativeOffset(in: lower) {
- let d = cal.date(byAdding: .day, value: offset, to: Date()) ?? Date()
- comps = cal.dateComponents([.year, .month, .day], from: d)
+ // ── Intent phrases (stripped from title) ──────────────────────────
+ for pat in ["\\bremind\\s+me(?:\\s+(?:to|of|about))?",
+ "\\bremember(?:\\s+(?:to|about))?"] {
+ if let r = match(pat, in: lower) { rawRanges.append(r) }
+ }
+
+ // ── Recurrence ──────────────────────────────────────────────────────
+ if let (r, label) = parseRecurrence(in: lower) {
+ result.isRecurring = true
+ result.recurrenceLabel = label
+ rawRanges.append(r)
+ }
+
+ // ── Absolute month + day ────────────────────────────────────────────
+ if let (r, month, day) = parseMonthDay(in: lower) {
+ var testComps = DateComponents()
+ testComps.year = cal.component(.year, from: Date())
+ testComps.month = month
+ testComps.day = day
+ let year: Int
+ if let candidate = cal.date(from: testComps),
+ candidate < cal.startOfDay(for: Date()) {
+ year = cal.component(.year, from: Date()) + 1
+ } else {
+ year = cal.component(.year, from: Date())
+ }
+ comps.year = year; comps.month = month; comps.day = day
rawRanges.append(r); foundDay = true
}
+ // ── Day keywords ────────────────────────────────────────────────────
if !foundDay {
- for (pat, offset) in dayTable {
- if let r = match(pat, in: lower) {
- let d = cal.date(byAdding: .day, value: offset, to: Date()) ?? Date()
- comps = cal.dateComponents([.year, .month, .day], from: d)
- rawRanges.append(r); foundDay = true; break
+ let dayTable: [(String, Int)] = [
+ ("\\btoday\\b", 0),
+ ("\\btom+[ao]r+ow\\b", 1),
+ ("\\bmon(day)?\\b", daysUntil(2)),
+ ("\\btue(sday)?\\b", daysUntil(3)),
+ ("\\bwed(nesday)?\\b", daysUntil(4)),
+ ("\\bthu(rsday)?\\b", daysUntil(5)),
+ ("\\bfri(day)?\\b", daysUntil(6)),
+ ("\\bsat(urday)?\\b", daysUntil(7)),
+ ("\\bsun(day)?\\b", daysUntil(1)),
+ ]
+ if let (r, offset) = parseRelativeOffset(in: lower) {
+ let d = cal.date(byAdding: .day, value: offset, to: Date()) ?? Date()
+ comps = cal.dateComponents([.year, .month, .day], from: d)
+ rawRanges.append(r); foundDay = true
+ }
+ if !foundDay {
+ for (pat, offset) in dayTable {
+ if let r = match(pat, in: lower) {
+ let d = cal.date(byAdding: .day, value: offset, to: Date()) ?? Date()
+ comps = cal.dateComponents([.year, .month, .day], from: d)
+ rawRanges.append(r); foundDay = true; break
+ }
}
}
}
- // ── Time patterns ──────────────────────────────────────────────────
+ // ── Time patterns ────────────────────────────────────────────────────
let timePatterns = [
- "at\\s+(\\d{4})\\b", // at 1500
- "at\\s+(\\d{1,2}):(\\d{2})\\s*(am|pm)?", // at 15:00 / at 3:30pm
- "at\\s+(\\d{1,2})\\s*(am|pm)", // at 3pm
- "\\b(\\d{1,2}):(\\d{2})\\s*(am|pm)\\b", // 3:30pm
- "\\b(\\d{1,2})\\s*(am|pm)\\b", // 3pm
+ "at\\s+(\\d{4})\\b",
+ "at\\s+(\\d{1,2}):(\\d{2})\\s*(am|pm)?",
+ "at\\s+(\\d{1,2})\\s*(am|pm)",
+ "\\b(\\d{1,2}):(\\d{2})\\s*(am|pm)\\b",
+ "\\b(\\d{1,2})\\s*(am|pm)\\b",
]
var foundTime = false
for pat in timePatterns {
@@ -885,10 +1237,10 @@ struct NLTaskParser {
}
if foundDay || foundTime {
- result.date = cal.date(from: comps)
+ result.date = cal.date(from: comps)
result.hasTime = foundTime
- result.tokenRanges = expand(rawRanges, in: lower)
}
+ result.tokenRanges = expand(rawRanges, in: lower)
return result
}
@@ -901,6 +1253,68 @@ struct NLTaskParser {
.replacingOccurrences(of: " ", with: " ")
}
+ // MARK: - Month + Day
+
+ private static let monthAlts = "jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?"
+
+ private static func parseMonthDay(in text: String) -> (NSRange, Int, Int)? {
+ let m = monthAlts
+ let o = "(?:st|nd|rd|th)?"
+ // (pattern, dayGroupIndex, monthGroupIndex)
+ let pats: [(String, Int, Int)] = [
+ ("\\bon\\s+(?:the\\s+)?(\\d{1,2})\(o)\\s+of\\s+(\(m))\\b", 1, 2),
+ ("\\bon\\s+(\(m))\\s+(\\d{1,2})\(o)\\b", 2, 1),
+ ("\\bon\\s+(\\d{1,2})\(o)\\s+(\(m))\\b", 1, 2),
+ ("\\b(\(m))\\s+(\\d{1,2})\(o)\\b", 2, 1),
+ ("\\b(\\d{1,2})(?:st|nd|rd|th)\\s+(\(m))\\b", 1, 2),
+ ]
+ for (pat, dayIdx, moIdx) in pats {
+ guard let rx = try? NSRegularExpression(pattern: pat, options: .caseInsensitive),
+ let hit = rx.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
+ hit.numberOfRanges > max(dayIdx, moIdx),
+ let dr = Range(hit.range(at: dayIdx), in: text),
+ let mr = Range(hit.range(at: moIdx), in: text),
+ let day = Int(String(text[dr])),
+ let month = resolveMonth(String(text[mr])),
+ day >= 1 && day <= 31
+ else { continue }
+ return (hit.range, month, day)
+ }
+ return nil
+ }
+
+ private static func resolveMonth(_ s: String) -> Int? {
+ let map: [String: Int] = [
+ "january":1,"jan":1,"february":2,"feb":2,"march":3,"mar":3,
+ "april":4,"apr":4,"may":5,"june":6,"jun":6,"july":7,"jul":7,
+ "august":8,"aug":8,"september":9,"sep":9,"sept":9,
+ "october":10,"oct":10,"november":11,"nov":11,"december":12,"dec":12,
+ ]
+ return map[s.lowercased()]
+ }
+
+ // MARK: - Recurrence
+
+ private static func parseRecurrence(in text: String) -> (NSRange, String)? {
+ let pats: [(String, String)] = [
+ ("\\bevery\\s+year\\b", "Yearly"),
+ ("\\bannually\\b", "Yearly"),
+ ("\\byearly\\b", "Yearly"),
+ ("\\bevery\\s+month\\b", "Monthly"),
+ ("\\bmonthly\\b", "Monthly"),
+ ("\\bevery\\s+week\\b", "Weekly"),
+ ("\\bweekly\\b", "Weekly"),
+ ("\\bevery\\s+day\\b", "Daily"),
+ ("\\bdaily\\b", "Daily"),
+ ]
+ for (pat, label) in pats {
+ if let r = match(pat, in: text) { return (r, label) }
+ }
+ return nil
+ }
+
+ // MARK: - Helpers
+
private static func parseRelativeOffset(in text: String) -> (NSRange, Int)? {
let wordNums = ["a": 1, "an": 1, "one": 1, "two": 2, "three": 3, "four": 4,
"five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10]
@@ -937,7 +1351,7 @@ struct NLTaskParser {
if m.numberOfRanges >= 2, let r1 = Range(m.range(at: 1), in: text) {
let s1 = String(text[r1])
- if s1.count == 4 { // packed 1500
+ if s1.count == 4 {
h = Int(s1.prefix(2)) ?? 0; mn = Int(s1.suffix(2)) ?? 0
} else {
h = Int(s1) ?? 0
@@ -954,14 +1368,17 @@ struct NLTaskParser {
return (m.range, h, mn)
}
- // widen ranges to absorb leading space so clean-strip leaves no double spaces
private static func expand(_ ranges: [NSRange], in text: String) -> [NSRange] {
let ns = text as NSString
return ranges.map { r in
- if r.location > 0 && ns.character(at: r.location - 1) == 32 {
- return NSRange(location: r.location - 1, length: r.length + 1)
+ var loc = r.location
+ var len = r.length
+ if loc > 0 && ns.character(at: loc - 1) == 32 {
+ loc -= 1; len += 1
+ } else if loc + len < ns.length && ns.character(at: loc + len) == 32 {
+ len += 1
}
- return r
+ return NSRange(location: loc, length: len)
}
}
}
@@ -1041,6 +1458,7 @@ struct AddTaskSheet: View {
@State private var parsed = NLParsed()
@State private var selectedQuadrant: Quadrant
@State private var selectedCategory: TaskCategory = .reminder
+ @State private var showDatePicker = false
var prefilledDate: Date?
var prefilledQuadrant: Quadrant
@@ -1049,9 +1467,9 @@ struct AddTaskSheet: View {
self.prefilledDate = prefilledDate
self.prefilledQuadrant = prefilledQuadrant
_selectedQuadrant = State(initialValue: prefilledQuadrant)
- if let d = prefilledDate {
- var p = NLParsed(); p.date = d; _parsed = State(initialValue: p)
- }
+ var p = NLParsed()
+ p.date = prefilledDate ?? Calendar.current.startOfDay(for: Date())
+ _parsed = State(initialValue: p)
}
private var cleanTitle: String { NLTaskParser.cleanTitle(rawText, ranges: parsed.tokenRanges) }
@@ -1067,7 +1485,9 @@ struct AddTaskSheet: View {
)
.frame(minHeight: 36, maxHeight: 72)
.onChange(of: rawText) { _ in
- withAnimation(KisaniSpring.micro) { parsed = NLTaskParser.parse(rawText) }
+ var p = NLTaskParser.parse(rawText)
+ if p.date == nil { p.date = Calendar.current.startOfDay(for: Date()) }
+ withAnimation(KisaniSpring.micro) { parsed = p }
}
.padding(.horizontal, 16)
.padding(.top, 14)
@@ -1078,7 +1498,7 @@ struct AddTaskSheet: View {
HStack(spacing: 0) {
// Date chip
- Button { } label: {
+ Button { showDatePicker = true } label: {
HStack(spacing: 5) {
Image(systemName: "calendar")
.font(.system(size: 13, weight: .semibold))
@@ -1093,6 +1513,22 @@ struct AddTaskSheet: View {
.buttonStyle(.plain)
.animation(KisaniSpring.micro, value: parsed.hasDate)
+ if parsed.isRecurring {
+ HStack(spacing: 4) {
+ Image(systemName: "arrow.clockwise")
+ .font(.system(size: 11, weight: .semibold))
+ Text(parsed.recurrenceLabel ?? "Recurring")
+ .font(AppFonts.sans(13, weight: .semibold))
+ }
+ .foregroundColor(AppColors.accent)
+ .padding(.horizontal, 10).padding(.vertical, 6)
+ .background(AppColors.accentSoft)
+ .clipShape(Capsule())
+ .transition(.scale(scale: 0.8).combined(with: .opacity))
+ .padding(.leading, 6)
+ .animation(KisaniSpring.micro, value: parsed.isRecurring)
+ }
+
Spacer(minLength: 4)
// Priority
@@ -1164,16 +1600,401 @@ struct AddTaskSheet: View {
.padding(.vertical, 4)
}
.background(AppColors.surface(cs).ignoresSafeArea())
+ .sheet(isPresented: $showDatePicker) {
+ TaskDatePickerSheet(
+ selectedDate: $parsed.date,
+ hasTime: $parsed.hasTime,
+ isRecurring: $parsed.isRecurring,
+ recurrenceLabel: $parsed.recurrenceLabel,
+ endDate: $parsed.endDate,
+ isAllDay: $parsed.isAllDay
+ )
+ }
}
private func submit() {
guard canAdd else { return }
taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
- quadrant: selectedQuadrant, category: selectedCategory)
+ quadrant: selectedQuadrant, category: selectedCategory,
+ isRecurring: parsed.isRecurring,
+ endDate: parsed.endDate, isAllDay: parsed.isAllDay)
dismiss()
}
}
+// MARK: - Task Date Picker Sheet
+
+struct TaskDatePickerSheet: View {
+ @Binding var selectedDate: Date?
+ @Binding var hasTime: Bool
+ @Binding var isRecurring: Bool
+ @Binding var recurrenceLabel: String?
+ @Binding var endDate: Date?
+ @Binding var isAllDay: Bool
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.colorScheme) private var cs
+
+ @State private var pickerMode: Int // 0 = Date, 1 = Duration
+ @State private var localDate: Date
+ @State private var localEndDate: Date
+ @State private var localTime: Date?
+ @State private var showTimePicker = false
+ @State private var localRepeat: String
+ @State private var localIsAllDay: Bool
+ @State private var editingEnd = false
+
+ init(selectedDate: Binding, hasTime: Binding,
+ isRecurring: Binding, recurrenceLabel: Binding,
+ endDate: Binding, isAllDay: Binding) {
+ _selectedDate = selectedDate
+ _hasTime = hasTime
+ _isRecurring = isRecurring
+ _recurrenceLabel = recurrenceLabel
+ _endDate = endDate
+ _isAllDay = isAllDay
+ let start = selectedDate.wrappedValue ?? Calendar.current.startOfDay(for: Date())
+ _localDate = State(initialValue: start)
+ _localEndDate = State(initialValue: endDate.wrappedValue ?? start)
+ _localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil)
+ _localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None")
+ _localIsAllDay = State(initialValue: isAllDay.wrappedValue)
+ _pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0)
+ }
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(spacing: 0) {
+ if pickerMode == 0 {
+ CalendarGridView(selectedDate: $localDate)
+ .padding(.horizontal, 20).padding(.top, 12).padding(.bottom, 20)
+ optionsCard
+ } else {
+ durationContent
+ }
+
+ Button {
+ selectedDate = nil; hasTime = false; endDate = nil; dismiss()
+ } label: {
+ Text("Clear")
+ .font(AppFonts.sans(16, weight: .medium))
+ .foregroundColor(AppColors.accent)
+ }
+ .padding(.top, 24).padding(.bottom, 12)
+ }
+ }
+ .background(Color(uiColor: cs == .dark ? .systemBackground : .systemGroupedBackground).ignoresSafeArea())
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button { dismiss() } label: {
+ Image(systemName: "xmark")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundColor(.primary)
+ .frame(width: 34, height: 34)
+ .background(Color(uiColor: .systemGray5))
+ .clipShape(Circle())
+ }
+ }
+ ToolbarItem(placement: .principal) {
+ HStack(spacing: 2) {
+ modeButton("Date", tag: 0)
+ modeButton("Duration", tag: 1)
+ }
+ .background(Color(uiColor: .systemGray6))
+ .clipShape(Capsule())
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button { commit(); dismiss() } label: {
+ Image(systemName: "checkmark")
+ .font(.system(size: 14, weight: .bold))
+ .foregroundColor(.white)
+ .frame(width: 36, height: 36)
+ .background(AppColors.accent)
+ .clipShape(Circle())
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func modeButton(_ label: String, tag: Int) -> some View {
+ Button { withAnimation(KisaniSpring.micro) { pickerMode = tag } } label: {
+ Text(label)
+ .font(AppFonts.sans(14, weight: pickerMode == tag ? .semibold : .regular))
+ .foregroundColor(pickerMode == tag ? .primary : .secondary)
+ .padding(.horizontal, 18).padding(.vertical, 7)
+ .background(pickerMode == tag ? Color(uiColor: .systemGray4) : Color.clear)
+ .clipShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ }
+
+ @ViewBuilder
+ private var optionsCard: some View {
+ VStack(spacing: 0) {
+ Button { withAnimation(KisaniSpring.micro) { showTimePicker.toggle() } } label: {
+ pickerRow(icon: "clock", label: "Time",
+ value: localTime != nil ? timeFmt.string(from: localTime!) : "None")
+ }
+ .buttonStyle(.plain)
+
+ if showTimePicker {
+ DatePicker("", selection: Binding(
+ get: { localTime ?? localDate },
+ set: { localTime = $0 }
+ ), displayedComponents: .hourAndMinute)
+ .datePickerStyle(.wheel).labelsHidden()
+ .transition(.opacity.combined(with: .move(edge: .top)))
+ }
+
+ Divider().padding(.leading, 54)
+ pickerRow(icon: "alarm", label: "Reminder", value: "None")
+ Divider().padding(.leading, 54)
+
+ Menu {
+ ForEach(["None", "Daily", "Weekly", "Monthly", "Yearly"], id: \.self) { opt in
+ Button { withAnimation(KisaniSpring.micro) { localRepeat = opt } } label: {
+ Label(opt, systemImage: localRepeat == opt ? "checkmark" : "")
+ }
+ }
+ } label: {
+ pickerRow(icon: "arrow.clockwise", label: "Repeat", value: localRepeat)
+ }
+ .buttonStyle(.plain)
+ }
+ .background(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ .padding(.horizontal, 16)
+ }
+
+ @ViewBuilder
+ private var durationContent: some View {
+ HStack(alignment: .top, spacing: 0) {
+ Button { withAnimation(KisaniSpring.micro) { editingEnd = false } } label: {
+ VStack(alignment: .leading, spacing: 5) {
+ Text("Start")
+ .font(AppFonts.sans(13)).foregroundColor(.secondary)
+ Text(dayStr(localDate))
+ .font(AppFonts.sans(17, weight: .semibold))
+ .foregroundColor(editingEnd ? .secondary : AppColors.accent)
+ Text(relStr(localDate))
+ .font(AppFonts.sans(13)).foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.leading, 24).padding(.vertical, 16)
+ }
+ .buttonStyle(.plain)
+
+ Rectangle()
+ .fill(Color(uiColor: .separator))
+ .frame(width: 0.5).padding(.vertical, 12)
+
+ Button { withAnimation(KisaniSpring.micro) { editingEnd = true } } label: {
+ VStack(alignment: .leading, spacing: 5) {
+ Text("End")
+ .font(AppFonts.sans(13)).foregroundColor(.secondary)
+ Text(dayStr(localEndDate))
+ .font(AppFonts.sans(17, weight: .semibold))
+ .foregroundColor(editingEnd ? AppColors.accent : .secondary)
+ Text(durationStr)
+ .font(AppFonts.sans(13)).foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.leading, 24).padding(.vertical, 16)
+ }
+ .buttonStyle(.plain)
+ }
+ .background(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ .padding(.horizontal, 16)
+ .padding(.top, 12)
+
+ CalendarGridView(selectedDate: Binding(
+ get: { editingEnd ? localEndDate : localDate },
+ set: { v in
+ if editingEnd {
+ localEndDate = max(v, localDate)
+ } else {
+ localDate = v
+ if localEndDate < v { localEndDate = v }
+ }
+ }
+ ))
+ .padding(.horizontal, 20).padding(.top, 12).padding(.bottom, 20)
+
+ VStack(spacing: 0) {
+ HStack(spacing: 14) {
+ Image(systemName: "sun.max")
+ .font(.system(size: 18)).foregroundColor(.secondary).frame(width: 24)
+ Text("All Day")
+ .font(AppFonts.sans(16)).foregroundColor(.primary)
+ Spacer()
+ Toggle("", isOn: $localIsAllDay)
+ .tint(AppColors.accent).labelsHidden()
+ }
+ .padding(.horizontal, 16).frame(height: 52)
+ }
+ .background(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ .padding(.horizontal, 16)
+ }
+
+ @ViewBuilder
+ private func pickerRow(icon: String, label: String, value: String) -> some View {
+ HStack(spacing: 14) {
+ Image(systemName: icon)
+ .font(.system(size: 18)).foregroundColor(.secondary).frame(width: 24)
+ Text(label)
+ .font(AppFonts.sans(16)).foregroundColor(.primary)
+ Spacer()
+ Text(value)
+ .font(AppFonts.sans(15)).foregroundColor(.secondary)
+ Image(systemName: "chevron.up.chevron.down")
+ .font(.system(size: 11)).foregroundColor(.secondary)
+ }
+ .padding(.horizontal, 16).frame(height: 52)
+ }
+
+ private var timeFmt: DateFormatter {
+ let f = DateFormatter(); f.timeStyle = .short; return f
+ }
+
+ private func dayStr(_ d: Date) -> String {
+ let f = DateFormatter(); f.dateFormat = "MMM d, EEE"; return f.string(from: d)
+ }
+
+ private func relStr(_ d: Date) -> String {
+ if Calendar.current.isDateInToday(d) { return "Today" }
+ if Calendar.current.isDateInTomorrow(d) { return "Tomorrow" }
+ if Calendar.current.isDateInYesterday(d) { return "Yesterday" }
+ return ""
+ }
+
+ private var durationStr: String {
+ let n = (Calendar.current.dateComponents([.day], from: localDate, to: localEndDate).day ?? 0) + 1
+ return "Duration: \(n) day\(n == 1 ? "" : "s")"
+ }
+
+ private func commit() {
+ if pickerMode == 0 {
+ var final = Calendar.current.startOfDay(for: localDate)
+ if let t = localTime {
+ let c = Calendar.current.dateComponents([.hour, .minute], from: t)
+ final = Calendar.current.date(bySettingHour: c.hour ?? 0, minute: c.minute ?? 0,
+ second: 0, of: localDate) ?? final
+ hasTime = true
+ } else {
+ hasTime = false
+ }
+ selectedDate = final
+ endDate = nil
+ isAllDay = false
+ isRecurring = localRepeat != "None"
+ recurrenceLabel = localRepeat != "None" ? localRepeat : nil
+ } else {
+ selectedDate = Calendar.current.startOfDay(for: localDate)
+ endDate = Calendar.current.startOfDay(for: localEndDate)
+ isAllDay = localIsAllDay
+ hasTime = false
+ }
+ }
+}
+
+// MARK: - Calendar Grid View
+
+struct CalendarGridView: View {
+ @Binding var selectedDate: Date
+ @State private var displayMonth: Date
+ @Environment(\.colorScheme) private var cs
+
+ private let cal = Calendar.current
+ private let dow = ["S", "M", "T", "W", "T", "F", "S"]
+
+ init(selectedDate: Binding) {
+ _selectedDate = selectedDate
+ let c = Calendar.current.dateComponents([.year, .month], from: selectedDate.wrappedValue)
+ _displayMonth = State(initialValue: Calendar.current.date(from: c) ?? selectedDate.wrappedValue)
+ }
+
+ var body: some View {
+ VStack(spacing: 10) {
+ HStack {
+ Button { shift(-1) } label: {
+ Image(systemName: "chevron.left")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundColor(.primary)
+ .frame(width: 36, height: 36)
+ }
+ Spacer()
+ Text(monthLabel)
+ .font(.system(size: 22, weight: .bold))
+ Spacer()
+ Button { shift(1) } label: {
+ Image(systemName: "chevron.right")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundColor(.primary)
+ .frame(width: 36, height: 36)
+ }
+ }
+
+ HStack(spacing: 0) {
+ ForEach(dow, id: \.self) { d in
+ Text(d)
+ .font(.system(size: 13, weight: .medium))
+ .foregroundColor(.secondary)
+ .frame(maxWidth: .infinity)
+ }
+ }
+
+ LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 4) {
+ ForEach(Array(days.enumerated()), id: \.offset) { _, day in
+ if let day = day {
+ let isSelected = cal.isDate(day, inSameDayAs: selectedDate)
+ let isToday = cal.isDateInToday(day)
+ Button { selectedDate = day } label: {
+ ZStack {
+ if isSelected {
+ Circle().fill(AppColors.accent).frame(width: 38, height: 38)
+ }
+ Text("\(cal.component(.day, from: day))")
+ .font(.system(size: 16, weight: isSelected ? .bold : .regular))
+ .foregroundColor(isSelected ? .white : isToday ? AppColors.accent : .primary)
+ }
+ .frame(height: 42)
+ }
+ .buttonStyle(.plain)
+ } else {
+ Color.clear.frame(height: 42)
+ }
+ }
+ }
+ }
+ }
+
+ private var monthLabel: String {
+ let f = DateFormatter(); f.dateFormat = "MMMM"; return f.string(from: displayMonth)
+ }
+
+ private func shift(_ d: Int) {
+ displayMonth = cal.date(byAdding: .month, value: d, to: displayMonth) ?? displayMonth
+ }
+
+ private var days: [Date?] {
+ guard let range = cal.range(of: .day, in: .month, for: displayMonth),
+ let first = cal.date(from: cal.dateComponents([.year, .month], from: displayMonth))
+ else { return [] }
+ let offset = cal.component(.weekday, from: first) - 1
+ var result: [Date?] = Array(repeating: nil, count: offset)
+ for i in 0..