Files
KisaniCal/KisaniCal/Views/CalendarView.swift
kutesir e0e4f709b6 feat: replace day strip with vertical timeline in month view
Selected-day panel below the month grid now renders as a proper vertical
timeline: time labels in the left gutter, continuous track line with
circle markers, and white cards for each item. All-day tasks and events
get a stroke circle; timed entries get a solid colored dot; workout gets
a dumbbell icon; completed items show a checkmark circle with strikethrough.
Tasks can be toggled directly from the timeline card. Cal events show
end time as subtitle. Empty state shows a calendar icon.
2026-05-29 13:51:37 +03:00

1298 lines
56 KiB
Swift

import SwiftUI
import EventKit
// MARK: - Calendar Store
@MainActor
final class CalendarStore: ObservableObject {
@Published var authStatus: EKAuthorizationStatus = .notDetermined
@Published var monthEvents: [EKEvent] = []
private let ekStore = EKEventStore()
private var changeToken: NSObjectProtocol?
private var fetchedMonth: Date?
init() {
authStatus = EKEventStore.authorizationStatus(for: .event)
changeToken = NotificationCenter.default.addObserver(
forName: .EKEventStoreChanged,
object: ekStore,
queue: .main
) { [weak self] _ in
guard let self, let m = self.fetchedMonth else { return }
self.fetchMonth(containing: m)
}
}
deinit {
if let t = changeToken { NotificationCenter.default.removeObserver(t) }
}
// MARK: - Permission
func requestAccess() {
if #available(iOS 17.0, *) {
ekStore.requestFullAccessToEvents { [weak self] granted, _ in
Task { @MainActor [weak self] in
self?.authStatus = granted ? .fullAccess : .denied
if granted { self?.fetchMonth(containing: Date()) }
}
}
} else {
ekStore.requestAccess(to: .event) { [weak self] granted, _ in
Task { @MainActor [weak self] in
self?.authStatus = granted ? .authorized : .denied
if granted { self?.fetchMonth(containing: Date()) }
}
}
}
}
// MARK: - Fetching
func fetchMonth(containing date: Date) {
guard isAuthorized else { return }
let cal = Calendar.current
guard let interval = cal.dateInterval(of: .month, for: date) else { return }
// Extend a week each side to cover leading/trailing grid overflow days
let start = cal.date(byAdding: .weekOfYear, value: -1, to: interval.start) ?? interval.start
let end = cal.date(byAdding: .weekOfYear, value: 1, to: interval.end) ?? interval.end
let pred = ekStore.predicateForEvents(withStart: start, end: end, calendars: nil)
fetchedMonth = date
monthEvents = ekStore.events(matching: pred)
.sorted { ($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture) }
}
// Events for a specific day, derived from the cached month set
func events(for date: Date) -> [EKEvent] {
let cal = Calendar.current
let start = cal.startOfDay(for: date)
let end = cal.date(byAdding: .day, value: 1, to: start)!
return monthEvents.filter { event in
guard let s = event.startDate, let e = event.endDate else { return false }
return s < end && e > start
}
}
var isAuthorized: Bool {
if #available(iOS 17.0, *) {
return authStatus == .authorized || authStatus == .fullAccess
}
return authStatus == .authorized
}
}
// MARK: - Calendar View Mode
enum CalendarViewMode: String, CaseIterable {
case list = "List"
case year = "Year"
case month = "Month"
case week = "Week"
case threeDay = "3 Day"
case day = "Day"
var sfSymbol: String {
switch self {
case .list: return "list.bullet.below.rectangle"
case .year: return "square.grid.4x3.fill"
case .month: return "square.grid.3x2.fill"
case .week: return "rectangle.split.3x1.fill"
case .threeDay: return "square.split.2x1.fill"
case .day: return "rectangle.portrait.fill"
}
}
}
// MARK: - Calendar View
struct CalendarView: View {
@Environment(\.colorScheme) var cs
@EnvironmentObject var taskVM: TaskViewModel
@EnvironmentObject var workoutVM: WorkoutViewModel
@StateObject private var calStore = CalendarStore()
@State private var selectedDate: Date = Date()
@State private var displayMonth: Date = {
let c = Calendar.current
return c.date(from: c.dateComponents([.year, .month], from: Date())) ?? Date()
}()
@State private var viewMode: CalendarViewMode = .month
@State private var calTaskListMode: Bool = false
@State private var showAddTask = false
@State private var showCalendarConnect = false
@State private var showWorkoutSession = false
private let cal = Calendar.current
private let weekdays = ["S","M","T","W","T","F","S"]
var body: some View {
ZStack(alignment: .bottomTrailing) {
AppColors.background(cs).ignoresSafeArea()
VStack(spacing: 0) {
// Header
HStack(spacing: 0) {
// Left circle: view mode picker
Menu {
Picker(selection: $viewMode, label: EmptyView()) {
ForEach(CalendarViewMode.allCases, id: \.self) { mode in
Label(mode.rawValue, systemImage: mode.sfSymbol).tag(mode)
}
}
} label: {
Image(systemName: viewMode.sfSymbol)
.font(.system(size: 15, weight: .medium))
.foregroundColor(AppColors.text(cs))
.frame(width: 46, height: 46)
.background(AppColors.surface(cs))
.clipShape(Circle())
.contentShape(Circle())
.shadow(color: .black.opacity(0.08), radius: 10, x: 0, y: 3)
}
.menuStyle(.automatic)
// Center: month name
Spacer()
Text(monthShortTitle)
.font(AppFonts.sans(17, weight: .semibold))
.foregroundColor(AppColors.text(cs))
Spacer()
// Right: controls pill (view mode picker + more)
HStack(spacing: 0) {
// Right pill left button: task list toggle
Button {
withAnimation(KisaniSpring.snappy) { calTaskListMode.toggle() }
} label: {
Image(systemName: calTaskListMode ? "calendar" : "slider.horizontal.3")
.font(.system(size: 14, weight: calTaskListMode ? .semibold : .medium))
.foregroundColor(calTaskListMode ? AppColors.accent : AppColors.text(cs))
.frame(width: 46, height: 36)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Rectangle()
.fill(AppColors.border(cs))
.frame(width: 1, height: 18)
Menu {
Button { } label: { Label("Filter View Range", systemImage: "line.3.horizontal.decrease") }
Button { } label: { Label("View Options", systemImage: "gearshape.2") }
Button { } label: { Label("Arrange Tasks", systemImage: "list.bullet.rectangle") }
Divider()
Button { showCalendarConnect = true } label: {
Label("Calendar Subscription", systemImage: calStore.isAuthorized ? "calendar.badge.checkmark" : "calendar.badge.plus")
}
Divider()
Button { } label: { Label("Share", systemImage: "square.and.arrow.up") }.disabled(true)
Button { } label: { Label("Print", systemImage: "printer") }.disabled(true)
Button { } label: { Label("Select", systemImage: "checkmark.circle") }.disabled(true)
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 14, weight: .medium))
.foregroundColor(AppColors.text(cs))
.frame(width: 46, height: 36)
.contentShape(Rectangle())
}
.menuStyle(.automatic)
}
.background(AppColors.surface2(cs))
.clipShape(Capsule())
.overlay(Capsule().stroke(AppColors.border(cs), lineWidth: 1))
}
.padding(.horizontal, 18).padding(.top, 8)
if calTaskListMode {
cvWeekStrip.padding(.top, 12).padding(.bottom, 6)
Divider().background(AppColors.border(cs)).padding(.horizontal, 16)
calDayTaskListView
} else {
calendarContent
}
}
FAB { showAddTask = true }
.padding(.trailing, 20).padding(.bottom, 10)
}
.sheet(isPresented: $showAddTask) {
AddTaskSheet(prefilledDate: selectedDate)
.environmentObject(taskVM)
.presentationDetents([.height(150)])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showCalendarConnect) {
CalendarConnectSheet(calStore: calStore)
.presentationDetents([.height(340)])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showWorkoutSession) {
WorkoutSessionSheet()
.environmentObject(workoutVM)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.onAppear {
if calStore.isAuthorized { calStore.fetchMonth(containing: displayMonth) }
}
.onChange(of: displayMonth) { newMonth in
if calStore.isAuthorized { calStore.fetchMonth(containing: newMonth) }
}
}
private var monthTitle: String {
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"
return f.string(from: displayMonth)
}
private var monthShortTitle: String {
let f = DateFormatter(); f.dateFormat = "MMMM"
return f.string(from: displayMonth)
}
private func navigateMonth(_ offset: Int) {
withAnimation(KisaniSpring.snappy) {
displayMonth = cal.date(byAdding: .month, value: offset, to: displayMonth) ?? displayMonth
}
}
private func gridDays() -> [Date] {
guard
let interval = cal.dateInterval(of: .month, for: displayMonth),
let firstWeek = cal.dateInterval(of: .weekOfMonth, for: interval.start),
let lastWeek = cal.dateInterval(of: .weekOfMonth, for: interval.end - 1)
else { return [] }
var days: [Date] = []; var cur = firstWeek.start
while cur < lastWeek.end {
days.append(cur)
cur = cal.date(byAdding: .day, value: 1, to: cur)!
}
return days
}
private func eventDots(for date: Date) -> [Color] {
var dots: [Color] = []
if workoutVM.program(for: date) != nil { dots.append(AppColors.blue) }
if calStore.isAuthorized && dots.count < 3 {
let calDay = calStore.events(for: date)
if !calDay.isEmpty {
let calColor = calDay.first.flatMap { Color(cgColor: $0.calendar.cgColor) } ?? AppColors.green
dots.append(calColor)
}
}
let taskDots = taskVM.tasks
.filter { !$0.isComplete }
.filter { guard let d = $0.dueDate else { return false }; return cal.isDate(d, inSameDayAs: date) }
.prefix(max(0, 3 - dots.count))
.map { $0.taskColor.color() }
dots.append(contentsOf: taskDots)
return dots
}
// MARK: - Task list mode (toggle view)
private var calDayTaskListView: some View {
let all = taskVM.tasks.filter { t in
guard let d = t.dueDate else { return false }
return cal.isDate(d, inSameDayAs: selectedDate)
}
let incomplete = all.filter { !$0.isComplete }
let complete = all.filter { $0.isComplete }
return ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
if !incomplete.isEmpty {
Text("Today")
.font(AppFonts.sans(12, weight: .semibold))
.foregroundColor(AppColors.text2(cs))
.padding(.horizontal, 18).padding(.top, 14).padding(.bottom, 8)
VStack(spacing: 0) {
ForEach(Array(incomplete.enumerated()), id: \.element.id) { i, task in
CalTaskRow(task: task) { taskVM.toggle(task) }
if i < incomplete.count - 1 {
Divider().background(AppColors.border(cs)).padding(.leading, 52)
}
}
}
.background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
.padding(.horizontal, 16)
}
if !complete.isEmpty {
Text("Completed")
.font(AppFonts.sans(12, weight: .semibold))
.foregroundColor(AppColors.text3(cs))
.padding(.horizontal, 18).padding(.top, 16).padding(.bottom, 8)
VStack(spacing: 0) {
ForEach(Array(complete.enumerated()), id: \.element.id) { i, task in
CalTaskRow(task: task) { taskVM.toggle(task) }
if i < complete.count - 1 {
Divider().background(AppColors.border(cs)).padding(.leading, 52)
}
}
}
.background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
.padding(.horizontal, 16)
}
if incomplete.isEmpty && complete.isEmpty {
cvEmptyDay.padding(.top, 48)
}
Spacer().frame(height: 90)
}
}
}
// MARK: - Content switcher
@ViewBuilder private var calendarContent: some View {
switch viewMode {
case .month: monthView
case .list: listView
case .year: yearView
case .week: weekView
case .threeDay: threeDayView
case .day: dayView
}
}
// MARK: - Month
private var monthView: some View {
Group {
HStack(spacing: 0) {
ForEach(Array(weekdays.enumerated()), id: \.offset) { _, d in
Text(d).font(AppFonts.mono(9, weight: .semibold))
.foregroundColor(AppColors.text3(cs)).frame(maxWidth: .infinity)
}
}
.padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 3)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 1), count: 7), spacing: 1) {
ForEach(gridDays(), id: \.self) { date in
DayCell(
date: date,
isCurrentMonth: cal.isDate(date, equalTo: displayMonth, toGranularity: .month),
isToday: cal.isDateInToday(date),
isSelected: cal.isDate(date, inSameDayAs: selectedDate),
dots: eventDots(for: date)
)
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
}
}
.padding(.horizontal, 16)
.gesture(DragGesture(minimumDistance: 40).onEnded { v in
if v.translation.width < -40 { navigateMonth(1) }
else if v.translation.width > 40 { navigateMonth(-1) }
})
Divider().background(AppColors.border(cs)).padding(.horizontal, 16).padding(.top, 6)
ScrollView(showsIndicators: false) {
DayTimelineView(
date: selectedDate,
tasks: taskVM.tasks.filter { t in
guard let d = t.dueDate else { return false }
return cal.isDate(d, inSameDayAs: selectedDate)
},
calEvents: calStore.isAuthorized ? calStore.events(for: selectedDate) : [],
workout: workoutVM.program(for: selectedDate),
onWorkoutTap: {
if let w = workoutVM.program(for: selectedDate),
workoutVM.activeProgramId != w.id { workoutVM.switchProgram(to: w.id) }
showWorkoutSession = true
},
onToggle: { task in
withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) }
}
)
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 20)
}
}
}
// MARK: - List
private var listView: some View {
VStack(spacing: 0) {
cvWeekStrip.padding(.top, 12).padding(.bottom, 6)
Divider().background(AppColors.border(cs)).padding(.horizontal, 16)
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
let dayTasks = taskVM.tasks.filter { t in
guard !t.isComplete, let d = t.dueDate else { return false }
return cal.isDate(d, inSameDayAs: selectedDate)
}
let events = calStore.isAuthorized ? calStore.events(for: selectedDate) : []
if dayTasks.isEmpty && events.isEmpty {
cvEmptyDay
} else {
ForEach(events, id: \.eventIdentifier) { ev in
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 2)
.fill(Color(cgColor: ev.calendar.cgColor))
.frame(width: 3, height: 38)
VStack(alignment: .leading, spacing: 2) {
Text(ev.title ?? "Event")
.font(AppFonts.sans(13, weight: .medium)).foregroundColor(AppColors.text(cs))
if let s = ev.startDate {
Text(cvTimeFmt.string(from: s)).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
}
}
Spacer()
}
.padding(.horizontal, 16).padding(.vertical, 10)
Divider().background(AppColors.border(cs)).padding(.leading, 44)
}
ForEach(dayTasks) { task in
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 3)
.strokeBorder(task.taskColor.color(), lineWidth: 1.5)
.frame(width: 18, height: 18)
Text(task.title).font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
Spacer()
if let d = task.dueDate {
let h = cal.component(.hour, from: d)
let m = cal.component(.minute, from: d)
if h != 0 || m != 0 {
Text(cvTimeFmt.string(from: d)).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
}
}
}
.padding(.horizontal, 16).padding(.vertical, 10)
Divider().background(AppColors.border(cs)).padding(.leading, 44)
}
}
}
.padding(.top, 4)
}
}
}
// MARK: - Year
private var yearView: some View {
let year = cal.component(.year, from: displayMonth)
let months: [Date] = (1...12).compactMap { cal.date(from: DateComponents(year: year, month: $0, day: 1)) }
return ScrollView(showsIndicators: false) {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 24) {
ForEach(months, id: \.self) { m in
cvMiniMonth(m)
}
}
.padding(16)
}
}
private func cvMiniMonth(_ month: Date) -> some View {
let mDays = cvMiniGridDays(month)
return VStack(spacing: 3) {
Text(cvMonthFmt.string(from: month))
.font(AppFonts.sans(10, weight: .semibold)).foregroundColor(AppColors.text(cs))
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 1) {
ForEach(Array(mDays.enumerated()), id: \.offset) { _, date in
let isToday = cal.isDateInToday(date)
let inMon = cal.isDate(date, equalTo: month, toGranularity: .month)
ZStack {
if isToday { Circle().fill(AppColors.accent).frame(width: 14, height: 14) }
Text("\(cal.component(.day, from: date))")
.font(AppFonts.sans(7, weight: isToday ? .bold : .regular))
.foregroundColor(isToday ? .white : inMon ? AppColors.text(cs) : AppColors.text3(cs))
}
.frame(height: 14)
.onTapGesture {
withAnimation(KisaniSpring.micro) {
selectedDate = date
displayMonth = cal.date(from: cal.dateComponents([.year, .month], from: date)) ?? date
viewMode = .month
}
}
}
}
}
}
private func cvMiniGridDays(_ month: Date) -> [Date] {
guard let interval = cal.dateInterval(of: .month, for: month),
let firstWeek = cal.dateInterval(of: .weekOfMonth, for: interval.start),
let lastWeek = cal.dateInterval(of: .weekOfMonth, for: interval.end - 1)
else { return [] }
var days: [Date] = []; var cur = firstWeek.start
while cur < lastWeek.end { days.append(cur); cur = cal.date(byAdding: .day, value: 1, to: cur)! }
return days
}
// MARK: - Week
private var weekView: some View {
HStack(alignment: .top, spacing: 0) {
VStack(spacing: 0) {
HStack(spacing: 0) {
ForEach(Array(weekdays.enumerated()), id: \.offset) { _, d in
Text(d).font(AppFonts.mono(7, weight: .semibold))
.foregroundColor(AppColors.text3(cs)).frame(maxWidth: .infinity)
}
}
.padding(.horizontal, 6).padding(.top, 10)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 2) {
ForEach(Array(gridDays().enumerated()), id: \.offset) { _, date in
let isTod = cal.isDateInToday(date)
let isSel = cal.isDate(date, inSameDayAs: selectedDate)
let inMon = cal.isDate(date, equalTo: displayMonth, toGranularity: .month)
ZStack {
if isTod { Circle().fill(AppColors.accent).frame(width: 20, height: 20) }
else if isSel { Circle().fill(AppColors.accentSoft).frame(width: 20, height: 20) }
Text("\(cal.component(.day, from: date))")
.font(AppFonts.sans(9, weight: isTod ? .bold : .regular))
.foregroundColor(isTod ? .white : inMon ? AppColors.text(cs) : AppColors.text3(cs))
}
.frame(height: 22)
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
}
}
.padding(.horizontal, 4)
.gesture(DragGesture(minimumDistance: 40).onEnded { v in
if v.translation.width < -40 { navigateMonth(1) }
else if v.translation.width > 40 { navigateMonth(-1) }
})
Spacer()
}
.frame(maxWidth: .infinity)
Rectangle().fill(AppColors.border(cs)).frame(width: 1)
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
ForEach(cvWeekDays(), id: \.self) { date in
let isTod = cal.isDateInToday(date)
let isSel = cal.isDate(date, inSameDayAs: selectedDate)
let dots = eventDots(for: date)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 4) {
Text(cvDayFmt.string(from: date))
.font(AppFonts.mono(9, weight: .semibold))
.foregroundColor(isTod ? AppColors.accent : AppColors.text2(cs))
Text("\(cal.component(.day, from: date))")
.font(AppFonts.sans(12, weight: isTod ? .bold : .medium))
.foregroundColor(isTod ? AppColors.accent : AppColors.text(cs))
}
if dots.isEmpty {
Text("Free").font(AppFonts.sans(9)).foregroundColor(AppColors.text3(cs))
} else {
ForEach(dots.indices, id: \.self) { i in
RoundedRectangle(cornerRadius: 2).fill(dots[i].opacity(0.5))
.frame(maxWidth: .infinity).frame(height: 7)
}
}
}
.padding(10)
.background(isSel ? AppColors.accentSoft : Color.clear)
.contentShape(Rectangle())
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
Divider().background(AppColors.border(cs))
}
}
}
.frame(maxWidth: .infinity)
}
}
// MARK: - 3 Day
private var threeDayView: some View {
let days = cvAdjacentDays(3)
return VStack(spacing: 0) {
cvDayColumnHeader(days)
Divider().background(AppColors.border(cs))
ScrollView(showsIndicators: false) {
cvTimeGrid(days: days).padding(.bottom, 40)
}
}
}
// MARK: - Day
private var dayView: some View {
VStack(spacing: 0) {
cvWeekStrip.padding(.top, 12).padding(.bottom, 6)
Divider().background(AppColors.border(cs))
ScrollView(showsIndicators: false) {
cvTimeGrid(days: [selectedDate]).padding(.bottom, 40)
}
}
}
// MARK: - Time grid
private let cvStartH = 6
private let cvEndH = 22
private let cvHourH: CGFloat = 52
private func cvDayColumnHeader(_ days: [Date]) -> some View {
HStack(spacing: 0) {
Color.clear.frame(width: 36)
ForEach(days, id: \.self) { date in
let isTod = cal.isDateInToday(date)
VStack(spacing: 2) {
Text(cvDayFmt.string(from: date).uppercased())
.font(AppFonts.mono(8, weight: .semibold)).foregroundColor(AppColors.text3(cs))
ZStack {
if isTod { Circle().fill(AppColors.accent).frame(width: 28, height: 28) }
Text("\(cal.component(.day, from: date))")
.font(AppFonts.sans(14, weight: isTod ? .bold : .medium))
.foregroundColor(isTod ? .white : AppColors.text(cs))
}
}
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 10).padding(.horizontal, 8)
}
private func cvTimeGrid(days: [Date]) -> some View {
let totalH = CGFloat(cvEndH - cvStartH) * cvHourH
return ZStack(alignment: .topLeading) {
VStack(spacing: 0) {
ForEach(cvStartH...cvEndH, id: \.self) { h in
HStack(spacing: 0) {
Text(h == 12 ? "12" : h > 12 ? "\(h - 12)" : "\(h)")
.font(AppFonts.mono(8.5)).foregroundColor(AppColors.text3(cs))
.frame(width: 28, alignment: .trailing)
Rectangle().fill(AppColors.border(cs)).frame(height: 0.5).padding(.leading, 8)
}
.frame(height: cvHourH, alignment: .top)
}
}
.padding(.horizontal, 8)
HStack(alignment: .top, spacing: 2) {
Color.clear.frame(width: 36)
ForEach(days, id: \.self) { date in
cvTimeColumn(date: date, totalH: totalH)
}
}
.padding(.horizontal, 8)
.frame(height: totalH)
}
}
private func cvTimeColumn(date: Date, totalH: CGFloat) -> some View {
ZStack(alignment: .topLeading) {
Color.clear.frame(maxWidth: .infinity).frame(height: totalH)
ForEach(cvTimeItems(date)) { item in
let top = max(0, CGFloat(item.startMin - cvStartH * 60) / 60.0 * cvHourH)
let dur = max(CGFloat(item.endMin - item.startMin) / 60.0 * cvHourH, 28)
RoundedRectangle(cornerRadius: 4)
.fill(item.color.opacity(0.28))
.overlay(alignment: .leading) {
Rectangle().fill(item.color).frame(width: 3)
.clipShape(RoundedRectangle(cornerRadius: 2))
}
.overlay(alignment: .topLeading) {
Text(item.title)
.font(AppFonts.sans(9, weight: .medium))
.foregroundColor(item.color)
.padding(.horizontal, 6).padding(.top, 4)
.lineLimit(2)
}
.frame(maxWidth: .infinity).frame(height: dur)
.padding(.trailing, 2)
.offset(y: top)
}
}
.frame(maxWidth: .infinity)
}
// MARK: - Shared helpers
private var cvWeekStrip: some View {
HStack(spacing: 0) {
ForEach(cvWeekDays(), id: \.self) { date in
let isTod = cal.isDateInToday(date)
let isSel = cal.isDate(date, inSameDayAs: selectedDate)
VStack(spacing: 3) {
Text(cvDayFmt.string(from: date))
.font(AppFonts.mono(9, weight: .semibold)).foregroundColor(AppColors.text3(cs))
ZStack {
if isTod { Circle().fill(AppColors.accent).frame(width: 28, height: 28) }
else if isSel { Circle().fill(AppColors.accentSoft).frame(width: 28, height: 28) }
Text("\(cal.component(.day, from: date))")
.font(AppFonts.sans(13, weight: isTod ? .bold : .regular))
.foregroundColor(isTod ? .white : AppColors.text(cs))
}
}
.frame(maxWidth: .infinity)
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
}
}
.padding(.horizontal, 16)
.gesture(DragGesture(minimumDistance: 40).onEnded { v in
let delta = v.translation.width < -40 ? 7 : v.translation.width > 40 ? -7 : 0
guard delta != 0 else { return }
withAnimation(KisaniSpring.snappy) {
selectedDate = cal.date(byAdding: .day, value: delta, to: selectedDate) ?? selectedDate
displayMonth = cal.date(from: cal.dateComponents([.year, .month], from: selectedDate)) ?? displayMonth
}
})
}
private var cvEmptyDay: some View {
VStack(spacing: 4) {
Text("Nothing scheduled").font(AppFonts.sans(13, weight: .medium)).foregroundColor(AppColors.text2(cs))
Text("Tap + to add a task").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
.frame(maxWidth: .infinity).padding(.vertical, 48)
}
private func cvWeekDays() -> [Date] {
let wd = cal.component(.weekday, from: selectedDate)
let start = cal.date(byAdding: .day, value: -(wd - 1), to: selectedDate) ?? selectedDate
return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: start) }
}
private func cvAdjacentDays(_ count: Int) -> [Date] {
let half = count / 2
return (-half...(count - half - 1)).compactMap { cal.date(byAdding: .day, value: $0, to: selectedDate) }
}
private func cvTimeItems(_ date: Date) -> [CVTimeItem] {
var items: [CVTimeItem] = []
for task in taskVM.tasks where !task.isComplete {
guard let d = task.dueDate, cal.isDate(d, inSameDayAs: date) else { continue }
let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d)
guard h != 0 || m != 0 else { continue }
items.append(CVTimeItem(title: task.title, color: task.taskColor.color(), startMin: h * 60 + m, endMin: h * 60 + m + 30))
}
for ev in calStore.isAuthorized ? calStore.events(for: date) : [] {
guard let s = ev.startDate, let e = ev.endDate, !ev.isAllDay else { continue }
let sh = cal.component(.hour, from: s), sm = cal.component(.minute, from: s)
let eh = cal.component(.hour, from: e), em = cal.component(.minute, from: e)
items.append(CVTimeItem(title: ev.title ?? "Event", color: Color(cgColor: ev.calendar.cgColor),
startMin: sh * 60 + sm, endMin: max(eh * 60 + em, sh * 60 + sm + 30)))
}
return items
}
private let cvTimeFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "h:mm a"; return f }()
private let cvDayFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "EEE"; return f }()
private let cvMonthFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "MMM"; return f }()
}
struct CVTimeItem: Identifiable {
let id: String
let title: String
let color: Color
let startMin: Int
let endMin: Int
init(title: String, color: Color, startMin: Int, endMin: Int) {
self.id = "\(title)-\(startMin)"
self.title = title
self.color = color
self.startMin = startMin
self.endMin = endMin
}
}
// MARK: - Calendar Connect Sheet
struct CalendarConnectSheet: View {
@ObservedObject var calStore: CalendarStore
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) var openURL
@State private var webCalURL = ""
@FocusState private var webCalFocused: Bool
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Calendars").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
Spacer()
Button("Done") { dismiss() }.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent).buttonStyle(.plain)
}
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
Divider().background(AppColors.border(cs))
VStack(spacing: 0) {
// Local Calendar row
HStack(spacing: 14) {
Image(systemName: "calendar")
.font(.system(size: 16))
.foregroundColor(.white)
.frame(width: 36, height: 36)
.background(AppColors.accent)
.clipShape(RoundedRectangle(cornerRadius: 9))
VStack(alignment: .leading, spacing: 2) {
Text("iPhone Calendar").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
Text(calStore.isAuthorized ? "Connected — events shown in day view" : "Tap to connect")
.font(AppFonts.sans(11)).foregroundColor(calStore.isAuthorized ? AppColors.green : AppColors.text3(cs))
}
Spacer()
if calStore.isAuthorized {
Image(systemName: "checkmark.circle.fill").foregroundColor(AppColors.green).font(.system(size: 18))
} else {
Button("Connect") {
calStore.requestAccess()
}
.font(AppFonts.mono(9.5, weight: .bold))
.foregroundColor(AppColors.accent)
.padding(.horizontal, 10).padding(.vertical, 5)
.background(AppColors.accentSoft)
.clipShape(Capsule())
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16).padding(.vertical, 12)
Divider().background(AppColors.border(cs)).padding(.leading, 66)
// WebCal row
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 14) {
Image(systemName: "globe")
.font(.system(size: 16))
.foregroundColor(.white)
.frame(width: 36, height: 36)
.background(AppColors.blue)
.clipShape(RoundedRectangle(cornerRadius: 9))
VStack(alignment: .leading, spacing: 2) {
Text("Web Calendar (WebCal)").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
Text("Subscribe to an iCal / .ics feed").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
Spacer()
}
.padding(.horizontal, 16).padding(.top, 12)
HStack(spacing: 8) {
TextField("webcal:// or https://…", text: $webCalURL)
.font(AppFonts.sans(12)).foregroundColor(AppColors.text(cs))
.focused($webCalFocused).padding(10)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(webCalFocused ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
.autocorrectionDisabled().textInputAutocapitalization(.never)
Button {
var urlString = webCalURL.trimmingCharacters(in: .whitespaces)
if urlString.hasPrefix("https://") { urlString = urlString.replacingOccurrences(of: "https://", with: "webcal://") }
if let url = URL(string: urlString) { openURL(url) }
} label: {
Text("Subscribe").font(AppFonts.sans(12, weight: .semibold)).foregroundColor(.white)
.padding(.horizontal, 12).padding(.vertical, 10)
.background(!webCalURL.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.blue : AppColors.borderHi(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
}
.buttonStyle(.plain).disabled(webCalURL.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(.horizontal, 16).padding(.bottom, 12)
}
Divider().background(AppColors.border(cs)).padding(.leading, 66)
// Google Calendar row
HStack(spacing: 14) {
Image(systemName: "g.circle.fill")
.font(.system(size: 18))
.foregroundColor(.white)
.frame(width: 36, height: 36)
.background(Color(red: 0.26, green: 0.52, blue: 0.96))
.clipShape(RoundedRectangle(cornerRadius: 9))
VStack(alignment: .leading, spacing: 2) {
Text("Google Calendar").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
Text("Open in Google Calendar app").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
Spacer()
Image(systemName: "arrow.up.right").font(.system(size: 11)).foregroundColor(AppColors.text3(cs))
}
.padding(.horizontal, 16).padding(.vertical, 12)
.contentShape(Rectangle())
.onTapGesture {
if let url = URL(string: "googlegmail://") {
openURL(url)
}
}
}
.background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
.padding(.horizontal, 20).padding(.top, 16)
Spacer()
}
.background(AppColors.background(cs).ignoresSafeArea())
}
}
// MARK: - Day Cell
struct DayCell: View {
@Environment(\.colorScheme) private var cs
let date: Date; let isCurrentMonth: Bool; let isToday: Bool; let isSelected: Bool; let dots: [Color]
private let cal = Calendar.current
var dayNum: String { "\(cal.component(.day, from: date))" }
var weekNum: Int { cal.component(.weekOfYear, from: date) }
var isSunday: Bool { cal.component(.weekday, from: date) == 1 }
var body: some View {
VStack(spacing: 2) {
ZStack {
if isToday { Circle().fill(AppColors.accent).frame(width: 26, height: 26) }
else if isSelected { Circle().fill(AppColors.accentSoft).frame(width: 26, height: 26) }
Text(dayNum)
.font(AppFonts.sans(12.5, weight: isToday ? .bold : (isCurrentMonth ? .medium : .regular)))
.foregroundColor(isToday ? .white : !isCurrentMonth ? AppColors.text3(cs) : AppColors.text(cs))
}
.frame(width: 26, height: 26)
.overlay(alignment: .topLeading) {
if isSunday {
Text("W\(weekNum)").font(AppFonts.mono(6.5)).foregroundColor(AppColors.text3(cs)).offset(x: -2, y: -10)
}
}
HStack(spacing: 2) {
ForEach(dots.indices, id: \.self) { i in
Circle().fill(dots[i]).frame(width: 3.5, height: 3.5)
}
}
.frame(height: 4)
}
.frame(maxWidth: .infinity).frame(height: 42).contentShape(Rectangle())
}
}
// MARK: - Day Timeline
private struct DayTLEntry: Identifiable {
var id = UUID()
var timeLabel: String
var ampm: String?
var title: String
var subtitle: String?
var color: Color
var isComplete: Bool
var isAllDay: Bool
var sortKey: Int // < 0 = all-day/special; else minute-of-day
var taskRef: TaskItem?
var workoutRef: WorkoutProgram?
}
struct DayTimelineView: View {
@Environment(\.colorScheme) private var cs
let date: Date
let tasks: [TaskItem]
let calEvents: [EKEvent]
let workout: WorkoutProgram?
var onWorkoutTap: (() -> Void)? = nil
var onToggle: ((TaskItem) -> Void)? = nil
private let cal = Calendar.current
private var entries: [DayTLEntry] {
var out: [DayTLEntry] = []
// All-day tasks (no specific time)
for t in tasks where !t.hasTime {
out.append(DayTLEntry(
timeLabel: "All Day",
title: t.title,
color: t.taskColor.color(),
isComplete: t.isComplete,
isAllDay: true, sortKey: -1, taskRef: t
))
}
// All-day calendar events
for ev in calEvents where ev.isAllDay {
out.append(DayTLEntry(
timeLabel: "All Day",
title: ev.title ?? "Event",
color: Color(cgColor: ev.calendar.cgColor),
isComplete: false, isAllDay: true, sortKey: -1
))
}
// Workout
if let w = workout {
let done = w.doneSets == w.totalSets && w.totalSets > 0
out.append(DayTLEntry(
timeLabel: "Workout",
title: w.name,
subtitle: "\(w.totalExercises) exercise\(w.totalExercises == 1 ? "" : "s")",
color: AppColors.blue,
isComplete: done, isAllDay: true, sortKey: -2, workoutRef: w
))
}
// Timed tasks
for t in tasks where t.hasTime {
guard let d = t.dueDate else { continue }
let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d)
let hd = h == 0 ? 12 : (h > 12 ? h - 12 : h)
let ms = m == 0 ? "" : ":\(String(format: "%02d", m))"
out.append(DayTLEntry(
timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM",
title: t.title, color: t.taskColor.color(),
isComplete: t.isComplete, isAllDay: false,
sortKey: h * 60 + m, taskRef: t
))
}
// Timed calendar events
for ev in calEvents where !ev.isAllDay {
guard let s = ev.startDate else { continue }
let h = cal.component(.hour, from: s), m = cal.component(.minute, from: s)
let hd = h == 0 ? 12 : (h > 12 ? h - 12 : h)
let ms = m == 0 ? "" : ":\(String(format: "%02d", m))"
let endLabel: String? = ev.endDate.map {
let eh = cal.component(.hour, from: $0), em = cal.component(.minute, from: $0)
let ehd = eh == 0 ? 12 : (eh > 12 ? eh - 12 : eh)
let ems = em == 0 ? "" : ":\(String(format: "%02d", em))"
let eap = eh < 12 ? "AM" : "PM"
return "until \(ehd)\(ems) \(eap)"
}
out.append(DayTLEntry(
timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM",
title: ev.title ?? "Event", subtitle: endLabel,
color: Color(cgColor: ev.calendar.cgColor),
isComplete: false, isAllDay: false, sortKey: h * 60 + m
))
}
return out.sorted {
if $0.sortKey < 0 && $1.sortKey < 0 { return $0.sortKey > $1.sortKey }
if $0.sortKey < 0 { return true }
if $1.sortKey < 0 { return false }
return $0.sortKey < $1.sortKey
}
}
var body: some View {
if entries.isEmpty {
VStack(spacing: 6) {
Image(systemName: "calendar.badge.checkmark")
.font(.system(size: 24))
.foregroundColor(AppColors.text3(cs))
Text("Nothing scheduled")
.font(AppFonts.sans(13, weight: .medium))
.foregroundColor(AppColors.text2(cs))
Text("Tap + to add a task")
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
}
.frame(maxWidth: .infinity).padding(.vertical, 36)
} else {
VStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.element.id) { i, entry in
DayTLRow(
entry: entry,
isFirst: i == 0,
isLast: i == entries.count - 1,
cs: cs,
onWorkoutTap: entry.workoutRef != nil ? onWorkoutTap : nil,
onToggle: entry.taskRef.map { t in { onToggle?(t) } }
)
}
}
}
}
}
private struct DayTLRow: View {
let entry: DayTLEntry
let isFirst: Bool
let isLast: Bool
let cs: ColorScheme
var onWorkoutTap: (() -> Void)?
var onToggle: (() -> Void)?
private let circleD: CGFloat = 18
private let timeW: CGFloat = 46
private let trackW: CGFloat = 26
private let topGap: CGFloat = 10
@ViewBuilder private var marker: some View {
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.workoutRef != nil {
Circle()
.fill(AppColors.blue.opacity(0.14))
.frame(width: circleD, height: circleD)
Image(systemName: "dumbbell.fill")
.font(.system(size: 8))
.foregroundColor(AppColors.blue)
} 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)
}
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))
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: top line circle bottom fill line
VStack(spacing: 0) {
Rectangle()
.fill(isFirst ? Color.clear : AppColors.border(cs))
.frame(width: 1, height: topGap)
.frame(maxWidth: trackW)
marker
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 let sub = entry.subtitle {
Text(sub)
.font(AppFonts.mono(9.5))
.foregroundColor(AppColors.text3(cs))
}
}
Spacer(minLength: 0)
if let toggle = onToggle {
Button(action: toggle) {
Image(systemName: entry.isComplete ? "checkmark.circle.fill" : "circle")
.font(.system(size: 18))
.foregroundColor(entry.isComplete ? entry.color : AppColors.text3(cs))
}
.buttonStyle(.plain)
} else if onWorkoutTap != nil {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(AppColors.text3(cs))
}
}
.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)
.contentShape(Rectangle())
.onTapGesture { if onWorkoutTap != nil { onWorkoutTap?() } }
}
}
}
// MARK: - Calendar Task Row (used in task list toggle mode)
private struct CalTaskRow: View {
@Environment(\.colorScheme) private var cs
let task: TaskItem
let onToggle: () -> Void
private static let timeFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "H:mm"; return f
}()
private var timeLabel: String? {
guard let d = task.dueDate, task.hasTime else { return nil }
let comps = Calendar.current.dateComponents([.hour, .minute], from: d)
guard comps.hour != 0 || comps.minute != 0 else { return nil }
return Self.timeFmt.string(from: d)
}
var body: some View {
HStack(spacing: 12) {
Button(action: onToggle) {
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(task.isComplete ? AppColors.surface3(cs) : Color.clear)
.frame(width: 22, height: 22)
RoundedRectangle(cornerRadius: 6)
.strokeBorder(
task.isComplete ? Color.clear : task.taskColor.color(),
lineWidth: 1.5
)
.frame(width: 22, height: 22)
if task.isComplete {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .bold))
.foregroundColor(AppColors.text3(cs))
}
}
}
.buttonStyle(PressButtonStyle(scale: 0.88))
Text(task.title)
.font(AppFonts.sans(13, weight: task.isComplete ? .regular : .medium))
.foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
.strikethrough(task.isComplete, color: AppColors.text3(cs))
Spacer()
if let t = timeLabel {
Text(t)
.font(AppFonts.mono(11, weight: .semibold))
.foregroundColor(AppColors.accent)
} else if task.isComplete {
Text("Today")
.font(AppFonts.mono(10))
.foregroundColor(AppColors.text3(cs))
}
}
.padding(.horizontal, 16).padding(.vertical, 13)
.contentShape(Rectangle())
}
}
// MARK: - Workout Section Bar
struct WorkoutSectionBar: View {
let count: Int
var body: some View {
let n = max(1, min(6, count))
VStack(spacing: 2.5) {
ForEach(0..<n, id: \.self) { _ in
RoundedRectangle(cornerRadius: 1.5)
.fill(AppColors.blue)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.padding(7)
.background(AppColors.blueSoft)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}