feat: Built the native SwiftUI app shell mirroring the WebOS interface (Dashboard, Inventory, Oracle tabs)
This commit is contained in:
@@ -1,38 +1,442 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
private let columns = [GridItem(.adaptive(minimum: 220), spacing: 16)]
|
||||
private var store: AppStore { AppStore.shared }
|
||||
@State private var chatInput = ""
|
||||
private let columns = [GridItem(.adaptive(minimum: 220), spacing: 14)]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
WidgetCard(title: "Listings", value: "128", subtitle: "Active units")
|
||||
WidgetCard(title: "Revenue", value: "$3.2M", subtitle: "30-day forecast")
|
||||
WidgetCard(title: "AI Jobs", value: "24", subtitle: "Queue depth")
|
||||
WidgetCard(title: "Visitors", value: "17", subtitle: "Today")
|
||||
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)
|
||||
}
|
||||
.navigationTitle("Dashboard")
|
||||
.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?"
|
||||
}
|
||||
}
|
||||
|
||||
private struct WidgetCard: View {
|
||||
// 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: 8) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
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(.largeTitle.bold())
|
||||
Text(subtitle)
|
||||
.foregroundStyle(.secondary)
|
||||
.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
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 140, alignment: .leading)
|
||||
.padding()
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user