SessionDetailView.swift 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924
  1. import SwiftUI
  2. import SwiftData
  3. import UIKit
  4. // MARK: - SessionDetailView
  5. /// The session detail & playback screen.
  6. /// Shows session info, multi-track timeline, playback controls,
  7. /// and a chronological feed of all events.
  8. /// Redesigned to use a minimalist wireframe business style.
  9. struct SessionDetailView: View {
  10. @Environment(\.modelContext) private var modelContext
  11. @ObservedObject private var bleManager: BLEManager = .shared
  12. @ObservedObject private var authManager: AuthManager = .shared
  13. let session: CelestiaSession
  14. @State private var playbackVM = PlaybackViewModel()
  15. @State private var navigateToRecording = false
  16. @State private var detector = RecordingEnvironmentDetector()
  17. @State private var showPrepAlert = false
  18. @State private var activeWarningMsg = ""
  19. @State private var showRecordingSourcePicker = false
  20. @State private var selectedRecordingSource: RecordingSourceChoice = .iPhone
  21. @State private var recordName = ""
  22. @State private var showRecordNameEditor = false
  23. @State private var showSessionInfo = false
  24. @State private var showShareUnavailableAlert = false
  25. @State private var audioShareItem: AudioShareItem?
  26. @State private var preparedShareDirectoryURL: URL?
  27. @State private var pendingTimelineTimeMs: Double = 0
  28. @State private var noteText = ""
  29. @State private var showNoteEditor = false
  30. @State private var showPhotoActionDialog = false
  31. @State private var showImagePicker = false
  32. @State private var imagePickerSource: UIImagePickerController.SourceType = .photoLibrary
  33. @State private var selectedTimelineEvent: CelestiaTimelineEvent?
  34. @State private var timelineEditError: String?
  35. var body: some View {
  36. ZStack {
  37. Color.spaceBlack.ignoresSafeArea()
  38. ScrollView {
  39. VStack(spacing: 20) {
  40. // Section 1: Multi-Track Timeline
  41. timelineSection
  42. .padding(.horizontal, 16)
  43. .padding(.top, 12)
  44. // Section 2: Playback Controls
  45. playbackControls
  46. .padding(.horizontal, 16)
  47. // Section 3: Chrono Feed
  48. chronoFeedSection
  49. .padding(.horizontal, 16)
  50. .padding(.bottom, 32)
  51. }
  52. }
  53. }
  54. .navigationBarTitleDisplayMode(.inline)
  55. .toolbar {
  56. ToolbarItem(placement: .principal) {
  57. Button {
  58. recordName = session.title
  59. showRecordNameEditor = true
  60. } label: {
  61. HStack(spacing: 5) {
  62. Text(session.title)
  63. .font(.system(size: 14, weight: .semibold))
  64. .lineLimit(1)
  65. Image(systemName: "pencil")
  66. .font(.system(size: 10, weight: .semibold))
  67. }
  68. .foregroundStyle(Color.primary)
  69. .contentShape(Rectangle())
  70. }
  71. .buttonStyle(.plain)
  72. }
  73. ToolbarItemGroup(placement: .topBarTrailing) {
  74. Button {
  75. showSessionInfo = true
  76. } label: {
  77. Image(systemName: "info.circle")
  78. .font(.system(size: 16, weight: .regular))
  79. .foregroundStyle(Color.primary)
  80. }
  81. .accessibilityLabel("记录信息")
  82. .popover(isPresented: $showSessionInfo, arrowEdge: .top) {
  83. sessionInfoCard
  84. .padding(16)
  85. .frame(idealWidth: 360)
  86. .presentationCompactAdaptation(.popover)
  87. }
  88. Button {
  89. prepareAudioForSharing()
  90. } label: {
  91. Image(systemName: "square.and.arrow.up")
  92. .font(.system(size: 16, weight: .regular))
  93. .foregroundStyle(Color.primary)
  94. }
  95. .accessibilityLabel("分享录音")
  96. }
  97. }
  98. .sheet(item: $audioShareItem, onDismiss: cleanupSharedAudioCopy) { item in
  99. AudioShareSheet(fileURL: item.fileURL)
  100. }
  101. .alert("无法分享录音", isPresented: $showShareUnavailableAlert) {
  102. Button("知道了", role: .cancel) {}
  103. } message: {
  104. Text("当前记录没有可用的本地录音文件,请先完成录制或同步录音。")
  105. }
  106. .alert("无法完成编辑", isPresented: Binding(
  107. get: { timelineEditError != nil },
  108. set: { if !$0 { timelineEditError = nil } }
  109. )) {
  110. Button("知道了", role: .cancel) {}
  111. } message: {
  112. Text(timelineEditError ?? "请稍后重试。")
  113. }
  114. .alert("录音环境提醒", isPresented: $showPrepAlert) {
  115. Button("继续录制", role: .none) {
  116. chooseSourceAndContinue()
  117. }
  118. Button("取消", role: .cancel) {}
  119. } message: {
  120. Text(activeWarningMsg + "\n\n另外请手动确认:\n1. 手机侧边静音开关已拨至红色(静音状态)\n2. 已关闭可能在此期间响起的系统闹钟")
  121. }
  122. .fullScreenCover(isPresented: $navigateToRecording) {
  123. ActiveRecordingView(
  124. session: session,
  125. initialDuration: Double(session.durationMs) / 1000.0,
  126. recordingSource: selectedRecordingSource
  127. ) {
  128. playbackVM.totalDurationMs = Double(session.durationMs)
  129. playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
  130. playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
  131. }
  132. }
  133. .sheet(isPresented: $showRecordingSourcePicker) {
  134. RecordingSourcePickerView(devices: connectedSparkDevices) { source in
  135. selectedRecordingSource = source
  136. showRecordingSourcePicker = false
  137. navigateToRecording = true
  138. }
  139. }
  140. .sheet(isPresented: $showRecordNameEditor) {
  141. RecordNameEditorSheet(recordName: $recordName) {
  142. saveRecordName()
  143. } onCancel: {
  144. recordName = session.title
  145. showRecordNameEditor = false
  146. }
  147. }
  148. .sheet(isPresented: $showNoteEditor) {
  149. timelineNoteEditor
  150. }
  151. .sheet(isPresented: $showImagePicker) {
  152. ImagePicker(sourceType: imagePickerSource) { image in
  153. addPhoto(image, at: pendingTimelineTimeMs)
  154. }
  155. }
  156. .sheet(item: $selectedTimelineEvent) { event in
  157. TimelineEventDetailSheet(event: event) {
  158. deleteTimelineEvent(event)
  159. }
  160. }
  161. .confirmationDialog(
  162. "在 \(formattedTimelineTime(pendingTimelineTimeMs)) 添加图片",
  163. isPresented: $showPhotoActionDialog,
  164. titleVisibility: .visible
  165. ) {
  166. if UIImagePickerController.isSourceTypeAvailable(.camera) {
  167. Button("拍照") {
  168. imagePickerSource = .camera
  169. showImagePicker = true
  170. }
  171. }
  172. Button("从相册选择") {
  173. imagePickerSource = .photoLibrary
  174. showImagePicker = true
  175. }
  176. Button("取消", role: .cancel) {}
  177. } message: {
  178. Text("图片会添加到当前时间位置。")
  179. }
  180. .onAppear {
  181. playbackVM.totalDurationMs = Double(session.durationMs)
  182. playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
  183. playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
  184. }
  185. }
  186. private func prepareContinuation() {
  187. playbackVM.pause()
  188. detector.checkEnvironment()
  189. if let firstWarning = detector.activeWarnings.first {
  190. activeWarningMsg = firstWarning
  191. showPrepAlert = true
  192. } else {
  193. chooseSourceAndContinue()
  194. }
  195. }
  196. private func chooseSourceAndContinue() {
  197. if connectedSparkDevices.isEmpty {
  198. selectedRecordingSource = .iPhone
  199. navigateToRecording = true
  200. } else {
  201. showRecordingSourcePicker = true
  202. }
  203. }
  204. private func saveRecordName() {
  205. let trimmedName = recordName.trimmingCharacters(in: .whitespacesAndNewlines)
  206. guard !trimmedName.isEmpty else { return }
  207. recordName = trimmedName
  208. if session.title != trimmedName {
  209. session.title = trimmedName
  210. session.isSynced = false
  211. session.syncState = .pending
  212. try? modelContext.save()
  213. }
  214. showRecordNameEditor = false
  215. }
  216. private func prepareAudioForSharing() {
  217. guard let sourceURL = AudioPathHelper.resolveURL(for: session.localAudioPath) else {
  218. showShareUnavailableAlert = true
  219. return
  220. }
  221. let shareDirectory = FileManager.default.temporaryDirectory
  222. .appendingPathComponent("CelestiaTraceShare", isDirectory: true)
  223. .appendingPathComponent(UUID().uuidString, isDirectory: true)
  224. let fileExtension = sourceURL.pathExtension
  225. let fileName = sanitizedShareFileName(from: session.title)
  226. let destinationURL = shareDirectory
  227. .appendingPathComponent(fileName)
  228. .appendingPathExtension(fileExtension)
  229. do {
  230. try FileManager.default.createDirectory(
  231. at: shareDirectory,
  232. withIntermediateDirectories: true
  233. )
  234. try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
  235. preparedShareDirectoryURL = shareDirectory
  236. audioShareItem = AudioShareItem(fileURL: destinationURL)
  237. } catch {
  238. audioShareItem = nil
  239. preparedShareDirectoryURL = nil
  240. showShareUnavailableAlert = true
  241. }
  242. }
  243. private func sanitizedShareFileName(from title: String) -> String {
  244. let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
  245. .union(.controlCharacters)
  246. .union(.newlines)
  247. let components = title.components(separatedBy: invalidCharacters)
  248. let sanitized = components
  249. .joined(separator: "-")
  250. .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines.union(
  251. CharacterSet(charactersIn: ".")
  252. ))
  253. return sanitized.isEmpty ? "现场记录" : sanitized
  254. }
  255. private func cleanupSharedAudioCopy() {
  256. guard let preparedShareDirectoryURL else { return }
  257. try? FileManager.default.removeItem(at: preparedShareDirectoryURL)
  258. self.preparedShareDirectoryURL = nil
  259. }
  260. private var connectedSparkDevices: [BoundDevice] {
  261. guard let userID = authManager.currentUser?.id else { return [] }
  262. return bleManager.connectedDevices(forUserId: userID)
  263. .sorted { $0.boundAt < $1.boundAt }
  264. }
  265. // MARK: - Session Info Card
  266. private var sessionInfoCard: some View {
  267. VStack(spacing: 14) {
  268. // Date range
  269. HStack {
  270. VStack(alignment: .leading, spacing: 4) {
  271. Label {
  272. Text("开始")
  273. .font(.system(size: 10, weight: .medium))
  274. .foregroundStyle(Color.secondary)
  275. } icon: {
  276. Image(systemName: "play.circle")
  277. .font(.system(size: 11))
  278. .foregroundStyle(Color.secondary)
  279. }
  280. Text(Self.chineseDateTimeFormatter.string(from: session.startTime))
  281. .font(.system(size: 13, weight: .medium))
  282. .foregroundStyle(Color.primary)
  283. }
  284. Spacer()
  285. VStack(alignment: .trailing, spacing: 4) {
  286. Label {
  287. Text("结束")
  288. .font(.system(size: 10, weight: .medium))
  289. .foregroundStyle(Color.secondary)
  290. } icon: {
  291. Image(systemName: "stop.circle")
  292. .font(.system(size: 11))
  293. .foregroundStyle(Color.secondary)
  294. }
  295. Text(session.endTime.map { Self.chineseDateTimeFormatter.string(from: $0) } ?? "进行中")
  296. .font(.system(size: 13, weight: .medium))
  297. .foregroundStyle(Color.primary)
  298. }
  299. }
  300. Divider()
  301. .background(Color.lineBorder)
  302. // Stats row
  303. HStack(spacing: 0) {
  304. infoStat(
  305. icon: "timer",
  306. label: "总时长",
  307. value: session.durationFormatted
  308. )
  309. infoStat(
  310. icon: "camera",
  311. label: "图片",
  312. value: "\(session.photoCount)"
  313. )
  314. infoStat(
  315. icon: "doc.text",
  316. label: "笔记",
  317. value: "\(session.noteCount)"
  318. )
  319. // Sync status
  320. VStack(spacing: 4) {
  321. Image(systemName: session.isSynced ? "cloud.fill" : "cloud")
  322. .font(.system(size: 14, weight: .light))
  323. .foregroundStyle(Color.secondary)
  324. Text(session.isSynced ? "已同步" : "未同步")
  325. .font(.system(size: 10, weight: .regular))
  326. .foregroundStyle(Color.secondary)
  327. }
  328. .frame(maxWidth: .infinity)
  329. }
  330. Divider()
  331. .background(Color.lineBorder)
  332. Button {
  333. showSessionInfo = false
  334. DispatchQueue.main.async {
  335. prepareContinuation()
  336. }
  337. } label: {
  338. HStack(spacing: 8) {
  339. Image(systemName: "record.circle")
  340. .font(.system(size: 14, weight: .medium))
  341. Text("续录")
  342. .font(.system(size: 13, weight: .semibold))
  343. }
  344. .foregroundStyle(Color.primary)
  345. .frame(maxWidth: .infinity)
  346. .padding(.vertical, 10)
  347. .background(Color.cardBackground.opacity(0.2))
  348. .businessBorder(cornerRadius: 8)
  349. }
  350. .buttonStyle(.plain)
  351. .accessibilityLabel("继续现场记录")
  352. }
  353. .padding(14)
  354. .background(Color.cardBackground.opacity(0.15))
  355. .businessBorder(cornerRadius: 10)
  356. }
  357. private static let chineseDateTimeFormatter: DateFormatter = {
  358. let formatter = DateFormatter()
  359. formatter.locale = Locale(identifier: "zh_CN")
  360. formatter.calendar = Calendar(identifier: .gregorian)
  361. formatter.dateFormat = "yyyy年M月d日 HH:mm:ss"
  362. return formatter
  363. }()
  364. private func infoStat(icon: String, label: String, value: String) -> some View {
  365. VStack(spacing: 4) {
  366. Image(systemName: icon)
  367. .font(.system(size: 14, weight: .light))
  368. .foregroundStyle(Color.secondary)
  369. Text(value)
  370. .font(.system(size: 13, weight: .medium, design: .monospaced))
  371. .foregroundStyle(Color.primary)
  372. Text(label)
  373. .font(.system(size: 9, weight: .regular))
  374. .foregroundStyle(Color.secondary.opacity(0.8))
  375. }
  376. .frame(maxWidth: .infinity)
  377. }
  378. // MARK: - Timeline Section
  379. private var timelineSection: some View {
  380. VStack(alignment: .leading, spacing: 8) {
  381. MultiTrackTimeline(
  382. events: session.events,
  383. currentTimeMs: Binding(
  384. get: { playbackVM.currentPlaybackTimeMs },
  385. set: { playbackVM.seekTo(timeMs: $0) }
  386. ),
  387. totalDurationMs: playbackVM.totalDurationMs,
  388. waveformSamples: playbackVM.waveformSamples,
  389. silentRanges: playbackVM.silentRanges,
  390. onEmptyTrackTap: handleEmptyTrackTap,
  391. onEventTap: { event in
  392. playbackVM.seekTo(timeMs: Double(event.relativeTimeMs))
  393. selectedTimelineEvent = event
  394. HapticManager.trigger(.tapFeedback)
  395. }
  396. )
  397. .frame(height: 220)
  398. }
  399. }
  400. // MARK: - Playback Controls
  401. private var playbackControls: some View {
  402. VStack(spacing: 12) {
  403. // Time display
  404. HStack {
  405. Text(playbackVM.currentTimeFormatted)
  406. .font(.system(size: 12, weight: .regular, design: .monospaced))
  407. .foregroundStyle(Color.primary)
  408. Spacer()
  409. Text(totalTimeFormatted)
  410. .font(.system(size: 12, weight: .regular, design: .monospaced))
  411. .foregroundStyle(Color.secondary)
  412. }
  413. // Seek slider
  414. Slider(
  415. value: Binding(
  416. get: { playbackVM.progress },
  417. set: { newValue in
  418. playbackVM.seekTo(timeMs: newValue * playbackVM.totalDurationMs)
  419. }
  420. ),
  421. in: 0...1
  422. )
  423. .tint(Color.primary)
  424. // Play/Pause button
  425. HStack(spacing: 36) {
  426. // Rewind 10s
  427. Button {
  428. let target = max(0, playbackVM.currentPlaybackTimeMs - 10_000)
  429. playbackVM.seekTo(timeMs: target)
  430. } label: {
  431. Image(systemName: "gobackward.10")
  432. .font(.system(size: 18, weight: .light))
  433. .foregroundStyle(Color.secondary)
  434. }
  435. // Play/Pause (High-contrast minimalist button)
  436. Button {
  437. playbackVM.togglePlayback()
  438. HapticManager.trigger(.tapFeedback)
  439. } label: {
  440. ZStack {
  441. Circle()
  442. .fill(Color.primary)
  443. .frame(width: 48, height: 48)
  444. Image(systemName: playbackVM.isPlaying ? "pause.fill" : "play.fill")
  445. .font(.system(size: 16, weight: .bold))
  446. .foregroundStyle(Color.spaceBlack)
  447. }
  448. }
  449. // Forward 10s
  450. Button {
  451. let target = min(playbackVM.totalDurationMs, playbackVM.currentPlaybackTimeMs + 10_000)
  452. playbackVM.seekTo(timeMs: target)
  453. } label: {
  454. Image(systemName: "goforward.10")
  455. .font(.system(size: 18, weight: .light))
  456. .foregroundStyle(Color.secondary)
  457. }
  458. }
  459. .padding(.top, 4)
  460. Divider()
  461. .background(Color.lineBorder)
  462. .padding(.vertical, 4)
  463. HStack {
  464. Label {
  465. Text("跳过静音")
  466. .font(.system(size: 12, weight: .medium))
  467. .foregroundStyle(Color.primary)
  468. } icon: {
  469. Image(systemName: playbackVM.isSilenceSkipEnabled ? "waveform.badge.minus" : "waveform")
  470. .font(.system(size: 13))
  471. .foregroundStyle(playbackVM.isSilenceSkipEnabled ? Color.primary : Color.secondary)
  472. }
  473. Spacer()
  474. if playbackVM.isAnalyzingSilence {
  475. ProgressView()
  476. .scaleEffect(0.7)
  477. .frame(width: 16, height: 16)
  478. } else if !playbackVM.silentRanges.isEmpty {
  479. Text("检测到 \(playbackVM.silentRanges.count) 处静音")
  480. .font(.system(size: 11))
  481. .foregroundStyle(Color.secondary)
  482. }
  483. Toggle("", isOn: Binding(
  484. get: { playbackVM.isSilenceSkipEnabled },
  485. set: { playbackVM.isSilenceSkipEnabled = $0 }
  486. ))
  487. .toggleStyle(SwitchToggleStyle(tint: Color.primary))
  488. .labelsHidden()
  489. .scaleEffect(0.8)
  490. }
  491. }
  492. .padding(14)
  493. .background(Color.cardBackground.opacity(0.15))
  494. .businessBorder(cornerRadius: 10)
  495. }
  496. // MARK: - Chrono Feed
  497. private var chronoFeedSection: some View {
  498. VStack(alignment: .leading, spacing: 10) {
  499. sectionHeader(icon: "list.dash", title: "时序事件列表")
  500. let sorted = playbackVM.sortedEvents(from: session)
  501. if sorted.isEmpty {
  502. HStack {
  503. Spacer()
  504. VStack(spacing: 8) {
  505. Image(systemName: "tray")
  506. .font(.system(size: 24, weight: .light))
  507. .foregroundStyle(Color.secondary.opacity(0.3))
  508. Text("暂无会话事件")
  509. .font(.system(size: 12))
  510. .foregroundStyle(Color.secondary.opacity(0.5))
  511. }
  512. .padding(.vertical, 24)
  513. Spacer()
  514. }
  515. } else {
  516. LazyVStack(spacing: 8) {
  517. ForEach(sorted) { event in
  518. ChronoFeedRow(event: event) {
  519. playbackVM.seekTo(timeMs: event.relativeTimeMs)
  520. if event.eventType == "PHOTO"
  521. || event.eventType == "NOTE"
  522. || event.eventType == "MARKER" {
  523. selectedTimelineEvent = event
  524. }
  525. HapticManager.trigger(.tapFeedback)
  526. }
  527. }
  528. }
  529. }
  530. }
  531. }
  532. // MARK: - Helpers
  533. private func sectionHeader(icon: String, title: String) -> some View {
  534. HStack(spacing: 6) {
  535. Image(systemName: icon)
  536. .font(.system(size: 12, weight: .light))
  537. .foregroundStyle(Color.secondary)
  538. Text(title)
  539. .font(.system(size: 13, weight: .semibold))
  540. .foregroundStyle(Color.primary.opacity(0.8))
  541. }
  542. }
  543. private var totalTimeFormatted: String {
  544. let totalSeconds = Int(playbackVM.totalDurationMs / 1000)
  545. let hours = totalSeconds / 3600
  546. let minutes = (totalSeconds % 3600) / 60
  547. let seconds = totalSeconds % 60
  548. if hours > 0 {
  549. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  550. }
  551. return String(format: "%02d:%02d", minutes, seconds)
  552. }
  553. // MARK: - Timeline Editing
  554. private var timelineNoteEditor: some View {
  555. NavigationStack {
  556. ZStack {
  557. Color.spaceBlack.ignoresSafeArea()
  558. VStack(alignment: .leading, spacing: 16) {
  559. Label(
  560. "添加到 \(formattedTimelineTime(pendingTimelineTimeMs))",
  561. systemImage: "clock"
  562. )
  563. .font(.system(size: 12, weight: .medium))
  564. .foregroundStyle(Color.secondary)
  565. ZStack(alignment: .topLeading) {
  566. if noteText.isEmpty {
  567. Text("输入笔记内容…")
  568. .font(.system(size: 15))
  569. .foregroundStyle(Color.secondary.opacity(0.6))
  570. .padding(.horizontal, 18)
  571. .padding(.vertical, 16)
  572. .allowsHitTesting(false)
  573. }
  574. TextEditor(text: $noteText)
  575. .font(.system(size: 15))
  576. .foregroundStyle(Color.primary)
  577. .scrollContentBackground(.hidden)
  578. .padding(10)
  579. .frame(minHeight: 160)
  580. .background(Color.cardBackground.opacity(0.5))
  581. .businessBorder(cornerRadius: 8)
  582. }
  583. Spacer()
  584. }
  585. .padding(20)
  586. }
  587. .navigationTitle("添加笔记")
  588. .navigationBarTitleDisplayMode(.inline)
  589. .toolbar {
  590. ToolbarItem(placement: .cancellationAction) {
  591. Button("取消") {
  592. noteText = ""
  593. showNoteEditor = false
  594. }
  595. }
  596. ToolbarItem(placement: .confirmationAction) {
  597. Button("保存") {
  598. addNote(at: pendingTimelineTimeMs)
  599. }
  600. .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
  601. }
  602. }
  603. }
  604. .presentationDetents([.medium])
  605. .presentationDragIndicator(.visible)
  606. .presentationBackground(Color.spaceBlack)
  607. }
  608. private func handleEmptyTrackTap(
  609. _ track: MultiTrackTimeline.EditableTrack,
  610. timeMs: Double
  611. ) {
  612. pendingTimelineTimeMs = min(max(timeMs, 0), playbackVM.totalDurationMs)
  613. playbackVM.seekTo(timeMs: pendingTimelineTimeMs)
  614. HapticManager.trigger(.tapFeedback)
  615. switch track {
  616. case .photo:
  617. showPhotoActionDialog = true
  618. case .note:
  619. noteText = ""
  620. showNoteEditor = true
  621. }
  622. }
  623. private func addNote(at timeMs: Double) {
  624. let trimmedText = noteText.trimmingCharacters(in: .whitespacesAndNewlines)
  625. guard !trimmedText.isEmpty else { return }
  626. let event = CelestiaTimelineEvent(
  627. relativeTimeMs: Int64(timeMs.rounded()),
  628. eventType: "NOTE"
  629. )
  630. event.textContent = trimmedText
  631. session.events.append(event)
  632. do {
  633. try saveTimelineChanges()
  634. noteText = ""
  635. showNoteEditor = false
  636. } catch {
  637. modelContext.rollback()
  638. timelineEditError = "笔记保存失败:\(error.localizedDescription)"
  639. }
  640. }
  641. private func addPhoto(_ image: UIImage, at timeMs: Double) {
  642. guard let data = image.jpegData(compressionQuality: 0.85),
  643. let documentsURL = FileManager.default.urls(
  644. for: .documentDirectory,
  645. in: .userDomainMask
  646. ).first else {
  647. timelineEditError = "无法读取所选图片。"
  648. return
  649. }
  650. let filename = "photo_\(UUID().uuidString).jpg"
  651. let fileURL = documentsURL.appendingPathComponent(filename)
  652. do {
  653. try data.write(to: fileURL, options: .atomic)
  654. let event = CelestiaTimelineEvent(
  655. relativeTimeMs: Int64(timeMs.rounded()),
  656. eventType: "PHOTO"
  657. )
  658. event.localFilePath = filename
  659. session.events.append(event)
  660. do {
  661. try saveTimelineChanges()
  662. } catch {
  663. modelContext.rollback()
  664. try? FileManager.default.removeItem(at: fileURL)
  665. throw error
  666. }
  667. } catch {
  668. timelineEditError = "图片保存失败:\(error.localizedDescription)"
  669. }
  670. }
  671. private func deleteTimelineEvent(_ event: CelestiaTimelineEvent) {
  672. let photoURL = event.eventType == "PHOTO"
  673. ? AudioPathHelper.resolveURL(for: event.localFilePath)
  674. : nil
  675. modelContext.delete(event)
  676. do {
  677. try saveTimelineChanges()
  678. if let photoURL {
  679. try? FileManager.default.removeItem(at: photoURL)
  680. }
  681. selectedTimelineEvent = nil
  682. } catch {
  683. modelContext.rollback()
  684. timelineEditError = "删除失败:\(error.localizedDescription)"
  685. }
  686. }
  687. private func saveTimelineChanges() throws {
  688. session.isSynced = false
  689. session.syncState = .pending
  690. try modelContext.save()
  691. }
  692. private func formattedTimelineTime(_ timeMs: Double) -> String {
  693. let totalSeconds = max(0, Int(timeMs / 1_000))
  694. let hours = totalSeconds / 3_600
  695. let minutes = (totalSeconds % 3_600) / 60
  696. let seconds = totalSeconds % 60
  697. if hours > 0 {
  698. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  699. }
  700. return String(format: "%02d:%02d", minutes, seconds)
  701. }
  702. }
  703. private struct TimelineEventDetailSheet: View {
  704. @Environment(\.dismiss) private var dismiss
  705. let event: CelestiaTimelineEvent
  706. let onDelete: () -> Void
  707. @State private var showDeleteConfirmation = false
  708. var body: some View {
  709. NavigationStack {
  710. ZStack {
  711. Color.spaceBlack.ignoresSafeArea()
  712. ScrollView {
  713. VStack(alignment: .leading, spacing: 16) {
  714. Label(event.relativeTimeFormatted, systemImage: "clock")
  715. .font(.system(size: 12, weight: .medium, design: .monospaced))
  716. .foregroundStyle(Color.secondary)
  717. eventContent
  718. Button(role: .destructive) {
  719. showDeleteConfirmation = true
  720. } label: {
  721. Label("删除这条记录", systemImage: "trash")
  722. .font(.system(size: 14, weight: .medium))
  723. .frame(maxWidth: .infinity)
  724. .padding(.vertical, 12)
  725. }
  726. .buttonStyle(.bordered)
  727. .tint(.red)
  728. }
  729. .padding(20)
  730. }
  731. }
  732. .navigationTitle(detailTitle)
  733. .navigationBarTitleDisplayMode(.inline)
  734. .toolbar {
  735. ToolbarItem(placement: .confirmationAction) {
  736. Button("完成") {
  737. dismiss()
  738. }
  739. }
  740. }
  741. .confirmationDialog(
  742. "确定删除这条记录吗?",
  743. isPresented: $showDeleteConfirmation,
  744. titleVisibility: .visible
  745. ) {
  746. Button("删除", role: .destructive) {
  747. onDelete()
  748. dismiss()
  749. }
  750. Button("取消", role: .cancel) {}
  751. } message: {
  752. Text(event.eventType == "PHOTO" ? "本地图片文件也会被删除,此操作无法撤销。" : "此操作无法撤销。")
  753. }
  754. }
  755. .presentationDetents(event.eventType == "PHOTO" ? [.medium, .large] : [.medium])
  756. .presentationDragIndicator(.visible)
  757. .presentationBackground(Color.spaceBlack)
  758. }
  759. @ViewBuilder
  760. private var eventContent: some View {
  761. if event.eventType == "PHOTO" {
  762. if let url = AudioPathHelper.resolveURL(for: event.localFilePath),
  763. let image = UIImage(contentsOfFile: url.path) {
  764. Image(uiImage: image)
  765. .resizable()
  766. .scaledToFit()
  767. .frame(maxWidth: .infinity)
  768. .clipShape(RoundedRectangle(cornerRadius: 10))
  769. .overlay {
  770. RoundedRectangle(cornerRadius: 10)
  771. .stroke(Color.lineBorder, lineWidth: 1)
  772. }
  773. } else {
  774. ContentUnavailableView(
  775. "图片不可用",
  776. systemImage: "photo.badge.exclamationmark",
  777. description: Text("本地图片文件可能已被移动或删除。")
  778. )
  779. .frame(maxWidth: .infinity, minHeight: 180)
  780. }
  781. } else {
  782. Text(event.textContent ?? "暂无笔记内容")
  783. .font(.system(size: 15))
  784. .foregroundStyle(Color.primary)
  785. .frame(maxWidth: .infinity, alignment: .leading)
  786. .padding(16)
  787. .background(Color.cardBackground.opacity(0.5))
  788. .businessBorder(cornerRadius: 8)
  789. }
  790. }
  791. private var detailTitle: String {
  792. if event.isContinuationMarker {
  793. return "续录详情"
  794. }
  795. return event.eventType == "PHOTO" ? "查看图片" : "查看笔记"
  796. }
  797. }
  798. private struct AudioShareItem: Identifiable {
  799. let id = UUID()
  800. let fileURL: URL
  801. }
  802. private struct AudioShareSheet: UIViewControllerRepresentable {
  803. let fileURL: URL
  804. func makeUIViewController(context: Context) -> UIActivityViewController {
  805. UIActivityViewController(activityItems: [fileURL], applicationActivities: nil)
  806. }
  807. func updateUIViewController(
  808. _ uiViewController: UIActivityViewController,
  809. context: Context
  810. ) {}
  811. }
  812. // MARK: - Preview
  813. #Preview {
  814. NavigationStack {
  815. SessionDetailView(session: {
  816. let s = CelestiaSession(title: "Preview 现场记录 · 6.24 14:30")
  817. s.endTime = Date().addingTimeInterval(3600)
  818. return s
  819. }())
  820. }
  821. .modelContainer(for: CelestiaSession.self, inMemory: true)
  822. }