feat: Ipad app production readiness, Colony orchestration, Social posting (#44)

#38 Ipad app production readiness, Colony orchestration, Social posting

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: sagnik/Project_Velocity#44
This commit is contained in:
2026-05-03 18:30:38 +05:30
parent 59d398abc3
commit eeb684b46c
86 changed files with 20349 additions and 1655 deletions

View File

@@ -1,4 +1,3 @@
import Combine
import SwiftUI
private struct CalendarAgendaItem: Identifiable {
@@ -9,6 +8,7 @@ private struct CalendarAgendaItem: Identifiable {
let location: String
let type: String
let color: Color
let pendingSync: Bool
let sortDate: Date?
let event: VelocityCalendarEventDTO?
let task: VelocityTaskDTO?
@@ -58,15 +58,19 @@ struct CalendarView: View {
@State private var actionError: String?
@State private var actionMessage: String?
@State private var actionMessageDismissTask: Task<Void, Never>?
@State private var activeDashboardFocus: VelocityDashboardCalendarFocus?
@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 editingEvent: VelocityCalendarEventDTO?
@State private var eventDraft = CalendarEventDraft()
@State private var isCreatingEvent = false
@State private var isSavingEvent = false
@State private var createEventError: String?
private let refreshTimer = Timer.publish(every: 15, on: .main, in: .common).autoconnect()
@State private var schedulingClientPersonID: String?
@State private var targetedDropDay: String?
private let visibleWeekdays = Calendar.current.weekdaySymbols
var body: some View {
@@ -82,10 +86,14 @@ struct CalendarView: View {
if let actionMessage {
successBanner(actionMessage)
}
if let activeDashboardFocus {
dashboardFocusBanner(activeDashboardFocus)
}
if store.isLoading && store.lastRefreshAt == nil {
loadingPanel
} else {
metricsRow
clientSchedulingStrip
HStack(alignment: .top, spacing: 18) {
scheduleRail
agendaPanel
@@ -96,11 +104,14 @@ struct CalendarView: View {
}
.background(VelocityTheme.background)
.scrollContentBackground(.hidden)
.task { await store.refresh() }
.refreshable { await store.refresh() }
.onReceive(refreshTimer) { _ in
Task { await store.refresh(silent: true) }
.task {
await store.refresh()
consumeRequestedCalendarFocus()
}
.onChange(of: store.requestedCalendarFocus) { _, _ in
consumeRequestedCalendarFocus()
}
.refreshable { await store.refresh() }
.onDisappear {
actionMessageDismissTask?.cancel()
actionMessageDismissTask = nil
@@ -108,6 +119,9 @@ struct CalendarView: View {
.sheet(isPresented: $isCreateEventPresented) {
createEventSheet
}
.sheet(item: $editingEvent) { event in
editEventSheet(event)
}
}
private var header: some View {
@@ -141,6 +155,9 @@ struct CalendarView: View {
Button {
selectedDay = Self.currentWeekdayName()
actionError = nil
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
activeDashboardFocus = nil
}
} label: {
Text("This week")
.font(.system(size: 11, weight: .semibold))
@@ -207,14 +224,25 @@ struct CalendarView: View {
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(selectedDay == day ? VelocityTheme.accent.opacity(0.12) : VelocityTheme.surface)
.fill(targetedDropDay == day ? VelocityTheme.accent.opacity(0.22) : (selectedDay == day ? VelocityTheme.accent.opacity(0.12) : VelocityTheme.surface))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(selectedDay == day ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
.stroke(targetedDropDay == day || selectedDay == day ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
.buttonStyle(.plain)
.dropDestination(for: String.self) { personIDs, _ in
guard let personID = personIDs.first?.trimmedNonEmpty else {
return false
}
Task { await scheduleSiteVisit(forPersonID: personID, on: day) }
return true
} isTargeted: { isTargeted in
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.82)) {
targetedDropDay = isTargeted ? day : nil
}
}
}
}
.padding(18)
@@ -222,6 +250,46 @@ struct CalendarView: View {
.glassCard(cornerRadius: 20)
}
private var clientSchedulingStrip: some View {
Group {
if !store.contacts.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(store.contacts.prefix(12)) { contact in
HStack(spacing: 8) {
ZStack {
Circle()
.fill(VelocityTheme.accent.opacity(0.16))
.frame(width: 32, height: 32)
Text(initials(for: contact.fullName))
.font(.system(size: 10, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
}
Text(contact.fullName)
.font(.system(size: 12, weight: .semibold))
.lineLimit(1)
}
.foregroundStyle(VelocityTheme.foreground)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
Capsule()
.fill(VelocityTheme.surface)
.overlay(Capsule().stroke(VelocityTheme.borderSubtle, lineWidth: 1))
)
.opacity(schedulingClientPersonID == contact.personId ? 0.55 : 1)
.scaleEffect(schedulingClientPersonID == contact.personId ? 0.97 : 1)
.draggable(contact.personId)
.animation(.interactiveSpring(response: 0.32, dampingFraction: 0.86), value: schedulingClientPersonID)
}
}
.padding(.vertical, 2)
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
private var agendaPanel: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
@@ -263,6 +331,12 @@ struct CalendarView: View {
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
if item.pendingSync {
Circle()
.fill(VelocityTheme.warning)
.frame(width: 8, height: 8)
.shadow(color: VelocityTheme.warning.opacity(0.75), radius: 6)
}
Text(item.type)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(item.color)
@@ -499,7 +573,157 @@ struct CalendarView: View {
.presentationDragIndicator(.visible)
}
private func editEventSheet(_ event: VelocityCalendarEventDTO) -> some View {
NavigationStack {
ZStack {
VelocityTheme.background.ignoresSafeArea()
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 4) {
Text("Edit Event")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Update the backend-owned calendar slot details.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if let createEventError {
errorBanner(createEventError)
}
ScrollView {
VStack(alignment: .leading, spacing: 14) {
formLabel("Title")
eventTextField("Site visit with client", text: $eventDraft.title)
HStack(spacing: 12) {
Picker("Status", selection: $eventDraft.status) {
Text("Normal Task").tag("tentative")
Text("Confirmed Task").tag("confirmed")
Text("Done").tag("done")
}
.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)
HStack(spacing: 12) {
Button {
editingEvent = nil
} label: {
Text("Cancel")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
.frame(maxWidth: .infinity)
.padding(.vertical, 13)
.background(fieldBackground)
}
.buttonStyle(.plain)
Button {
saveEventEdits(event)
} label: {
HStack(spacing: 8) {
if isSavingEvent {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(isSavingEvent ? "Saving..." : "Save Event")
}
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 13)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(eventDraft.isValid && !isSavingEvent ? VelocityTheme.accent : VelocityTheme.surface3)
)
}
.buttonStyle(.plain)
.disabled(!eventDraft.isValid || isSavingEvent)
}
}
.padding(24)
.frame(maxWidth: 620)
.frame(maxWidth: .infinity)
}
}
.presentationDetents([.height(690)])
.presentationDragIndicator(.visible)
}
private var filteredAgendaItems: [CalendarAgendaItem] {
if let activeDashboardFocus {
switch activeDashboardFocus {
case .today:
let weekday = Self.currentWeekdayName().lowercased()
return agendaItems.filter {
$0.slot.lowercased().contains(weekday) && !isInactiveAgendaItem($0)
}
case .pendingTasks:
return agendaItems.filter { item in
guard let task = item.task else { return false }
return ["pending", "snoozed", "confirmed"].contains(task.status.lowercased())
}
case .urgentTasks:
return agendaItems.filter { item in
guard let task = item.task else { return false }
return ["urgent", "high"].contains(task.priority.lowercased()) && !isInactiveAgendaItem(item)
}
}
}
let weekday = selectedDay.lowercased()
return agendaItems.filter { $0.slot.lowercased().contains(weekday) }
}
@@ -514,6 +738,7 @@ struct CalendarView: View {
location: event.location ?? "No location",
type: eventStatusLabel(event.status),
color: color(for: event.status),
pendingSync: store.pendingSyncCalendarEventIDs.contains(event.calendarEventId) || event.calendarEventId.hasPrefix("local-"),
sortDate: event.startDate,
event: event,
task: nil
@@ -528,6 +753,7 @@ struct CalendarView: View {
location: task.clientPhone ?? "Canonical CRM task",
type: taskStatusLabel(task),
color: taskColor(for: task),
pendingSync: store.pendingSyncTaskIDs.contains(task.reminderId),
sortDate: task.dueDate,
event: nil,
task: task
@@ -673,6 +899,34 @@ struct CalendarView: View {
)
}
private func dashboardFocusBanner(_ focus: VelocityDashboardCalendarFocus) -> some View {
HStack(spacing: 10) {
Label(calendarFocusLabel(focus), systemImage: "line.3.horizontal.decrease.circle")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.warning)
Text(calendarFocusDescription(focus))
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
Spacer()
Button("Clear") {
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
activeDashboardFocus = nil
}
}
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.warning.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.warning.opacity(0.22), lineWidth: 1)
)
)
}
private func buildMetrics(
events: [VelocityCalendarEventDTO],
tasks: [VelocityTaskDTO],
@@ -884,6 +1138,14 @@ struct CalendarView: View {
private func eventActionsMenu(_ event: VelocityCalendarEventDTO) -> some View {
Menu {
let status = event.status.lowercased()
if status != "cancelled" {
Button {
presentEditEvent(event)
} label: {
Label("Edit Event", systemImage: "square.and.pencil")
}
}
if status == "done" {
Button(role: .destructive) {
cancelEvent(event, message: "Task removed.", supportsUndo: false)
@@ -946,6 +1208,79 @@ struct CalendarView: View {
isCreateEventPresented = true
}
private func presentEditEvent(_ event: VelocityCalendarEventDTO) {
let startDate = event.startDate ?? CalendarEventDraft.defaultStartDate()
var draft = CalendarEventDraft(startDate: startDate)
draft.title = event.title
draft.description = event.description ?? ""
draft.location = event.location ?? ""
draft.endDate = event.endDate ?? startDate.addingTimeInterval(60 * 60)
draft.allDay = event.allDay
draft.status = event.status.lowercased()
draft.reminderMinutes = event.reminderMinutes.first ?? 0
eventDraft = draft
createEventError = nil
actionError = nil
clearActionMessage()
editingEvent = event
}
@MainActor
private func scheduleSiteVisit(forPersonID personID: String, on weekday: String) async {
guard schedulingClientPersonID == nil else {
return
}
guard let contact = store.contacts.first(where: { $0.personId == personID }) else {
actionError = "Unable to schedule: this client is not present in the canonical CRM payload."
return
}
guard let leadId = contact.leadId?.trimmedNonEmpty else {
actionError = "Unable to schedule \(contact.fullName): no canonical lead is attached."
return
}
let startDate = defaultEventStartDate(for: weekday)
let endDate = startDate.addingTimeInterval(60 * 60)
var metadata = [
"created_from": "ipad_calendar_drag_drop",
"surface": "velocity_ipad",
"person_id": contact.personId,
"client_name": contact.fullName,
]
if let phone = contact.primaryPhone?.trimmedNonEmpty {
metadata["client_phone"] = phone
}
schedulingClientPersonID = personID
actionError = nil
clearActionMessage()
do {
_ = try await store.createCalendarEvent(
leadId: leadId,
title: "Site visit with \(contact.fullName)",
description: contact.primaryInterest.flatMap { "Property focus: \($0)".trimmedNonEmpty },
startAt: iso8601Timestamp(startDate),
endAt: iso8601Timestamp(endDate),
allDay: false,
status: "confirmed",
reminderMinutes: [60, 15],
location: contact.primaryInterest?.trimmedNonEmpty ?? "Project site",
metadata: metadata
)
withAnimation(.interactiveSpring(response: 0.42, dampingFraction: 0.86)) {
selectedDay = weekday
schedulingClientPersonID = nil
targetedDropDay = nil
}
showActionMessage("Site visit scheduled for \(contact.fullName).")
} catch {
schedulingClientPersonID = nil
targetedDropDay = nil
actionError = calendarActionErrorMessage(error)
}
}
private func createEvent() {
guard eventDraft.isValid else {
createEventError = "Add an event title and make sure the end time is after the start time."
@@ -1004,6 +1339,50 @@ struct CalendarView: View {
}
}
private func saveEventEdits(_ event: VelocityCalendarEventDTO) {
guard eventDraft.isValid else {
createEventError = "Add an event title and make sure the end time is after the start time."
return
}
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] : []
createEventError = nil
isSavingEvent = true
Task {
do {
_ = try await store.updateCalendarEvent(
event,
title: eventDraft.title.trimmedNonEmpty ?? event.title,
description: eventDraft.description,
status: eventDraft.status,
startAt: iso8601Timestamp(startDate),
endAt: iso8601Timestamp(endDate),
reminderMinutes: reminderMinutes,
location: eventDraft.location
)
await MainActor.run {
selectedDay = weekdayName(for: startDate)
isSavingEvent = false
editingEvent = nil
showActionMessage("Event updated.")
actionError = nil
}
} catch {
await MainActor.run {
isSavingEvent = false
createEventError = calendarActionErrorMessage(error)
}
}
}
}
private func mutateEvent(
_ event: VelocityCalendarEventDTO,
status: String,
@@ -1170,6 +1549,41 @@ struct CalendarView: View {
}
}
private func consumeRequestedCalendarFocus() {
guard let focus = store.requestedCalendarFocus else {
return
}
store.requestedCalendarFocus = nil
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
activeDashboardFocus = focus
if focus == .today {
selectedDay = Self.currentWeekdayName()
}
}
}
private func calendarFocusLabel(_ focus: VelocityDashboardCalendarFocus) -> String {
switch focus {
case .today:
return "Today"
case .pendingTasks:
return "Pending tasks"
case .urgentTasks:
return "Urgent tasks"
}
}
private func calendarFocusDescription(_ focus: VelocityDashboardCalendarFocus) -> String {
switch focus {
case .today:
return "Showing todays confirmed events and CRM reminders."
case .pendingTasks:
return "Showing actionable CRM reminders across the week."
case .urgentTasks:
return "Showing high-priority and urgent CRM reminders."
}
}
private func clearActionMessage(clearUndo: Bool = true) {
actionMessageDismissTask?.cancel()
actionMessageDismissTask = nil
@@ -1233,6 +1647,15 @@ struct CalendarView: View {
return formatter.string(from: date)
}
private func initials(for name: String) -> String {
let pieces = name
.split(separator: " ")
.prefix(2)
.compactMap { $0.first }
let initials = String(pieces).uppercased()
return initials.isEmpty ? "CL" : initials
}
private func menuIcon(_ systemName: String) -> some View {
Image(systemName: systemName)
.font(.system(size: 17, weight: .semibold))