Files
KisaniCal/KisaniCal/Views/TodayView.swift
kutesir ac147c55b0 Matrix/menus: Pick Date everywhere, time-based reminders, notification snooze (KC-21)
- Date menu now offers "Pick Date" (and Edit) in the Matrix grid cards and the
  Calendar reschedule/move menu, matching the tasks view — both open the shared
  TaskEditSheet via a new editingTask sheet.
- Reworked the reminder picker (TaskDatePickerSheet, shared by all entry points)
  from day/week offsets to sensible time-based ones relative to the event time:
  None, On time, 5 min, 30 min, 1 hour, 1 day, plus a Minutes/Hours/Days custom
  wheel. Existing absolute reminderDates migrate to the nearest minute offset.
- Notifications gain Snooze actions (15/30/60/120 min) that reschedule the task
  reminder; postpone now clears the Matrix urgency override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:14:10 +03:00

2753 lines
117 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
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.shared
@State private var showAddTask = false
@State private var showWorkoutSession = false
@State private var editingTask: TaskItem? = nil
@AppStorage("todayHideCompleted") private var hideCompleted = false
@ObservedObject private var tm = TutorialManager.shared
@ObservedObject private var hk = HealthKitManager.shared
private let dateFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEEE, MMM d"; return f
}()
private var todayCalEvents: [EKEvent] {
calStore.monthEvents.filter {
// Subscribed/RSS feed calendars (F1 schedule, TV shows, etc.) belong in the
// Calendar tab only keep them out of the Today/Tasks timeline.
$0.calendar?.type != .subscription
&& Calendar.current.isDateInToday($0.startDate ?? .distantFuture)
}
}
var body: some View {
ZStack(alignment: .bottomTrailing) {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
Color.clear.frame(height: 0).trackScrollForTabBar()
// Header
HStack {
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))
}
Spacer()
}
.padding(.horizontal, 18)
.padding(.top, 8)
// Title
Text("Today")
.font(AppFonts.sans(34, weight: .bold))
.tracking(-0.8)
.foregroundColor(AppColors.text(cs))
.padding(.horizontal, 18)
.padding(.top, 6)
Text(dateFmt.string(from: Date()))
.font(AppFonts.mono(10))
.foregroundColor(AppColors.text3(cs).opacity(0.7))
.padding(.horizontal, 18)
.padding(.bottom, 8)
// Health stats strip
if hk.authorized {
TodayHealthStrip(hk: hk, workoutVM: workoutVM)
.padding(.horizontal, 18)
.padding(.bottom, 10)
}
// Today timeline: active completed (green)
let activeTasks = taskVM.todayTasks
let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks
DayTimelineView(
date: Date(),
tasks: activeTasks + completedTasks,
overdueTasks: [],
calEvents: todayCalEvents,
workout: workoutVM.program(for: Date()),
workoutDone: workoutVM.isWorkoutComplete(on: Date()),
workoutMissed: workoutVM.isWorkoutMissed(on: Date()),
workoutCompensation: workoutVM.isCompensation(on: Date()),
onWorkoutDone: { workoutVM.markWorkoutDone(on: Date()) },
onWorkoutMissed: { workoutVM.markWorkoutMissed(on: Date()) },
onWorkoutCompensate: {
workoutVM.markWorkoutMissed(on: Date())
workoutVM.promptCompensation(forMissed: Date())
},
onWorkoutTap: {
if let w = workoutVM.program(for: Date()), workoutVM.activeProgramId != w.id {
workoutVM.switchProgram(to: w.id)
}
showWorkoutSession = true
},
onToggle: { task in withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } },
onPin: { taskVM.pin($0) },
onPostpone: { taskVM.postpone($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }
)
.padding(.horizontal, 18)
// Overdue (below Today's tasks, above Next 3 Days)
if !taskVM.overdueTasks.isEmpty {
OverdueCard(
tasks: taskVM.overdueTasks,
onPostpone: { taskVM.postponeAllOverdue() },
onToggle: { task in withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } },
onPostponeTask: { taskVM.postpone($0) },
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }
)
.padding(.horizontal, 18)
.padding(.top, 10)
}
// Tomorrow
if !taskVM.tomorrowTasks.isEmpty {
UpcomingSection(
title: "Tomorrow",
tasks: taskVM.tomorrowTasks,
onToggle: { taskVM.toggle($0) },
onPostpone: { taskVM.postpone($0) },
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }
)
}
// Next 7 Days (27 days out)
if !taskVM.next7DaysTasks.isEmpty {
UpcomingSection(
title: "Next 7 Days",
tasks: taskVM.next7DaysTasks,
onToggle: { taskVM.toggle($0) },
onPostpone: { taskVM.postpone($0) },
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }
)
}
// Later (beyond 7 days)
if !taskVM.laterTasks.isEmpty {
UpcomingSection(
title: "Later",
tasks: taskVM.laterTasks,
onToggle: { taskVM.toggle($0) },
onPostpone: { taskVM.postpone($0) },
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }
)
}
// Completed (all, collapsible)
if !taskVM.completedTasks.isEmpty {
CompletedSection(
tasks: taskVM.completedTasks,
onUndo: { taskVM.toggle($0) }
)
.padding(.horizontal, 18)
.padding(.top, 10)
}
let hasAnything = !taskVM.overdueTasks.isEmpty || !taskVM.todayTasks.isEmpty
|| !taskVM.tomorrowTasks.isEmpty || !taskVM.next7DaysTasks.isEmpty
|| !taskVM.laterTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty
|| !todayCalEvents.isEmpty
|| workoutVM.program(for: Date()) != nil
if !hasAnything {
VStack(spacing: 6) {
Text("All caught up")
.font(AppFonts.sans(15, weight: .semibold))
.foregroundColor(AppColors.text2(cs))
Text("Nothing on your plate — tap + to start")
.font(AppFonts.sans(12))
.foregroundColor(AppColors.text3(cs))
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.top, 60)
}
Spacer(minLength: 100)
}
}
FAB { showAddTask = true }
.tutorialAnchor(0)
.padding(.trailing, 20)
.padding(.bottom, 10)
if tm.todayIsActive {
VStack {
Spacer()
InViewTutorialCard(
tips: tm.todayTips,
step: $tm.todayStep,
onAdvance: { tm.advanceToday() },
onSkip: { tm.skipToday() }
)
.padding(.bottom, 84)
}
.transition(.opacity)
.zIndex(10)
}
}
.background(AppColors.background(cs).ignoresSafeArea())
.sheet(isPresented: $showAddTask) {
AddTaskSheet()
.environmentObject(taskVM)
.presentationDetents([.height(150)])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showWorkoutSession) {
WorkoutSessionSheet()
.environmentObject(workoutVM)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(item: $editingTask) { task in
TaskEditSheet(task: task)
.environmentObject(taskVM)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
.onAppear {
calStore.refreshStatus()
calStore.fetchMonth(containing: Date())
if hk.authorized { Task { await hk.refresh() } }
}
.onChange(of: scenePhase) { phase in
if phase == .active {
calStore.refreshStatus()
if hk.authorized { Task { await hk.refresh() } }
}
}
}
}
// 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.quadrant.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.quadrant.softColor
)
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)
}
}
}
// MARK: - Overdue Card
struct OverdueCard: View {
@Environment(\.colorScheme) private var cs
let tasks: [TaskItem]
let onPostpone: () -> Void
let onToggle: (TaskItem) -> Void
var onPostponeTask: (TaskItem) -> Void = { _ in }
var onPin: (TaskItem) -> Void = { _ in }
var onEdit: (TaskItem) -> Void = { _ in }
var onDelete: (TaskItem) -> Void = { _ in }
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in }
var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in }
var onResetMatrixUrgency: (TaskItem) -> Void = { _ in }
@State private var expanded = true
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("OVERDUE")
.font(AppFonts.mono(9.5, weight: .bold))
.foregroundColor(AppColors.accent)
Spacer()
HStack(spacing: 7) {
Button(action: onPostpone) {
Text("POSTPONE")
.font(AppFonts.mono(9, weight: .bold))
.foregroundColor(AppColors.accent)
.padding(.horizontal, 8).padding(.vertical, 3)
.background(AppColors.accentSoft)
.cornerRadius(5)
}
.buttonStyle(.plain)
Text("\(tasks.count)")
.font(AppFonts.mono(9.5))
.foregroundColor(AppColors.text3(cs))
Image(systemName: "chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(AppColors.text3(cs))
.rotationEffect(.degrees(expanded ? 0 : -90))
.animation(KisaniSpring.micro, value: expanded)
}
}
.padding(.horizontal, 13).padding(.vertical, 10)
.contentShape(Rectangle())
.onTapGesture { withAnimation(KisaniSpring.snappy) { expanded.toggle() } }
if expanded {
Divider().background(AppColors.accent.opacity(0.10))
ForEach(tasks) { task in
HStack(spacing: 10) {
Button { withAnimation(KisaniSpring.bounce) { onToggle(task) } } label: {
Circle()
.stroke(AppColors.red, lineWidth: 1.5)
.frame(width: 20, height: 20)
.frame(width: 36, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Text(task.title)
.font(AppFonts.sans(12.5, weight: .medium))
.foregroundColor(AppColors.red)
.lineLimit(1)
Spacer(minLength: 6)
// Small inline postpone
Button { withAnimation(KisaniSpring.snappy) { onPostponeTask(task) } } label: {
Text("+1d")
.font(AppFonts.mono(9, weight: .bold))
.foregroundColor(AppColors.red)
.padding(.horizontal, 7).padding(.vertical, 3)
.background(AppColors.redSoft)
.cornerRadius(5)
}
.buttonStyle(.plain)
if let d = task.dueDate {
Text(d, style: .date)
.font(AppFonts.mono(10))
.foregroundColor(AppColors.red.opacity(0.7))
}
}
.padding(.horizontal, 10)
.contentShape(Rectangle())
.contextMenu { taskMenu(task) }
}
}
}
.background(AppColors.red.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large)
.stroke(AppColors.red.opacity(0.18), lineWidth: 1))
}
@ViewBuilder
private func taskMenu(_ task: TaskItem) -> some View {
Button { withAnimation(KisaniSpring.bounce) { onToggle(task) } } label: {
Label("Mark Complete", systemImage: "checkmark.circle")
}
Button { withAnimation(KisaniSpring.snappy) { onPostponeTask(task) } } label: {
Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
}
Divider()
TaskMenuItems(
task: task,
onPin: onPin,
onSetDate: onSetDate,
onPostponeMinutes: onPostponeMinutes,
onMove: onMove,
onSetPriority: onSetPriority,
onSetCategory: onSetCategory,
onResetMatrixUrgency: onResetMatrixUrgency,
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
onEdit: onEdit,
onDelete: onDelete
)
}
}
// MARK: - Workout Today Banner
struct WorkoutTodayBanner: View {
@Environment(\.colorScheme) private var cs
let program: WorkoutProgram
let vm: WorkoutViewModel
@Binding var isExpanded: Bool
let onTap: () -> Void
private var isActiveProgram: Bool { vm.activeProgramId == program.id }
private var isDone: Bool { isActiveProgram && vm.progress >= 1 && vm.totalSets > 0 }
private var accent: Color { isDone ? AppColors.green : AppColors.blue }
private var setsText: String {
guard isActiveProgram && vm.totalSets > 0 else { return "\(program.totalExercises) exercises" }
return "\(vm.doneSets)/\(vm.totalSets) sets"
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Main row progress ring leads
HStack(spacing: 13) {
// Progress ring (or idle ring when not active)
ZStack {
Circle()
.stroke(AppColors.borderHi(cs), lineWidth: 3)
.frame(width: 44, height: 44)
if isActiveProgram && vm.totalSets > 0 {
Circle()
.trim(from: 0, to: CGFloat(min(vm.progress, 1)))
.stroke(accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.frame(width: 44, height: 44)
.rotationEffect(.degrees(-90))
.animation(KisaniSpring.entrance, value: vm.progress)
}
if isDone {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(accent)
} else if isActiveProgram && vm.totalSets > 0 {
Text("\(Int(vm.progress * 100))%")
.font(AppFonts.mono(9, weight: .bold))
.foregroundColor(accent)
} else {
Image(systemName: "dumbbell")
.font(.system(size: 13, weight: .regular))
.foregroundColor(AppColors.text3(cs))
}
}
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) {
Text(isDone ? "DONE" : "TODAY")
.font(AppFonts.mono(8, weight: .bold))
.foregroundColor(accent)
Text("·")
.font(AppFonts.mono(8))
.foregroundColor(AppColors.text3(cs))
Text(setsText)
.font(AppFonts.mono(8))
.foregroundColor(AppColors.text3(cs))
}
Text(program.name)
.font(AppFonts.sans(14, weight: .semibold))
.foregroundColor(AppColors.text(cs))
Text("\(program.sections.count) section\(program.sections.count == 1 ? "" : "s") · \(program.totalExercises) exercise\(program.totalExercises == 1 ? "" : "s")")
.font(AppFonts.mono(9))
.foregroundColor(AppColors.text3(cs))
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(AppColors.text3(cs))
}
.padding(.horizontal, 13).padding(.vertical, 12)
.contentShape(Rectangle())
.onTapGesture { onTap() }
// Expand toggle
if !program.sections.isEmpty {
Button {
withAnimation(KisaniSpring.snappy) { isExpanded.toggle() }
} label: {
HStack(spacing: 4) {
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 8, weight: .bold))
Text(isExpanded ? "Less" : "Show \(program.sections.count) sections")
.font(AppFonts.mono(8.5, weight: .bold))
}
.foregroundColor(accent.opacity(0.55))
.frame(maxWidth: .infinity)
.padding(.vertical, 7)
}
.buttonStyle(.plain)
// Section breakdown (expanded only)
if isExpanded {
Divider().background(accent.opacity(0.10))
VStack(alignment: .leading, spacing: 6) {
ForEach(program.sections) { section in
HStack(alignment: .top, spacing: 8) {
Text(section.name.uppercased())
.font(AppFonts.mono(8.5, weight: .bold))
.foregroundColor(accent.opacity(0.8))
.frame(width: 72, alignment: .leading)
Text(section.exercises.isEmpty
? "No exercises"
: section.exercises.map(\.name).joined(separator: " · "))
.font(AppFonts.sans(10.5))
.foregroundColor(AppColors.text2(cs))
.lineLimit(1)
Spacer()
}
}
}
.padding(.horizontal, 13).padding(.top, 8).padding(.bottom, 12)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
.background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
.animation(KisaniSpring.entrance, value: isDone)
}
}
// MARK: - Workout Session Sheet
struct WorkoutSessionSheet: View {
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
var body: some View {
VStack(spacing: 0) {
// Header
HStack(spacing: 12) {
WorkoutSectionBar(count: vm.activeSections.count)
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text(vm.workoutTitle)
.font(AppFonts.sans(17, weight: .bold))
.foregroundColor(AppColors.text(cs))
Text(vm.totalSets > 0
? "\(vm.doneSets) of \(vm.totalSets) sets done"
: "No exercises added yet")
.font(AppFonts.mono(10))
.foregroundColor(AppColors.text3(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)
// Dot Grid Progress
VStack(spacing: 8) {
HStack {
Text("PROGRESS")
.font(AppFonts.mono(8.5, weight: .bold))
.foregroundColor(AppColors.text3(cs))
Spacer()
Text(vm.totalSets > 0 ? "\(Int(vm.progress * 100))%" : "0%")
.font(AppFonts.mono(10, weight: .bold))
.foregroundColor(vm.progress >= 1 ? AppColors.green : AppColors.text2(cs))
}
DotGridProgress(progress: vm.totalSets > 0 ? vm.progress : 0, cs: cs)
}
.padding(.horizontal, 20).padding(.bottom, 14)
// Completion Banner
if vm.progress >= 1 && vm.totalSets > 0 {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 14)).foregroundColor(AppColors.green)
Text("All sets done")
.font(AppFonts.mono(11, weight: .bold))
.foregroundColor(AppColors.green)
Spacer()
}
.padding(.horizontal, 20).padding(.vertical, 10)
.background(AppColors.green.opacity(0.08))
}
Divider().background(AppColors.border(cs))
// Exercise List
List {
ForEach($vm.activeSections) { $section in
Section {
if section.isExpanded {
if section.exercises.isEmpty {
Text("No exercises in this section")
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
.frame(maxWidth: .infinity).padding(.vertical, 12)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} else {
ForEach($section.exercises) { $ex in
ExerciseCard(exercise: $ex, sectionId: section.id)
.environmentObject(vm)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 3, leading: 14, bottom: 3, trailing: 14))
}
}
}
} header: {
SessionSectionHeader(section: $section)
.environmentObject(vm)
}
}
Color.clear.frame(height: 30)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollDismissesKeyboard(.immediately)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil
)
}
.font(AppFonts.sans(13, weight: .semibold))
.foregroundColor(AppColors.accent)
}
}
}
.background(AppColors.background(cs).ignoresSafeArea())
}
}
// MARK: - Session Section Header (expand/collapse + done count only)
private struct SessionSectionHeader: View {
@Binding var section: WorkoutSection
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
private var doneSets: Int { section.exercises.reduce(0) { $0 + $1.doneSets } }
private var totalSets: Int { section.exercises.reduce(0) { $0 + $1.totalSets } }
private var isComplete: Bool { totalSets > 0 && doneSets == totalSets }
var body: some View {
HStack(spacing: 8) {
Button {
withAnimation(KisaniSpring.snappy) { vm.toggleSectionExpanded(section.id) }
} label: {
Image(systemName: "chevron.right")
.font(.system(size: 9, weight: .bold))
.foregroundColor(AppColors.text3(cs))
.rotationEffect(.degrees(section.isExpanded ? 90 : 0))
.frame(width: 18)
}
.buttonStyle(.plain)
Text(section.name.uppercased())
.font(AppFonts.mono(9.5, weight: .bold))
.foregroundColor(AppColors.text2(cs))
.textCase(nil)
if totalSets > 0 {
HStack(spacing: 3) {
if isComplete {
Image(systemName: "checkmark")
.font(.system(size: 7, weight: .bold))
.foregroundColor(AppColors.green)
}
Text("\(doneSets)/\(totalSets)")
.font(AppFonts.mono(8.5, weight: .bold))
.foregroundColor(isComplete ? AppColors.green : AppColors.text3(cs))
}
.padding(.horizontal, 6).padding(.vertical, 2)
.background(isComplete ? AppColors.greenSoft : AppColors.surface2(cs))
.clipShape(Capsule())
}
Spacer()
}
.padding(.horizontal, 14).padding(.vertical, 6)
.background(AppColors.background(cs))
}
}
// MARK: - Upcoming Section
struct UpcomingSection: View {
@Environment(\.colorScheme) private var cs
var title: String = "Upcoming"
let tasks: [TaskItem]
let onToggle: (TaskItem) -> Void
let onPostpone: (TaskItem) -> Void
var onPin: (TaskItem) -> Void = { _ in }
var onEdit: (TaskItem) -> Void = { _ in }
let onDelete: (TaskItem) -> Void
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in }
var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in }
var onResetMatrixUrgency: (TaskItem) -> Void = { _ in }
@State private var collapsed = false
@State private var showAll = false
private let pageSize = 5
private var visible: [TaskItem] {
showAll ? tasks : Array(tasks.prefix(pageSize))
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header (tappable to collapse)
HStack(spacing: 6) {
Text(title)
.font(AppFonts.sans(10.5, weight: .semibold))
.foregroundColor(AppColors.text3(cs))
Text("\(tasks.count)")
.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 { showAll = false }
}
}
if !collapsed {
// Task rows
ForEach(visible) { task in
TaskRowView(task: task) {
withAnimation(KisaniSpring.snappy) { onToggle(task) }
}
.contextMenu {
Button { withAnimation(KisaniSpring.snappy) { onPostpone(task) } } label: {
Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
}
Divider()
TaskMenuItems(
task: task,
onPin: onPin,
onSetDate: onSetDate,
onPostponeMinutes: onPostponeMinutes,
onMove: onMove,
onSetPriority: onSetPriority,
onSetCategory: onSetCategory,
onResetMatrixUrgency: onResetMatrixUrgency,
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
onEdit: onEdit,
onDelete: { task in withAnimation(KisaniSpring.snappy) { onDelete(task) } }
)
}
.padding(.horizontal, 18)
.padding(.bottom, 5)
}
// Show more / collapse overflow
if tasks.count > pageSize {
Button {
withAnimation(KisaniSpring.snappy) { showAll.toggle() }
} label: {
HStack(spacing: 5) {
Text(showAll
? "Show less"
: "\(tasks.count - pageSize) more")
.font(AppFonts.mono(9.5, weight: .semibold))
.foregroundColor(AppColors.accent)
Image(systemName: showAll ? "chevron.up" : "chevron.down")
.font(.system(size: 8, weight: .bold))
.foregroundColor(AppColors.accent)
}
.padding(.horizontal, 18)
.padding(.top, 2)
.padding(.bottom, 4)
}
.buttonStyle(.plain)
}
}
}
}
}
// MARK: - Completed Today Section
struct CompletedTodaySection: View {
@Environment(\.colorScheme) private var cs
let tasks: [TaskItem]
let onUndo: (TaskItem) -> Void
@State private var expanded = true
private let timeFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "h:mm a"; return f
}()
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 11))
.foregroundColor(AppColors.green)
Text("COMPLETED TODAY")
.font(AppFonts.mono(9.5, weight: .bold))
.foregroundColor(AppColors.green)
}
Spacer()
HStack(spacing: 6) {
Text("\(tasks.count)")
.font(AppFonts.mono(9.5))
.foregroundColor(AppColors.text3(cs))
Image(systemName: "chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(AppColors.text3(cs))
.rotationEffect(.degrees(expanded ? 0 : -90))
.animation(KisaniSpring.micro, value: expanded)
}
}
.padding(.horizontal, 13).padding(.vertical, 10)
.contentShape(Rectangle())
.onTapGesture { withAnimation(KisaniSpring.snappy) { expanded.toggle() } }
if expanded {
Divider().background(AppColors.green.opacity(0.15))
ForEach(tasks) { task in
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 18))
.foregroundColor(AppColors.green)
.frame(width: 36, height: 44)
VStack(alignment: .leading, spacing: 1) {
Text(task.title)
.font(AppFonts.sans(12, weight: .medium))
.foregroundColor(AppColors.text3(cs))
.strikethrough(true, color: AppColors.text3(cs))
if let t = task.completedAt {
Text("Done at \(timeFmt.string(from: t))")
.font(AppFonts.mono(9.5))
.foregroundColor(AppColors.text3(cs).opacity(0.6))
}
}
Spacer()
Button {
withAnimation(KisaniSpring.snappy) { onUndo(task) }
} label: {
Text("UNDO")
.font(AppFonts.mono(8.5, weight: .bold))
.foregroundColor(AppColors.text3(cs))
.padding(.horizontal, 7).padding(.vertical, 3)
.background(AppColors.surface2(cs))
.cornerRadius(5)
.overlay(RoundedRectangle(cornerRadius: 5).stroke(AppColors.border(cs), lineWidth: 1))
}
.buttonStyle(.plain)
.padding(.trailing, 4)
}
.padding(.horizontal, 10)
}
.padding(.bottom, 4)
}
}
.background(AppColors.green.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large)
.stroke(AppColors.green.opacity(0.18), lineWidth: 1))
}
}
// MARK: - Completed Section (all completed, collapsible archive)
struct CompletedSection: View {
@Environment(\.colorScheme) private var cs
let tasks: [TaskItem]
let onUndo: (TaskItem) -> Void
@State private var expanded = false
private let dateFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "MMM d"; return f
}()
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Completed")
.font(AppFonts.sans(15, weight: .bold))
.foregroundColor(AppColors.text2(cs))
Spacer()
Text("\(tasks.count)")
.font(AppFonts.mono(11))
.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, 14).padding(.vertical, 12)
.contentShape(Rectangle())
.onTapGesture { withAnimation(KisaniSpring.snappy) { expanded.toggle() } }
if expanded {
ForEach(tasks) { task in
HStack(spacing: 12) {
Button {
withAnimation(KisaniSpring.snappy) { onUndo(task) }
} label: {
Image(systemName: "checkmark.square.fill")
.font(.system(size: 22))
.foregroundColor(AppColors.text3(cs).opacity(0.6))
}
.buttonStyle(.plain)
Text(task.title)
.font(AppFonts.sans(14))
.foregroundColor(AppColors.text3(cs))
.strikethrough(true, color: AppColors.text3(cs).opacity(0.7))
.lineLimit(1)
Spacer(minLength: 8)
if let d = task.completedAt {
Text(dateFmt.string(from: d))
.font(AppFonts.sans(13))
.foregroundColor(AppColors.text3(cs))
}
}
.padding(.horizontal, 14).padding(.vertical, 10)
}
.padding(.bottom, 4)
}
}
.background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
}
}
// MARK: - Task Row
struct TaskRowView: View {
@Environment(\.colorScheme) private var cs
@AppStorage("todayShowDetails") private var showDetails = true
let task: TaskItem
let onToggle: () -> Void
private let dueFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "MMM d"; return f
}()
var body: some View {
HStack(spacing: 10) {
Button(action: onToggle) {
ZStack {
Circle()
.fill(task.quadrant.softColor)
.frame(width: 22, height: 22)
Circle()
.stroke(task.quadrant.color, lineWidth: 2)
.frame(width: 22, height: 22)
}
.frame(width: 36, height: 44)
.padding(.leading, 4)
.contentShape(Circle())
}
.buttonStyle(PressButtonStyle(scale: 0.82))
VStack(alignment: .leading, spacing: 2) {
Text(task.title)
.font(AppFonts.sans(13))
.foregroundColor(AppColors.text(cs))
if let d = task.dueDate, showDetails {
HStack(spacing: 4) {
Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceLabel ?? "Repeat")" : "")")
.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))
}
}
}
Spacer()
// reminder · 🔁 recurring indicators
HStack(spacing: 5) {
if task.reminderDate != nil || task.constantReminder {
Image(systemName: "alarm")
.font(.system(size: 11))
.foregroundColor(AppColors.text3(cs))
}
if task.isRecurring {
Image(systemName: "repeat")
.font(.system(size: 11))
.foregroundColor(AppColors.text3(cs))
}
}
TagChip(
text: task.category.rawValue,
color: task.quadrant.color,
bg: task.quadrant.softColor
)
.padding(.trailing, 9)
}
.frame(minHeight: 48)
.background(AppColors.surface(cs))
.overlay(alignment: .leading) {
Rectangle()
.fill(task.quadrant.color)
.frame(width: 2.5)
}
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
.overlay(RoundedRectangle(cornerRadius: AppRadius.small)
.stroke(AppColors.border(cs), lineWidth: 1))
}
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
struct NLParsed {
var date: Date?
var hasTime: Bool = false
var tokenRanges: [NSRange] = []
var isRecurring: Bool = false
var recurrenceLabel: String? = nil
var recurrenceEnd: Date? = nil
var endDate: Date? = nil
var isAllDay: Bool = false
var isBirthday: Bool = false
var hasDate: Bool { date != nil }
var dateLabel: String {
guard let d = date 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" : "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)
}
}
struct NLTaskParser {
static func parse(_ input: String) -> NLParsed {
var result = NLParsed()
guard !input.isEmpty else { return result }
let lower = input.lowercased()
let cal = Calendar.current
var comps = cal.dateComponents([.year, .month, .day], from: Date())
var rawRanges: [NSRange] = []
var foundDay = false
// 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) }
}
// 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
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 {
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
let timePatterns = [
"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 {
if let (r, h, m) = parseTime(pat, in: lower) {
comps.hour = h; comps.minute = m; comps.second = 0
rawRanges.append(r); foundTime = true; break
}
}
if foundDay || foundTime {
result.date = cal.date(from: comps)
result.hasTime = foundTime
}
result.tokenRanges = expand(rawRanges, in: lower)
return result
}
static func cleanTitle(_ text: String, ranges: [NSRange]) -> String {
var s = text as NSString
for r in ranges.sorted(by: { $0.location > $1.location }) where r.location + r.length <= s.length {
s = s.replacingCharacters(in: r, with: "") as NSString
}
return (s as String).trimmingCharacters(in: .whitespaces)
.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]
if let r = match("\\bnext\\s+week\\b", in: text) { return (r, 7) }
for (unit, mult) in [("days?", 1), ("weeks?", 7)] {
let pat = "\\bin\\s+(\\d+|a|an|one|two|three|four|five|six|seven|eight|nine|ten)\\s+\(unit)\\b"
guard let rx = try? NSRegularExpression(pattern: pat),
let m = rx.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
m.numberOfRanges >= 2,
let nr = Range(m.range(at: 1), in: text) else { continue }
let ns = String(text[nr])
return (m.range, (Int(ns) ?? wordNums[ns] ?? 1) * mult)
}
return nil
}
private static func daysUntil(_ target: Int) -> Int {
let today = Calendar.current.component(.weekday, from: Date())
let d = target - today
return d <= 0 ? d + 7 : d
}
private static func match(_ pattern: String, in text: String) -> NSRange? {
guard let rx = try? NSRegularExpression(pattern: pattern) else { return nil }
return rx.firstMatch(in: text, range: NSRange(text.startIndex..., in: text))?.range
}
private static func parseTime(_ pattern: String, in text: String) -> (NSRange, Int, Int)? {
guard let rx = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
let m = rx.firstMatch(in: text, range: NSRange(text.startIndex..., in: text))
else { return nil }
let full = (text as NSString).substring(with: m.range).lowercased()
var h = 0, mn = 0
if m.numberOfRanges >= 2, let r1 = Range(m.range(at: 1), in: text) {
let s1 = String(text[r1])
if s1.count == 4 {
h = Int(s1.prefix(2)) ?? 0; mn = Int(s1.suffix(2)) ?? 0
} else {
h = Int(s1) ?? 0
}
}
if m.numberOfRanges >= 3, m.range(at: 2).location != NSNotFound,
let r2 = Range(m.range(at: 2), in: text) {
mn = Int(String(text[r2])) ?? mn
}
let isPM = full.contains("pm") && h < 12
let isAM = full.contains("am") && h == 12
if isPM { h += 12 }; if isAM { h = 0 }
guard h >= 0 && h < 24 && mn >= 0 && mn < 60 else { return nil }
return (m.range, h, mn)
}
private static func expand(_ ranges: [NSRange], in text: String) -> [NSRange] {
let ns = text as NSString
return ranges.map { r in
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 NSRange(location: loc, length: len)
}
}
}
// MARK: - NL Task Field
struct NLTaskField: UIViewRepresentable {
@Binding var text: String
let placeholder: String
var highlightRanges: [NSRange]
var onReturn: (() -> Void)? = nil
func makeUIView(context: Context) -> UITextView {
let tv = UITextView()
tv.delegate = context.coordinator
tv.isScrollEnabled = false
tv.backgroundColor = .clear
tv.textContainerInset = .zero
tv.textContainer.lineFragmentPadding = 0
tv.font = UIFont.systemFont(ofSize: 18)
tv.textColor = .placeholderText
tv.text = placeholder
tv.autocorrectionType = .yes
tv.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return tv
}
func updateUIView(_ tv: UITextView, context: Context) {
guard context.coordinator.isEditing else {
if text.isEmpty {
tv.text = placeholder
tv.textColor = .placeholderText
}
return
}
let sel = tv.selectedRange
let attr = NSMutableAttributedString(string: text)
let full = NSRange(location: 0, length: attr.length)
attr.addAttributes([.font: UIFont.systemFont(ofSize: 18), .foregroundColor: UIColor.label], range: full)
for r in highlightRanges where r.location + r.length <= attr.length {
attr.addAttributes([
.backgroundColor: UIColor.systemBlue.withAlphaComponent(0.13),
.foregroundColor: UIColor.systemBlue,
], range: r)
}
tv.attributedText = attr
tv.selectedRange = sel
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, UITextViewDelegate {
var parent: NLTaskField
var isEditing = false
init(_ p: NLTaskField) { parent = p }
func textViewDidBeginEditing(_ tv: UITextView) {
isEditing = true
if tv.textColor == .placeholderText { tv.text = ""; tv.textColor = .label }
}
func textViewDidEndEditing(_ tv: UITextView) {
isEditing = false
if tv.text.isEmpty { tv.text = parent.placeholder; tv.textColor = .placeholderText }
}
func textViewDidChange(_ tv: UITextView) { parent.text = tv.text }
}
}
// MARK: - Add Task Sheet
struct AddTaskSheet: View {
@EnvironmentObject var taskVM: TaskViewModel
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
@State private var rawText = ""
@State private var parsed = NLParsed()
@State private var selectedPriority: Priority = .none
@State private var selectedCategory: TaskCategory = .reminder
@State private var reminderDate: Date? = nil
@State private var constantReminder = false
@State private var showDatePicker = false
var prefilledDate: Date?
var prefilledQuadrant: Quadrant
init(prefilledDate: Date? = nil, prefilledQuadrant: Quadrant = .urgent) {
self.prefilledDate = prefilledDate
self.prefilledQuadrant = prefilledQuadrant
// Adding from a Matrix quadrant: seed the Priority that lands the task there.
let importantRow = (prefilledQuadrant == .urgent || prefilledQuadrant == .schedule)
_selectedPriority = State(initialValue: importantRow ? .high : .none)
var p = NLParsed()
p.date = prefilledDate
_parsed = State(initialValue: p)
}
private var cleanTitle: String { NLTaskParser.cleanTitle(rawText, ranges: parsed.tokenRanges) }
private var canAdd: Bool { !cleanTitle.trimmingCharacters(in: .whitespaces).isEmpty }
var body: some View {
VStack(spacing: 0) {
// Text input
NLTaskField(
text: $rawText,
placeholder: "What would you like to do?",
highlightRanges: parsed.tokenRanges
)
.frame(minHeight: 36, maxHeight: 72)
.onChange(of: rawText) { _ in
var p = NLTaskParser.parse(rawText)
if p.date == nil { p.date = prefilledDate }
withAnimation(KisaniSpring.micro) {
parsed = p
if p.isBirthday { selectedCategory = .birthday }
}
}
.padding(.horizontal, 16)
.padding(.top, 14)
.padding(.bottom, 10)
// Toolbar
Divider().background(AppColors.border(cs))
HStack(spacing: 0) {
// Date chip
Button { showDatePicker = true } label: {
HStack(spacing: 5) {
Image(systemName: "calendar")
.font(.system(size: 13, weight: .semibold))
Text(parsed.hasDate ? parsed.dateLabel : "No Date")
.font(AppFonts.sans(13, weight: .semibold))
}
.foregroundColor(parsed.hasDate ? AppColors.accent : AppColors.text3(cs))
.padding(.horizontal, 10).padding(.vertical, 6)
.background(parsed.hasDate ? AppColors.accentSoft : Color.clear)
.clipShape(Capsule())
}
.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 drives Matrix placement (importance axis), TickTick-style
Menu {
ForEach(Priority.allCases) { p in
Button { withAnimation(KisaniSpring.micro) { selectedPriority = p } } label: {
Label(p.label, systemImage: selectedPriority == p ? "checkmark" : "flag")
}
}
} label: {
Image(systemName: selectedPriority == .none ? "flag" : "flag.fill")
.font(.system(size: 15))
.foregroundColor(selectedPriority == .none ? AppColors.text3(cs) : selectedPriority.color)
.frame(width: 42, height: 44)
}
.buttonStyle(.plain)
// Category
Menu {
ForEach([TaskCategory.reminder, .birthday, .domain, .annual, .custom], id: \.rawValue) { cat in
Button { withAnimation(KisaniSpring.micro) { selectedCategory = cat } } label: {
Label(cat.rawValue, systemImage: selectedCategory == cat ? "checkmark" : "")
}
}
} label: {
Image(systemName: selectedCategory != .reminder ? "tag.fill" : "tag")
.font(.system(size: 15))
.foregroundColor(selectedCategory != .reminder ? AppColors.accent : AppColors.text3(cs))
.frame(width: 42, height: 44)
}
.buttonStyle(.plain)
Image(systemName: "arrow.right.square")
.font(.system(size: 15))
.foregroundColor(AppColors.text3(cs))
.frame(width: 42, height: 44)
Image(systemName: "ellipsis")
.font(.system(size: 15))
.foregroundColor(AppColors.text3(cs))
.frame(width: 42, height: 44)
Spacer()
// Submit / mic
ZStack {
if canAdd {
Button { submit() } label: {
Image(systemName: "arrow.up")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
.frame(width: 34, height: 34)
.background(AppColors.accent)
.clipShape(Circle())
}
.buttonStyle(PressButtonStyle(scale: 0.90))
.transition(.scale(scale: 0.5).combined(with: .opacity))
} else {
Image(systemName: "mic")
.font(.system(size: 18))
.foregroundColor(AppColors.text3(cs))
.frame(width: 42, height: 44)
.transition(.scale(scale: 0.5).combined(with: .opacity))
}
}
.animation(KisaniSpring.micro, value: canAdd)
}
.padding(.horizontal, 12)
.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,
reminderDate: $reminderDate,
constantReminder: $constantReminder,
recurrenceEnd: $parsed.recurrenceEnd
)
}
}
private func submit() {
guard canAdd else { return }
taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
quadrant: prefilledQuadrant, category: selectedCategory,
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
endDate: parsed.endDate, isAllDay: parsed.isAllDay,
priority: selectedPriority,
reminderDate: reminderDate, constantReminder: constantReminder,
recurrenceEnd: parsed.recurrenceEnd)
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 recurrenceEnd: Date?
@Binding var endDate: Date?
@Binding var isAllDay: Bool
@Binding var reminderDate: Date?
@Binding var constantReminder: 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 showReminderPicker = false
// Reminder lead-time, expressed as MINUTES before the task time (nil = no reminder).
@State private var localReminderOffset: Int?
@State private var localConstant: Bool
// Custom wheel state (draft committed to the reminder only on "Add")
@State private var showCustomReminder = false
@State private var customNumber: Int
@State private var customUnit: Int // 0 = minutes, 1 = hours, 2 = days
@State private var localRepeat: String
@State private var localRecurrenceEnd: Date?
@State private var showRecurrenceEndPicker = false
@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>,
reminderDate: Binding<Date?> = .constant(nil),
constantReminder: Binding<Bool> = .constant(false),
recurrenceEnd: Binding<Date?> = .constant(nil)) {
_selectedDate = selectedDate
_hasTime = hasTime
_isRecurring = isRecurring
_recurrenceLabel = recurrenceLabel
_recurrenceEnd = recurrenceEnd
_endDate = endDate
_isAllDay = isAllDay
_reminderDate = reminderDate
_constantReminder = constantReminder
let cal = Calendar.current
let start = selectedDate.wrappedValue ?? cal.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")
_localRecurrenceEnd = State(initialValue: recurrenceEnd.wrappedValue)
_localIsAllDay = State(initialValue: isAllDay.wrappedValue)
_pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0)
_localConstant = State(initialValue: constantReminder.wrappedValue)
// Derive the lead-time (minutes before the event time) from any existing reminder.
if let r = reminderDate.wrappedValue {
let base: Date = hasTime.wrappedValue
? (selectedDate.wrappedValue ?? start)
: (cal.date(bySettingHour: 9, minute: 0, second: 0, of: start) ?? start)
let mins = max(0, cal.dateComponents([.minute], from: r, to: base).minute ?? 0)
_localReminderOffset = State(initialValue: mins)
if mins > 0 && mins % 1440 == 0 {
_customUnit = State(initialValue: 2); _customNumber = State(initialValue: mins / 1440)
} else if mins > 0 && mins % 60 == 0 {
_customUnit = State(initialValue: 1); _customNumber = State(initialValue: mins / 60)
} else {
_customUnit = State(initialValue: 0); _customNumber = State(initialValue: max(1, mins))
}
} else {
_localReminderOffset = State(initialValue: nil)
_customUnit = State(initialValue: 1) // default Custom draft: 1 hour
_customNumber = State(initialValue: 1)
}
}
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
reminderDate = 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())
}
}
}
}
.overlay {
if showCustomReminder { customReminderModal }
}
}
@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(); if showTimePicker { showReminderPicker = false } } } 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)
Button { withAnimation(KisaniSpring.micro) { showReminderPicker.toggle(); if showReminderPicker { showTimePicker = false } } } label: {
pickerRow(icon: "alarm", label: "Reminder",
value: reminderSummary,
active: localReminderOffset != nil)
}
.buttonStyle(.plain)
if showReminderPicker {
reminderOptions
.transition(.opacity.combined(with: .move(edge: .top)))
}
Divider().padding(.leading, 54)
Menu {
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: repeatDisplayValue, active: localRepeat != "None")
}
.buttonStyle(.plain)
// Repeat until (only when a recurrence is set)
if localRepeat != "None" {
Divider().padding(.leading, 54)
Button { withAnimation(KisaniSpring.micro) { showRecurrenceEndPicker.toggle() } } label: {
pickerRow(icon: "calendar.badge.exclamationmark", label: "Repeat until",
value: localRecurrenceEnd.map { repeatEndFmt.string(from: $0) } ?? "Forever",
active: localRecurrenceEnd != nil)
}
.buttonStyle(.plain)
if showRecurrenceEndPicker {
DatePicker("", selection: Binding(
get: { localRecurrenceEnd ?? localDate },
set: { localRecurrenceEnd = $0 }
), in: localDate..., displayedComponents: .date)
.datePickerStyle(.graphical).labelsHidden()
.padding(.horizontal, 8)
if localRecurrenceEnd != nil {
Button { withAnimation(KisaniSpring.micro) { localRecurrenceEnd = nil; showRecurrenceEndPicker = false } } label: {
Text("Clear end date").font(AppFonts.sans(13)).foregroundColor(AppColors.accent)
}
.frame(maxWidth: .infinity).padding(.bottom, 8)
}
}
}
}
.background(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 14))
.padding(.horizontal, 16)
}
// MARK: - Reminder lead-time picker
private struct ReminderPreset: Identifiable {
let id = UUID()
let offset: Int? // days before; nil = None
let label: String
}
// Offsets are MINUTES before the event time.
private var reminderPresets: [ReminderPreset] {
[
ReminderPreset(offset: nil, label: "None"),
ReminderPreset(offset: 0, label: "On time"),
ReminderPreset(offset: 5, label: "5 minutes early"),
ReminderPreset(offset: 30, label: "30 minutes early"),
ReminderPreset(offset: 60, label: "1 hour early"),
ReminderPreset(offset: 1440, label: "1 day early"),
]
}
@ViewBuilder
private var reminderOptions: some View {
VStack(spacing: 0) {
ForEach(reminderPresets) { preset in
Button {
withAnimation(KisaniSpring.micro) {
localReminderOffset = preset.offset
if preset.offset == nil { localConstant = false }
showCustomReminder = false
}
} label: {
HStack(spacing: 8) {
Text(preset.label)
.font(AppFonts.sans(15))
.foregroundColor(preset.offset == nil ? AppColors.accent : .primary)
Spacer()
if !showCustomReminder && localReminderOffset == preset.offset {
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(AppColors.accent)
}
}
.padding(.horizontal, 16).frame(height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
// Custom opens the Day/Week wheel picker.
Button {
seedCustomDraft()
withAnimation(KisaniSpring.snappy) { showCustomReminder = true }
} label: {
HStack(spacing: 8) {
Text("Custom")
.font(AppFonts.sans(15)).foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.secondary)
}
.padding(.horizontal, 16).frame(height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// Subtle (non-red) note when the chosen reminder time is already past.
if reminderInPast {
Text("Selected reminder time has already passed.")
.font(AppFonts.sans(12))
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16).padding(.bottom, 8)
}
// Constant Reminder toggle intentionally hidden to be gated behind a
// paywall later. Model field + scheduler support remain in place.
}
.padding(.vertical, 4)
}
// MARK: - Custom reminder wheel (Day / Week)
// Minutes-per-unit for the Custom wheel: minutes / hours / days.
private let customUnitMinutes = [1, 60, 1440]
private let customUnitNames = ["minute", "hour", "day"]
private func seedCustomDraft() {
guard let off = localReminderOffset, off > 0 else { return }
if off % 1440 == 0 { customUnit = 2; customNumber = off / 1440 }
else if off % 60 == 0 { customUnit = 1; customNumber = off / 60 }
else { customUnit = 0; customNumber = off }
}
private var customOffsetMinutes: Int { max(1, customNumber) * customUnitMinutes[customUnit] }
private func commitCustomReminder() {
localReminderOffset = customOffsetMinutes
}
// Label for a given count under the current unit.
private func customLeadLabel(_ n: Int) -> String {
let unit = customUnitNames[customUnit]
return "\(n) \(unit)\(n == 1 ? "" : "s") early"
}
private var customMaxCount: Int { [59, 23, 30][customUnit] }
// Reminder date implied by the current *draft* wheel selection.
private var draftReminderDate: Date? {
Calendar.current.date(byAdding: .minute, value: -customOffsetMinutes, to: reminderBaseDate())
}
private var draftReminderInPast: Bool {
(draftReminderDate ?? Date()) < Date()
}
@ViewBuilder
private var customReminderModal: some View {
ZStack {
// Dimmed scrim tap to cancel.
Color.black.opacity(0.45)
.ignoresSafeArea()
.onTapGesture { withAnimation(KisaniSpring.snappy) { showCustomReminder = false } }
VStack(spacing: 18) {
// Minutes / Hours / Days segmented toggle
HStack(spacing: 2) {
customModeButton("Minutes", unit: 0)
customModeButton("Hours", unit: 1)
customModeButton("Days", unit: 2)
}
.padding(3)
.background(Color(uiColor: .tertiarySystemFill))
.clipShape(Capsule())
.padding(.top, 20)
// Single lead-time wheel (relative to the event time)
Picker("", selection: $customNumber) {
ForEach(1...customMaxCount, id: \.self) { n in
Text(customLeadLabel(n)).tag(n)
}
}
.pickerStyle(.wheel)
.frame(maxWidth: .infinity)
.frame(height: 180)
.clipped()
.onChange(of: customUnit) { _ in
if customNumber > customMaxCount { customNumber = customMaxCount }
if customNumber < 1 { customNumber = 1 }
}
// Subtle (non-red) note never alarming.
if draftReminderInPast {
Text("Selected reminder time has already passed.")
.font(AppFonts.sans(13))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
// Cancel / Add
HStack(spacing: 12) {
Button {
withAnimation(KisaniSpring.snappy) { showCustomReminder = false }
} label: {
Text("Cancel")
.font(AppFonts.sans(16, weight: .medium))
.foregroundColor(.primary)
.frame(maxWidth: .infinity).frame(height: 52)
.background(Color(uiColor: .tertiarySystemFill))
.clipShape(RoundedRectangle(cornerRadius: 16))
}
Button {
commitCustomReminder()
withAnimation(KisaniSpring.snappy) { showCustomReminder = false }
} label: {
Text("Add")
.font(AppFonts.sans(16, weight: .semibold))
.foregroundColor(.primary)
.frame(maxWidth: .infinity).frame(height: 52)
.background(Color(uiColor: .tertiarySystemFill))
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
.padding(.horizontal, 20).padding(.bottom, 20)
}
.frame(maxWidth: 380)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28))
.overlay(RoundedRectangle(cornerRadius: 28).stroke(Color.white.opacity(0.08), lineWidth: 1))
.padding(.horizontal, 20)
.transition(.scale(scale: 0.92).combined(with: .opacity))
}
}
@ViewBuilder
private func customModeButton(_ label: String, unit: Int) -> some View {
Button {
withAnimation(KisaniSpring.micro) {
customUnit = unit
if customNumber > customMaxCount { customNumber = customMaxCount }
if customNumber < 1 { customNumber = 1 }
}
} label: {
Text(label)
.font(AppFonts.sans(15, weight: customUnit == unit ? .semibold : .regular))
.foregroundColor(customUnit == unit ? .primary : .secondary)
.frame(maxWidth: .infinity).frame(height: 36)
.background(customUnit == unit ? Color(uiColor: .systemGray4) : Color.clear)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private var reminderInPast: Bool {
guard let off = localReminderOffset else { return false }
return computedReminderDate(offset: off) ?? Date() < Date()
}
// The event datetime the reminder counts back from: the task's time, or 9 AM if undated.
private func reminderBaseDate() -> Date {
let cal = Calendar.current
if let t = localTime {
let tc = cal.dateComponents([.hour, .minute], from: t)
return cal.date(bySettingHour: tc.hour ?? 9, minute: tc.minute ?? 0, second: 0, of: localDate) ?? localDate
}
return cal.date(bySettingHour: 9, minute: 0, second: 0, of: localDate) ?? localDate
}
private func computedReminderDate(offset: Int) -> Date? {
Calendar.current.date(byAdding: .minute, value: -offset, to: reminderBaseDate())
}
private var reminderSummary: String {
guard let off = localReminderOffset else { return "None" }
if off == 0 { return "On time" }
if off % 1440 == 0 { let d = off / 1440; return "\(d) day\(d == 1 ? "" : "s") early" }
if off % 60 == 0 { let h = off / 60; return "\(h) hour\(h == 1 ? "" : "s") early" }
return "\(off) minute\(off == 1 ? "" : "s") early"
}
@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, active: Bool = false) -> some View {
HStack(spacing: 14) {
Image(systemName: icon)
.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(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 (MonFri)"),
]
}
private var repeatDisplayValue: String {
repeatOptions.first(where: { $0.key == localRepeat })?.display ?? localRepeat
}
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 let repeatEndFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "MMM d, yyyy"; return f
}()
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
recurrenceEnd = localRepeat != "None" ? localRecurrenceEnd : nil
// Reminder is a lead-time offset from the task date at the chosen time-of-day.
if let off = localReminderOffset {
reminderDate = computedReminderDate(offset: off)
constantReminder = localConstant
} else {
reminderDate = nil
constantReminder = false
}
} else {
selectedDate = Calendar.current.startOfDay(for: localDate)
endDate = Calendar.current.startOfDay(for: localEndDate)
isAllDay = localIsAllDay
hasTime = false
reminderDate = nil
constantReminder = 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<Date>) {
_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 yyyy"; 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..<range.count {
result.append(cal.date(byAdding: .day, value: i, to: first))
}
while result.count % 7 != 0 { result.append(nil) }
return result
}
}
// MARK: - Dot Grid Progress
struct DotGridProgress: View {
let progress: Double
let cs: ColorScheme
private let dotSize: CGFloat = 7.5
private let gap: CGFloat = 2.5
private let rows = 7
var body: some View {
Canvas { ctx, size in
let step = dotSize + gap
let cols = max(1, Int((size.width + gap) / step))
let total = cols * rows
let filled = Int(Double(total) * min(max(progress, 0), 1))
for row in 0..<rows {
for col in 0..<cols {
let idx = row * cols + col
let rect = CGRect(
x: CGFloat(col) * step,
y: CGFloat(row) * step,
width: dotSize, height: dotSize
)
ctx.fill(
Path(ellipseIn: rect),
with: .color(idx < filled ? AppColors.green : AppColors.green.opacity(0.12))
)
}
}
}
.frame(maxWidth: .infinity)
.frame(height: CGFloat(rows) * (dotSize + gap) - gap)
.animation(KisaniSpring.entrance, value: progress)
}
}
// MARK: - Today Health Strip
struct TodayHealthStrip: View {
@ObservedObject var hk: HealthKitManager
@ObservedObject var workoutVM: WorkoutViewModel
@Environment(\.colorScheme) var cs
var body: some View {
HStack(spacing: 8) {
statPill(icon: "figure.walk", value: stepsLabel, label: "steps", color: AppColors.blue)
statPill(icon: "flame.fill", value: calsLabel, label: "kcal", color: AppColors.accent)
statPill(icon: "heart.fill", value: hrLabel, label: "bpm", color: .red)
statPill(icon: "dumbbell.fill", value: "\(workoutVM.streakDays)", label: "streak", color: AppColors.green)
}
}
private func statPill(icon: String, value: String, label: String, color: Color) -> some View {
HStack(spacing: 5) {
Image(systemName: icon)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(color)
VStack(alignment: .leading, spacing: 0) {
Text(value)
.font(AppFonts.mono(12, weight: .bold))
.foregroundColor(AppColors.text(cs))
Text(label)
.font(AppFonts.mono(8))
.foregroundColor(AppColors.text3(cs))
}
}
.padding(.horizontal, 10).padding(.vertical, 7)
.frame(maxWidth: .infinity)
.background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(AppColors.border(cs), lineWidth: 1))
}
private var stepsLabel: String {
hk.stepsToday >= 1000
? String(format: "%.1fk", Double(hk.stepsToday) / 1000)
: "\(hk.stepsToday)"
}
private var calsLabel: String { "\(hk.activeCaloriesToday)" }
private var hrLabel: String {
hk.restingHeartRate > 0 ? "\(hk.restingHeartRate)" : "--"
}
}