forked from sagnik/Project_Velocity
#24 WebOS Completion Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#25
364 lines
14 KiB
Swift
364 lines
14 KiB
Swift
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
|
|
}
|
|
|
|
private struct CalendarQuickMetric: Identifiable {
|
|
let id: String
|
|
let label: String
|
|
let value: String
|
|
let color: Color
|
|
}
|
|
|
|
struct CalendarView: View {
|
|
@State private var selectedDay = "Wednesday"
|
|
@State private var agendaItems: [CalendarAgendaItem] = []
|
|
@State private var calendarMetrics: [CalendarQuickMetric] = []
|
|
@State private var isLoading = true
|
|
@State private var errorMessage: String?
|
|
private let refreshTimer = Timer.publish(every: 15, on: .main, in: .common).autoconnect()
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
header
|
|
if let errorMessage {
|
|
errorBanner(errorMessage)
|
|
}
|
|
if isLoading {
|
|
loadingPanel
|
|
} else {
|
|
metricsRow
|
|
HStack(alignment: .top, spacing: 18) {
|
|
scheduleRail
|
|
agendaPanel
|
|
}
|
|
}
|
|
}
|
|
.padding(20)
|
|
}
|
|
.background(VelocityTheme.background)
|
|
.scrollContentBackground(.hidden)
|
|
.task { await loadCalendar() }
|
|
.refreshable { await loadCalendar() }
|
|
.onReceive(refreshTimer) { _ in
|
|
Task { await loadCalendar(silent: true) }
|
|
}
|
|
}
|
|
|
|
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 follow-ups, tours, and legal milestones.")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
}
|
|
Spacer()
|
|
Text("Live sync")
|
|
.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))
|
|
)
|
|
}
|
|
}
|
|
|
|
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(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], 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("Confirmed live schedule for the authenticated Velocity operator.")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
}
|
|
Spacer()
|
|
}
|
|
|
|
if agendaItems.isEmpty {
|
|
Text("No live calendar events are scheduled yet for this user.")
|
|
.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)
|
|
}
|
|
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 filteredAgendaItems: [CalendarAgendaItem] {
|
|
let weekday = selectedDay.lowercased()
|
|
let filtered = agendaItems.filter { $0.slot.lowercased().contains(weekday) }
|
|
return filtered.isEmpty ? agendaItems : filtered
|
|
}
|
|
|
|
private var calendarSynthesis: String {
|
|
if agendaItems.isEmpty {
|
|
return "Velocity has not received any live calendar events yet. Once mobile-edge reminders and confirmed follow-ups are written, they will appear here automatically."
|
|
}
|
|
return "Live calendar events are being pulled from the mobile-edge backend and refreshed automatically so follow-up timing stays aligned with confirmed operator actions."
|
|
}
|
|
|
|
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 for the authenticated Velocity user.")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
}
|
|
.padding(20)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.glassCard(cornerRadius: 20)
|
|
}
|
|
|
|
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 loadCalendar(silent: Bool = false) async {
|
|
if !silent {
|
|
isLoading = true
|
|
}
|
|
do {
|
|
let events = try await VelocityAPIClient.shared.fetchCalendarEvents()
|
|
let mapped = events.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: event.status.capitalized,
|
|
color: color(for: event.status)
|
|
)
|
|
}
|
|
let metrics = buildMetrics(from: events)
|
|
|
|
await MainActor.run {
|
|
agendaItems = mapped
|
|
calendarMetrics = metrics
|
|
if let firstDay = mapped.first?.slot.components(separatedBy: " · ").first {
|
|
selectedDay = firstDay
|
|
}
|
|
errorMessage = nil
|
|
isLoading = false
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
agendaItems = []
|
|
calendarMetrics = [
|
|
CalendarQuickMetric(id: "today", label: "Today", value: "0 slots", color: VelocityTheme.accent),
|
|
CalendarQuickMetric(id: "priority", label: "Confirmed", value: "0", color: VelocityTheme.success),
|
|
CalendarQuickMetric(id: "pending", label: "Pending invites", value: "0", color: VelocityTheme.warning),
|
|
]
|
|
errorMessage = error.localizedDescription
|
|
isLoading = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func buildMetrics(from events: [VelocityCalendarEventDTO]) -> [CalendarQuickMetric] {
|
|
let today = events.filter { isToday($0.startAt) }.count
|
|
let confirmed = events.filter { $0.status.lowercased() == "confirmed" }.count
|
|
let tentative = events.filter { $0.status.lowercased() == "tentative" }.count
|
|
return [
|
|
CalendarQuickMetric(id: "today", label: "Today", value: "\(today) slots", color: VelocityTheme.accent),
|
|
CalendarQuickMetric(id: "priority", label: "Confirmed", value: "\(confirmed)", color: VelocityTheme.success),
|
|
CalendarQuickMetric(id: "pending", label: "Pending invites", value: "\(tentative)", color: VelocityTheme.warning),
|
|
]
|
|
}
|
|
|
|
private func daySubtitle(_ day: String) -> String {
|
|
let count = agendaItems.filter { $0.slot.lowercased().contains(day.lowercased()) }.count
|
|
return count == 1 ? "1 scheduled item" : "\(count) scheduled items"
|
|
}
|
|
|
|
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 isToday(_ startAt: String) -> Bool {
|
|
guard let date = ISO8601DateFormatter().date(from: startAt) else {
|
|
return false
|
|
}
|
|
return Calendar.current.isDateInToday(date)
|
|
}
|
|
|
|
private func color(for status: String) -> Color {
|
|
switch status.lowercased() {
|
|
case "confirmed":
|
|
return VelocityTheme.success
|
|
case "tentative":
|
|
return VelocityTheme.warning
|
|
default:
|
|
return VelocityTheme.mutedFg
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
CalendarView()
|
|
}
|