Files
Project_Velocity/iOS/velocity-ipad/velocity/Features/Calendar/CalendarView.swift
Sayan Datta fefe8373ec
Some checks failed
Production Readiness / backend-contracts (pull_request) Has been cancelled
Production Readiness / webos-typecheck (pull_request) Has been cancelled
Production Readiness / ipad-parse (pull_request) Has been cancelled
feat: Ipad app features and Dream Weaver for Velocity WebOS
2026-04-28 10:59:07 +05:30

1261 lines
48 KiB
Swift

import Combine
import SwiftUI
private struct CalendarAgendaItem: Identifiable {
let id: String
let title: String
let slot: String
let owner: String
let location: String
let type: String
let color: Color
let sortDate: Date?
let event: VelocityCalendarEventDTO?
let task: VelocityTaskDTO?
}
private struct CalendarQuickMetric: Identifiable {
let id: String
let label: String
let value: String
let color: Color
}
private struct CalendarEventDraft {
var title = ""
var clientPersonId = ""
var description = ""
var location = ""
var startDate: Date
var endDate: Date
var allDay = false
var status = "tentative"
var reminderMinutes = 15
init(startDate: Date = CalendarEventDraft.defaultStartDate()) {
self.startDate = startDate
self.endDate = startDate.addingTimeInterval(60 * 60)
}
var isValid: Bool {
guard title.trimmedNonEmpty != nil else {
return false
}
return allDay || endDate > startDate
}
static func defaultStartDate() -> Date {
let calendar = Calendar.current
let now = Date()
let nextHour = calendar.dateInterval(of: .hour, for: now)?.end ?? now.addingTimeInterval(60 * 60)
return nextHour
}
}
struct CalendarView: View {
@State private var store = AppStore.shared
@State private var selectedDay = Self.currentWeekdayName()
@State private var actionError: String?
@State private var actionMessage: String?
@State private var actionMessageDismissTask: Task<Void, Never>?
@State private var activeTaskMutationID: String?
@State private var activeEventMutationID: String?
@State private var undoCancelledTask: VelocityTaskDTO?
@State private var undoCancelledEvent: VelocityCalendarEventDTO?
@State private var isCreateEventPresented = false
@State private var eventDraft = CalendarEventDraft()
@State private var isCreatingEvent = false
@State private var createEventError: String?
private let refreshTimer = Timer.publish(every: 15, on: .main, in: .common).autoconnect()
private let visibleWeekdays = Calendar.current.weekdaySymbols
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
header
if let errorMessage = store.errorMessage {
errorBanner(errorMessage)
}
if let actionError {
errorBanner(actionError)
}
if let actionMessage {
successBanner(actionMessage)
}
if store.isLoading && store.lastRefreshAt == nil {
loadingPanel
} else {
metricsRow
HStack(alignment: .top, spacing: 18) {
scheduleRail
agendaPanel
}
}
}
.padding(20)
}
.background(VelocityTheme.background)
.scrollContentBackground(.hidden)
.task { await store.refresh() }
.refreshable { await store.refresh() }
.onReceive(refreshTimer) { _ in
Task { await store.refresh(silent: true) }
}
.onDisappear {
actionMessageDismissTask?.cancel()
actionMessageDismissTask = nil
}
.sheet(isPresented: $isCreateEventPresented) {
createEventSheet
}
}
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("Calendar")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Operator scheduling edge for confirmed calendar events and canonical CRM follow-up tasks.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
HStack(spacing: 10) {
Button {
presentCreateEvent()
} label: {
Label("Create Event", systemImage: "plus")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Color.white)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
Capsule()
.fill(VelocityTheme.accent)
.overlay(Capsule().stroke(VelocityTheme.accentDim, lineWidth: 1))
)
}
.buttonStyle(.plain)
Button {
selectedDay = Self.currentWeekdayName()
actionError = nil
} label: {
Text("This week")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
Capsule()
.fill(VelocityTheme.accent.opacity(0.12))
.overlay(Capsule().stroke(VelocityTheme.borderAccent, lineWidth: 1))
)
}
.buttonStyle(.plain)
}
}
}
private var metricsRow: some View {
HStack(spacing: 12) {
ForEach(calendarMetrics) { metric in
VStack(alignment: .leading, spacing: 8) {
Text(metric.label.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Text(metric.value)
.font(.system(size: 20, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
RoundedRectangle(cornerRadius: 4)
.fill(metric.color)
.frame(width: 48, height: 4)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 16)
}
}
}
private var scheduleRail: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Week Grid")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
ForEach(visibleWeekdays, id: \.self) { day in
Button {
selectedDay = day
} label: {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(day)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(daySubtitle(day))
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Circle()
.fill(selectedDay == day ? VelocityTheme.accent : VelocityTheme.borderSubtle)
.frame(width: 10, height: 10)
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(selectedDay == day ? VelocityTheme.accent.opacity(0.12) : VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(selectedDay == day ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
.buttonStyle(.plain)
}
}
.padding(18)
.frame(maxWidth: 300, alignment: .topLeading)
.glassCard(cornerRadius: 20)
}
private var agendaPanel: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(selectedDay)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Recommended schedule blended from confirmed calendar events and canonical CRM reminders.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
}
if agendaItems.isEmpty {
Text("No live calendar events or canonical CRM tasks are scheduled yet for this operator.")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
} else if filteredAgendaItems.isEmpty {
Text("No live calendar events or canonical CRM tasks are scheduled for \(selectedDay).")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
}
ForEach(filteredAgendaItems) { item in
HStack(alignment: .top, spacing: 14) {
VStack(spacing: 6) {
Circle()
.fill(item.color)
.frame(width: 12, height: 12)
Rectangle()
.fill(item.color.opacity(0.22))
.frame(width: 2, height: 44)
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(item.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text(item.type)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(item.color)
}
Text(item.slot)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.foreground)
Text("Owner: \(item.owner) · \(item.location)")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
if let task = item.task {
if activeTaskMutationID == task.reminderId {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
} else {
taskActionsMenu(task)
}
} else if let event = item.event {
if activeEventMutationID == event.calendarEventId {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
} else {
eventActionsMenu(event)
}
} else {
Spacer()
}
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
VStack(alignment: .leading, spacing: 8) {
Text("Calendar synthesis")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(calendarSynthesis)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(red: 0.09, green: 0.15, blue: 0.33).opacity(0.5))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
)
)
}
.padding(22)
.frame(maxWidth: .infinity, alignment: .topLeading)
.glassCard(cornerRadius: 20)
}
private var createEventSheet: some View {
NavigationStack {
ZStack {
VelocityTheme.background.ignoresSafeArea()
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 4) {
Text("Create Event")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Add a confirmed operator calendar slot with client context, reminders, and meeting details.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if let createEventError {
errorBanner(createEventError)
}
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Color.clear
.frame(height: 0)
.id("create-event-form-top")
formLabel("Title")
eventTextField("Site visit with client", text: $eventDraft.title)
formLabel("Client")
Picker("Client", selection: $eventDraft.clientPersonId) {
Text("No linked client").tag("")
ForEach(store.contacts) { contact in
Text(contact.fullName).tag(contact.personId)
}
}
.pickerStyle(.menu)
.tint(VelocityTheme.accent)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(fieldBackground)
HStack(spacing: 12) {
Toggle("All-day", isOn: $eventDraft.allDay)
.toggleStyle(.switch)
.tint(VelocityTheme.accent)
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Picker("Status", selection: $eventDraft.status) {
Text("Normal Task").tag("tentative")
Text("Confirmed Task").tag("confirmed")
}
.pickerStyle(.menu)
.tint(VelocityTheme.accent)
Picker("Reminder", selection: $eventDraft.reminderMinutes) {
Text("None").tag(0)
Text("5 min").tag(5)
Text("15 min").tag(15)
Text("30 min").tag(30)
Text("1 hour").tag(60)
Text("1 day").tag(1_440)
}
.pickerStyle(.menu)
.tint(VelocityTheme.accent)
}
.padding(12)
.background(fieldBackground)
DatePicker(
"Starts",
selection: eventStartBinding,
displayedComponents: eventDatePickerComponents
)
.tint(VelocityTheme.accent)
.foregroundStyle(VelocityTheme.foreground)
.padding(12)
.background(fieldBackground)
if !eventDraft.allDay {
DatePicker(
"Ends",
selection: $eventDraft.endDate,
in: eventDraft.startDate.addingTimeInterval(60)...,
displayedComponents: [.date, .hourAndMinute]
)
.tint(VelocityTheme.accent)
.foregroundStyle(VelocityTheme.foreground)
.padding(12)
.background(fieldBackground)
}
formLabel("Location")
eventTextField("Project site, sales lounge, video call", text: $eventDraft.location)
formLabel("Description")
TextEditor(text: $eventDraft.description)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
.scrollContentBackground(.hidden)
.frame(minHeight: 110)
.padding(10)
.background(fieldBackground)
}
.padding(18)
.glassCard(cornerRadius: 18)
}
.frame(height: 436)
.onAppear {
DispatchQueue.main.async {
proxy.scrollTo("create-event-form-top", anchor: .top)
}
}
}
HStack(spacing: 12) {
Button {
isCreateEventPresented = false
} label: {
Text("Cancel")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
.frame(maxWidth: .infinity)
.padding(.vertical, 13)
.background(fieldBackground)
}
.buttonStyle(.plain)
Button {
createEvent()
} label: {
HStack(spacing: 8) {
if isCreatingEvent {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(isCreatingEvent ? "Creating..." : "Create Event")
}
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 13)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(eventDraft.isValid && !isCreatingEvent ? VelocityTheme.accent : VelocityTheme.surface3)
)
}
.buttonStyle(.plain)
.disabled(!eventDraft.isValid || isCreatingEvent)
}
}
.padding(24)
.frame(maxWidth: 620)
.frame(maxWidth: .infinity)
}
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button {
isCreateEventPresented = false
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(VelocityTheme.mutedFg)
}
.buttonStyle(.plain)
}
}
}
.presentationDetents([.height(690)])
.presentationDragIndicator(.visible)
}
private var filteredAgendaItems: [CalendarAgendaItem] {
let weekday = selectedDay.lowercased()
return agendaItems.filter { $0.slot.lowercased().contains(weekday) }
}
private var agendaItems: [CalendarAgendaItem] {
let eventItems = store.calendarEvents.filter { $0.status.lowercased() != "cancelled" }.map { event in
CalendarAgendaItem(
id: event.calendarEventId,
title: event.title,
slot: formattedSlot(startAt: event.startAt),
owner: event.createdBy.replacingOccurrences(of: "_", with: " ").capitalized,
location: event.location ?? "No location",
type: eventStatusLabel(event.status),
color: color(for: event.status),
sortDate: event.startDate,
event: event,
task: nil
)
}
let taskItems = store.prioritizedTasks.filter { $0.status.lowercased() != "cancelled" }.map { task in
CalendarAgendaItem(
id: task.reminderId,
title: task.title,
slot: formattedTaskSlot(task),
owner: task.ownerLabel,
location: task.clientPhone ?? "Canonical CRM task",
type: taskStatusLabel(task),
color: taskColor(for: task),
sortDate: task.dueDate,
event: nil,
task: task
)
}
return (eventItems + taskItems).sorted { lhs, rhs in
switch (lhs.sortDate, rhs.sortDate) {
case let (left?, right?):
return left < right
case (_?, nil):
return true
case (nil, _?):
return false
default:
return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending
}
}
}
private var calendarMetrics: [CalendarQuickMetric] {
buildMetrics(
events: store.calendarEvents,
tasks: store.prioritizedTasks,
pendingTaskCount: store.pendingTaskMetricCount,
agendaItems: agendaItems
)
}
private var calendarSynthesis: String {
if agendaItems.isEmpty {
return "Velocity has not received any live calendar events or canonical CRM reminder tasks yet. Once reminders and confirmed follow-ups are written, they will appear here automatically."
}
return "Live mobile-edge calendar events are now blended with canonical CRM reminder tasks so the iPad operator schedule reflects both confirmed meetings and pending follow-up work."
}
private var loadingPanel: some View {
VStack(alignment: .leading, spacing: 12) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
Text("Loading live calendar events...")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("This surface reads confirmed mobile-edge calendar records and canonical CRM reminder tasks for the authenticated Velocity user.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 20)
}
private var eventStartBinding: Binding<Date> {
Binding(
get: { eventDraft.startDate },
set: { newValue in
let previousDuration = max(eventDraft.endDate.timeIntervalSince(eventDraft.startDate), 60 * 60)
eventDraft.startDate = newValue
if eventDraft.endDate <= newValue {
eventDraft.endDate = newValue.addingTimeInterval(previousDuration)
}
}
)
}
private var eventDatePickerComponents: DatePickerComponents {
eventDraft.allDay ? [.date] : [.date, .hourAndMinute]
}
private var fieldBackground: some View {
RoundedRectangle(cornerRadius: 12)
.fill(VelocityTheme.surface2)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
}
private func formLabel(_ text: String) -> some View {
Text(text.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
}
private func eventTextField(_ prompt: String, text: Binding<String>) -> some View {
TextField(prompt, text: text)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
.padding(12)
.background(fieldBackground)
}
private func errorBanner(_ message: String) -> some View {
Text(message)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.danger.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
)
)
}
private func successBanner(_ message: String) -> some View {
HStack(spacing: 12) {
Text(message)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.success)
Spacer()
if undoCancelledTask != nil || undoCancelledEvent != nil {
Button {
undoCancellation()
} label: {
Label("Undo", systemImage: "arrow.uturn.backward")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.success)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(
Capsule()
.fill(VelocityTheme.success.opacity(0.14))
.overlay(Capsule().stroke(VelocityTheme.success.opacity(0.28), lineWidth: 1))
)
}
.buttonStyle(.plain)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.success.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.success.opacity(0.22), lineWidth: 1)
)
)
}
private func buildMetrics(
events: [VelocityCalendarEventDTO],
tasks: [VelocityTaskDTO],
pendingTaskCount: Int,
agendaItems: [CalendarAgendaItem]
) -> [CalendarQuickMetric] {
let currentWeekday = Self.currentWeekdayName().lowercased()
let today = agendaItems.filter {
$0.slot.lowercased().contains(currentWeekday) && !isInactiveAgendaItem($0)
}.count
let confirmed = events.filter { $0.status.lowercased() == "confirmed" }.count
+ tasks.filter { $0.status.lowercased() == "confirmed" }.count
return [
CalendarQuickMetric(id: "today", label: "Today", value: "\(today) items", color: VelocityTheme.accent),
CalendarQuickMetric(id: "priority", label: "Confirmed", value: "\(confirmed)", color: VelocityTheme.success),
CalendarQuickMetric(id: "pending", label: "Pending tasks", value: "\(pendingTaskCount)", color: VelocityTheme.warning),
]
}
private func daySubtitle(_ day: String) -> String {
let count = agendaItems.filter {
$0.slot.lowercased().contains(day.lowercased()) && !isInactiveAgendaItem($0)
}.count
return count == 1 ? "1 scheduled item" : "\(count) scheduled items"
}
private func isInactiveAgendaItem(_ item: CalendarAgendaItem) -> Bool {
if let task = item.task {
return ["done", "cancelled"].contains(task.status.lowercased())
}
if let event = item.event {
return ["done", "cancelled"].contains(event.status.lowercased())
}
return false
}
private func formattedSlot(startAt: String) -> String {
guard let date = ISO8601DateFormatter().date(from: startAt) else {
return startAt
}
let dayFormatter = DateFormatter()
dayFormatter.dateFormat = "EEEE"
let timeFormatter = DateFormatter()
timeFormatter.dateFormat = "h:mm a"
return "\(dayFormatter.string(from: date)) · \(timeFormatter.string(from: date))"
}
private func formattedTaskSlot(_ task: VelocityTaskDTO) -> String {
guard let dueDate = task.dueDate else {
return "Unscheduled"
}
let dayFormatter = DateFormatter()
dayFormatter.dateFormat = "EEEE"
let timeFormatter = DateFormatter()
timeFormatter.dateFormat = "h:mm a"
return "\(dayFormatter.string(from: dueDate)) · \(timeFormatter.string(from: dueDate))"
}
private func isToday(_ startAt: String) -> Bool {
guard let date = ISO8601DateFormatter().date(from: startAt) else {
return false
}
return Calendar.current.isDateInToday(date)
}
private func isToday(_ date: Date?) -> Bool {
guard let date else {
return false
}
return Calendar.current.isDateInToday(date)
}
private func color(for status: String) -> Color {
switch status.lowercased() {
case "confirmed":
return VelocityTheme.success
case "done":
return VelocityTheme.mutedFg
case "tentative":
return VelocityTheme.accent
case "cancelled":
return VelocityTheme.danger
default:
return VelocityTheme.mutedFg
}
}
private func eventStatusLabel(_ status: String) -> String {
switch status.lowercased() {
case "tentative":
return "Normal Task"
case "confirmed":
return "Confirmed Task"
case "done":
return "Done"
case "cancelled":
return "Cancelled"
default:
return status.capitalized
}
}
private func taskStatusLabel(_ task: VelocityTaskDTO) -> String {
switch task.status.lowercased() {
case "confirmed":
return "Confirmed Task"
case "done":
return "Done"
case "snoozed":
return "Snoozed"
case "cancelled":
return "Cancelled"
default:
return "Normal Task"
}
}
private func taskColor(for task: VelocityTaskDTO) -> Color {
switch task.status.lowercased() {
case "confirmed":
return VelocityTheme.success
case "done":
return VelocityTheme.mutedFg
case "cancelled":
return VelocityTheme.danger
default:
break
}
return VelocityTheme.accent
}
private func taskActionsMenu(_ task: VelocityTaskDTO) -> some View {
Menu {
let status = task.status.lowercased()
if status == "done" {
Button(role: .destructive) {
mutateTask(
task,
status: "cancelled",
dueAt: nil,
notes: "Removed after completion from the iPad calendar.",
successMessage: "Task removed.",
supportsUndo: false
)
} label: {
Label("Remove Task", systemImage: "trash")
}
} else if status == "confirmed" {
Button {
mutateTask(task, status: "pending", dueAt: task.dueAt, notes: "Set normal from the iPad calendar.")
} label: {
Label("Set Normal", systemImage: "circle")
}
} else if status != "done" && status != "cancelled" {
Button {
mutateTask(task, status: "confirmed", dueAt: task.dueAt, notes: "Marked confirmed from the iPad calendar.")
} label: {
Label("Mark Confirmed", systemImage: "checkmark.seal")
}
}
if status != "done" && status != "cancelled" {
Button {
mutateTask(task, status: "done", dueAt: task.dueAt, notes: "Marked done from the iPad calendar.")
} label: {
Label("Mark Done", systemImage: "checkmark.circle")
}
}
if !["done", "cancelled"].contains(status) {
Button {
let snoozeDate = task.nextSnoozeDate(adding: 2 * 60 * 60)
mutateTask(
task,
status: "snoozed",
dueAt: iso8601Timestamp(snoozeDate),
notes: "Snoozed by 2 hours from the iPad calendar."
)
} label: {
Label("Snooze 2h", systemImage: "clock.badge")
}
Button {
let snoozeDate = task.nextSnoozeDate(adding: 24 * 60 * 60)
mutateTask(
task,
status: "snoozed",
dueAt: iso8601Timestamp(snoozeDate),
notes: "Snoozed by 1 day from the iPad calendar."
)
} label: {
Label("Snooze 1 Day", systemImage: "moon")
}
}
if status != "done" && status != "cancelled" {
Button(role: .destructive) {
mutateTask(task, status: "cancelled", dueAt: nil, notes: "Cancelled from the iPad calendar.")
} label: {
Label("Cancel Task", systemImage: "xmark.circle")
}
}
} label: {
menuIcon("ellipsis.circle")
}
}
private func eventActionsMenu(_ event: VelocityCalendarEventDTO) -> some View {
Menu {
let status = event.status.lowercased()
if status == "done" {
Button(role: .destructive) {
cancelEvent(event, message: "Task removed.", supportsUndo: false)
} label: {
Label("Remove Task", systemImage: "trash")
}
} else if status == "confirmed" {
Button {
mutateEvent(event, status: "tentative", message: "Task set to normal.")
} label: {
Label("Set Normal", systemImage: "circle")
}
} else if status != "done" && status != "cancelled" {
Button {
mutateEvent(event, status: "confirmed", message: "Task marked confirmed.")
} label: {
Label("Mark Confirmed", systemImage: "checkmark.seal")
}
}
if status != "done" && status != "cancelled" {
Button {
mutateEvent(event, status: "done", message: "Task marked done.")
} label: {
Label("Mark Done", systemImage: "checkmark.circle")
}
}
if !["done", "cancelled"].contains(status) {
Button {
shiftEvent(event, adding: 2 * 60 * 60, message: "Task snoozed by 2 hours.")
} label: {
Label("Snooze 2h", systemImage: "clock.badge")
}
Button {
shiftEvent(event, adding: 24 * 60 * 60, message: "Task snoozed by 1 day.")
} label: {
Label("Snooze 1 Day", systemImage: "moon")
}
}
if status != "done" && status != "cancelled" {
Button(role: .destructive) {
cancelEvent(event)
} label: {
Label("Cancel Task", systemImage: "xmark.circle")
}
}
} label: {
menuIcon("ellipsis.circle")
}
}
private func presentCreateEvent() {
eventDraft = CalendarEventDraft(startDate: defaultEventStartDate(for: selectedDay))
createEventError = nil
actionError = nil
clearActionMessage()
isCreateEventPresented = true
}
private func createEvent() {
guard eventDraft.isValid else {
createEventError = "Add an event title and make sure the end time is after the start time."
return
}
let selectedContact = store.contacts.first { $0.personId == eventDraft.clientPersonId }
let calendar = Calendar.current
let startDate = eventDraft.allDay ? calendar.startOfDay(for: eventDraft.startDate) : eventDraft.startDate
let endDate = eventDraft.allDay
? (calendar.date(byAdding: .day, value: 1, to: startDate) ?? startDate.addingTimeInterval(24 * 60 * 60))
: eventDraft.endDate
let reminderMinutes = eventDraft.reminderMinutes > 0 ? [eventDraft.reminderMinutes] : []
var metadata = [
"created_from": "ipad_calendar",
"surface": "velocity_ipad",
]
if let selectedContact {
metadata["person_id"] = selectedContact.personId
metadata["client_name"] = selectedContact.fullName
if let phone = selectedContact.primaryPhone?.trimmedNonEmpty {
metadata["client_phone"] = phone
}
}
createEventError = nil
isCreatingEvent = true
Task {
do {
_ = try await store.createCalendarEvent(
leadId: selectedContact?.leadId,
title: eventDraft.title.trimmedNonEmpty ?? "Calendar event",
description: eventDraft.description.trimmedNonEmpty,
startAt: iso8601Timestamp(startDate),
endAt: iso8601Timestamp(endDate),
allDay: eventDraft.allDay,
status: eventDraft.status,
reminderMinutes: reminderMinutes,
location: eventDraft.location.trimmedNonEmpty,
metadata: metadata
)
await MainActor.run {
selectedDay = weekdayName(for: startDate)
isCreatingEvent = false
isCreateEventPresented = false
showActionMessage("Event created.")
actionError = nil
}
} catch {
await MainActor.run {
isCreatingEvent = false
createEventError = calendarActionErrorMessage(error)
}
}
}
}
private func mutateEvent(
_ event: VelocityCalendarEventDTO,
status: String,
message: String
) {
actionError = nil
clearActionMessage()
activeEventMutationID = event.calendarEventId
Task {
do {
_ = try await store.updateCalendarEvent(event, status: status)
await MainActor.run {
activeEventMutationID = nil
showActionMessage(message)
}
} catch {
await MainActor.run {
activeEventMutationID = nil
actionError = calendarActionErrorMessage(error)
}
}
}
}
private func shiftEvent(
_ event: VelocityCalendarEventDTO,
adding interval: TimeInterval,
message: String
) {
guard let startDate = event.startDate, let endDate = event.endDate else {
actionError = "This event cannot be rescheduled because its date was not readable."
return
}
actionError = nil
clearActionMessage()
activeEventMutationID = event.calendarEventId
Task {
do {
_ = try await store.updateCalendarEvent(
event,
startAt: iso8601Timestamp(startDate.addingTimeInterval(interval)),
endAt: iso8601Timestamp(endDate.addingTimeInterval(interval))
)
await MainActor.run {
activeEventMutationID = nil
showActionMessage(message)
}
} catch {
await MainActor.run {
activeEventMutationID = nil
actionError = calendarActionErrorMessage(error)
}
}
}
}
private func cancelEvent(
_ event: VelocityCalendarEventDTO,
message: String = "Event cancelled.",
supportsUndo: Bool = true
) {
actionError = nil
clearActionMessage()
activeEventMutationID = event.calendarEventId
Task {
do {
try await store.cancelCalendarEvent(event)
await MainActor.run {
activeEventMutationID = nil
if supportsUndo {
undoCancelledEvent = event
}
showActionMessage(message)
}
} catch {
await MainActor.run {
activeEventMutationID = nil
actionError = calendarActionErrorMessage(error)
}
}
}
}
private func mutateTask(
_ task: VelocityTaskDTO,
status: String,
dueAt: String?,
notes: String,
successMessage: String? = nil,
supportsUndo: Bool = true
) {
actionError = nil
clearActionMessage()
activeTaskMutationID = task.reminderId
Task {
do {
_ = try await store.updateTaskStatus(
reminderId: task.reminderId,
status: status,
dueAt: dueAt,
notes: notes
)
await MainActor.run {
activeTaskMutationID = nil
if status.lowercased() == "cancelled" && supportsUndo {
undoCancelledTask = task
}
showActionMessage(successMessage ?? taskActionMessage(status: status))
}
} catch {
if let apiError = error as? VelocityAPIError, apiError.statusCode == 404 {
await store.refresh(silent: true)
}
await MainActor.run {
activeTaskMutationID = nil
actionError = calendarActionErrorMessage(error)
}
}
}
}
private func calendarActionErrorMessage(_ error: Error) -> String {
if let apiError = error as? VelocityAPIError, apiError.statusCode == 404 {
return "The backend returned Not Found for this calendar request. I refreshed the live schedule so stale items are cleared."
}
return error.localizedDescription
}
private func taskActionMessage(status: String) -> String {
switch status {
case "confirmed":
return "Task marked confirmed."
case "pending":
return "Task set to normal."
case "done":
return "Task marked done."
case "snoozed":
return "Task snoozed."
case "cancelled":
return "Task cancelled."
default:
return "Task updated."
}
}
private func showActionMessage(_ message: String) {
actionMessageDismissTask?.cancel()
actionMessage = message
actionMessageDismissTask = Task {
try? await Task.sleep(nanoseconds: 10_000_000_000)
guard !Task.isCancelled else {
return
}
await MainActor.run {
actionMessage = nil
clearCancellationUndo()
actionMessageDismissTask = nil
}
}
}
private func clearActionMessage(clearUndo: Bool = true) {
actionMessageDismissTask?.cancel()
actionMessageDismissTask = nil
actionMessage = nil
if clearUndo {
clearCancellationUndo()
}
}
private func undoCancellation() {
if let task = undoCancelledTask {
let restoreStatus = task.status.lowercased() == "cancelled" ? "pending" : task.status
mutateTask(
task,
status: restoreStatus,
dueAt: task.dueAt,
notes: "Cancellation undone from the iPad calendar."
)
return
}
if let event = undoCancelledEvent {
let restoreStatus = event.status.lowercased() == "cancelled" ? "tentative" : event.status
mutateEvent(event, status: restoreStatus, message: "Event restored.")
}
}
private func clearCancellationUndo() {
undoCancelledTask = nil
undoCancelledEvent = nil
}
private func iso8601Timestamp(_ date: Date) -> String {
ISO8601DateFormatter().string(from: date)
}
private func defaultEventStartDate(for weekdayName: String) -> Date {
let calendar = Calendar.current
let now = Date()
let todayIndex = calendar.component(.weekday, from: now)
let targetIndex = (visibleWeekdays.firstIndex(of: weekdayName) ?? (todayIndex - 1)) + 1
let dayDelta = targetIndex - todayIndex
let targetDate = calendar.date(byAdding: .day, value: dayDelta, to: now) ?? now
let startOfTargetDay = calendar.startOfDay(for: targetDate)
let nextHourToday = CalendarEventDraft.defaultStartDate()
if calendar.isDateInToday(startOfTargetDay) {
return nextHourToday
}
var components = DateComponents()
components.hour = 10
components.minute = 0
let tenAM = calendar.date(byAdding: components, to: startOfTargetDay) ?? startOfTargetDay.addingTimeInterval(10 * 60 * 60)
return tenAM > now ? tenAM : calendar.date(byAdding: .day, value: 7, to: tenAM) ?? tenAM
}
private func weekdayName(for date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE"
return formatter.string(from: date)
}
private func menuIcon(_ systemName: String) -> some View {
Image(systemName: systemName)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(VelocityTheme.mutedFg)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
private static func currentWeekdayName() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE"
return formatter.string(from: Date())
}
}
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
#Preview {
CalendarView()
}