SessionDetailView.swift 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214
  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. @ObservedObject private var syncManager: SyncManager = .shared
  14. @ObservedObject private var networkMonitor: NetworkStatusMonitor = .shared
  15. let session: CelestiaSession
  16. @State private var playbackVM = PlaybackViewModel()
  17. @State private var navigateToRecording = false
  18. @State private var detector = RecordingEnvironmentDetector()
  19. @State private var showPrepAlert = false
  20. @State private var activeWarningMsg = ""
  21. @State private var showRecordingSourcePicker = false
  22. @State private var selectedRecordingSource: RecordingSourceChoice = .iPhone
  23. @State private var recordName = ""
  24. @State private var showRecordNameEditor = false
  25. @State private var showSessionInfo = false
  26. @State private var showShareUnavailableAlert = false
  27. @State private var audioShareItem: AudioShareItem?
  28. @State private var preparedShareDirectoryURL: URL?
  29. @State private var pendingTimelineTimeMs: Double = 0
  30. @State private var noteText = ""
  31. @State private var showNoteEditor = false
  32. @State private var showPhotoActionDialog = false
  33. @State private var showImagePicker = false
  34. @State private var imagePickerSource: UIImagePickerController.SourceType = .photoLibrary
  35. @State private var selectedTimelineEvent: CelestiaTimelineEvent?
  36. @State private var timelineEditError: String?
  37. @State private var showSyncAuthPrompt = false
  38. @State private var showAuthModal = false
  39. @State private var showLargeSyncConfirmation = false
  40. @State private var showSyncInfo = false
  41. @State private var hasApprovedLargeTransfer = false
  42. var body: some View {
  43. ZStack {
  44. Color.spaceBlack.ignoresSafeArea()
  45. ScrollView {
  46. VStack(spacing: 20) {
  47. detailHeader
  48. .padding(.horizontal, 16)
  49. .padding(.top, 12)
  50. // Section 1: Multi-Track Timeline
  51. timelineSection
  52. .padding(.horizontal, 16)
  53. // Section 2: Playback Controls
  54. playbackControls
  55. .padding(.horizontal, 16)
  56. // Section 3: Chrono Feed
  57. chronoFeedSection
  58. .padding(.horizontal, 16)
  59. .padding(.bottom, 32)
  60. }
  61. }
  62. }
  63. .navigationBarTitleDisplayMode(.inline)
  64. .toolbar {
  65. ToolbarItemGroup(placement: .topBarTrailing) {
  66. Button {
  67. showSessionInfo = true
  68. } label: {
  69. Image(systemName: "info.circle")
  70. .font(.system(size: 16, weight: .regular))
  71. .foregroundStyle(Color.primary)
  72. }
  73. .accessibilityLabel("记录信息")
  74. .popover(isPresented: $showSessionInfo, arrowEdge: .top) {
  75. sessionInfoCard
  76. .padding(16)
  77. .frame(idealWidth: 360)
  78. .presentationCompactAdaptation(.popover)
  79. }
  80. Button {
  81. prepareAudioForSharing()
  82. } label: {
  83. Image(systemName: "square.and.arrow.up")
  84. .font(.system(size: 16, weight: .regular))
  85. .foregroundStyle(Color.primary)
  86. }
  87. .accessibilityLabel("分享录音")
  88. }
  89. }
  90. .sheet(item: $audioShareItem, onDismiss: cleanupSharedAudioCopy) { item in
  91. AudioShareSheet(fileURL: item.fileURL)
  92. }
  93. .alert("无法分享录音", isPresented: $showShareUnavailableAlert) {
  94. Button("知道了", role: .cancel) {}
  95. } message: {
  96. Text("当前记录没有可用的本地录音文件,请先完成录制或同步录音。")
  97. }
  98. .alert("无法完成编辑", isPresented: Binding(
  99. get: { timelineEditError != nil },
  100. set: { if !$0 { timelineEditError = nil } }
  101. )) {
  102. Button("知道了", role: .cancel) {}
  103. } message: {
  104. Text(timelineEditError ?? "请稍后重试。")
  105. }
  106. .alert("录音环境提醒", isPresented: $showPrepAlert) {
  107. Button("继续录制", role: .none) {
  108. chooseSourceAndContinue()
  109. }
  110. Button("取消", role: .cancel) {}
  111. } message: {
  112. Text(activeWarningMsg + "\n\n另外请手动确认:\n1. 手机侧边静音开关已拨至红色(静音状态)\n2. 已关闭可能在此期间响起的系统闹钟")
  113. }
  114. .fullScreenCover(isPresented: $navigateToRecording) {
  115. ActiveRecordingView(
  116. session: session,
  117. initialDuration: Double(session.durationMs) / 1000.0,
  118. recordingSource: selectedRecordingSource
  119. ) {
  120. playbackVM.totalDurationMs = Double(session.durationMs)
  121. playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
  122. playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
  123. scheduleAutoSync()
  124. }
  125. }
  126. .sheet(isPresented: $showRecordingSourcePicker) {
  127. RecordingSourcePickerView(devices: connectedSparkDevices) { source in
  128. selectedRecordingSource = source
  129. showRecordingSourcePicker = false
  130. navigateToRecording = true
  131. }
  132. }
  133. .sheet(isPresented: $showRecordNameEditor) {
  134. RecordNameEditorSheet(recordName: $recordName) {
  135. saveRecordName()
  136. } onCancel: {
  137. recordName = session.title
  138. showRecordNameEditor = false
  139. }
  140. }
  141. .sheet(isPresented: $showNoteEditor) {
  142. timelineNoteEditor
  143. }
  144. .sheet(isPresented: $showImagePicker) {
  145. ImagePicker(sourceType: imagePickerSource) { image in
  146. addPhoto(image, at: pendingTimelineTimeMs)
  147. }
  148. }
  149. .sheet(item: $selectedTimelineEvent) { event in
  150. TimelineEventDetailSheet(event: event) {
  151. deleteTimelineEvent(event)
  152. }
  153. }
  154. .sheet(isPresented: $showSyncAuthPrompt) {
  155. SyncAuthPromptModal {
  156. showSyncAuthPrompt = false
  157. showAuthModal = true
  158. }
  159. }
  160. .sheet(isPresented: $showAuthModal) {
  161. AuthModalView()
  162. }
  163. .alert("同步较大文件", isPresented: $showLargeSyncConfirmation) {
  164. Button("开始同步") {
  165. hasApprovedLargeTransfer = true
  166. performSessionSync()
  167. }
  168. Button("取消", role: .cancel) {}
  169. } message: {
  170. Text(largeTransferMessage)
  171. }
  172. .confirmationDialog(
  173. "在 \(formattedTimelineTime(pendingTimelineTimeMs)) 添加图片",
  174. isPresented: $showPhotoActionDialog,
  175. titleVisibility: .visible
  176. ) {
  177. if UIImagePickerController.isSourceTypeAvailable(.camera) {
  178. Button("拍照") {
  179. imagePickerSource = .camera
  180. showImagePicker = true
  181. }
  182. }
  183. Button("从相册选择") {
  184. imagePickerSource = .photoLibrary
  185. showImagePicker = true
  186. }
  187. Button("取消", role: .cancel) {}
  188. } message: {
  189. Text("图片会添加到当前时间位置。")
  190. }
  191. .onAppear {
  192. playbackVM.totalDurationMs = Double(session.durationMs)
  193. playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
  194. playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
  195. if session.needsCloudSync {
  196. scheduleAutoSync()
  197. }
  198. }
  199. .onChange(of: session.syncStateRaw) { _, newValue in
  200. guard let state = SessionSyncState(rawValue: newValue),
  201. state == .pending || state == .localOnly,
  202. session.needsCloudSync else { return }
  203. scheduleAutoSync()
  204. }
  205. }
  206. private var detailHeader: some View {
  207. HStack(spacing: 12) {
  208. Button {
  209. recordName = session.title
  210. showRecordNameEditor = true
  211. } label: {
  212. Text(session.title)
  213. .font(.system(size: 22, weight: .semibold))
  214. .lineLimit(1)
  215. .minimumScaleFactor(0.75)
  216. .foregroundStyle(Color.primary)
  217. .contentShape(Rectangle())
  218. }
  219. .buttonStyle(.plain)
  220. .accessibilityLabel("编辑记录名称")
  221. .layoutPriority(1)
  222. Spacer(minLength: 0)
  223. compactSyncButton
  224. .fixedSize()
  225. }
  226. .frame(maxWidth: .infinity, alignment: .leading)
  227. }
  228. private func prepareContinuation() {
  229. playbackVM.pause()
  230. detector.checkEnvironment()
  231. if let firstWarning = detector.activeWarnings.first {
  232. activeWarningMsg = firstWarning
  233. showPrepAlert = true
  234. } else {
  235. chooseSourceAndContinue()
  236. }
  237. }
  238. private func chooseSourceAndContinue() {
  239. if connectedSparkDevices.isEmpty {
  240. selectedRecordingSource = .iPhone
  241. navigateToRecording = true
  242. } else {
  243. showRecordingSourcePicker = true
  244. }
  245. }
  246. private func saveRecordName() {
  247. let trimmedName = recordName.trimmingCharacters(in: .whitespacesAndNewlines)
  248. guard !trimmedName.isEmpty else { return }
  249. recordName = trimmedName
  250. if session.title != trimmedName {
  251. session.title = trimmedName
  252. session.markContentModified()
  253. try? modelContext.save()
  254. scheduleAutoSync()
  255. }
  256. showRecordNameEditor = false
  257. }
  258. private func prepareAudioForSharing() {
  259. guard let sourceURL = AudioPathHelper.resolveURL(for: session.localAudioPath) else {
  260. showShareUnavailableAlert = true
  261. return
  262. }
  263. let shareDirectory = FileManager.default.temporaryDirectory
  264. .appendingPathComponent("CelestiaTraceShare", isDirectory: true)
  265. .appendingPathComponent(UUID().uuidString, isDirectory: true)
  266. let fileExtension = sourceURL.pathExtension
  267. let fileName = sanitizedShareFileName(from: session.title)
  268. let destinationURL = shareDirectory
  269. .appendingPathComponent(fileName)
  270. .appendingPathExtension(fileExtension)
  271. do {
  272. try FileManager.default.createDirectory(
  273. at: shareDirectory,
  274. withIntermediateDirectories: true
  275. )
  276. try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
  277. preparedShareDirectoryURL = shareDirectory
  278. audioShareItem = AudioShareItem(fileURL: destinationURL)
  279. } catch {
  280. audioShareItem = nil
  281. preparedShareDirectoryURL = nil
  282. showShareUnavailableAlert = true
  283. }
  284. }
  285. private func sanitizedShareFileName(from title: String) -> String {
  286. let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
  287. .union(.controlCharacters)
  288. .union(.newlines)
  289. let components = title.components(separatedBy: invalidCharacters)
  290. let sanitized = components
  291. .joined(separator: "-")
  292. .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines.union(
  293. CharacterSet(charactersIn: ".")
  294. ))
  295. return sanitized.isEmpty ? "现场记录" : sanitized
  296. }
  297. private func cleanupSharedAudioCopy() {
  298. guard let preparedShareDirectoryURL else { return }
  299. try? FileManager.default.removeItem(at: preparedShareDirectoryURL)
  300. self.preparedShareDirectoryURL = nil
  301. }
  302. private var connectedSparkDevices: [BoundDevice] {
  303. guard let userID = authManager.currentUser?.id else { return [] }
  304. return bleManager.connectedDevices(forUserId: userID)
  305. .sorted { $0.boundAt < $1.boundAt }
  306. }
  307. // MARK: - Session Info Card
  308. private var sessionInfoCard: some View {
  309. VStack(spacing: 14) {
  310. // Date range
  311. HStack {
  312. VStack(alignment: .leading, spacing: 4) {
  313. Label {
  314. Text("开始")
  315. .font(.system(size: 10, weight: .medium))
  316. .foregroundStyle(Color.secondary)
  317. } icon: {
  318. Image(systemName: "play.circle")
  319. .font(.system(size: 11))
  320. .foregroundStyle(Color.secondary)
  321. }
  322. VStack(alignment: .leading, spacing: 1) {
  323. Text(Self.chineseDateFormatter.string(from: session.startTime))
  324. Text(Self.chineseTimeFormatter.string(from: session.startTime))
  325. }
  326. .font(.system(size: 13, weight: .medium))
  327. .foregroundStyle(Color.primary)
  328. }
  329. Spacer()
  330. VStack(alignment: .trailing, spacing: 4) {
  331. Label {
  332. Text("结束")
  333. .font(.system(size: 10, weight: .medium))
  334. .foregroundStyle(Color.secondary)
  335. } icon: {
  336. Image(systemName: "stop.circle")
  337. .font(.system(size: 11))
  338. .foregroundStyle(Color.secondary)
  339. }
  340. if let endTime = session.endTime {
  341. VStack(alignment: .trailing, spacing: 1) {
  342. Text(Self.chineseDateFormatter.string(from: endTime))
  343. Text(Self.chineseTimeFormatter.string(from: endTime))
  344. }
  345. .font(.system(size: 13, weight: .medium))
  346. .foregroundStyle(Color.primary)
  347. } else {
  348. Text("进行中")
  349. .font(.system(size: 13, weight: .medium))
  350. .foregroundStyle(Color.primary)
  351. }
  352. }
  353. }
  354. Divider()
  355. .background(Color.lineBorder)
  356. // Stats row
  357. HStack(spacing: 0) {
  358. infoStat(
  359. icon: "timer",
  360. label: "总时长",
  361. value: session.durationFormatted
  362. )
  363. infoStat(
  364. icon: "camera",
  365. label: "图片",
  366. value: "\(session.photoCount)"
  367. )
  368. infoStat(
  369. icon: "doc.text",
  370. label: "笔记",
  371. value: "\(session.noteCount)"
  372. )
  373. }
  374. Divider()
  375. .background(Color.lineBorder)
  376. HStack(spacing: 8) {
  377. Label {
  378. Text("版本号")
  379. .font(.system(size: 11, weight: .medium))
  380. } icon: {
  381. Image(systemName: "number")
  382. .font(.system(size: 11, weight: .medium))
  383. }
  384. .foregroundStyle(Color.secondary)
  385. Spacer()
  386. Text("v\(session.localRevision)")
  387. .font(.system(size: 12, weight: .semibold, design: .monospaced))
  388. .foregroundStyle(Color.primary)
  389. }
  390. Divider()
  391. .background(Color.lineBorder)
  392. Button {
  393. showSessionInfo = false
  394. DispatchQueue.main.async {
  395. prepareContinuation()
  396. }
  397. } label: {
  398. HStack(spacing: 8) {
  399. Image(systemName: "record.circle")
  400. .font(.system(size: 14, weight: .medium))
  401. Text("续录")
  402. .font(.system(size: 13, weight: .semibold))
  403. }
  404. .foregroundStyle(Color.primary)
  405. .frame(maxWidth: .infinity)
  406. .padding(.vertical, 10)
  407. .background(Color.cardBackground.opacity(0.2))
  408. .businessBorder(cornerRadius: 8)
  409. }
  410. .buttonStyle(.plain)
  411. .accessibilityLabel("继续现场记录")
  412. }
  413. .padding(14)
  414. .background(Color.cardBackground.opacity(0.15))
  415. .businessBorder(cornerRadius: 10)
  416. }
  417. private static let chineseDateFormatter: DateFormatter = {
  418. let formatter = DateFormatter()
  419. formatter.locale = Locale(identifier: "zh_CN")
  420. formatter.calendar = Calendar(identifier: .gregorian)
  421. formatter.dateFormat = "yyyy年M月d日"
  422. return formatter
  423. }()
  424. private static let chineseTimeFormatter: DateFormatter = {
  425. let formatter = DateFormatter()
  426. formatter.locale = Locale(identifier: "zh_CN")
  427. formatter.calendar = Calendar(identifier: .gregorian)
  428. formatter.dateFormat = "HH:mm:ss"
  429. return formatter
  430. }()
  431. private var compactSyncButton: some View {
  432. VStack(spacing: 3) {
  433. Button {
  434. if isThisSessionSyncing {
  435. syncManager.pauseSync(sessionID: session.id)
  436. } else if isDisplayedSynced {
  437. showSyncInfo = true
  438. } else {
  439. requestSessionSync(isAutomatic: false)
  440. }
  441. } label: {
  442. Group {
  443. if isDisplayedSyncing {
  444. ZStack {
  445. Circle()
  446. .stroke(Color.secondary.opacity(0.22), lineWidth: 2)
  447. Circle()
  448. .trim(from: 0, to: max(sessionSyncProgress, 0.04))
  449. .stroke(
  450. Color.lessCosmosGold,
  451. style: StrokeStyle(lineWidth: 2, lineCap: .round)
  452. )
  453. .rotationEffect(.degrees(-90))
  454. TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
  455. Image(systemName: "arrow.triangle.2.circlepath")
  456. .font(.system(size: 9, weight: .semibold))
  457. .foregroundStyle(Color.lessCosmosGold)
  458. .rotationEffect(syncRotation(at: context.date))
  459. }
  460. }
  461. } else if isDisplayedSynced {
  462. Image(systemName: "checkmark.icloud.fill")
  463. .symbolRenderingMode(.hierarchical)
  464. .foregroundStyle(Color.lessCosmosGold)
  465. } else {
  466. Image(systemName: "icloud.and.arrow.up")
  467. .symbolRenderingMode(.hierarchical)
  468. .foregroundStyle(Color.secondary)
  469. }
  470. }
  471. .font(.system(size: 17, weight: .medium))
  472. .frame(width: 32, height: 32)
  473. .contentShape(Rectangle())
  474. }
  475. .buttonStyle(.plain)
  476. .disabled(syncManager.isSyncing && !isThisSessionSyncing)
  477. .accessibilityLabel(isThisSessionSyncing ? "暂停同步" : "同步记录")
  478. .accessibilityValue(
  479. isDisplayedSyncing
  480. ? "\(syncStatusText),\(Int((sessionSyncProgress * 100).rounded()))%"
  481. : syncStatusText
  482. )
  483. .popover(isPresented: $showSyncInfo, arrowEdge: .top) {
  484. syncInfoCard
  485. .padding(16)
  486. .frame(idealWidth: 320)
  487. .presentationCompactAdaptation(.popover)
  488. }
  489. Text(syncStatusText)
  490. .font(.system(size: 9, weight: .medium))
  491. .foregroundStyle(syncStatusColor)
  492. }
  493. }
  494. private var syncInfoCard: some View {
  495. VStack(alignment: .leading, spacing: 12) {
  496. HStack(spacing: 8) {
  497. Image(systemName: "checkmark.icloud.fill")
  498. .foregroundStyle(Color.lessCosmosGold)
  499. Text("同步信息")
  500. .font(.system(size: 15, weight: .semibold))
  501. .foregroundStyle(Color.primary)
  502. }
  503. Divider()
  504. .background(Color.lineBorder)
  505. syncInfoRow(
  506. label: "同步时间",
  507. value: session.lastSyncedAt.map {
  508. Self.syncDateTimeFormatter.string(from: $0)
  509. } ?? "历史记录未保存"
  510. )
  511. syncInfoRow(
  512. label: "云端版本",
  513. value: "v\(max(session.serverRevision, 1))",
  514. monospaced: true
  515. )
  516. if let cloudSessionID = session.cloudSessionId {
  517. syncInfoRow(
  518. label: "云端记录",
  519. value: cloudSessionID,
  520. monospaced: true
  521. )
  522. }
  523. }
  524. .padding(14)
  525. .background(Color.cardBackground.opacity(0.15))
  526. .businessBorder(cornerRadius: 10)
  527. }
  528. private func syncInfoRow(
  529. label: String,
  530. value: String,
  531. monospaced: Bool = false
  532. ) -> some View {
  533. HStack(alignment: .firstTextBaseline, spacing: 14) {
  534. Text(label)
  535. .font(.system(size: 11, weight: .medium))
  536. .foregroundStyle(Color.secondary)
  537. Spacer(minLength: 12)
  538. Text(value)
  539. .font(.system(size: 12, weight: .medium, design: monospaced ? .monospaced : .default))
  540. .foregroundStyle(Color.primary)
  541. .multilineTextAlignment(.trailing)
  542. .lineLimit(2)
  543. .textSelection(.enabled)
  544. }
  545. }
  546. private var isThisSessionSyncing: Bool {
  547. syncManager.isSyncing && syncManager.activeSessionID == session.id
  548. }
  549. private var isDisplayedSyncing: Bool {
  550. isThisSessionSyncing
  551. }
  552. private var isDisplayedSynced: Bool {
  553. !isDisplayedSyncing && session.hasConfirmedCloudSync
  554. }
  555. private var sessionSyncProgress: Double {
  556. if isThisSessionSyncing {
  557. return min(max(syncManager.syncProgress, 0), 1)
  558. }
  559. return session.hasConfirmedCloudSync ? 1 : 0
  560. }
  561. private var syncStatusText: String {
  562. if isDisplayedSyncing { return "正在同步" }
  563. return isDisplayedSynced ? "已同步" : "未同步"
  564. }
  565. private var syncStatusColor: Color {
  566. isDisplayedSynced || isDisplayedSyncing ? Color.lessCosmosGold : Color.secondary
  567. }
  568. private func syncRotation(at date: Date) -> Angle {
  569. let cycle = date.timeIntervalSinceReferenceDate
  570. .truncatingRemainder(dividingBy: 1.2)
  571. return .degrees(cycle / 1.2 * 360)
  572. }
  573. private static let syncDateTimeFormatter: DateFormatter = {
  574. let formatter = DateFormatter()
  575. formatter.locale = Locale(identifier: "zh_CN")
  576. formatter.calendar = Calendar(identifier: .gregorian)
  577. formatter.dateFormat = "yyyy年M月d日 HH:mm:ss"
  578. return formatter
  579. }()
  580. private var largeTransferMessage: String {
  581. let bytes = syncManager.estimatedUploadBytes(for: session)
  582. let size = ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
  583. return "预计上传约 \(size),将占用约 \(size) \(networkMonitor.connectionName)流量。是否继续?"
  584. }
  585. private func requestSessionSync(isAutomatic: Bool) {
  586. guard !syncManager.isSyncing else { return }
  587. guard networkMonitor.isConnected else {
  588. if !isAutomatic {
  589. timelineEditError = "当前没有可用网络,请连接网络后重试。"
  590. }
  591. return
  592. }
  593. guard authManager.currentUser?.id != nil else {
  594. if !isAutomatic {
  595. showSyncAuthPrompt = true
  596. }
  597. return
  598. }
  599. let largeTransferThreshold: Int64 = 70 * 1_024 * 1_024
  600. if syncManager.estimatedUploadBytes(for: session) >= largeTransferThreshold,
  601. !hasApprovedLargeTransfer {
  602. showLargeSyncConfirmation = true
  603. return
  604. }
  605. performSessionSync()
  606. }
  607. private func performSessionSync() {
  608. guard let userID = authManager.currentUser?.id else { return }
  609. Task {
  610. _ = await syncManager.sync(
  611. sessions: [session],
  612. modelContext: modelContext,
  613. userID: userID
  614. )
  615. }
  616. }
  617. private func scheduleAutoSync() {
  618. syncManager.scheduleAutomaticSync(
  619. for: session,
  620. modelContext: modelContext
  621. )
  622. }
  623. private func infoStat(icon: String, label: String, value: String) -> some View {
  624. VStack(spacing: 4) {
  625. Image(systemName: icon)
  626. .font(.system(size: 14, weight: .light))
  627. .foregroundStyle(Color.secondary)
  628. Text(value)
  629. .font(.system(size: 13, weight: .medium, design: .monospaced))
  630. .foregroundStyle(Color.primary)
  631. Text(label)
  632. .font(.system(size: 9, weight: .regular))
  633. .foregroundStyle(Color.secondary.opacity(0.8))
  634. }
  635. .frame(maxWidth: .infinity)
  636. }
  637. // MARK: - Timeline Section
  638. private var timelineSection: some View {
  639. VStack(alignment: .leading, spacing: 8) {
  640. MultiTrackTimeline(
  641. events: session.events,
  642. currentTimeMs: Binding(
  643. get: { playbackVM.currentPlaybackTimeMs },
  644. set: { playbackVM.seekTo(timeMs: $0) }
  645. ),
  646. totalDurationMs: playbackVM.totalDurationMs,
  647. waveformSamples: playbackVM.waveformSamples,
  648. silentRanges: playbackVM.silentRanges,
  649. onEmptyTrackTap: handleEmptyTrackTap,
  650. onEventTap: { event in
  651. playbackVM.seekTo(timeMs: Double(event.relativeTimeMs))
  652. selectedTimelineEvent = event
  653. HapticManager.trigger(.tapFeedback)
  654. }
  655. )
  656. .frame(height: 220)
  657. }
  658. }
  659. // MARK: - Playback Controls
  660. private var playbackControls: some View {
  661. VStack(spacing: 12) {
  662. // Time display
  663. HStack {
  664. Text(playbackVM.currentTimeFormatted)
  665. .font(.system(size: 12, weight: .regular, design: .monospaced))
  666. .foregroundStyle(Color.primary)
  667. Spacer()
  668. Text(totalTimeFormatted)
  669. .font(.system(size: 12, weight: .regular, design: .monospaced))
  670. .foregroundStyle(Color.secondary)
  671. }
  672. // Seek slider
  673. Slider(
  674. value: Binding(
  675. get: { playbackVM.progress },
  676. set: { newValue in
  677. playbackVM.seekTo(timeMs: newValue * playbackVM.totalDurationMs)
  678. }
  679. ),
  680. in: 0...1
  681. )
  682. .tint(Color.primary)
  683. // Play/Pause button
  684. HStack(spacing: 36) {
  685. // Rewind 10s
  686. Button {
  687. let target = max(0, playbackVM.currentPlaybackTimeMs - 10_000)
  688. playbackVM.seekTo(timeMs: target)
  689. } label: {
  690. Image(systemName: "gobackward.10")
  691. .font(.system(size: 18, weight: .light))
  692. .foregroundStyle(Color.secondary)
  693. }
  694. // Play/Pause (High-contrast minimalist button)
  695. Button {
  696. playbackVM.togglePlayback()
  697. HapticManager.trigger(.tapFeedback)
  698. } label: {
  699. ZStack {
  700. Circle()
  701. .fill(Color.primary)
  702. .frame(width: 48, height: 48)
  703. Image(systemName: playbackVM.isPlaying ? "pause.fill" : "play.fill")
  704. .font(.system(size: 16, weight: .bold))
  705. .foregroundStyle(Color.spaceBlack)
  706. }
  707. }
  708. // Forward 10s
  709. Button {
  710. let target = min(playbackVM.totalDurationMs, playbackVM.currentPlaybackTimeMs + 10_000)
  711. playbackVM.seekTo(timeMs: target)
  712. } label: {
  713. Image(systemName: "goforward.10")
  714. .font(.system(size: 18, weight: .light))
  715. .foregroundStyle(Color.secondary)
  716. }
  717. }
  718. .padding(.top, 4)
  719. Divider()
  720. .background(Color.lineBorder)
  721. .padding(.vertical, 4)
  722. HStack {
  723. Label {
  724. Text("跳过静音")
  725. .font(.system(size: 12, weight: .medium))
  726. .foregroundStyle(Color.primary)
  727. } icon: {
  728. Image(systemName: playbackVM.isSilenceSkipEnabled ? "waveform.badge.minus" : "waveform")
  729. .font(.system(size: 13))
  730. .foregroundStyle(playbackVM.isSilenceSkipEnabled ? Color.primary : Color.secondary)
  731. }
  732. Spacer()
  733. if playbackVM.isAnalyzingSilence {
  734. ProgressView()
  735. .scaleEffect(0.7)
  736. .frame(width: 16, height: 16)
  737. } else if !playbackVM.silentRanges.isEmpty {
  738. Text("检测到 \(playbackVM.silentRanges.count) 处静音")
  739. .font(.system(size: 11))
  740. .foregroundStyle(Color.secondary)
  741. }
  742. Toggle("", isOn: Binding(
  743. get: { playbackVM.isSilenceSkipEnabled },
  744. set: { playbackVM.isSilenceSkipEnabled = $0 }
  745. ))
  746. .toggleStyle(SwitchToggleStyle(tint: Color.primary))
  747. .labelsHidden()
  748. .scaleEffect(0.8)
  749. }
  750. }
  751. .padding(14)
  752. .background(Color.cardBackground.opacity(0.15))
  753. .businessBorder(cornerRadius: 10)
  754. }
  755. // MARK: - Chrono Feed
  756. private var chronoFeedSection: some View {
  757. VStack(alignment: .leading, spacing: 10) {
  758. sectionHeader(icon: "list.dash", title: "事件")
  759. let sorted = playbackVM.sortedEvents(from: session)
  760. if sorted.isEmpty {
  761. HStack {
  762. Spacer()
  763. VStack(spacing: 8) {
  764. Image(systemName: "tray")
  765. .font(.system(size: 24, weight: .light))
  766. .foregroundStyle(Color.secondary.opacity(0.3))
  767. Text("暂无会话事件")
  768. .font(.system(size: 12))
  769. .foregroundStyle(Color.secondary.opacity(0.5))
  770. }
  771. .padding(.vertical, 24)
  772. Spacer()
  773. }
  774. } else {
  775. LazyVStack(spacing: 8) {
  776. ForEach(sorted) { event in
  777. ChronoFeedRow(event: event) {
  778. playbackVM.seekTo(timeMs: event.relativeTimeMs)
  779. if event.eventType == "PHOTO"
  780. || event.eventType == "NOTE"
  781. || event.eventType == "MARKER" {
  782. selectedTimelineEvent = event
  783. }
  784. HapticManager.trigger(.tapFeedback)
  785. }
  786. }
  787. }
  788. }
  789. }
  790. }
  791. // MARK: - Helpers
  792. private func sectionHeader(icon: String, title: String) -> some View {
  793. HStack(spacing: 6) {
  794. Image(systemName: icon)
  795. .font(.system(size: 12, weight: .light))
  796. .foregroundStyle(Color.secondary)
  797. Text(title)
  798. .font(.system(size: 13, weight: .semibold))
  799. .foregroundStyle(Color.primary.opacity(0.8))
  800. }
  801. }
  802. private var totalTimeFormatted: String {
  803. let totalSeconds = Int(playbackVM.totalDurationMs / 1000)
  804. let hours = totalSeconds / 3600
  805. let minutes = (totalSeconds % 3600) / 60
  806. let seconds = totalSeconds % 60
  807. if hours > 0 {
  808. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  809. }
  810. return String(format: "%02d:%02d", minutes, seconds)
  811. }
  812. // MARK: - Timeline Editing
  813. private var timelineNoteEditor: some View {
  814. NavigationStack {
  815. ZStack {
  816. Color.spaceBlack.ignoresSafeArea()
  817. VStack(alignment: .leading, spacing: 16) {
  818. Label(
  819. "添加到 \(formattedTimelineTime(pendingTimelineTimeMs))",
  820. systemImage: "clock"
  821. )
  822. .font(.system(size: 12, weight: .medium))
  823. .foregroundStyle(Color.secondary)
  824. ZStack(alignment: .topLeading) {
  825. if noteText.isEmpty {
  826. Text("输入笔记内容…")
  827. .font(.system(size: 15))
  828. .foregroundStyle(Color.secondary.opacity(0.6))
  829. .padding(.horizontal, 18)
  830. .padding(.vertical, 16)
  831. .allowsHitTesting(false)
  832. }
  833. TextEditor(text: $noteText)
  834. .font(.system(size: 15))
  835. .foregroundStyle(Color.primary)
  836. .scrollContentBackground(.hidden)
  837. .padding(10)
  838. .frame(minHeight: 160)
  839. .background(Color.cardBackground.opacity(0.5))
  840. .businessBorder(cornerRadius: 8)
  841. }
  842. Spacer()
  843. }
  844. .padding(20)
  845. }
  846. .navigationTitle("添加笔记")
  847. .navigationBarTitleDisplayMode(.inline)
  848. .toolbar {
  849. ToolbarItem(placement: .cancellationAction) {
  850. Button("取消") {
  851. noteText = ""
  852. showNoteEditor = false
  853. }
  854. }
  855. ToolbarItem(placement: .confirmationAction) {
  856. Button("保存") {
  857. addNote(at: pendingTimelineTimeMs)
  858. }
  859. .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
  860. }
  861. }
  862. }
  863. .presentationDetents([.medium])
  864. .presentationDragIndicator(.visible)
  865. .presentationBackground(Color.spaceBlack)
  866. }
  867. private func handleEmptyTrackTap(
  868. _ track: MultiTrackTimeline.EditableTrack,
  869. timeMs: Double
  870. ) {
  871. pendingTimelineTimeMs = min(max(timeMs, 0), playbackVM.totalDurationMs)
  872. playbackVM.seekTo(timeMs: pendingTimelineTimeMs)
  873. HapticManager.trigger(.tapFeedback)
  874. switch track {
  875. case .photo:
  876. showPhotoActionDialog = true
  877. case .note:
  878. noteText = ""
  879. showNoteEditor = true
  880. }
  881. }
  882. private func addNote(at timeMs: Double) {
  883. let trimmedText = noteText.trimmingCharacters(in: .whitespacesAndNewlines)
  884. guard !trimmedText.isEmpty else { return }
  885. let event = CelestiaTimelineEvent(
  886. relativeTimeMs: Int64(timeMs.rounded()),
  887. eventType: "NOTE"
  888. )
  889. event.textContent = trimmedText
  890. session.events.append(event)
  891. do {
  892. try saveTimelineChanges()
  893. noteText = ""
  894. showNoteEditor = false
  895. } catch {
  896. modelContext.rollback()
  897. timelineEditError = "笔记保存失败:\(error.localizedDescription)"
  898. }
  899. }
  900. private func addPhoto(_ image: UIImage, at timeMs: Double) {
  901. guard let data = image.jpegData(compressionQuality: 0.85),
  902. let documentsURL = FileManager.default.urls(
  903. for: .documentDirectory,
  904. in: .userDomainMask
  905. ).first else {
  906. timelineEditError = "无法读取所选图片。"
  907. return
  908. }
  909. let filename = "photo_\(UUID().uuidString).jpg"
  910. let fileURL = documentsURL.appendingPathComponent(filename)
  911. do {
  912. try data.write(to: fileURL, options: .atomic)
  913. let event = CelestiaTimelineEvent(
  914. relativeTimeMs: Int64(timeMs.rounded()),
  915. eventType: "PHOTO"
  916. )
  917. event.localFilePath = filename
  918. session.events.append(event)
  919. do {
  920. try saveTimelineChanges()
  921. } catch {
  922. modelContext.rollback()
  923. try? FileManager.default.removeItem(at: fileURL)
  924. throw error
  925. }
  926. } catch {
  927. timelineEditError = "图片保存失败:\(error.localizedDescription)"
  928. }
  929. }
  930. private func deleteTimelineEvent(_ event: CelestiaTimelineEvent) {
  931. let photoURL = event.eventType == "PHOTO"
  932. ? AudioPathHelper.resolveURL(for: event.localFilePath)
  933. : nil
  934. modelContext.delete(event)
  935. do {
  936. try saveTimelineChanges()
  937. if let photoURL {
  938. try? FileManager.default.removeItem(at: photoURL)
  939. }
  940. selectedTimelineEvent = nil
  941. } catch {
  942. modelContext.rollback()
  943. timelineEditError = "删除失败:\(error.localizedDescription)"
  944. }
  945. }
  946. private func saveTimelineChanges() throws {
  947. session.markContentModified()
  948. try modelContext.save()
  949. scheduleAutoSync()
  950. }
  951. private func formattedTimelineTime(_ timeMs: Double) -> String {
  952. let totalSeconds = max(0, Int(timeMs / 1_000))
  953. let hours = totalSeconds / 3_600
  954. let minutes = (totalSeconds % 3_600) / 60
  955. let seconds = totalSeconds % 60
  956. if hours > 0 {
  957. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  958. }
  959. return String(format: "%02d:%02d", minutes, seconds)
  960. }
  961. }
  962. private struct TimelineEventDetailSheet: View {
  963. @Environment(\.dismiss) private var dismiss
  964. let event: CelestiaTimelineEvent
  965. let onDelete: () -> Void
  966. @State private var showDeleteConfirmation = false
  967. var body: some View {
  968. NavigationStack {
  969. ZStack {
  970. Color.spaceBlack.ignoresSafeArea()
  971. ScrollView {
  972. VStack(alignment: .leading, spacing: 16) {
  973. Label(event.relativeTimeFormatted, systemImage: "clock")
  974. .font(.system(size: 12, weight: .medium, design: .monospaced))
  975. .foregroundStyle(Color.secondary)
  976. eventContent
  977. Button(role: .destructive) {
  978. showDeleteConfirmation = true
  979. } label: {
  980. Label("删除这条记录", systemImage: "trash")
  981. .font(.system(size: 14, weight: .medium))
  982. .frame(maxWidth: .infinity)
  983. .padding(.vertical, 12)
  984. }
  985. .buttonStyle(.bordered)
  986. .tint(.red)
  987. }
  988. .padding(20)
  989. }
  990. }
  991. .navigationTitle(detailTitle)
  992. .navigationBarTitleDisplayMode(.inline)
  993. .toolbar {
  994. ToolbarItem(placement: .confirmationAction) {
  995. Button("完成") {
  996. dismiss()
  997. }
  998. }
  999. }
  1000. .confirmationDialog(
  1001. "确定删除这条记录吗?",
  1002. isPresented: $showDeleteConfirmation,
  1003. titleVisibility: .visible
  1004. ) {
  1005. Button("删除", role: .destructive) {
  1006. onDelete()
  1007. dismiss()
  1008. }
  1009. Button("取消", role: .cancel) {}
  1010. } message: {
  1011. Text(event.eventType == "PHOTO" ? "本地图片文件也会被删除,此操作无法撤销。" : "此操作无法撤销。")
  1012. }
  1013. }
  1014. .presentationDetents(event.eventType == "PHOTO" ? [.medium, .large] : [.medium])
  1015. .presentationDragIndicator(.visible)
  1016. .presentationBackground(Color.spaceBlack)
  1017. }
  1018. @ViewBuilder
  1019. private var eventContent: some View {
  1020. if event.eventType == "PHOTO" {
  1021. if let url = AudioPathHelper.resolveURL(for: event.localFilePath),
  1022. let image = UIImage(contentsOfFile: url.path) {
  1023. Image(uiImage: image)
  1024. .resizable()
  1025. .scaledToFit()
  1026. .frame(maxWidth: .infinity)
  1027. .clipShape(RoundedRectangle(cornerRadius: 10))
  1028. .overlay {
  1029. RoundedRectangle(cornerRadius: 10)
  1030. .stroke(Color.lineBorder, lineWidth: 1)
  1031. }
  1032. } else {
  1033. ContentUnavailableView(
  1034. "图片不可用",
  1035. systemImage: "photo.badge.exclamationmark",
  1036. description: Text("本地图片文件可能已被移动或删除。")
  1037. )
  1038. .frame(maxWidth: .infinity, minHeight: 180)
  1039. }
  1040. } else {
  1041. Text(event.textContent ?? "暂无笔记内容")
  1042. .font(.system(size: 15))
  1043. .foregroundStyle(Color.primary)
  1044. .frame(maxWidth: .infinity, alignment: .leading)
  1045. .padding(16)
  1046. .background(Color.cardBackground.opacity(0.5))
  1047. .businessBorder(cornerRadius: 8)
  1048. }
  1049. }
  1050. private var detailTitle: String {
  1051. if event.isContinuationMarker {
  1052. return "续录详情"
  1053. }
  1054. return event.eventType == "PHOTO" ? "查看图片" : "查看笔记"
  1055. }
  1056. }
  1057. private struct AudioShareItem: Identifiable {
  1058. let id = UUID()
  1059. let fileURL: URL
  1060. }
  1061. private struct AudioShareSheet: UIViewControllerRepresentable {
  1062. let fileURL: URL
  1063. func makeUIViewController(context: Context) -> UIActivityViewController {
  1064. UIActivityViewController(activityItems: [fileURL], applicationActivities: nil)
  1065. }
  1066. func updateUIViewController(
  1067. _ uiViewController: UIActivityViewController,
  1068. context: Context
  1069. ) {}
  1070. }
  1071. // MARK: - Preview
  1072. #Preview {
  1073. NavigationStack {
  1074. SessionDetailView(session: {
  1075. let s = CelestiaSession(title: "Preview 现场记录 · 6.24 14:30")
  1076. s.endTime = Date().addingTimeInterval(3600)
  1077. return s
  1078. }())
  1079. }
  1080. .modelContainer(for: CelestiaSession.self, inMemory: true)
  1081. }