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,9 +1,10 @@
import Combine
import SwiftUI
struct SentinelView: View {
@State private var store = AppStore.shared
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
@State private var analytics: VelocitySentinelLiveAnalyticsDTO?
@State private var analyticsError: String?
@State private var isAnalyticsLoading = false
var body: some View {
ScrollView {
@@ -15,6 +16,9 @@ struct SentinelView: View {
}
availabilityCard
analyticsCards
sentimentCard
journeyCard
postureCards
timelineCard
}
@@ -22,10 +26,13 @@ struct SentinelView: 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()
await loadAnalytics()
}
.refreshable {
await store.refresh()
await loadAnalytics()
}
}
@@ -38,7 +45,7 @@ struct SentinelView: View {
Text(SentinelScope.navigationTitle)
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Truthful live posture for alerts and comms load. \(SentinelScope.productFamilyName) analytics stay disabled on iPad until a real production stream is exposed.")
Text("Live showroom perception analytics from the production Sentinel websocket and persisted perception intelligence.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
@@ -53,17 +60,122 @@ struct SentinelView: View {
Spacer()
statusBadge(
label: SentinelScope.availabilityBadge,
color: VelocityTheme.warning
color: VelocityTheme.success
)
}
Text("This iPad build does not synthesize \(SentinelScope.disabledAnalyticsSummary). A dedicated production Sentinel route is still required before those analytics can be shown safely.")
Text("This iPad build reads `/api/sentinel/analytics/live`, which summarizes the real `/api/sentinel/ws/perception` stream after biometric packets are persisted by the backend.")
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
Text("Current surface instead reports real \(SentinelScope.liveBackedSummary) from the live mobile-edge backend.")
Text("Live-backed capabilities: \(SentinelScope.liveBackedSummary).")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
if let analyticsError {
Text(analyticsError)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
} else if isAnalyticsLoading {
Text("Loading live perception analytics...")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
} else if let analytics {
Text("Stream: \(analytics.liveStreamPath)")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(VelocityTheme.accent)
}
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private var analyticsCards: some View {
HStack(spacing: 14) {
SentinelCard(
title: "Active visitors",
value: "\(analytics?.activeSessions ?? 0)",
subtitle: "Open Sentinel perception sessions",
color: VelocityTheme.accent
)
SentinelCard(
title: "Visitors 24h",
value: "\(analytics?.visitorCount24h ?? 0)",
subtitle: "Sessions started in the last day",
color: VelocityTheme.success
)
SentinelCard(
title: "Avg QD",
value: String(format: "%.0f", analytics?.averageQdScore ?? 0),
subtitle: "Average finalized session score",
color: VelocityTheme.warning
)
}
}
private var sentimentCard: some View {
let distribution = analytics?.sentimentDistribution ?? [:]
return VStack(alignment: .leading, spacing: 14) {
Text("Sentiment Distribution")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
HStack(spacing: 12) {
sentimentPill("Positive", distribution["positive"] ?? 0, VelocityTheme.success)
sentimentPill("Neutral", distribution["neutral"] ?? 0, VelocityTheme.accent)
sentimentPill("Negative", distribution["negative"] ?? 0, VelocityTheme.danger)
}
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private var journeyCard: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("Showroom Journey")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text("Live feed")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.success)
}
if analytics?.journey.isEmpty ?? true {
Text("No perception journey events have been persisted yet.")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
} else {
ForEach(analytics?.journey.prefix(8) ?? []) { event in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(event.eventType.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text(String(format: "%.0f%%", event.engagementScore * 100))
.font(.system(size: 11, weight: .bold))
.foregroundStyle(event.engagementScore >= 0.7 ? VelocityTheme.success : VelocityTheme.accent)
}
Text(event.sceneLabel?.trimmedNonEmpty ?? event.sessionRef ?? "No scene label")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(event.happenedAt ?? "Timestamp pending")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
}
}
.padding(20)
.glassCard(cornerRadius: 18)
@@ -174,6 +286,54 @@ struct SentinelView: View {
)
)
}
private func sentimentPill(_ label: String, _ value: Int, _ color: Color) -> some View {
VStack(alignment: .leading, spacing: 5) {
Text(label.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Text("\(value)")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(color)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(color.opacity(0.22), lineWidth: 1)
)
)
}
private func loadAnalytics(silent: Bool = false) async {
if !silent {
await MainActor.run {
isAnalyticsLoading = true
analyticsError = nil
}
}
do {
let response = try await VelocityAPIClient.shared.fetchSentinelLiveAnalytics()
await MainActor.run {
analytics = response
analyticsError = nil
isAnalyticsLoading = false
}
} catch {
await MainActor.run {
if let apiError = error as? VelocityAPIError, apiError.statusCode == 404 {
analyticsError = "Sentinel analytics is not available on the configured backend yet."
} else {
analyticsError = error.localizedDescription
}
isAnalyticsLoading = false
}
}
}
}
private struct SentinelCard: View {
@@ -207,3 +367,10 @@ private struct SentinelCard: View {
#Preview {
SentinelView()
}
private extension String {
var trimmedNonEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}