| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460 |
- import SwiftUI
- import SwiftData
- // MARK: - HomeView
- /// The main launch screen of CelestiaTrace.
- /// Features a minimalist, line-drawn aesthetic with a central record button,
- /// bordered session cards, and a subtle technical grid background.
- struct HomeView: View {
- @Environment(\.modelContext) private var modelContext
- @Query(sort: \CelestiaSession.startTime, order: .reverse) private var sessions: [CelestiaSession]
- @ObservedObject private var bleManager: BLEManager = .shared
- @ObservedObject private var authManager: AuthManager = .shared
- @State private var navigateToRecording = false
- @State private var activeSession: CelestiaSession?
- @State private var selectedSessionForDetail: CelestiaSession?
- @State private var detector = RecordingEnvironmentDetector()
- @State private var dismissedWarnings: Set<String> = []
- @State private var showRecordingSourcePicker = false
- @State private var selectedRecordingSource: RecordingSourceChoice = .iPhone
- /// Callback to switch to the History tab from the parent TabView.
- var onSwitchToHistory: (() -> Void)?
- 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()
- recentSessionsSection
- .padding(.bottom, 16)
- historyLink
- .padding(.bottom, 24)
- }
- .padding(.horizontal, 24)
- }
- .navigationBarHidden(true)
- .onAppear {
- detector.checkEnvironment()
- }
- .fullScreenCover(isPresented: $navigateToRecording) {
- if let session = activeSession {
- ActiveRecordingView(session: session, recordingSource: selectedRecordingSource) {
- selectedSessionForDetail = session
- }
- }
- }
- .sheet(isPresented: $showRecordingSourcePicker) {
- RecordingSourcePickerView(devices: connectedSparkDevices) { source in
- showRecordingSourcePicker = false
- createSessionAndNavigate(source: source)
- }
- }
- .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()
- // Status indicator
- HStack(spacing: 6) {
- Circle()
- .fill(Color.primary.opacity(0.7))
- .frame(width: 5, height: 5)
- Text("就绪")
- .font(.system(size: 9, weight: .semibold, design: .monospaced))
- .foregroundStyle(Color.secondary)
- }
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .businessBorder(cornerRadius: 4)
- }
- }
- // MARK: - Record Section
- private var recordSection: some View {
- VStack(spacing: 24) {
- PulsingRecordButton {
- beginNewRecording()
- }
- VStack(spacing: 6) {
- Text("新建现场记录")
- .font(.system(size: 16, weight: .medium))
- .foregroundStyle(Color.primary)
- Text("点击开启全新现场录音")
- .font(.system(size: 9, weight: .medium, design: .monospaced))
- .foregroundStyle(Color.secondary.opacity(0.7))
- .tracking(2)
- }
- }
- }
- // MARK: - Recent Sessions
- private var recentSessionsSection: some View {
- VStack(alignment: .leading, spacing: 12) {
- HStack {
- Image(systemName: "list.dash")
- .font(.system(size: 12))
- .foregroundStyle(Color.secondary)
- Text("最近记录")
- .font(.system(size: 13, weight: .semibold))
- .foregroundStyle(Color.primary.opacity(0.8))
- }
- if sessions.isEmpty {
- emptyRecentState
- } else {
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 12) {
- ForEach(sessions.prefix(3)) { session in
- Button {
- selectedSessionForDetail = session
- } label: {
- RecentSessionCard(session: session)
- }
- .buttonStyle(.plain)
- }
- }
- .padding(.vertical, 2)
- }
- }
- }
- }
- private var emptyRecentState: some View {
- HStack {
- Spacer()
- VStack(spacing: 8) {
- Image(systemName: "plus.square.dashed")
- .font(.system(size: 20))
- .foregroundStyle(Color.secondary.opacity(0.4))
- Text("暂无记录")
- .font(.system(size: 12))
- .foregroundStyle(Color.secondary.opacity(0.5))
- }
- .padding(.vertical, 32)
- Spacer()
- }
- .background(Color.cardBackground.opacity(0.3))
- .businessBorder(cornerRadius: 8)
- }
- // MARK: - History Link
- private var historyLink: some View {
- Button {
- onSwitchToHistory?()
- } label: {
- HStack(spacing: 6) {
- Text("查看历史列表")
- .font(.system(size: 13, weight: .regular))
- Image(systemName: "chevron.right")
- .font(.system(size: 10))
- }
- .foregroundStyle(Color.primary.opacity(0.7))
- .padding(.horizontal, 14)
- .padding(.vertical, 8)
- .businessBorder(cornerRadius: 6)
- }
- }
- // 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 = "yyyy.MM.dd HH:mm"
- let title = "现场记录 · \(formatter.string(from: Date()))"
- let session = CelestiaSession(title: title)
- modelContext.insert(session)
- try? modelContext.save()
- activeSession = session
- selectedRecordingSource = source
- HapticManager.trigger(.recordStart)
- navigateToRecording = true
- }
- 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())
- }
- }
- // 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: - Recent Session Card
- private struct RecentSessionCard: View {
- let session: CelestiaSession
- var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- Text(session.title)
- .font(.system(size: 13, weight: .medium))
- .foregroundStyle(Color.primary)
- .lineLimit(1)
- HStack(spacing: 4) {
- Image(systemName: "waveform")
- .font(.system(size: 9))
- .foregroundStyle(Color.secondary)
- Text(session.durationFormatted)
- .font(.system(size: 11, weight: .regular, design: .monospaced))
- .foregroundStyle(Color.secondary)
- }
- HStack(spacing: 12) {
- HStack(spacing: 3) {
- Image(systemName: "camera")
- .font(.system(size: 9))
- Text("\(session.photoCount)")
- .font(.system(size: 10, design: .monospaced))
- }
- .foregroundStyle(Color.secondary)
- HStack(spacing: 3) {
- Image(systemName: "doc.text")
- .font(.system(size: 9))
- Text("\(session.noteCount)")
- .font(.system(size: 10, design: .monospaced))
- }
- .foregroundStyle(Color.secondary)
- }
- .padding(.top, 4)
- }
- .padding(12)
- .frame(width: 150, alignment: .leading)
- .background(Color.cardBackground.opacity(0.3))
- .businessBorder(cornerRadius: 8)
- }
- }
- // MARK: - Preview
- #Preview {
- HomeView()
- .modelContainer(for: CelestiaSession.self, inMemory: true)
- }
|