forked from sagnik/Project_Velocity
570 lines
21 KiB
Swift
570 lines
21 KiB
Swift
import SwiftUI
|
||
import LocalAuthentication
|
||
import UIKit
|
||
|
||
enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||
var id: String { rawValue }
|
||
case dashboard = "Dashboard"
|
||
case clients = "Clients"
|
||
case communications = "Communications"
|
||
case calendar = "Calendar"
|
||
case inventory = "Inventory"
|
||
case settings = "Settings"
|
||
|
||
var displayTitle: String {
|
||
rawValue
|
||
}
|
||
|
||
var dockTitle: String {
|
||
switch self {
|
||
case .communications:
|
||
return "Comms"
|
||
default:
|
||
return rawValue
|
||
}
|
||
}
|
||
|
||
var systemImage: String {
|
||
switch self {
|
||
case .dashboard: return "square.grid.2x2"
|
||
case .clients: return "person.text.rectangle"
|
||
case .communications: return "phone.connection"
|
||
case .calendar: return "calendar.badge.clock"
|
||
case .inventory: return "shippingbox"
|
||
case .settings: return "gearshape"
|
||
}
|
||
}
|
||
|
||
var accentColor: Color {
|
||
switch self {
|
||
case .dashboard: return VelocityTheme.accent
|
||
case .clients: return Color(red: 0.22, green: 0.78, blue: 0.96)
|
||
case .communications: return Color(red: 0.19, green: 0.84, blue: 0.63)
|
||
case .calendar: return Color(red: 0.96, green: 0.67, blue: 0.16)
|
||
case .inventory: return VelocityTheme.warning
|
||
case .settings: return VelocityTheme.mutedFg
|
||
}
|
||
}
|
||
}
|
||
|
||
struct ContentView: View {
|
||
@State private var selectedSection: AppSection = .dashboard
|
||
@State private var session = SessionStore.shared
|
||
@State private var store = AppStore.shared
|
||
@State private var isOraclePresented = false
|
||
@State private var dockFocusedSection: AppSection?
|
||
@State private var isPrivacyLocked = true
|
||
@State private var isAuthenticating = false
|
||
@State private var privacyMessage = "Unlock Velocity"
|
||
@Environment(\.scenePhase) private var scenePhase
|
||
@Namespace private var dockSelectionNamespace
|
||
|
||
var body: some View {
|
||
Group {
|
||
if session.isConfigured {
|
||
ZStack(alignment: .bottom) {
|
||
detailContent
|
||
floatingNavigationPill
|
||
.padding(.horizontal, 24)
|
||
.padding(.bottom, 24)
|
||
}
|
||
.overlay(alignment: .bottomTrailing) {
|
||
OracleFloatingOrb(alertCount: oracleAlertCount) {
|
||
isOraclePresented = true
|
||
}
|
||
.padding(.trailing, 28)
|
||
.padding(.bottom, selectedSection == .clients ? 154 : 112)
|
||
}
|
||
.overlay(alignment: .top) {
|
||
OfflineSyncGlow(isActive: hasPendingSync)
|
||
}
|
||
.overlay(alignment: .top) {
|
||
VaultShareToast(
|
||
message: store.vaultShareMessage,
|
||
error: store.vaultShareError
|
||
) {
|
||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||
store.vaultShareMessage = nil
|
||
store.vaultShareError = nil
|
||
}
|
||
}
|
||
.padding(.top, 18)
|
||
}
|
||
.overlay {
|
||
if store.isShowroomModeEnabled, store.errorMessage != nil {
|
||
ShowroomAmbientFallback()
|
||
.transition(.opacity.animation(.interactiveSpring(response: 0.55, dampingFraction: 0.9)))
|
||
}
|
||
}
|
||
.overlay {
|
||
if isPrivacyLocked {
|
||
PrivacyLockOverlay(
|
||
message: privacyMessage,
|
||
isAuthenticating: isAuthenticating
|
||
) {
|
||
authenticateSession()
|
||
}
|
||
.transition(.opacity.animation(.interactiveSpring(response: 0.35, dampingFraction: 0.88)))
|
||
}
|
||
}
|
||
.sheet(isPresented: $isOraclePresented) {
|
||
OracleConciergeSheet()
|
||
.presentationDetents([.medium, .large])
|
||
.presentationDragIndicator(.visible)
|
||
}
|
||
} else {
|
||
ConfigurationGateView()
|
||
}
|
||
}
|
||
.onAppear {
|
||
authenticateSession()
|
||
}
|
||
.onChange(of: scenePhase) { _, phase in
|
||
switch phase {
|
||
case .background, .inactive:
|
||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.9)) {
|
||
isPrivacyLocked = true
|
||
privacyMessage = "Velocity is locked"
|
||
}
|
||
case .active:
|
||
if isPrivacyLocked {
|
||
authenticateSession()
|
||
}
|
||
@unknown default:
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
private var floatingNavigationPill: some View {
|
||
VStack(spacing: 6) {
|
||
HStack(alignment: .bottom, spacing: 12) {
|
||
ForEach(AppSection.allCases) { section in
|
||
let isSelected = selectedSection == section
|
||
let isFocused = dockFocusedSection == section
|
||
Button {
|
||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||
withAnimation(.interactiveSpring(response: 0.46, dampingFraction: 0.78)) {
|
||
selectedSection = section
|
||
dockFocusedSection = section
|
||
}
|
||
hideDockTooltipAfterTap(for: section)
|
||
} label: {
|
||
dockItem(for: section, isSelected: isSelected, isFocused: isFocused)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(section.dockTitle)
|
||
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
||
.onHover { hovering in
|
||
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.76)) {
|
||
dockFocusedSection = hovering ? section : nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 13)
|
||
.padding(.top, 9)
|
||
.padding(.bottom, 8)
|
||
}
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||
.fill(.ultraThinMaterial)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||
.fill(Color.black.opacity(0.34))
|
||
.blur(radius: 0.5)
|
||
)
|
||
.shadow(color: Color.black.opacity(0.42), radius: 28, y: 18)
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||
.stroke(Color.white.opacity(0.16), lineWidth: 1)
|
||
)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func dockItem(for section: AppSection, isSelected: Bool, isFocused: Bool) -> some View {
|
||
VStack(spacing: 7) {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [
|
||
section.accentColor.opacity(isSelected ? 0.34 : 0.18),
|
||
Color.white.opacity(isSelected ? 0.12 : 0.05),
|
||
],
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
.stroke(Color.white.opacity(isSelected ? 0.26 : 0.12), lineWidth: 1)
|
||
)
|
||
.shadow(
|
||
color: section.accentColor.opacity(isSelected || isFocused ? 0.32 : 0.10),
|
||
radius: isSelected || isFocused ? 16 : 7,
|
||
y: isSelected || isFocused ? 8 : 3
|
||
)
|
||
.if(isSelected) { view in
|
||
view.matchedGeometryEffect(id: "selectedDockTile", in: dockSelectionNamespace)
|
||
}
|
||
Image(systemName: section.systemImage)
|
||
.font(.system(size: isFocused ? 24 : (isSelected ? 22 : 19), weight: .semibold))
|
||
.foregroundStyle(isSelected ? VelocityTheme.foreground : section.accentColor)
|
||
}
|
||
.frame(width: 50, height: 50)
|
||
.scaleEffect(isFocused ? 1.34 : (isSelected ? 1.16 : 1.0), anchor: .bottom)
|
||
.offset(y: isFocused ? -13 : (isSelected ? -5 : 0))
|
||
|
||
Circle()
|
||
.fill(isSelected ? section.accentColor.opacity(0.92) : Color.clear)
|
||
.frame(width: 5, height: 5)
|
||
.shadow(color: section.accentColor.opacity(isSelected ? 0.45 : 0), radius: 5)
|
||
}
|
||
.frame(width: 58, height: 70, alignment: .bottom)
|
||
.overlay(alignment: .top) {
|
||
if isFocused {
|
||
dockTooltip(section.dockTitle)
|
||
.offset(y: -46)
|
||
.transition(
|
||
.asymmetric(
|
||
insertion: .scale(scale: 0.88, anchor: .bottom).combined(with: .opacity),
|
||
removal: .opacity
|
||
)
|
||
)
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
.animation(.interactiveSpring(response: 0.44, dampingFraction: 0.76), value: isSelected)
|
||
.animation(.interactiveSpring(response: 0.32, dampingFraction: 0.70), value: isFocused)
|
||
}
|
||
|
||
private func dockTooltip(_ title: String) -> some View {
|
||
VStack(spacing: 0) {
|
||
Text(title)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
.lineLimit(1)
|
||
.padding(.horizontal, 11)
|
||
.padding(.vertical, 6)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||
.fill(Color(red: 0.13, green: 0.13, blue: 0.13).opacity(0.96))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||
.stroke(Color.white.opacity(0.18), lineWidth: 1)
|
||
)
|
||
)
|
||
Triangle()
|
||
.fill(Color(red: 0.13, green: 0.13, blue: 0.13).opacity(0.96))
|
||
.frame(width: 12, height: 7)
|
||
}
|
||
.fixedSize()
|
||
.shadow(color: Color.black.opacity(0.42), radius: 10, y: 6)
|
||
}
|
||
|
||
private func hideDockTooltipAfterTap(for section: AppSection) {
|
||
Task {
|
||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||
await MainActor.run {
|
||
guard dockFocusedSection == section else { return }
|
||
withAnimation(.interactiveSpring(response: 0.32, dampingFraction: 0.78)) {
|
||
dockFocusedSection = nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: – Detail
|
||
private var detailContent: some View {
|
||
ZStack {
|
||
VelocityTheme.background.ignoresSafeArea()
|
||
|
||
Group {
|
||
switch selectedSection {
|
||
case .dashboard:
|
||
DashboardView { section in
|
||
withAnimation(.interactiveSpring(response: 0.46, dampingFraction: 0.82)) {
|
||
selectedSection = section
|
||
}
|
||
}
|
||
case .clients: ClientsView()
|
||
case .communications: CommunicationsView()
|
||
case .calendar: CalendarView()
|
||
case .inventory: InventoryView()
|
||
case .settings: SettingsView()
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
}
|
||
|
||
private var operatorName: String {
|
||
session.operatorIdentity
|
||
}
|
||
|
||
private var operatorInitials: String {
|
||
let source = session.operatorIdentity
|
||
let parts = source
|
||
.replacingOccurrences(of: "@", with: " ")
|
||
.split(separator: ".")
|
||
.flatMap { $0.split(separator: " ") }
|
||
let initials = parts.prefix(2).compactMap(\.first)
|
||
return initials.isEmpty ? "VO" : String(initials)
|
||
}
|
||
|
||
private var hasPendingSync: Bool {
|
||
!store.isShowroomModeEnabled && (!store.pendingSyncTaskIDs.isEmpty || !store.pendingSyncCalendarEventIDs.isEmpty)
|
||
}
|
||
|
||
private var oracleAlertCount: Int {
|
||
guard let alertSnapshot = store.alertSnapshot else { return 0 }
|
||
return alertSnapshot.pendingInsights + alertSnapshot.pendingTranscriptions + alertSnapshot.upcomingCalendarEvents24h
|
||
}
|
||
|
||
private func authenticateSession() {
|
||
guard session.isConfigured, !isAuthenticating else { return }
|
||
isAuthenticating = true
|
||
privacyMessage = "Authenticating..."
|
||
|
||
let context = LAContext()
|
||
context.localizedCancelTitle = "Lock"
|
||
var error: NSError?
|
||
let policy: LAPolicy = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||
? .deviceOwnerAuthenticationWithBiometrics
|
||
: .deviceOwnerAuthentication
|
||
|
||
guard context.canEvaluatePolicy(policy, error: &error) else {
|
||
isAuthenticating = false
|
||
privacyMessage = error?.localizedDescription ?? "Device authentication is unavailable."
|
||
return
|
||
}
|
||
|
||
context.evaluatePolicy(policy, localizedReason: "Unlock Project Velocity") { success, authError in
|
||
Task { @MainActor in
|
||
isAuthenticating = false
|
||
withAnimation(.interactiveSpring(response: 0.38, dampingFraction: 0.86)) {
|
||
isPrivacyLocked = !success
|
||
}
|
||
privacyMessage = success
|
||
? "Unlocked"
|
||
: (authError?.localizedDescription ?? "Authentication failed.")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private extension View {
|
||
@ViewBuilder
|
||
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
||
if condition {
|
||
transform(self)
|
||
} else {
|
||
self
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct Triangle: Shape {
|
||
func path(in rect: CGRect) -> Path {
|
||
var path = Path()
|
||
path.move(to: CGPoint(x: rect.midX, y: rect.maxY))
|
||
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
|
||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
|
||
path.closeSubpath()
|
||
return path
|
||
}
|
||
}
|
||
|
||
private struct OracleFloatingOrb: View {
|
||
let alertCount: Int
|
||
let action: () -> Void
|
||
@State private var isPressed = false
|
||
|
||
var body: some View {
|
||
Button {
|
||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||
action()
|
||
} label: {
|
||
ZStack {
|
||
Circle()
|
||
.fill(.ultraThinMaterial)
|
||
.frame(width: 64, height: 64)
|
||
.overlay(
|
||
Circle()
|
||
.stroke(Color(red: 0.13, green: 0.83, blue: 0.93).opacity(0.55), lineWidth: 1)
|
||
)
|
||
.shadow(color: Color(red: 0.13, green: 0.83, blue: 0.93).opacity(0.35), radius: isPressed ? 10 : 18)
|
||
Image(systemName: "sparkles")
|
||
.font(.system(size: 24, weight: .semibold))
|
||
.foregroundStyle(Color(red: 0.68, green: 0.95, blue: 1.0))
|
||
if alertCount > 0 {
|
||
Text(alertCount > 99 ? "99+" : "\(alertCount)")
|
||
.font(.system(size: 9, weight: .bold))
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 3)
|
||
.background(Capsule().fill(VelocityTheme.danger))
|
||
.offset(x: 22, y: -22)
|
||
.transition(.scale.combined(with: .opacity))
|
||
}
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Open Oracle")
|
||
.simultaneousGesture(
|
||
DragGesture(minimumDistance: 0)
|
||
.onChanged { _ in
|
||
withAnimation(.interactiveSpring(response: 0.24, dampingFraction: 0.8)) {
|
||
isPressed = true
|
||
}
|
||
}
|
||
.onEnded { _ in
|
||
withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.82)) {
|
||
isPressed = false
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
private struct OfflineSyncGlow: View {
|
||
let isActive: Bool
|
||
|
||
var body: some View {
|
||
Rectangle()
|
||
.fill(VelocityTheme.warning.opacity(isActive ? 0.28 : 0))
|
||
.frame(height: isActive ? 5 : 0)
|
||
.shadow(color: VelocityTheme.warning.opacity(isActive ? 0.65 : 0), radius: 14, y: 5)
|
||
.animation(.interactiveSpring(response: 0.42, dampingFraction: 0.9), value: isActive)
|
||
.ignoresSafeArea(edges: .top)
|
||
}
|
||
}
|
||
|
||
private struct VaultShareToast: View {
|
||
let message: String?
|
||
let error: String?
|
||
let dismiss: () -> Void
|
||
|
||
var body: some View {
|
||
if let text = message ?? error {
|
||
HStack(spacing: 10) {
|
||
Image(systemName: error == nil ? "link.circle.fill" : "exclamationmark.triangle.fill")
|
||
.foregroundStyle(error == nil ? VelocityTheme.success : VelocityTheme.warning)
|
||
Text(text)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
Button {
|
||
dismiss()
|
||
} label: {
|
||
Image(systemName: "xmark")
|
||
.font(.system(size: 10, weight: .bold))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 9)
|
||
.background(
|
||
Capsule()
|
||
.fill(.ultraThinMaterial)
|
||
.overlay(Capsule().stroke(Color.white.opacity(0.16), lineWidth: 1))
|
||
)
|
||
.transition(.move(edge: .top).combined(with: .opacity))
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct ShowroomAmbientFallback: View {
|
||
var body: some View {
|
||
ZStack {
|
||
LinearGradient(
|
||
colors: [
|
||
Color.black.opacity(0.88),
|
||
Color(red: 0.045, green: 0.055, blue: 0.075).opacity(0.96),
|
||
Color.black.opacity(0.92),
|
||
],
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
.ignoresSafeArea()
|
||
|
||
VStack(spacing: 14) {
|
||
Image(systemName: "building.2.crop.circle")
|
||
.font(.system(size: 58, weight: .light))
|
||
.foregroundStyle(.white.opacity(0.72))
|
||
Text("Velocity")
|
||
.font(.system(size: 34, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
Text("Preparing the showroom view")
|
||
.font(.system(size: 13, weight: .medium))
|
||
.foregroundStyle(.white.opacity(0.58))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct PrivacyLockOverlay: View {
|
||
let message: String
|
||
let isAuthenticating: Bool
|
||
let unlock: () -> Void
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Rectangle()
|
||
.fill(.ultraThinMaterial)
|
||
.ignoresSafeArea()
|
||
Color.black.opacity(0.62)
|
||
.ignoresSafeArea()
|
||
|
||
VStack(spacing: 16) {
|
||
Image(systemName: "faceid")
|
||
.font(.system(size: 50, weight: .light))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
Text("Velocity")
|
||
.font(.system(size: 30, weight: .semibold, design: .default))
|
||
.foregroundStyle(VelocityTheme.foreground)
|
||
Text(message)
|
||
.font(.system(size: 13, weight: .medium))
|
||
.foregroundStyle(VelocityTheme.mutedFg)
|
||
|
||
Button {
|
||
unlock()
|
||
} label: {
|
||
HStack(spacing: 8) {
|
||
if isAuthenticating {
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||
} else {
|
||
Image(systemName: "lock.open")
|
||
}
|
||
Text(isAuthenticating ? "Unlocking" : "Unlock")
|
||
}
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 11)
|
||
.background(Capsule().fill(VelocityTheme.accent))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(isAuthenticating)
|
||
}
|
||
.padding(28)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 26)
|
||
.fill(Color.black.opacity(0.32))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 26)
|
||
.stroke(Color.white.opacity(0.16), lineWidth: 1)
|
||
)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
ContentView()
|
||
}
|