443 lines
19 KiB
Swift
443 lines
19 KiB
Swift
import SwiftUI
|
||
|
||
struct DashboardView: View {
|
||
private var store: AppStore { AppStore.shared }
|
||
@State private var chatInput = ""
|
||
private let columns = [GridItem(.adaptive(minimum: 220), spacing: 14)]
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
pageHeader
|
||
|
||
// KPI Grid — live from store
|
||
LazyVGrid(columns: columns, spacing: 14) {
|
||
LiveKPICard(
|
||
title: "Visitors",
|
||
value: "\(store.metrics.activeVisitors)",
|
||
subtitle: "Active now",
|
||
icon: "person.2",
|
||
accentColor: VelocityTheme.accent,
|
||
glowColor: VelocityTheme.accent.opacity(0.22),
|
||
badge: "LIVE"
|
||
)
|
||
LiveKPICard(
|
||
title: "Revenue",
|
||
value: store.metrics.revenue,
|
||
subtitle: "30-day forecast",
|
||
icon: "chart.line.uptrend.xyaxis",
|
||
accentColor: Color(red: 0.13, green: 0.83, blue: 0.93),
|
||
glowColor: Color(red: 0.13, green: 0.83, blue: 0.93).opacity(0.18)
|
||
)
|
||
LiveKPICard(
|
||
title: "AI Jobs",
|
||
value: "\(store.metrics.aiJobs)",
|
||
subtitle: "Queue depth",
|
||
icon: "cpu",
|
||
accentColor: Color(red: 0.60, green: 0.57, blue: 0.99),
|
||
glowColor: Color(red: 0.60, green: 0.57, blue: 0.99).opacity(0.20)
|
||
)
|
||
LiveKPICard(
|
||
title: "Listings",
|
||
value: "\(store.metrics.dailyVisitors)",
|
||
subtitle: "Active units",
|
||
icon: "building.2",
|
||
accentColor: VelocityTheme.success,
|
||
glowColor: VelocityTheme.success.opacity(0.18)
|
||
)
|
||
}
|
||
.animation(.easeInOut(duration: 0.4), value: store.metrics.activeVisitors)
|
||
|
||
// Sentiment Gauge
|
||
sentimentGauge
|
||
|
||
// System Health
|
||
systemHealthPanel
|
||
|
||
// AI Chat Widget
|
||
aiChatWidget
|
||
}
|
||
.padding(20)
|
||
}
|
||
.background(VelocityTheme.background)
|
||
.scrollContentBackground(.hidden)
|
||
}
|
||
|
||
// MARK: – Page Header
|
||
private var pageHeader: some View {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text("Dashboard")
|
||
.font(.system(size: 28, weight: .bold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
Text("Project Velocity · v.1.1")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
Spacer()
|
||
HStack(spacing: 5) {
|
||
Circle()
|
||
.fill(VelocityTheme.success)
|
||
.frame(width: 7, height: 7)
|
||
Text("Live")
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.success)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: – Sentiment Gauge
|
||
private var sentimentGauge: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Image(systemName: "waveform.path.ecg")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(VelocityTheme.accent)
|
||
Text("Sentiment Thermometer")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
Spacer()
|
||
Text("Showroom Vibe")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
let label = store.metrics.sentimentScore >= 70 ? "Excellent" :
|
||
store.metrics.sentimentScore >= 50 ? "Good" : "Needs Attention"
|
||
let labelColor: Color = store.metrics.sentimentScore >= 70 ? VelocityTheme.success :
|
||
store.metrics.sentimentScore >= 50 ? VelocityTheme.warning : VelocityTheme.danger
|
||
Text(label)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(labelColor)
|
||
}
|
||
|
||
GeometryReader { geo in
|
||
ZStack(alignment: .leading) {
|
||
RoundedRectangle(cornerRadius: 5)
|
||
.fill(Color.white.opacity(0.05))
|
||
.frame(height: 26)
|
||
|
||
RoundedRectangle(cornerRadius: 5)
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [Color(red: 0.11, green: 0.30, blue: 0.86),
|
||
VelocityTheme.accent,
|
||
Color(red: 0.38, green: 0.65, blue: 0.98)],
|
||
startPoint: .leading, endPoint: .trailing
|
||
)
|
||
)
|
||
.frame(width: geo.size.width * (store.metrics.sentimentScore / 100), height: 26)
|
||
.shadow(color: VelocityTheme.accent.opacity(0.6), radius: 6)
|
||
.animation(.easeInOut(duration: 0.8), value: store.metrics.sentimentScore)
|
||
|
||
Text("\(Int(store.metrics.sentimentScore))%")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
.frame(height: 26)
|
||
}
|
||
.padding(20)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 16)
|
||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||
)
|
||
}
|
||
|
||
// MARK: – System Health
|
||
private var systemHealthPanel: some View {
|
||
let gauges: [(label: String, value: Double, color: Color)] = [
|
||
("CPU", store.metrics.systemHealth.cpu, VelocityTheme.accent),
|
||
("GPU", store.metrics.systemHealth.gpu, Color(red: 0.50, green: 0.56, blue: 0.97)),
|
||
("Memory", store.metrics.systemHealth.memory, Color(red: 0.13, green: 0.83, blue: 0.93)),
|
||
]
|
||
|
||
return VStack(alignment: .leading, spacing: 14) {
|
||
HStack {
|
||
Image(systemName: "cpu").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||
Text("System Health")
|
||
.font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||
Spacer()
|
||
HStack(spacing: 4) {
|
||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||
Text("Optimal").font(.system(size: 11, weight: .medium)).foregroundStyle(VelocityTheme.success)
|
||
}
|
||
}
|
||
|
||
HStack(spacing: 16) {
|
||
ForEach(gauges, id: \.label) { g in
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack {
|
||
Text(g.label).font(.system(size: 10, weight: .medium)).tracking(0.8)
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
Spacer()
|
||
Text("\(Int(g.value * 100))%").font(.system(size: 10, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
}
|
||
GeometryReader { geo in
|
||
ZStack(alignment: .leading) {
|
||
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.05)).frame(height: 5)
|
||
RoundedRectangle(cornerRadius: 3).fill(g.color)
|
||
.frame(width: geo.size.width * g.value, height: 5)
|
||
.shadow(color: g.color.opacity(0.6), radius: 4)
|
||
.animation(.easeInOut(duration: 0.6), value: g.value)
|
||
}
|
||
}
|
||
.frame(height: 5)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
}
|
||
.padding(20)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 16)
|
||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||
)
|
||
}
|
||
|
||
// MARK: – AI Chat Widget
|
||
private var aiChatWidget: some View {
|
||
VStack(spacing: 0) {
|
||
// Header
|
||
HStack(spacing: 10) {
|
||
ZStack {
|
||
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 34, height: 34)
|
||
Image(systemName: "sparkles").font(.system(size: 14)).foregroundStyle(VelocityTheme.accent)
|
||
}
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack(spacing: 5) {
|
||
Text("AI Onboard").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||
Image(systemName: "staroflife.fill").font(.system(size: 7)).foregroundStyle(VelocityTheme.accent)
|
||
}
|
||
HStack(spacing: 4) {
|
||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||
Text("Online").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(16)
|
||
|
||
Divider().background(VelocityTheme.borderSubtle)
|
||
|
||
// Messages
|
||
ScrollViewReader { proxy in
|
||
ScrollView {
|
||
VStack(spacing: 12) {
|
||
ForEach(store.dashboardMessages) { msg in
|
||
ChatBubble(message: msg)
|
||
.id(msg.id)
|
||
}
|
||
if store.isDashboardThinking {
|
||
TypingIndicator()
|
||
}
|
||
}
|
||
.padding(16)
|
||
}
|
||
.frame(height: 240)
|
||
.onChange(of: store.dashboardMessages.count) {
|
||
if let last = store.dashboardMessages.last {
|
||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||
}
|
||
}
|
||
.onChange(of: store.isDashboardThinking) {
|
||
if store.isDashboardThinking {
|
||
withAnimation { proxy.scrollTo("typing", anchor: .bottom) }
|
||
}
|
||
}
|
||
}
|
||
|
||
Divider().background(VelocityTheme.borderSubtle)
|
||
|
||
// Input
|
||
HStack(spacing: 10) {
|
||
TextField("Ask AI assistant...", text: $chatInput)
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
.tint(VelocityTheme.accent)
|
||
.onSubmit { sendDashboardMessage() }
|
||
|
||
Button(action: sendDashboardMessage) {
|
||
Image(systemName: "arrow.up.circle.fill")
|
||
.font(.system(size: 22))
|
||
.foregroundStyle(chatInput.isEmpty ? VelocityTheme.mutedFg : VelocityTheme.accent)
|
||
}
|
||
.disabled(chatInput.isEmpty || store.isDashboardThinking)
|
||
}
|
||
.padding(14)
|
||
}
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 16)
|
||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||
)
|
||
}
|
||
|
||
private func sendDashboardMessage() {
|
||
let text = chatInput.trimmingCharacters(in: .whitespaces)
|
||
guard !text.isEmpty else { return }
|
||
chatInput = ""
|
||
store.addDashboardMessage(sender: "user", content: text)
|
||
store.isDashboardThinking = true
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||
store.isDashboardThinking = false
|
||
store.addDashboardMessage(
|
||
sender: "ai",
|
||
content: dashboardAIResponse(for: text)
|
||
)
|
||
}
|
||
}
|
||
|
||
private func dashboardAIResponse(for prompt: String) -> String {
|
||
let p = prompt.lowercased()
|
||
if p.contains("penthouse") || p.contains("apex") {
|
||
return "Apex Innovations probability score is now at 85%. I recommend scheduling a closing meeting next Tuesday — sentiment analysis shows high engagement."
|
||
} else if p.contains("visitor") || p.contains("traffic") {
|
||
return "Currently \(store.metrics.activeVisitors) active visitors. Zone B (Penthouse Gallery) is generating the highest dwell time today — average 8 minutes."
|
||
} else if p.contains("revenue") || p.contains("deal") {
|
||
return "Pipeline value stands at \(store.metrics.revenue) for the 30-day forecast. 3 deals are in final negotiation — I recommend prioritising Mohammed Al-Rashid."
|
||
} else if p.contains("sentiment") {
|
||
return "Showroom sentiment is at \(Int(store.metrics.sentimentScore))% — above the excellent threshold. Visitors in Zone D (Reception) show the highest satisfaction scores."
|
||
}
|
||
return "I've analysed the current data. Revenue is tracking at \(store.metrics.revenue) with \(store.metrics.activeVisitors) active visitors. Shall I prepare a detailed pipeline report?"
|
||
}
|
||
}
|
||
|
||
// MARK: – KPI Card (live-bound)
|
||
private struct LiveKPICard: View {
|
||
let title: String
|
||
let value: String
|
||
let subtitle: String
|
||
let icon: String
|
||
let accentColor: Color
|
||
let glowColor: Color
|
||
var badge: String? = nil
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
HStack {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 8).fill(accentColor.opacity(0.12)).frame(width: 32, height: 32)
|
||
Image(systemName: icon).font(.system(size: 14, weight: .medium)).foregroundStyle(accentColor)
|
||
}
|
||
Spacer()
|
||
if let badge {
|
||
HStack(spacing: 4) {
|
||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||
Text(badge).font(.system(size: 9, weight: .semibold)).foregroundStyle(VelocityTheme.success)
|
||
}
|
||
}
|
||
}
|
||
.padding(.bottom, 20)
|
||
|
||
Text(title.uppercased())
|
||
.font(.system(size: 10, weight: .medium)).tracking(1.2)
|
||
.foregroundStyle(VelocityTheme.mutedFg).padding(.bottom, 4)
|
||
|
||
Text(value)
|
||
.font(.system(size: 34, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
.contentTransition(.numericText())
|
||
.minimumScaleFactor(0.7).lineLimit(1).padding(.bottom, 4)
|
||
|
||
Text(subtitle).font(.system(size: 11)).foregroundStyle(VelocityTheme.subtleFg)
|
||
}
|
||
.padding(20)
|
||
.frame(maxWidth: .infinity, minHeight: 148, alignment: .leading)
|
||
.background(
|
||
ZStack(alignment: .bottomTrailing) {
|
||
RoundedRectangle(cornerRadius: 16).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||
Ellipse().fill(glowColor).frame(width: 120, height: 90).blur(radius: 28).offset(x: 20, y: 20)
|
||
VStack {
|
||
Rectangle()
|
||
.fill(LinearGradient(colors: [.clear, .white.opacity(0.10), .clear], startPoint: .leading, endPoint: .trailing))
|
||
.frame(height: 1)
|
||
Spacer()
|
||
}
|
||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||
}
|
||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||
.shadow(color: .black.opacity(0.55), radius: 16, y: 4)
|
||
)
|
||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||
}
|
||
}
|
||
|
||
// MARK: – Chat Bubble
|
||
private struct ChatBubble: View {
|
||
let message: ChatMessage
|
||
private var isUser: Bool { message.sender == "user" }
|
||
|
||
var body: some View {
|
||
HStack(alignment: .bottom, spacing: 8) {
|
||
if isUser { Spacer(minLength: 40) }
|
||
|
||
if !isUser {
|
||
ZStack {
|
||
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 26, height: 26)
|
||
Image(systemName: "sparkles").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
|
||
}
|
||
}
|
||
|
||
Text(message.content)
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(isUser ? .white : VelocityTheme.foreground)
|
||
.padding(.horizontal, 12).padding(.vertical, 9)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: isUser ? 14 : 14)
|
||
.fill(isUser
|
||
? VelocityTheme.accent.opacity(0.85)
|
||
: Color.white.opacity(0.06))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 14)
|
||
.stroke(isUser ? .clear : Color.white.opacity(0.10), lineWidth: 1)
|
||
)
|
||
)
|
||
|
||
if isUser {
|
||
ZStack {
|
||
Circle().fill(Color(red: 0.08, green: 0.10, blue: 0.18)).frame(width: 26, height: 26)
|
||
Text("A").font(.system(size: 10, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||
}
|
||
}
|
||
if !isUser { Spacer(minLength: 40) }
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: – Typing Indicator
|
||
private struct TypingIndicator: View {
|
||
@State private var phase = 0
|
||
|
||
var body: some View {
|
||
HStack(alignment: .bottom, spacing: 8) {
|
||
ZStack {
|
||
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 26, height: 26)
|
||
Image(systemName: "sparkles").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
|
||
}
|
||
HStack(spacing: 4) {
|
||
ForEach(0..<3, id: \.self) { i in
|
||
Circle()
|
||
.fill(VelocityTheme.mutedFg)
|
||
.frame(width: 6, height: 6)
|
||
.scaleEffect(phase == i ? 1.4 : 0.8)
|
||
.animation(.easeInOut(duration: 0.4).repeatForever().delay(Double(i) * 0.15), value: phase)
|
||
}
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||
.background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.06))
|
||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.white.opacity(0.10), lineWidth: 1)))
|
||
Spacer(minLength: 40)
|
||
}
|
||
.id("typing")
|
||
.onAppear {
|
||
withAnimation { phase = 1 }
|
||
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
|
||
phase = (phase + 1) % 3
|
||
}
|
||
}
|
||
}
|
||
}
|