HomeView.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  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. /// bordered session cards, and a subtle technical grid background.
  7. struct HomeView: View {
  8. @Environment(\.modelContext) private var modelContext
  9. @Query(sort: \CelestiaSession.startTime, order: .reverse) private var sessions: [CelestiaSession]
  10. @ObservedObject private var bleManager: BLEManager = .shared
  11. @ObservedObject private var authManager: AuthManager = .shared
  12. @State private var navigateToRecording = false
  13. @State private var activeSession: CelestiaSession?
  14. @State private var selectedSessionForDetail: CelestiaSession?
  15. @State private var detector = RecordingEnvironmentDetector()
  16. @State private var dismissedWarnings: Set<String> = []
  17. @State private var showRecordingSourcePicker = false
  18. @State private var selectedRecordingSource: RecordingSourceChoice = .iPhone
  19. /// Callback to switch to the History tab from the parent TabView.
  20. var onSwitchToHistory: (() -> Void)?
  21. var body: some View {
  22. NavigationStack {
  23. ZStack {
  24. // Dynamic clean background
  25. Color.spaceBlack
  26. .ignoresSafeArea()
  27. // Subtle technical grid background
  28. businessGrid
  29. // Main content
  30. VStack(spacing: 0) {
  31. topBar
  32. .padding(.top, 16)
  33. if let firstWarning = detector.activeWarnings.first(where: { !dismissedWarnings.contains($0) }) {
  34. DiscreetWarningBanner(warning: firstWarning) {
  35. withAnimation {
  36. _ = dismissedWarnings.insert(firstWarning)
  37. }
  38. }
  39. .padding(.top, 12)
  40. }
  41. Spacer()
  42. recordSection
  43. Spacer()
  44. recentSessionsSection
  45. .padding(.bottom, 16)
  46. historyLink
  47. .padding(.bottom, 24)
  48. }
  49. .padding(.horizontal, 24)
  50. }
  51. .navigationBarHidden(true)
  52. .onAppear {
  53. detector.checkEnvironment()
  54. }
  55. .fullScreenCover(isPresented: $navigateToRecording) {
  56. if let session = activeSession {
  57. ActiveRecordingView(session: session, recordingSource: selectedRecordingSource) {
  58. selectedSessionForDetail = session
  59. }
  60. }
  61. }
  62. .sheet(isPresented: $showRecordingSourcePicker) {
  63. RecordingSourcePickerView(devices: connectedSparkDevices) { source in
  64. showRecordingSourcePicker = false
  65. createSessionAndNavigate(source: source)
  66. }
  67. }
  68. .navigationDestination(item: $selectedSessionForDetail) { session in
  69. SessionDetailView(session: session)
  70. }
  71. }
  72. }
  73. // MARK: - Top Bar
  74. private var topBar: some View {
  75. HStack(alignment: .top) {
  76. VStack(alignment: .leading, spacing: 4) {
  77. Text("星痕现场记录")
  78. .font(.system(size: 11, weight: .bold, design: .monospaced))
  79. .foregroundStyle(Color.primary.opacity(0.8))
  80. .tracking(3)
  81. Text(currentDateFormatted)
  82. .font(.system(size: 13, weight: .regular))
  83. .foregroundStyle(Color.secondary)
  84. }
  85. Spacer()
  86. // Status indicator
  87. HStack(spacing: 6) {
  88. Circle()
  89. .fill(Color.primary.opacity(0.7))
  90. .frame(width: 5, height: 5)
  91. Text("就绪")
  92. .font(.system(size: 9, weight: .semibold, design: .monospaced))
  93. .foregroundStyle(Color.secondary)
  94. }
  95. .padding(.horizontal, 8)
  96. .padding(.vertical, 4)
  97. .businessBorder(cornerRadius: 4)
  98. }
  99. }
  100. // MARK: - Record Section
  101. private var recordSection: some View {
  102. VStack(spacing: 24) {
  103. PulsingRecordButton {
  104. beginNewRecording()
  105. }
  106. VStack(spacing: 6) {
  107. Text("新建现场记录")
  108. .font(.system(size: 16, weight: .medium))
  109. .foregroundStyle(Color.primary)
  110. Text("点击开启全新现场录音")
  111. .font(.system(size: 9, weight: .medium, design: .monospaced))
  112. .foregroundStyle(Color.secondary.opacity(0.7))
  113. .tracking(2)
  114. }
  115. }
  116. }
  117. // MARK: - Recent Sessions
  118. private var recentSessionsSection: some View {
  119. VStack(alignment: .leading, spacing: 12) {
  120. HStack {
  121. Image(systemName: "list.dash")
  122. .font(.system(size: 12))
  123. .foregroundStyle(Color.secondary)
  124. Text("最近记录")
  125. .font(.system(size: 13, weight: .semibold))
  126. .foregroundStyle(Color.primary.opacity(0.8))
  127. }
  128. if sessions.isEmpty {
  129. emptyRecentState
  130. } else {
  131. ScrollView(.horizontal, showsIndicators: false) {
  132. HStack(spacing: 12) {
  133. ForEach(sessions.prefix(3)) { session in
  134. Button {
  135. selectedSessionForDetail = session
  136. } label: {
  137. RecentSessionCard(session: session)
  138. }
  139. .buttonStyle(.plain)
  140. }
  141. }
  142. .padding(.vertical, 2)
  143. }
  144. }
  145. }
  146. }
  147. private var emptyRecentState: some View {
  148. HStack {
  149. Spacer()
  150. VStack(spacing: 8) {
  151. Image(systemName: "plus.square.dashed")
  152. .font(.system(size: 20))
  153. .foregroundStyle(Color.secondary.opacity(0.4))
  154. Text("暂无记录")
  155. .font(.system(size: 12))
  156. .foregroundStyle(Color.secondary.opacity(0.5))
  157. }
  158. .padding(.vertical, 32)
  159. Spacer()
  160. }
  161. .background(Color.cardBackground.opacity(0.3))
  162. .businessBorder(cornerRadius: 8)
  163. }
  164. // MARK: - History Link
  165. private var historyLink: some View {
  166. Button {
  167. onSwitchToHistory?()
  168. } label: {
  169. HStack(spacing: 6) {
  170. Text("查看历史列表")
  171. .font(.system(size: 13, weight: .regular))
  172. Image(systemName: "chevron.right")
  173. .font(.system(size: 10))
  174. }
  175. .foregroundStyle(Color.primary.opacity(0.7))
  176. .padding(.horizontal, 14)
  177. .padding(.vertical, 8)
  178. .businessBorder(cornerRadius: 6)
  179. }
  180. }
  181. // MARK: - Technical Grid Background
  182. private var businessGrid: some View {
  183. Canvas { context, size in
  184. let step: CGFloat = 40
  185. let cols = Int(size.width / step)
  186. let rows = Int(size.height / step)
  187. for col in 0...cols {
  188. let x = CGFloat(col) * step
  189. var path = Path()
  190. path.move(to: CGPoint(x: x, y: 0))
  191. path.addLine(to: CGPoint(x: x, y: size.height))
  192. context.stroke(path, with: .color(Color.primary.opacity(0.015)), lineWidth: 0.5)
  193. }
  194. for row in 0...rows {
  195. let y = CGFloat(row) * step
  196. var path = Path()
  197. path.move(to: CGPoint(x: 0, y: y))
  198. path.addLine(to: CGPoint(x: size.width, y: y))
  199. context.stroke(path, with: .color(Color.primary.opacity(0.015)), lineWidth: 0.5)
  200. }
  201. }
  202. .ignoresSafeArea()
  203. .allowsHitTesting(false)
  204. }
  205. // MARK: - Actions
  206. private func beginNewRecording() {
  207. if connectedSparkDevices.isEmpty {
  208. createSessionAndNavigate(source: .iPhone)
  209. } else {
  210. showRecordingSourcePicker = true
  211. }
  212. }
  213. private func createSessionAndNavigate(source: RecordingSourceChoice) {
  214. let formatter = DateFormatter()
  215. formatter.dateFormat = "yyyy.MM.dd HH:mm"
  216. let title = "现场记录 · \(formatter.string(from: Date()))"
  217. let session = CelestiaSession(title: title)
  218. modelContext.insert(session)
  219. try? modelContext.save()
  220. activeSession = session
  221. selectedRecordingSource = source
  222. HapticManager.trigger(.recordStart)
  223. navigateToRecording = true
  224. }
  225. private var connectedSparkDevices: [BoundDevice] {
  226. guard let userID = authManager.currentUser?.id else { return [] }
  227. return bleManager.connectedDevices(forUserId: userID)
  228. .sorted { $0.boundAt < $1.boundAt }
  229. }
  230. // MARK: - Helpers
  231. private var currentDateFormatted: String {
  232. let formatter = DateFormatter()
  233. formatter.locale = Locale(identifier: "zh_Hans")
  234. formatter.dateFormat = "yyyy年M月d日 EEEE"
  235. return formatter.string(from: Date())
  236. }
  237. }
  238. // MARK: - Recording source picker
  239. /// Shared by new recordings and continuation recordings. When at least one
  240. /// connected Spark exists, the first Spark is selected by default.
  241. struct RecordingSourcePickerView: View {
  242. @Environment(\.dismiss) private var dismiss
  243. let devices: [BoundDevice]
  244. let onConfirm: (RecordingSourceChoice) -> Void
  245. @State private var selection: RecordingSourceChoice
  246. init(devices: [BoundDevice], onConfirm: @escaping (RecordingSourceChoice) -> Void) {
  247. self.devices = devices
  248. self.onConfirm = onConfirm
  249. if let first = devices.first {
  250. _selection = State(initialValue: .spark(deviceID: first.id, displayName: first.name))
  251. } else {
  252. _selection = State(initialValue: .iPhone)
  253. }
  254. }
  255. var body: some View {
  256. NavigationStack {
  257. ZStack {
  258. Color.spaceBlack.ignoresSafeArea()
  259. VStack(alignment: .leading, spacing: 14) {
  260. Text("选择本次现场记录使用的录音设备")
  261. .font(.system(size: 13))
  262. .foregroundStyle(Color.secondary)
  263. ForEach(Array(devices.enumerated()), id: \.element.id) { index, device in
  264. sourceRow(
  265. source: .spark(deviceID: device.id, displayName: device.name),
  266. title: device.name,
  267. subtitle: sparkSubtitle(device),
  268. badge: index == 0 ? "默认" : nil
  269. )
  270. }
  271. sourceRow(
  272. source: .iPhone,
  273. title: "iPhone 麦克风",
  274. subtitle: "使用手机内置或当前系统音频输入",
  275. badge: devices.isEmpty ? "默认" : nil
  276. )
  277. Button {
  278. onConfirm(selection)
  279. } label: {
  280. Text("开始现场记录")
  281. .font(.system(size: 14, weight: .semibold))
  282. .foregroundStyle(Color.spaceBlack)
  283. .frame(maxWidth: .infinity)
  284. .padding(.vertical, 12)
  285. .background(Color.primary)
  286. .cornerRadius(8)
  287. }
  288. .padding(.top, 8)
  289. Spacer()
  290. }
  291. .padding(20)
  292. }
  293. .navigationTitle("录音设备")
  294. .navigationBarTitleDisplayMode(.inline)
  295. .toolbar {
  296. ToolbarItem(placement: .cancellationAction) {
  297. Button("取消") { dismiss() }
  298. .foregroundStyle(Color.secondary)
  299. }
  300. }
  301. }
  302. .presentationDetents([.medium, .large])
  303. .presentationBackground(Color.spaceBlack)
  304. }
  305. private func sourceRow(
  306. source: RecordingSourceChoice,
  307. title: String,
  308. subtitle: String,
  309. badge: String?
  310. ) -> some View {
  311. Button {
  312. selection = source
  313. } label: {
  314. HStack(spacing: 12) {
  315. Image(systemName: source.systemImage)
  316. .font(.system(size: 19, weight: .light))
  317. .foregroundStyle(Color.primary)
  318. .frame(width: 34)
  319. VStack(alignment: .leading, spacing: 4) {
  320. HStack(spacing: 7) {
  321. Text(title)
  322. .font(.system(size: 14, weight: .semibold))
  323. .foregroundStyle(Color.primary)
  324. if let badge {
  325. Text(badge)
  326. .font(.system(size: 9, weight: .bold))
  327. .foregroundStyle(Color.spaceBlack)
  328. .padding(.horizontal, 6)
  329. .padding(.vertical, 2)
  330. .background(Color.primary)
  331. .cornerRadius(4)
  332. }
  333. }
  334. Text(subtitle)
  335. .font(.system(size: 11))
  336. .foregroundStyle(Color.secondary)
  337. }
  338. Spacer()
  339. Image(systemName: selection == source ? "checkmark.circle.fill" : "circle")
  340. .font(.system(size: 18))
  341. .foregroundStyle(selection == source ? Color.primary : Color.secondary)
  342. }
  343. .padding(14)
  344. .background(Color.cardBackground.opacity(selection == source ? 0.55 : 0.25))
  345. .businessBorder(cornerRadius: 10)
  346. }
  347. .buttonStyle(.plain)
  348. }
  349. private func sparkSubtitle(_ device: BoundDevice) -> String {
  350. var parts = ["已连接"]
  351. if let battery = device.batteryLevel { parts.append("电量 \(battery)%") }
  352. if let free = device.freeStorageMB { parts.append("剩余 \(free) MB") }
  353. return parts.joined(separator: " · ")
  354. }
  355. }
  356. // MARK: - Recent Session Card
  357. private struct RecentSessionCard: View {
  358. let session: CelestiaSession
  359. var body: some View {
  360. VStack(alignment: .leading, spacing: 8) {
  361. Text(session.title)
  362. .font(.system(size: 13, weight: .medium))
  363. .foregroundStyle(Color.primary)
  364. .lineLimit(1)
  365. HStack(spacing: 4) {
  366. Image(systemName: "waveform")
  367. .font(.system(size: 9))
  368. .foregroundStyle(Color.secondary)
  369. Text(session.durationFormatted)
  370. .font(.system(size: 11, weight: .regular, design: .monospaced))
  371. .foregroundStyle(Color.secondary)
  372. }
  373. HStack(spacing: 12) {
  374. HStack(spacing: 3) {
  375. Image(systemName: "camera")
  376. .font(.system(size: 9))
  377. Text("\(session.photoCount)")
  378. .font(.system(size: 10, design: .monospaced))
  379. }
  380. .foregroundStyle(Color.secondary)
  381. HStack(spacing: 3) {
  382. Image(systemName: "doc.text")
  383. .font(.system(size: 9))
  384. Text("\(session.noteCount)")
  385. .font(.system(size: 10, design: .monospaced))
  386. }
  387. .foregroundStyle(Color.secondary)
  388. }
  389. .padding(.top, 4)
  390. }
  391. .padding(12)
  392. .frame(width: 150, alignment: .leading)
  393. .background(Color.cardBackground.opacity(0.3))
  394. .businessBorder(cornerRadius: 8)
  395. }
  396. }
  397. // MARK: - Preview
  398. #Preview {
  399. HomeView()
  400. .modelContainer(for: CelestiaSession.self, inMemory: true)
  401. }