forked from sagnik/Project_Velocity
1261 lines
48 KiB
Swift
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()
|
|
}
|