- 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>
2753 lines
117 KiB
Swift
2753 lines
117 KiB
Swift
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 (2–7 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 (Mon–Fri)"),
|
||
]
|
||
}
|
||
|
||
private var repeatDisplayValue: String {
|
||
repeatOptions.first(where: { $0.key == localRepeat })?.display ?? localRepeat
|
||
}
|
||
|
||
private var timeFmt: DateFormatter {
|
||
let f = DateFormatter(); f.timeStyle = .short; return f
|
||
}
|
||
|
||
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)" : "--"
|
||
}
|
||
}
|