import SwiftUI import SwiftData // MARK: - HomeView /// The main launch screen of CelestiaTrace. /// Features a minimalist, line-drawn aesthetic with a central record button /// and a subtle technical grid background. struct HomeView: View { @Environment(\.modelContext) private var modelContext @ObservedObject private var bleManager: BLEManager = .shared @ObservedObject private var authManager: AuthManager = .shared @State private var activeRecording: ActiveRecordingPresentation? @State private var completedRecordingSession: CelestiaSession? @State private var selectedSessionForDetail: CelestiaSession? @State private var detector = RecordingEnvironmentDetector() @State private var dismissedWarnings: Set = [] @State private var showRecordingSourcePicker = false @State private var pendingRecordingSource: RecordingSourceChoice? var body: some View { NavigationStack { ZStack { // Dynamic clean background Color.spaceBlack .ignoresSafeArea() // Subtle technical grid background businessGrid // Main content VStack(spacing: 0) { topBar .padding(.top, 16) if let firstWarning = detector.activeWarnings.first(where: { !dismissedWarnings.contains($0) }) { DiscreetWarningBanner(warning: firstWarning) { withAnimation { _ = dismissedWarnings.insert(firstWarning) } } .padding(.top, 12) } Spacer() recordSection Spacer() } .padding(.horizontal, 24) } .navigationBarHidden(true) .onAppear { detector.checkEnvironment() } .fullScreenCover(item: $activeRecording, onDismiss: { if let session = completedRecordingSession { selectedSessionForDetail = session completedRecordingSession = nil } }) { recording in ActiveRecordingView( session: recording.session, recordingSource: recording.source ) { completedRecordingSession = recording.session } } .sheet(isPresented: $showRecordingSourcePicker, onDismiss: { guard let source = pendingRecordingSource else { return } pendingRecordingSource = nil createSessionAndNavigate(source: source) }) { RecordingSourcePickerView(devices: connectedSparkDevices) { source in pendingRecordingSource = source showRecordingSourcePicker = false } } .navigationDestination(item: $selectedSessionForDetail) { session in SessionDetailView(session: session) } } } // MARK: - Top Bar private var topBar: some View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { Text("星痕现场记录") .font(.system(size: 11, weight: .bold, design: .monospaced)) .foregroundStyle(Color.primary.opacity(0.8)) .tracking(3) Text(currentDateFormatted) .font(.system(size: 13, weight: .regular)) .foregroundStyle(Color.secondary) } Spacer() } } // MARK: - Record Section private var recordSection: some View { VStack(spacing: 24) { PulsingRecordButton { beginNewRecording() } Text("开始") .font(.system(size: 16, weight: .medium)) .foregroundStyle(Color.primary.opacity(0.88)) .tracking(0.4) } } // MARK: - Technical Grid Background private var businessGrid: some View { Canvas { context, size in let step: CGFloat = 40 let cols = Int(size.width / step) let rows = Int(size.height / step) for col in 0...cols { let x = CGFloat(col) * step var path = Path() path.move(to: CGPoint(x: x, y: 0)) path.addLine(to: CGPoint(x: x, y: size.height)) context.stroke(path, with: .color(Color.primary.opacity(0.015)), lineWidth: 0.5) } for row in 0...rows { let y = CGFloat(row) * step var path = Path() path.move(to: CGPoint(x: 0, y: y)) path.addLine(to: CGPoint(x: size.width, y: y)) context.stroke(path, with: .color(Color.primary.opacity(0.015)), lineWidth: 0.5) } } .ignoresSafeArea() .allowsHitTesting(false) } // MARK: - Actions private func beginNewRecording() { if connectedSparkDevices.isEmpty { createSessionAndNavigate(source: .iPhone) } else { showRecordingSourcePicker = true } } private func createSessionAndNavigate(source: RecordingSourceChoice) { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd_HHmm" let title = "现场记录_\(formatter.string(from: Date()))" let session = CelestiaSession(title: title) modelContext.insert(session) try? modelContext.save() HapticManager.trigger(.recordStart) activeRecording = ActiveRecordingPresentation(session: session, source: source) } private var connectedSparkDevices: [BoundDevice] { guard let userID = authManager.currentUser?.id else { return [] } return bleManager.connectedDevices(forUserId: userID) .sorted { $0.boundAt < $1.boundAt } } // MARK: - Helpers private var currentDateFormatted: String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_Hans") formatter.dateFormat = "yyyy年M月d日 EEEE" return formatter.string(from: Date()) } } private struct ActiveRecordingPresentation: Identifiable { let id = UUID() let session: CelestiaSession let source: RecordingSourceChoice } // MARK: - Recording source picker /// Shared by new recordings and continuation recordings. When at least one /// connected Spark exists, the first Spark is selected by default. struct RecordingSourcePickerView: View { @Environment(\.dismiss) private var dismiss let devices: [BoundDevice] let onConfirm: (RecordingSourceChoice) -> Void @State private var selection: RecordingSourceChoice init(devices: [BoundDevice], onConfirm: @escaping (RecordingSourceChoice) -> Void) { self.devices = devices self.onConfirm = onConfirm if let first = devices.first { _selection = State(initialValue: .spark(deviceID: first.id, displayName: first.name)) } else { _selection = State(initialValue: .iPhone) } } var body: some View { NavigationStack { ZStack { Color.spaceBlack.ignoresSafeArea() VStack(alignment: .leading, spacing: 14) { Text("选择本次现场记录使用的录音设备") .font(.system(size: 13)) .foregroundStyle(Color.secondary) ForEach(Array(devices.enumerated()), id: \.element.id) { index, device in sourceRow( source: .spark(deviceID: device.id, displayName: device.name), title: device.name, subtitle: sparkSubtitle(device), badge: index == 0 ? "默认" : nil ) } sourceRow( source: .iPhone, title: "iPhone 麦克风", subtitle: "使用手机内置或当前系统音频输入", badge: devices.isEmpty ? "默认" : nil ) Button { onConfirm(selection) } label: { Text("开始现场记录") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.spaceBlack) .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(Color.primary) .cornerRadius(8) } .padding(.top, 8) Spacer() } .padding(20) } .navigationTitle("录音设备") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } .foregroundStyle(Color.secondary) } } } .presentationDetents([.medium, .large]) .presentationBackground(Color.spaceBlack) } private func sourceRow( source: RecordingSourceChoice, title: String, subtitle: String, badge: String? ) -> some View { Button { selection = source } label: { HStack(spacing: 12) { Image(systemName: source.systemImage) .font(.system(size: 19, weight: .light)) .foregroundStyle(Color.primary) .frame(width: 34) VStack(alignment: .leading, spacing: 4) { HStack(spacing: 7) { Text(title) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.primary) if let badge { Text(badge) .font(.system(size: 9, weight: .bold)) .foregroundStyle(Color.spaceBlack) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.primary) .cornerRadius(4) } } Text(subtitle) .font(.system(size: 11)) .foregroundStyle(Color.secondary) } Spacer() Image(systemName: selection == source ? "checkmark.circle.fill" : "circle") .font(.system(size: 18)) .foregroundStyle(selection == source ? Color.primary : Color.secondary) } .padding(14) .background(Color.cardBackground.opacity(selection == source ? 0.55 : 0.25)) .businessBorder(cornerRadius: 10) } .buttonStyle(.plain) } private func sparkSubtitle(_ device: BoundDevice) -> String { var parts = ["已连接"] if let battery = device.batteryLevel { parts.append("电量 \(battery)%") } if let free = device.freeStorageMB { parts.append("剩余 \(free) MB") } return parts.joined(separator: " · ") } } // MARK: - Preview #Preview { HomeView() .modelContainer(for: CelestiaSession.self, inMemory: true) }