forked from sagnik/Project_Velocity
I have attached the screenshots of the native SwiftUI app. <img width="1705" alt="image.png" src="attachments/59fec2f3-0ae2-4b58-9349-457618ea0678"> <img width="1699" alt="image.png" src="attachments/0bf7c4f9-c883-4929-be36-774685b82fc4"> <img width="1698" alt="image.png" src="attachments/e3407e84-aaf2-45c0-9325-247d4020bace"> <img width="1694" alt="image.png" src="attachments/ee2cd47d-800d-4a40-855c-d54856680e79"> <img width="1694" alt="image.png" src="attachments/a2c902f1-9bc9-4427-8cae-b5801527c1ff"> Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#2 Co-authored-by: sayan <sayan@desineuron.in> Co-committed-by: sayan <sayan@desineuron.in>
414 lines
20 KiB
Swift
414 lines
20 KiB
Swift
import SwiftUI
|
||
|
||
struct SentinelView: View {
|
||
private var store: AppStore { AppStore.shared }
|
||
private let indigo = Color(red: 0.60, green: 0.57, blue: 0.99)
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
pageHeader
|
||
kpiGrid
|
||
analyticsRow
|
||
bottomRow
|
||
}
|
||
.padding(24)
|
||
}
|
||
.background(VelocityTheme.background)
|
||
.scrollContentBackground(.hidden)
|
||
}
|
||
|
||
// MARK: – Sub-views extracted so the type-checker can cope
|
||
private var pageHeader: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Sentinel")
|
||
.font(.system(size: 28, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||
Text("FaceID · visitor analytics · real-time alerts")
|
||
.font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
}
|
||
|
||
private var kpiGrid: some View {
|
||
let cols = [GridItem(.adaptive(minimum: 180), spacing: 12)]
|
||
return LazyVGrid(columns: cols, spacing: 12) {
|
||
SentinelKPI(icon: "person.2.fill", iconColor: VelocityTheme.accent,
|
||
label: "Active Visitors", value: "\(store.visitors.count)",
|
||
sub: "Currently tracked", badge: "LIVE")
|
||
SentinelKPI(icon: "waveform.path.ecg", iconColor: VelocityTheme.success,
|
||
label: "Avg Sentiment", value: "\(avgSentiment)%",
|
||
sub: "Overall mood")
|
||
SentinelKPI(icon: "eye.fill", iconColor: indigo,
|
||
label: "Detection Accuracy", value: "\(avgConfidence)%",
|
||
sub: "Avg confidence")
|
||
SentinelKPI(icon: "faceid", iconColor: VelocityTheme.warning,
|
||
label: "Tracked Today", value: "47",
|
||
sub: "Unique faces")
|
||
}
|
||
.animation(.easeInOut(duration: 0.4), value: store.visitors.count)
|
||
}
|
||
|
||
private var analyticsRow: some View {
|
||
let cols = [GridItem(.flexible(), spacing: 14), GridItem(.flexible(minimum: 260), spacing: 14)]
|
||
return LazyVGrid(columns: cols, alignment: .leading, spacing: 14) {
|
||
ZoneAnalyticsPanel()
|
||
ClientInsightsPanel()
|
||
}
|
||
}
|
||
|
||
private var bottomRow: some View {
|
||
let cols = [GridItem(.flexible(), spacing: 14),
|
||
GridItem(.flexible(), spacing: 14),
|
||
GridItem(.flexible(), spacing: 14)]
|
||
return LazyVGrid(columns: cols, alignment: .leading, spacing: 14) {
|
||
SentimentDistributionPanel(visitors: store.visitors)
|
||
DwellTimePanel()
|
||
AlertPanel(isActive: store.isAlertActive, message: store.alertMessage)
|
||
}
|
||
}
|
||
|
||
private var avgSentiment: Int {
|
||
guard !store.visitors.isEmpty else { return 0 }
|
||
let total = store.visitors.reduce(0) { $0 + $1.sentiment.score }
|
||
return total / store.visitors.count
|
||
}
|
||
|
||
private var avgConfidence: Int {
|
||
guard !store.visitors.isEmpty else { return 0 }
|
||
let total = store.visitors.reduce(0.0) { $0 + $1.confidence }
|
||
return Int((total / Double(store.visitors.count)) * 100)
|
||
}
|
||
}
|
||
|
||
// MARK: – KPI Card
|
||
private struct SentinelKPI: View {
|
||
let icon: String; let iconColor: Color
|
||
let label: String; let value: String; let sub: String
|
||
var badge: String? = nil
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 8).fill(iconColor.opacity(0.14)).frame(width: 34, height: 34)
|
||
Image(systemName: icon).font(.system(size: 14)).foregroundStyle(iconColor)
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
Text(label.uppercased()).font(.system(size: 10, weight: .medium)).tracking(1.0)
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
Text(value).font(.system(size: 30, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||
.contentTransition(.numericText())
|
||
Text(sub).font(.system(size: 11)).foregroundStyle(VelocityTheme.subtleFg)
|
||
}
|
||
.padding(18)
|
||
.frame(maxWidth: .infinity, minHeight: 140, alignment: .topLeading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 14)
|
||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(iconColor.opacity(0.18), lineWidth: 1))
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: – Zone Analytics
|
||
private struct ZoneAnalyticsPanel: View {
|
||
private let zones: [(id: String, name: String, count: Int, sentiment: Int)] = [
|
||
("A", "Main Showroom", 5, 72),
|
||
("B", "Penthouse Gallery",3, 85),
|
||
("C", "Amenity Deck VR", 2, 68),
|
||
("D", "Reception", 2, 90),
|
||
]
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "mappin.and.ellipse").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||
Text("Zone Analytics").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||
}
|
||
ForEach(zones, id: \.id) { zone in
|
||
HStack(spacing: 10) {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 6).fill(VelocityTheme.accent.opacity(0.14)).frame(width: 28, height: 28)
|
||
Text(zone.id).font(.system(size: 11, weight: .bold)).foregroundStyle(VelocityTheme.accent)
|
||
}
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(zone.name).font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||
Text("\(zone.count) visitors").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
Spacer()
|
||
HStack(spacing: 4) {
|
||
let c: Color = zone.sentiment >= 80 ? VelocityTheme.success :
|
||
zone.sentiment >= 60 ? VelocityTheme.accent : VelocityTheme.warning
|
||
Circle().fill(c).frame(width: 7, height: 7)
|
||
Text("\(zone.sentiment)%").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||
}
|
||
}
|
||
.padding(10)
|
||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06), lineWidth: 1)))
|
||
}
|
||
}
|
||
.padding(16)
|
||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||
}
|
||
}
|
||
|
||
// MARK: – Client Insights
|
||
private struct ClientInsightsPanel: View {
|
||
private struct Insight {
|
||
let name: String; let stage: String; let sentiment: String
|
||
let score: Int; let insight: String; let color: Color
|
||
|
||
var icon: String {
|
||
score >= 80 ? "arrow.up.right" : score >= 50 ? "minus" : "exclamationmark.triangle"
|
||
}
|
||
var scoreColor: Color {
|
||
score >= 80 ? VelocityTheme.success : score >= 50 ? VelocityTheme.warning : VelocityTheme.danger
|
||
}
|
||
}
|
||
|
||
private let insights: [Insight] = [
|
||
.init(name: "Quantum Dynamics", stage: "Proposal Review", sentiment: "Positive", score: 92,
|
||
insight: "Key decision maker showed high engagement. Emphasise custom penthouse integration.",
|
||
color: VelocityTheme.success),
|
||
.init(name: "Nebula Ventures", stage: "Discovery", sentiment: "Neutral", score: 45,
|
||
insight: "Initial interest detected but hesitation around pricing model tier.",
|
||
color: VelocityTheme.warning),
|
||
.init(name: "Apex Industries", stage: "Negotiation", sentiment: "Mixed", score: 68,
|
||
insight: "Confusion markers during contract terms review. Legal team is the blocker.",
|
||
color: VelocityTheme.danger),
|
||
.init(name: "Starlight Systems", stage: "Initial Contact", sentiment: "Positive", score: 78,
|
||
insight: "Strong ESG engagement. Align pitch with sustainability goals to accelerate close.",
|
||
color: VelocityTheme.accent),
|
||
]
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
insightHeader
|
||
insightGrid
|
||
}
|
||
.padding(16)
|
||
.background(RoundedRectangle(cornerRadius: 14)
|
||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||
.overlay(RoundedRectangle(cornerRadius: 14)
|
||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||
}
|
||
|
||
private var insightHeader: some View {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "sparkles").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||
Text("AI Strategic Insights")
|
||
.font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||
Spacer()
|
||
Text("LIVE ANALYSIS").font(.system(size: 8, weight: .bold)).tracking(1)
|
||
.foregroundStyle(VelocityTheme.accent)
|
||
.padding(.horizontal, 6).padding(.vertical, 3)
|
||
.background(RoundedRectangle(cornerRadius: 4).fill(VelocityTheme.accent.opacity(0.12))
|
||
.overlay(RoundedRectangle(cornerRadius: 4)
|
||
.stroke(VelocityTheme.accent.opacity(0.2), lineWidth: 1)))
|
||
}
|
||
}
|
||
|
||
private var insightGrid: some View {
|
||
let cols = [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)]
|
||
return LazyVGrid(columns: cols, alignment: .leading, spacing: 10) {
|
||
ForEach(insights, id: \.name) { item in
|
||
InsightCard(
|
||
name: item.name, stage: item.stage, sentiment: item.sentiment,
|
||
score: item.score, insight: item.insight, color: item.color,
|
||
icon: item.icon, scoreColor: item.scoreColor
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct InsightCard: View {
|
||
struct Item {
|
||
let name: String; let stage: String; let sentiment: String
|
||
let score: Int; let insight: String; let color: Color
|
||
var icon: String { score >= 80 ? "arrow.up.right" : score >= 50 ? "minus" : "exclamationmark.triangle" }
|
||
var scoreColor: Color { score >= 80 ? VelocityTheme.success : score >= 50 ? VelocityTheme.warning : VelocityTheme.danger }
|
||
}
|
||
|
||
// Accept ClientInsightsPanel.Insight via protocol duck-typing workaround:
|
||
let name: String; let stage: String; let sentiment: String
|
||
let score: Int; let insight: String; let color: Color
|
||
let icon: String; let scoreColor: Color
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 6).fill(color.opacity(0.18)).frame(width: 28, height: 28)
|
||
Image(systemName: icon).font(.system(size: 11)).foregroundStyle(color)
|
||
}
|
||
Spacer()
|
||
Text("\(score)").font(.system(size: 11, weight: .bold))
|
||
.foregroundStyle(scoreColor)
|
||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||
.background(RoundedRectangle(cornerRadius: 4).fill(scoreColor.opacity(0.15)))
|
||
}
|
||
Text(name).font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.foreground).lineLimit(1)
|
||
Text(insight).font(.system(size: 10))
|
||
.foregroundStyle(VelocityTheme.mutedFg).lineLimit(3)
|
||
HStack {
|
||
Text(stage).font(.system(size: 9)).foregroundStyle(VelocityTheme.subtleFg)
|
||
Spacer()
|
||
Text(sentiment).font(.system(size: 9, weight: .semibold)).foregroundStyle(color)
|
||
}
|
||
}
|
||
.padding(10)
|
||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.15), lineWidth: 1)))
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: – Sentiment Distribution
|
||
private struct SentimentDistributionPanel: View {
|
||
let visitors: [Visitor]
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "chart.bar").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||
Text("Sentiment").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||
}
|
||
ForEach(SentimentType.allCases, id: \.self) { type in
|
||
let count = visitors.filter { $0.sentiment == type }.count
|
||
let fraction = visitors.isEmpty ? 0.0 : Double(count) / Double(visitors.count)
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack {
|
||
Text(type.emoji).font(.system(size: 14))
|
||
Text(type.rawValue.capitalized).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||
Spacer()
|
||
Text("\(count)").font(.system(size: 11, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||
}
|
||
GeometryReader { geo in
|
||
ZStack(alignment: .leading) {
|
||
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.06)).frame(height: 5)
|
||
RoundedRectangle(cornerRadius: 3).fill(type.color)
|
||
.frame(width: geo.size.width * fraction, height: 5)
|
||
.animation(.easeOut(duration: 0.6), value: fraction)
|
||
}
|
||
}
|
||
.frame(height: 5)
|
||
}
|
||
}
|
||
}
|
||
.padding(16)
|
||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||
}
|
||
}
|
||
|
||
// MARK: – Dwell Time Panel
|
||
private struct DwellTimePanel: View {
|
||
private let data: [(range: String, count: Int, trend: String)] = [
|
||
("< 5 min", 3, "down"),
|
||
("5–15 min", 5, "up"),
|
||
("15–30 min", 8, "up"),
|
||
("> 30 min", 4, "stable"),
|
||
]
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "timer").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||
Text("Dwell Time").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||
}
|
||
let cols = [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)]
|
||
LazyVGrid(columns: cols, spacing: 8) {
|
||
ForEach(data, id: \.range) { item in
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack {
|
||
Text(item.range).font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg)
|
||
Spacer()
|
||
Image(systemName: item.trend == "up" ? "arrow.up.right" :
|
||
item.trend == "down" ? "arrow.down.right" : "minus")
|
||
.font(.system(size: 9))
|
||
.foregroundStyle(item.trend == "up" ? VelocityTheme.success :
|
||
item.trend == "down" ? VelocityTheme.danger : VelocityTheme.mutedFg)
|
||
}
|
||
Text("\(item.count)").font(.system(size: 22, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||
Text("visitors").font(.system(size: 9)).foregroundStyle(VelocityTheme.subtleFg)
|
||
}
|
||
.padding(10)
|
||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06), lineWidth: 1)))
|
||
}
|
||
}
|
||
}
|
||
.padding(16)
|
||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||
}
|
||
}
|
||
|
||
// MARK: – Alert Panel
|
||
private struct AlertPanel: View {
|
||
let isActive: Bool
|
||
let message: String
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "bell.badge").font(.system(size: 12)).foregroundStyle(VelocityTheme.warning)
|
||
Text("Alerts").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||
Spacer()
|
||
Text(isActive ? "Active" : "Clear")
|
||
.font(.system(size: 9, weight: .semibold))
|
||
.foregroundStyle(isActive ? VelocityTheme.warning : VelocityTheme.success)
|
||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||
.background(Capsule().fill((isActive ? VelocityTheme.warning : VelocityTheme.success).opacity(0.15))
|
||
.overlay(Capsule().stroke((isActive ? VelocityTheme.warning : VelocityTheme.success).opacity(0.25), lineWidth: 1)))
|
||
}
|
||
|
||
if isActive {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.font(.system(size: 14)).foregroundStyle(VelocityTheme.warning)
|
||
Text("Sentiment Alert").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.warning)
|
||
}
|
||
Text(message).font(.system(size: 12)).foregroundStyle(VelocityTheme.foreground).lineLimit(3)
|
||
Text("Just now").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
.padding(12)
|
||
.background(RoundedRectangle(cornerRadius: 10).fill(VelocityTheme.warning.opacity(0.08))
|
||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(VelocityTheme.warning.opacity(0.3), lineWidth: 1)))
|
||
.transition(.asymmetric(insertion: .scale(scale: 0.95).combined(with: .opacity),
|
||
removal: .scale(scale: 0.95).combined(with: .opacity)))
|
||
} else {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "checkmark.shield.fill")
|
||
.font(.system(size: 14)).foregroundStyle(VelocityTheme.success)
|
||
Text("All Clear").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.success)
|
||
}
|
||
Text("No alerts at this time").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||
Text("System nominal").font(.system(size: 10)).foregroundStyle(VelocityTheme.subtleFg)
|
||
}
|
||
.padding(12)
|
||
.background(RoundedRectangle(cornerRadius: 10).fill(VelocityTheme.success.opacity(0.08))
|
||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(VelocityTheme.success.opacity(0.3), lineWidth: 1)))
|
||
.transition(.asymmetric(insertion: .scale(scale: 0.95).combined(with: .opacity),
|
||
removal: .scale(scale: 0.95).combined(with: .opacity)))
|
||
}
|
||
}
|
||
.padding(16)
|
||
.animation(.easeInOut(duration: 0.3), value: isActive)
|
||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||
}
|
||
}
|