HomeView.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. import SwiftUI
  2. import SwiftData
  3. // MARK: - HomeView
  4. /// The main launch screen of CelestiaTrace.
  5. /// Features a minimalist, line-drawn aesthetic with a central record button
  6. /// and a subtle technical grid background.
  7. struct HomeView: View {
  8. @Environment(\.modelContext) private var modelContext
  9. @ObservedObject private var bleManager: BLEManager = .shared
  10. @ObservedObject private var authManager: AuthManager = .shared
  11. @State private var activeRecording: ActiveRecordingPresentation?
  12. @State private var completedRecordingSession: CelestiaSession?
  13. @State private var selectedSessionForDetail: CelestiaSession?
  14. @State private var detector = RecordingEnvironmentDetector()
  15. @State private var dismissedWarnings: Set<String> = []
  16. @State private var showRecordingSourcePicker = false
  17. @State private var pendingRecordingSource: RecordingSourceChoice?
  18. var body: some View {
  19. NavigationStack {
  20. ZStack {
  21. // Dynamic clean background
  22. Color.spaceBlack
  23. .ignoresSafeArea()
  24. // Subtle technical grid background
  25. businessGrid
  26. // Main content
  27. VStack(spacing: 0) {
  28. topBar
  29. .padding(.top, 16)
  30. if let firstWarning = detector.activeWarnings.first(where: { !dismissedWarnings.contains($0) }) {
  31. DiscreetWarningBanner(warning: firstWarning) {
  32. withAnimation {
  33. _ = dismissedWarnings.insert(firstWarning)
  34. }
  35. }
  36. .padding(.top, 12)
  37. }
  38. Spacer()
  39. recordSection
  40. Spacer()
  41. }
  42. .padding(.horizontal, 24)
  43. }
  44. .navigationBarHidden(true)
  45. .onAppear {
  46. detector.checkEnvironment()
  47. }
  48. .fullScreenCover(item: $activeRecording, onDismiss: {
  49. if let session = completedRecordingSession {
  50. selectedSessionForDetail = session
  51. completedRecordingSession = nil
  52. }
  53. }) { recording in
  54. ActiveRecordingView(
  55. session: recording.session,
  56. recordingSource: recording.source
  57. ) {
  58. completedRecordingSession = recording.session
  59. }
  60. }
  61. .sheet(isPresented: $showRecordingSourcePicker, onDismiss: {
  62. guard let source = pendingRecordingSource else { return }
  63. pendingRecordingSource = nil
  64. createSessionAndNavigate(source: source)
  65. }) {
  66. RecordingSourcePickerView(devices: connectedSparkDevices) { source in
  67. pendingRecordingSource = source
  68. showRecordingSourcePicker = false
  69. }
  70. }
  71. .navigationDestination(item: $selectedSessionForDetail) { session in
  72. SessionDetailView(session: session)
  73. }
  74. }
  75. }
  76. // MARK: - Top Bar
  77. private var topBar: some View {
  78. HStack(alignment: .top) {
  79. VStack(alignment: .leading, spacing: 4) {
  80. Text("星痕现场记录")
  81. .font(.system(size: 11, weight: .bold, design: .monospaced))
  82. .foregroundStyle(Color.primary.opacity(0.8))
  83. .tracking(3)
  84. Text(currentDateFormatted)
  85. .font(.system(size: 13, weight: .regular))
  86. .foregroundStyle(Color.secondary)
  87. }
  88. Spacer()
  89. }
  90. }
  91. // MARK: - Record Section
  92. private var recordSection: some View {
  93. VStack(spacing: 24) {
  94. PulsingRecordButton {
  95. beginNewRecording()
  96. }
  97. Button {
  98. beginNewRecording()
  99. } label: {
  100. Text("开始")
  101. .font(.system(size: 16, weight: .medium))
  102. .foregroundStyle(Color.primary.opacity(0.88))
  103. .tracking(0.4)
  104. }
  105. .buttonStyle(.plain)
  106. }
  107. }
  108. // MARK: - Technical Grid Background
  109. private var businessGrid: some View {
  110. Canvas { context, size in
  111. let step: CGFloat = 40
  112. let cols = Int(size.width / step)
  113. let rows = Int(size.height / step)
  114. for col in 0...cols {
  115. let x = CGFloat(col) * step
  116. var path = Path()
  117. path.move(to: CGPoint(x: x, y: 0))
  118. path.addLine(to: CGPoint(x: x, y: size.height))
  119. context.stroke(path, with: .color(Color.primary.opacity(0.015)), lineWidth: 0.5)
  120. }
  121. for row in 0...rows {
  122. let y = CGFloat(row) * step
  123. var path = Path()
  124. path.move(to: CGPoint(x: 0, y: y))
  125. path.addLine(to: CGPoint(x: size.width, y: y))
  126. context.stroke(path, with: .color(Color.primary.opacity(0.015)), lineWidth: 0.5)
  127. }
  128. }
  129. .ignoresSafeArea()
  130. .allowsHitTesting(false)
  131. }
  132. // MARK: - Actions
  133. private func beginNewRecording() {
  134. if connectedSparkDevices.isEmpty {
  135. createSessionAndNavigate(source: .iPhone)
  136. } else {
  137. showRecordingSourcePicker = true
  138. }
  139. }
  140. private func createSessionAndNavigate(source: RecordingSourceChoice) {
  141. let formatter = DateFormatter()
  142. formatter.dateFormat = "yyyyMMdd_HHmm"
  143. let title = "\(formatter.string(from: Date()))_现场记录"
  144. let session = CelestiaSession(title: title)
  145. modelContext.insert(session)
  146. try? modelContext.save()
  147. HapticManager.trigger(.recordStart)
  148. activeRecording = ActiveRecordingPresentation(session: session, source: source)
  149. }
  150. private var connectedSparkDevices: [BoundDevice] {
  151. guard let userID = authManager.currentUser?.id else { return [] }
  152. return bleManager.connectedDevices(forUserId: userID)
  153. .sorted { $0.boundAt < $1.boundAt }
  154. }
  155. // MARK: - Helpers
  156. private var currentDateFormatted: String {
  157. let formatter = DateFormatter()
  158. formatter.locale = Locale(identifier: "zh_Hans")
  159. formatter.dateFormat = "yyyy年M月d日 EEEE"
  160. return formatter.string(from: Date())
  161. }
  162. }
  163. private struct ActiveRecordingPresentation: Identifiable {
  164. let id = UUID()
  165. let session: CelestiaSession
  166. let source: RecordingSourceChoice
  167. }
  168. // MARK: - Recording source picker
  169. /// Shared by new recordings and continuation recordings. When at least one
  170. /// connected Spark exists, the first Spark is selected by default.
  171. struct RecordingSourcePickerView: View {
  172. @Environment(\.dismiss) private var dismiss
  173. let devices: [BoundDevice]
  174. let onConfirm: (RecordingSourceChoice) -> Void
  175. @State private var selection: RecordingSourceChoice
  176. init(devices: [BoundDevice], onConfirm: @escaping (RecordingSourceChoice) -> Void) {
  177. self.devices = devices
  178. self.onConfirm = onConfirm
  179. if let first = devices.first {
  180. _selection = State(initialValue: .spark(deviceID: first.id, displayName: first.name))
  181. } else {
  182. _selection = State(initialValue: .iPhone)
  183. }
  184. }
  185. var body: some View {
  186. NavigationStack {
  187. ZStack {
  188. Color.spaceBlack.ignoresSafeArea()
  189. VStack(alignment: .leading, spacing: 14) {
  190. Text("选择本次现场记录使用的录音设备")
  191. .font(.system(size: 13))
  192. .foregroundStyle(Color.secondary)
  193. ForEach(Array(devices.enumerated()), id: \.element.id) { index, device in
  194. sourceRow(
  195. source: .spark(deviceID: device.id, displayName: device.name),
  196. title: device.name,
  197. subtitle: sparkSubtitle(device),
  198. badge: index == 0 ? "默认" : nil
  199. )
  200. }
  201. sourceRow(
  202. source: .iPhone,
  203. title: "iPhone 麦克风",
  204. subtitle: "使用手机内置或当前系统音频输入",
  205. badge: devices.isEmpty ? "默认" : nil
  206. )
  207. Button {
  208. onConfirm(selection)
  209. } label: {
  210. Text("开始现场记录")
  211. .font(.system(size: 14, weight: .semibold))
  212. .foregroundStyle(Color.spaceBlack)
  213. .frame(maxWidth: .infinity)
  214. .padding(.vertical, 12)
  215. .background(Color.primary)
  216. .cornerRadius(8)
  217. }
  218. .padding(.top, 8)
  219. Spacer()
  220. }
  221. .padding(20)
  222. }
  223. .navigationTitle("录音设备")
  224. .navigationBarTitleDisplayMode(.inline)
  225. .toolbar {
  226. ToolbarItem(placement: .cancellationAction) {
  227. Button("取消") { dismiss() }
  228. .foregroundStyle(Color.secondary)
  229. }
  230. }
  231. }
  232. .presentationDetents([.medium, .large])
  233. .presentationBackground(Color.spaceBlack)
  234. }
  235. private func sourceRow(
  236. source: RecordingSourceChoice,
  237. title: String,
  238. subtitle: String,
  239. badge: String?
  240. ) -> some View {
  241. Button {
  242. selection = source
  243. } label: {
  244. HStack(spacing: 12) {
  245. Image(systemName: source.systemImage)
  246. .font(.system(size: 19, weight: .light))
  247. .foregroundStyle(Color.primary)
  248. .frame(width: 34)
  249. VStack(alignment: .leading, spacing: 4) {
  250. HStack(spacing: 7) {
  251. Text(title)
  252. .font(.system(size: 14, weight: .semibold))
  253. .foregroundStyle(Color.primary)
  254. if let badge {
  255. Text(badge)
  256. .font(.system(size: 9, weight: .bold))
  257. .foregroundStyle(Color.spaceBlack)
  258. .padding(.horizontal, 6)
  259. .padding(.vertical, 2)
  260. .background(Color.primary)
  261. .cornerRadius(4)
  262. }
  263. }
  264. Text(subtitle)
  265. .font(.system(size: 11))
  266. .foregroundStyle(Color.secondary)
  267. }
  268. Spacer()
  269. Image(systemName: selection == source ? "checkmark.circle.fill" : "circle")
  270. .font(.system(size: 18))
  271. .foregroundStyle(selection == source ? Color.primary : Color.secondary)
  272. }
  273. .padding(14)
  274. .background(Color.cardBackground.opacity(selection == source ? 0.55 : 0.25))
  275. .businessBorder(cornerRadius: 10)
  276. }
  277. .buttonStyle(.plain)
  278. }
  279. private func sparkSubtitle(_ device: BoundDevice) -> String {
  280. var parts = ["已连接"]
  281. if let battery = device.batteryLevel { parts.append("电量 \(battery)%") }
  282. if let free = device.freeStorageMB { parts.append("剩余 \(free) MB") }
  283. return parts.joined(separator: " · ")
  284. }
  285. }
  286. // MARK: - Preview
  287. #Preview {
  288. HomeView()
  289. .modelContainer(for: CelestiaSession.self, inMemory: true)
  290. }