SessionDetailView.swift 66 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876
  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 noteLocation: TimelineLocation?
  32. @State private var showNoteEditor = false
  33. @State private var showPhotoEditor = false
  34. @State private var selectedTimelineEvent: CelestiaTimelineEvent?
  35. @State private var selectedPhotoPoint: PhotoPointSelection?
  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: $showPhotoEditor) {
  145. PhotoRecordEditorSheet(
  146. timeLabel: formattedTimelineTime(pendingTimelineTimeMs)
  147. ) { drafts, location in
  148. addPhotos(
  149. drafts,
  150. location: location,
  151. at: pendingTimelineTimeMs
  152. )
  153. }
  154. }
  155. .sheet(item: $selectedTimelineEvent) { event in
  156. TimelineEventDetailSheet(
  157. event: event,
  158. onSaveNote: { updatedText, updatedLocation in
  159. updateTimelineNote(
  160. event,
  161. text: updatedText,
  162. location: updatedLocation
  163. )
  164. },
  165. onDelete: {
  166. deleteTimelineEvent(event)
  167. }
  168. )
  169. }
  170. .sheet(item: $selectedPhotoPoint) { selection in
  171. PhotoPointDetailSheet(
  172. events: photoEvents(at: selection.relativeTimeMs),
  173. onSaveEdits: updatePhotoEdits,
  174. onDelete: deleteTimelineEvent
  175. )
  176. }
  177. .sheet(isPresented: $showSyncAuthPrompt) {
  178. SyncAuthPromptModal {
  179. showSyncAuthPrompt = false
  180. showAuthModal = true
  181. }
  182. }
  183. .sheet(isPresented: $showAuthModal) {
  184. AuthModalView()
  185. }
  186. .alert("同步较大文件", isPresented: $showLargeSyncConfirmation) {
  187. Button("开始同步") {
  188. hasApprovedLargeTransfer = true
  189. performSessionSync()
  190. }
  191. Button("取消", role: .cancel) {}
  192. } message: {
  193. Text(largeTransferMessage)
  194. }
  195. .onAppear {
  196. playbackVM.totalDurationMs = Double(session.durationMs)
  197. playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
  198. playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
  199. if session.needsCloudSync {
  200. scheduleAutoSync()
  201. }
  202. }
  203. .onChange(of: session.syncStateRaw) { _, newValue in
  204. guard let state = SessionSyncState(rawValue: newValue),
  205. state == .pending || state == .localOnly,
  206. session.needsCloudSync else { return }
  207. scheduleAutoSync()
  208. }
  209. }
  210. private var detailHeader: some View {
  211. HStack(spacing: 12) {
  212. Button {
  213. recordName = session.title
  214. showRecordNameEditor = true
  215. } label: {
  216. Text(session.title)
  217. .font(.system(size: 22, weight: .semibold))
  218. .lineLimit(1)
  219. .minimumScaleFactor(0.75)
  220. .foregroundStyle(Color.primary)
  221. .contentShape(Rectangle())
  222. }
  223. .buttonStyle(.plain)
  224. .accessibilityLabel("编辑记录名称")
  225. .layoutPriority(1)
  226. Spacer(minLength: 0)
  227. compactSyncButton
  228. .fixedSize()
  229. }
  230. .frame(maxWidth: .infinity, alignment: .leading)
  231. }
  232. private func prepareContinuation() {
  233. playbackVM.pause()
  234. detector.checkEnvironment()
  235. if let firstWarning = detector.activeWarnings.first {
  236. activeWarningMsg = firstWarning
  237. showPrepAlert = true
  238. } else {
  239. chooseSourceAndContinue()
  240. }
  241. }
  242. private func chooseSourceAndContinue() {
  243. if connectedSparkDevices.isEmpty {
  244. selectedRecordingSource = .iPhone
  245. navigateToRecording = true
  246. } else {
  247. showRecordingSourcePicker = true
  248. }
  249. }
  250. private func saveRecordName() {
  251. let trimmedName = recordName.trimmingCharacters(in: .whitespacesAndNewlines)
  252. guard !trimmedName.isEmpty else { return }
  253. recordName = trimmedName
  254. if session.title != trimmedName {
  255. session.title = trimmedName
  256. session.markContentModified()
  257. try? modelContext.save()
  258. scheduleAutoSync()
  259. }
  260. showRecordNameEditor = false
  261. }
  262. private func prepareAudioForSharing() {
  263. guard let sourceURL = AudioPathHelper.resolveURL(for: session.localAudioPath) else {
  264. showShareUnavailableAlert = true
  265. return
  266. }
  267. let shareDirectory = FileManager.default.temporaryDirectory
  268. .appendingPathComponent("CelestiaTraceShare", isDirectory: true)
  269. .appendingPathComponent(UUID().uuidString, isDirectory: true)
  270. let fileExtension = sourceURL.pathExtension
  271. let fileName = sanitizedShareFileName(from: session.title)
  272. let destinationURL = shareDirectory
  273. .appendingPathComponent(fileName)
  274. .appendingPathExtension(fileExtension)
  275. do {
  276. try FileManager.default.createDirectory(
  277. at: shareDirectory,
  278. withIntermediateDirectories: true
  279. )
  280. try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
  281. preparedShareDirectoryURL = shareDirectory
  282. audioShareItem = AudioShareItem(fileURL: destinationURL)
  283. } catch {
  284. audioShareItem = nil
  285. preparedShareDirectoryURL = nil
  286. showShareUnavailableAlert = true
  287. }
  288. }
  289. private func sanitizedShareFileName(from title: String) -> String {
  290. let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
  291. .union(.controlCharacters)
  292. .union(.newlines)
  293. let components = title.components(separatedBy: invalidCharacters)
  294. let sanitized = components
  295. .joined(separator: "-")
  296. .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines.union(
  297. CharacterSet(charactersIn: ".")
  298. ))
  299. return sanitized.isEmpty ? "现场记录" : sanitized
  300. }
  301. private func cleanupSharedAudioCopy() {
  302. guard let preparedShareDirectoryURL else { return }
  303. try? FileManager.default.removeItem(at: preparedShareDirectoryURL)
  304. self.preparedShareDirectoryURL = nil
  305. }
  306. private var connectedSparkDevices: [BoundDevice] {
  307. guard let userID = authManager.currentUser?.id else { return [] }
  308. return bleManager.connectedDevices(forUserId: userID)
  309. .sorted { $0.boundAt < $1.boundAt }
  310. }
  311. // MARK: - Session Info Card
  312. private var sessionInfoCard: some View {
  313. VStack(spacing: 14) {
  314. // Date range
  315. HStack {
  316. VStack(alignment: .leading, spacing: 4) {
  317. Label {
  318. Text("开始")
  319. .font(.system(size: 10, weight: .medium))
  320. .foregroundStyle(Color.secondary)
  321. } icon: {
  322. Image(systemName: "play.circle")
  323. .font(.system(size: 11))
  324. .foregroundStyle(Color.secondary)
  325. }
  326. VStack(alignment: .leading, spacing: 1) {
  327. Text(Self.chineseDateFormatter.string(from: session.startTime))
  328. Text(Self.chineseTimeFormatter.string(from: session.startTime))
  329. }
  330. .font(.system(size: 13, weight: .medium))
  331. .foregroundStyle(Color.primary)
  332. }
  333. Spacer()
  334. VStack(alignment: .trailing, spacing: 4) {
  335. Label {
  336. Text("结束")
  337. .font(.system(size: 10, weight: .medium))
  338. .foregroundStyle(Color.secondary)
  339. } icon: {
  340. Image(systemName: "stop.circle")
  341. .font(.system(size: 11))
  342. .foregroundStyle(Color.secondary)
  343. }
  344. if let endTime = session.endTime {
  345. VStack(alignment: .trailing, spacing: 1) {
  346. Text(Self.chineseDateFormatter.string(from: endTime))
  347. Text(Self.chineseTimeFormatter.string(from: endTime))
  348. }
  349. .font(.system(size: 13, weight: .medium))
  350. .foregroundStyle(Color.primary)
  351. } else {
  352. Text("进行中")
  353. .font(.system(size: 13, weight: .medium))
  354. .foregroundStyle(Color.primary)
  355. }
  356. }
  357. }
  358. Divider()
  359. .background(Color.lineBorder)
  360. // Stats row
  361. HStack(spacing: 0) {
  362. infoStat(
  363. icon: "timer",
  364. label: "总时长",
  365. value: session.durationFormatted
  366. )
  367. infoStat(
  368. icon: "camera",
  369. label: "图片",
  370. value: "\(session.photoCount)"
  371. )
  372. infoStat(
  373. icon: "doc.text",
  374. label: "笔记",
  375. value: "\(session.noteCount)"
  376. )
  377. }
  378. Divider()
  379. .background(Color.lineBorder)
  380. HStack(spacing: 8) {
  381. Label {
  382. Text("版本号")
  383. .font(.system(size: 11, weight: .medium))
  384. } icon: {
  385. Image(systemName: "number")
  386. .font(.system(size: 11, weight: .medium))
  387. }
  388. .foregroundStyle(Color.secondary)
  389. Spacer()
  390. Text("v\(session.localRevision)")
  391. .font(.system(size: 12, weight: .semibold, design: .monospaced))
  392. .foregroundStyle(Color.primary)
  393. }
  394. Divider()
  395. .background(Color.lineBorder)
  396. Button {
  397. showSessionInfo = false
  398. DispatchQueue.main.async {
  399. prepareContinuation()
  400. }
  401. } label: {
  402. HStack(spacing: 8) {
  403. Image(systemName: "record.circle")
  404. .font(.system(size: 14, weight: .medium))
  405. Text("续录")
  406. .font(.system(size: 13, weight: .semibold))
  407. }
  408. .foregroundStyle(Color.primary)
  409. .frame(maxWidth: .infinity)
  410. .padding(.vertical, 10)
  411. .background(Color.cardBackground.opacity(0.2))
  412. .businessBorder(cornerRadius: 8)
  413. }
  414. .buttonStyle(.plain)
  415. .accessibilityLabel("继续现场记录")
  416. }
  417. .padding(14)
  418. .background(Color.cardBackground.opacity(0.15))
  419. .businessBorder(cornerRadius: 10)
  420. }
  421. private static let chineseDateFormatter: DateFormatter = {
  422. let formatter = DateFormatter()
  423. formatter.locale = Locale(identifier: "zh_CN")
  424. formatter.calendar = Calendar(identifier: .gregorian)
  425. formatter.dateFormat = "yyyy年M月d日"
  426. return formatter
  427. }()
  428. private static let chineseTimeFormatter: DateFormatter = {
  429. let formatter = DateFormatter()
  430. formatter.locale = Locale(identifier: "zh_CN")
  431. formatter.calendar = Calendar(identifier: .gregorian)
  432. formatter.dateFormat = "HH:mm:ss"
  433. return formatter
  434. }()
  435. private var compactSyncButton: some View {
  436. VStack(spacing: 3) {
  437. Button {
  438. if isThisSessionSyncing {
  439. syncManager.pauseSync(sessionID: session.id)
  440. } else if isDisplayedSynced {
  441. showSyncInfo = true
  442. } else {
  443. requestSessionSync(isAutomatic: false)
  444. }
  445. } label: {
  446. Group {
  447. if isDisplayedSyncing {
  448. ZStack {
  449. Circle()
  450. .stroke(Color.secondary.opacity(0.22), lineWidth: 2)
  451. Circle()
  452. .trim(from: 0, to: max(sessionSyncProgress, 0.04))
  453. .stroke(
  454. Color.lessCosmosGold,
  455. style: StrokeStyle(lineWidth: 2, lineCap: .round)
  456. )
  457. .rotationEffect(.degrees(-90))
  458. TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
  459. Image(systemName: "arrow.triangle.2.circlepath")
  460. .font(.system(size: 9, weight: .semibold))
  461. .foregroundStyle(Color.lessCosmosGold)
  462. .rotationEffect(syncRotation(at: context.date))
  463. }
  464. }
  465. } else if isDisplayedSynced {
  466. Image(systemName: "checkmark.icloud.fill")
  467. .symbolRenderingMode(.hierarchical)
  468. .foregroundStyle(Color.lessCosmosGold)
  469. } else {
  470. Image(systemName: "icloud.and.arrow.up")
  471. .symbolRenderingMode(.hierarchical)
  472. .foregroundStyle(Color.secondary)
  473. }
  474. }
  475. .font(.system(size: 17, weight: .medium))
  476. .frame(width: 32, height: 32)
  477. .contentShape(Rectangle())
  478. }
  479. .buttonStyle(.plain)
  480. .disabled(syncManager.isSyncing && !isThisSessionSyncing)
  481. .accessibilityLabel(isThisSessionSyncing ? "暂停同步" : "同步记录")
  482. .accessibilityValue(
  483. isDisplayedSyncing
  484. ? "\(syncStatusText),\(Int((sessionSyncProgress * 100).rounded()))%"
  485. : syncStatusText
  486. )
  487. .popover(isPresented: $showSyncInfo, arrowEdge: .top) {
  488. syncInfoCard
  489. .padding(16)
  490. .frame(idealWidth: 320)
  491. .presentationCompactAdaptation(.popover)
  492. }
  493. Text(syncStatusText)
  494. .font(.system(size: 9, weight: .medium))
  495. .foregroundStyle(syncStatusColor)
  496. }
  497. }
  498. private var syncInfoCard: some View {
  499. VStack(alignment: .leading, spacing: 12) {
  500. HStack(spacing: 8) {
  501. Image(systemName: "checkmark.icloud.fill")
  502. .foregroundStyle(Color.lessCosmosGold)
  503. Text("同步信息")
  504. .font(.system(size: 15, weight: .semibold))
  505. .foregroundStyle(Color.primary)
  506. }
  507. Divider()
  508. .background(Color.lineBorder)
  509. syncInfoRow(
  510. label: "同步时间",
  511. value: session.lastSyncedAt.map {
  512. Self.syncDateTimeFormatter.string(from: $0)
  513. } ?? "现场记录未保存"
  514. )
  515. syncInfoRow(
  516. label: "云端版本",
  517. value: "v\(max(session.serverRevision, 1))",
  518. monospaced: true
  519. )
  520. if let cloudSessionID = session.cloudSessionId {
  521. syncInfoRow(
  522. label: "云端记录",
  523. value: cloudSessionID,
  524. monospaced: true
  525. )
  526. }
  527. }
  528. .padding(14)
  529. .background(Color.cardBackground.opacity(0.15))
  530. .businessBorder(cornerRadius: 10)
  531. }
  532. private func syncInfoRow(
  533. label: String,
  534. value: String,
  535. monospaced: Bool = false
  536. ) -> some View {
  537. HStack(alignment: .firstTextBaseline, spacing: 14) {
  538. Text(label)
  539. .font(.system(size: 11, weight: .medium))
  540. .foregroundStyle(Color.secondary)
  541. Spacer(minLength: 12)
  542. Text(value)
  543. .font(.system(size: 12, weight: .medium, design: monospaced ? .monospaced : .default))
  544. .foregroundStyle(Color.primary)
  545. .multilineTextAlignment(.trailing)
  546. .lineLimit(2)
  547. .textSelection(.enabled)
  548. }
  549. }
  550. private var isThisSessionSyncing: Bool {
  551. syncManager.isSyncing && syncManager.activeSessionID == session.id
  552. }
  553. private var isDisplayedSyncing: Bool {
  554. isThisSessionSyncing
  555. }
  556. private var isDisplayedSynced: Bool {
  557. !isDisplayedSyncing && session.hasConfirmedCloudSync
  558. }
  559. private var sessionSyncProgress: Double {
  560. if isThisSessionSyncing {
  561. return min(max(syncManager.syncProgress, 0), 1)
  562. }
  563. return session.hasConfirmedCloudSync ? 1 : 0
  564. }
  565. private var syncStatusText: String {
  566. if isDisplayedSyncing { return "正在同步" }
  567. return isDisplayedSynced ? "已同步" : "未同步"
  568. }
  569. private var syncStatusColor: Color {
  570. isDisplayedSynced || isDisplayedSyncing ? Color.lessCosmosGold : Color.secondary
  571. }
  572. private func syncRotation(at date: Date) -> Angle {
  573. let cycle = date.timeIntervalSinceReferenceDate
  574. .truncatingRemainder(dividingBy: 1.2)
  575. return .degrees(cycle / 1.2 * 360)
  576. }
  577. private static let syncDateTimeFormatter: DateFormatter = {
  578. let formatter = DateFormatter()
  579. formatter.locale = Locale(identifier: "zh_CN")
  580. formatter.calendar = Calendar(identifier: .gregorian)
  581. formatter.dateFormat = "yyyy年M月d日 HH:mm:ss"
  582. return formatter
  583. }()
  584. private var largeTransferMessage: String {
  585. let bytes = syncManager.estimatedUploadBytes(for: session)
  586. let size = ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
  587. return "预计上传约 \(size),将占用约 \(size) \(networkMonitor.connectionName)流量。是否继续?"
  588. }
  589. private func requestSessionSync(isAutomatic: Bool) {
  590. guard !syncManager.isSyncing else { return }
  591. guard networkMonitor.isConnected else {
  592. if !isAutomatic {
  593. timelineEditError = "当前没有可用网络,请连接网络后重试。"
  594. }
  595. return
  596. }
  597. guard authManager.currentUser?.id != nil else {
  598. if !isAutomatic {
  599. showSyncAuthPrompt = true
  600. }
  601. return
  602. }
  603. let largeTransferThreshold: Int64 = 70 * 1_024 * 1_024
  604. if syncManager.estimatedUploadBytes(for: session) >= largeTransferThreshold,
  605. !hasApprovedLargeTransfer {
  606. showLargeSyncConfirmation = true
  607. return
  608. }
  609. performSessionSync()
  610. }
  611. private func performSessionSync() {
  612. guard let userID = authManager.currentUser?.id else { return }
  613. Task {
  614. _ = await syncManager.sync(
  615. sessions: [session],
  616. modelContext: modelContext,
  617. userID: userID
  618. )
  619. }
  620. }
  621. private func scheduleAutoSync() {
  622. syncManager.scheduleAutomaticSync(
  623. for: session,
  624. modelContext: modelContext
  625. )
  626. }
  627. private func infoStat(icon: String, label: String, value: String) -> some View {
  628. VStack(spacing: 4) {
  629. Image(systemName: icon)
  630. .font(.system(size: 14, weight: .light))
  631. .foregroundStyle(Color.secondary)
  632. Text(value)
  633. .font(.system(size: 13, weight: .medium, design: .monospaced))
  634. .foregroundStyle(Color.primary)
  635. Text(label)
  636. .font(.system(size: 9, weight: .regular))
  637. .foregroundStyle(Color.secondary.opacity(0.8))
  638. }
  639. .frame(maxWidth: .infinity)
  640. }
  641. // MARK: - Timeline Section
  642. private var timelineSection: some View {
  643. VStack(alignment: .leading, spacing: 8) {
  644. MultiTrackTimeline(
  645. events: session.events,
  646. currentTimeMs: Binding(
  647. get: { playbackVM.currentPlaybackTimeMs },
  648. set: { playbackVM.seekTo(timeMs: $0) }
  649. ),
  650. totalDurationMs: playbackVM.totalDurationMs,
  651. waveformSamples: playbackVM.waveformSamples,
  652. silentRanges: playbackVM.silentRanges,
  653. onEmptyTrackTap: handleEmptyTrackTap,
  654. onEventTap: { event in
  655. playbackVM.seekTo(timeMs: Double(event.relativeTimeMs))
  656. openTimelineEvent(event)
  657. HapticManager.trigger(.tapFeedback)
  658. }
  659. )
  660. .frame(height: 220)
  661. }
  662. }
  663. // MARK: - Playback Controls
  664. private var playbackControls: some View {
  665. VStack(spacing: 12) {
  666. // Time display
  667. HStack {
  668. Text(playbackVM.currentTimeFormatted)
  669. .font(.system(size: 12, weight: .regular, design: .monospaced))
  670. .foregroundStyle(Color.primary)
  671. Spacer()
  672. Text(totalTimeFormatted)
  673. .font(.system(size: 12, weight: .regular, design: .monospaced))
  674. .foregroundStyle(Color.secondary)
  675. }
  676. // Seek slider
  677. Slider(
  678. value: Binding(
  679. get: { playbackVM.progress },
  680. set: { newValue in
  681. playbackVM.seekTo(timeMs: newValue * playbackVM.totalDurationMs)
  682. }
  683. ),
  684. in: 0...1
  685. )
  686. .tint(Color.primary)
  687. // Play/Pause button
  688. HStack(spacing: 36) {
  689. // Rewind 10s
  690. Button {
  691. let target = max(0, playbackVM.currentPlaybackTimeMs - 10_000)
  692. playbackVM.seekTo(timeMs: target)
  693. } label: {
  694. Image(systemName: "gobackward.10")
  695. .font(.system(size: 18, weight: .light))
  696. .foregroundStyle(Color.secondary)
  697. }
  698. // Play/Pause (High-contrast minimalist button)
  699. Button {
  700. playbackVM.togglePlayback()
  701. HapticManager.trigger(.tapFeedback)
  702. } label: {
  703. ZStack {
  704. Circle()
  705. .fill(Color.primary)
  706. .frame(width: 48, height: 48)
  707. Image(systemName: playbackVM.isPlaying ? "pause.fill" : "play.fill")
  708. .font(.system(size: 16, weight: .bold))
  709. .foregroundStyle(Color.spaceBlack)
  710. }
  711. }
  712. // Forward 10s
  713. Button {
  714. let target = min(playbackVM.totalDurationMs, playbackVM.currentPlaybackTimeMs + 10_000)
  715. playbackVM.seekTo(timeMs: target)
  716. } label: {
  717. Image(systemName: "goforward.10")
  718. .font(.system(size: 18, weight: .light))
  719. .foregroundStyle(Color.secondary)
  720. }
  721. }
  722. .padding(.top, 4)
  723. Divider()
  724. .background(Color.lineBorder)
  725. .padding(.vertical, 4)
  726. HStack {
  727. Label {
  728. Text("跳过静音")
  729. .font(.system(size: 12, weight: .medium))
  730. .foregroundStyle(Color.primary)
  731. } icon: {
  732. Image(systemName: playbackVM.isSilenceSkipEnabled ? "waveform.badge.minus" : "waveform")
  733. .font(.system(size: 13))
  734. .foregroundStyle(playbackVM.isSilenceSkipEnabled ? Color.primary : Color.secondary)
  735. }
  736. Spacer()
  737. if playbackVM.isAnalyzingSilence {
  738. ProgressView()
  739. .scaleEffect(0.7)
  740. .frame(width: 16, height: 16)
  741. } else if !playbackVM.silentRanges.isEmpty {
  742. Text("检测到 \(playbackVM.silentRanges.count) 处静音")
  743. .font(.system(size: 11))
  744. .foregroundStyle(Color.secondary)
  745. }
  746. Toggle("", isOn: Binding(
  747. get: { playbackVM.isSilenceSkipEnabled },
  748. set: { playbackVM.isSilenceSkipEnabled = $0 }
  749. ))
  750. .toggleStyle(SwitchToggleStyle(tint: Color.primary))
  751. .labelsHidden()
  752. .scaleEffect(0.8)
  753. }
  754. }
  755. .padding(14)
  756. .background(Color.cardBackground.opacity(0.15))
  757. .businessBorder(cornerRadius: 10)
  758. }
  759. // MARK: - Chrono Feed
  760. private var chronoFeedSection: some View {
  761. VStack(alignment: .leading, spacing: 10) {
  762. sectionHeader(icon: "list.dash", title: "事件")
  763. let sorted = chronoFeedItems
  764. if sorted.isEmpty {
  765. HStack {
  766. Spacer()
  767. VStack(spacing: 8) {
  768. Image(systemName: "tray")
  769. .font(.system(size: 24, weight: .light))
  770. .foregroundStyle(Color.secondary.opacity(0.3))
  771. Text("暂无会话事件")
  772. .font(.system(size: 12))
  773. .foregroundStyle(Color.secondary.opacity(0.5))
  774. }
  775. .padding(.vertical, 24)
  776. Spacer()
  777. }
  778. } else {
  779. LazyVStack(spacing: 8) {
  780. ForEach(sorted) { item in
  781. ChronoFeedRow(
  782. event: item.event,
  783. photoEvents: item.photoEvents
  784. ) {
  785. playbackVM.seekTo(timeMs: item.event.relativeTimeMs)
  786. openTimelineEvent(item.event)
  787. HapticManager.trigger(.tapFeedback)
  788. }
  789. }
  790. }
  791. }
  792. }
  793. }
  794. // MARK: - Helpers
  795. private func sectionHeader(icon: String, title: String) -> some View {
  796. HStack(spacing: 6) {
  797. Image(systemName: icon)
  798. .font(.system(size: 12, weight: .light))
  799. .foregroundStyle(Color.secondary)
  800. Text(title)
  801. .font(.system(size: 13, weight: .semibold))
  802. .foregroundStyle(Color.primary.opacity(0.8))
  803. }
  804. }
  805. private var totalTimeFormatted: String {
  806. let totalSeconds = Int(playbackVM.totalDurationMs / 1000)
  807. let hours = totalSeconds / 3600
  808. let minutes = (totalSeconds % 3600) / 60
  809. let seconds = totalSeconds % 60
  810. if hours > 0 {
  811. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  812. }
  813. return String(format: "%02d:%02d", minutes, seconds)
  814. }
  815. // MARK: - Timeline Editing
  816. private var timelineNoteEditor: some View {
  817. NavigationStack {
  818. ZStack {
  819. Color.spaceBlack.ignoresSafeArea()
  820. VStack(alignment: .leading, spacing: 16) {
  821. Label(
  822. "添加到 \(formattedTimelineTime(pendingTimelineTimeMs))",
  823. systemImage: "clock"
  824. )
  825. .font(.system(size: 12, weight: .medium))
  826. .foregroundStyle(Color.secondary)
  827. ZStack(alignment: .topLeading) {
  828. if noteText.isEmpty {
  829. Text("这一刻的想法")
  830. .font(.system(size: 15))
  831. .foregroundStyle(Color.secondary.opacity(0.6))
  832. .padding(.horizontal, 18)
  833. .padding(.vertical, 16)
  834. .allowsHitTesting(false)
  835. }
  836. TextEditor(text: $noteText)
  837. .font(.system(size: 15))
  838. .foregroundStyle(Color.primary)
  839. .scrollContentBackground(.hidden)
  840. .padding(10)
  841. .frame(minHeight: 160)
  842. .background(Color.cardBackground.opacity(0.5))
  843. .businessBorder(cornerRadius: 8)
  844. }
  845. TimelineLocationButton(location: $noteLocation)
  846. Spacer()
  847. }
  848. .padding(20)
  849. }
  850. .navigationTitle("添加笔记")
  851. .navigationBarTitleDisplayMode(.inline)
  852. .toolbar {
  853. ToolbarItem(placement: .cancellationAction) {
  854. Button("取消") {
  855. noteText = ""
  856. noteLocation = nil
  857. showNoteEditor = false
  858. }
  859. }
  860. ToolbarItem(placement: .confirmationAction) {
  861. Button("保存") {
  862. addNote(at: pendingTimelineTimeMs)
  863. }
  864. .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
  865. }
  866. }
  867. }
  868. .presentationDetents([.medium])
  869. .presentationDragIndicator(.visible)
  870. .presentationBackground(Color.spaceBlack)
  871. }
  872. private func handleEmptyTrackTap(
  873. _ track: MultiTrackTimeline.EditableTrack,
  874. timeMs: Double
  875. ) {
  876. pendingTimelineTimeMs = min(max(timeMs, 0), playbackVM.totalDurationMs)
  877. playbackVM.seekTo(timeMs: pendingTimelineTimeMs)
  878. HapticManager.trigger(.tapFeedback)
  879. switch track {
  880. case .photo:
  881. showPhotoEditor = true
  882. case .note:
  883. noteText = ""
  884. noteLocation = nil
  885. showNoteEditor = true
  886. }
  887. }
  888. private func addNote(at timeMs: Double) {
  889. let trimmedText = noteText.trimmingCharacters(in: .whitespacesAndNewlines)
  890. guard !trimmedText.isEmpty else { return }
  891. let event = CelestiaTimelineEvent(
  892. relativeTimeMs: Int64(timeMs.rounded()),
  893. eventType: "NOTE"
  894. )
  895. event.textContent = trimmedText
  896. event.location = noteLocation
  897. session.events.append(event)
  898. do {
  899. try saveTimelineChanges()
  900. noteText = ""
  901. noteLocation = nil
  902. showNoteEditor = false
  903. } catch {
  904. modelContext.rollback()
  905. timelineEditError = "笔记保存失败:\(error.localizedDescription)"
  906. }
  907. }
  908. private func addPhotos(
  909. _ drafts: [PhotoRecordDraft],
  910. location: TimelineLocation?,
  911. at timeMs: Double
  912. ) -> Bool {
  913. guard !drafts.isEmpty,
  914. let documentsURL = FileManager.default.urls(
  915. for: .documentDirectory,
  916. in: .userDomainMask
  917. ).first else {
  918. timelineEditError = "无法访问本地照片目录。"
  919. return false
  920. }
  921. var storedPhotos: [
  922. (
  923. filename: String,
  924. fileURL: URL,
  925. note: String
  926. )
  927. ] = []
  928. do {
  929. for draft in drafts {
  930. guard let data = draft.image.jpegData(compressionQuality: 0.85) else {
  931. throw CocoaError(.fileWriteUnknown)
  932. }
  933. let filename = "photo_\(UUID().uuidString).jpg"
  934. let fileURL = documentsURL.appendingPathComponent(filename)
  935. try data.write(to: fileURL, options: .atomic)
  936. storedPhotos.append(
  937. (filename, fileURL, draft.note)
  938. )
  939. }
  940. let relativeTimeMs = Int64(timeMs.rounded())
  941. for storedPhoto in storedPhotos {
  942. let event = CelestiaTimelineEvent(
  943. relativeTimeMs: relativeTimeMs,
  944. eventType: "PHOTO"
  945. )
  946. event.localFilePath = storedPhoto.filename
  947. let trimmedNote = storedPhoto.note.trimmingCharacters(in: .whitespacesAndNewlines)
  948. event.textContent = trimmedNote.isEmpty ? nil : trimmedNote
  949. event.location = location
  950. session.events.append(event)
  951. }
  952. do {
  953. try saveTimelineChanges()
  954. return true
  955. } catch {
  956. modelContext.rollback()
  957. for storedPhoto in storedPhotos {
  958. try? FileManager.default.removeItem(at: storedPhoto.fileURL)
  959. }
  960. throw error
  961. }
  962. } catch {
  963. for storedPhoto in storedPhotos {
  964. try? FileManager.default.removeItem(at: storedPhoto.fileURL)
  965. }
  966. timelineEditError = "照片保存失败:\(error.localizedDescription)"
  967. return false
  968. }
  969. }
  970. private func updatePhotoEdits(
  971. _ notes: [UUID: String],
  972. location: TimelineLocation?
  973. ) -> Bool {
  974. let photoEventsByID = Dictionary(
  975. uniqueKeysWithValues: session.events
  976. .filter { $0.eventType == "PHOTO" }
  977. .map { ($0.id, $0) }
  978. )
  979. let previousValues = notes.reduce(
  980. into: [UUID: TimelineEventEditDraft]()
  981. ) { result, item in
  982. guard let event = photoEventsByID[item.key] else { return }
  983. result[item.key] = TimelineEventEditDraft(
  984. text: event.textContent ?? "",
  985. location: event.location
  986. )
  987. let trimmedNote = item.value
  988. .trimmingCharacters(in: .whitespacesAndNewlines)
  989. event.textContent = trimmedNote.isEmpty ? nil : trimmedNote
  990. event.location = location
  991. }
  992. do {
  993. try saveTimelineChanges()
  994. return true
  995. } catch {
  996. for (eventID, previousValue) in previousValues {
  997. photoEventsByID[eventID]?.textContent = previousValue.text.isEmpty
  998. ? nil
  999. : previousValue.text
  1000. photoEventsByID[eventID]?.location = previousValue.location
  1001. }
  1002. modelContext.rollback()
  1003. timelineEditError = "照片信息保存失败:\(error.localizedDescription)"
  1004. return false
  1005. }
  1006. }
  1007. private func updateTimelineNote(
  1008. _ event: CelestiaTimelineEvent,
  1009. text: String,
  1010. location: TimelineLocation?
  1011. ) -> Bool {
  1012. let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
  1013. guard !trimmedText.isEmpty else { return false }
  1014. let previousText = event.textContent
  1015. let previousLocation = event.location
  1016. event.textContent = trimmedText
  1017. event.location = location
  1018. do {
  1019. try saveTimelineChanges()
  1020. return true
  1021. } catch {
  1022. event.textContent = previousText
  1023. event.location = previousLocation
  1024. modelContext.rollback()
  1025. timelineEditError = "笔记保存失败:\(error.localizedDescription)"
  1026. return false
  1027. }
  1028. }
  1029. private func deleteTimelineEvent(_ event: CelestiaTimelineEvent) {
  1030. let photoURL = event.eventType == "PHOTO"
  1031. ? AudioPathHelper.resolveURL(for: event.localFilePath)
  1032. : nil
  1033. modelContext.delete(event)
  1034. do {
  1035. try saveTimelineChanges()
  1036. if let photoURL {
  1037. try? FileManager.default.removeItem(at: photoURL)
  1038. }
  1039. selectedTimelineEvent = nil
  1040. selectedPhotoPoint = nil
  1041. } catch {
  1042. modelContext.rollback()
  1043. timelineEditError = "删除失败:\(error.localizedDescription)"
  1044. }
  1045. }
  1046. private func saveTimelineChanges() throws {
  1047. session.markContentModified()
  1048. try modelContext.save()
  1049. scheduleAutoSync()
  1050. }
  1051. private func formattedTimelineTime(_ timeMs: Double) -> String {
  1052. let totalSeconds = max(0, Int(timeMs / 1_000))
  1053. let hours = totalSeconds / 3_600
  1054. let minutes = (totalSeconds % 3_600) / 60
  1055. let seconds = totalSeconds % 60
  1056. if hours > 0 {
  1057. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  1058. }
  1059. return String(format: "%02d:%02d", minutes, seconds)
  1060. }
  1061. private func openTimelineEvent(_ event: CelestiaTimelineEvent) {
  1062. if event.eventType == "PHOTO" {
  1063. selectedPhotoPoint = PhotoPointSelection(
  1064. eventID: event.id,
  1065. relativeTimeMs: event.relativeTimeMs
  1066. )
  1067. } else if event.eventType == "NOTE" || event.eventType == "MARKER" {
  1068. selectedTimelineEvent = event
  1069. }
  1070. }
  1071. private func photoEvents(at relativeTimeMs: Int64) -> [CelestiaTimelineEvent] {
  1072. session.events
  1073. .filter {
  1074. $0.eventType == "PHOTO"
  1075. && $0.relativeTimeMs == relativeTimeMs
  1076. }
  1077. .sorted {
  1078. if $0.createdAt == $1.createdAt {
  1079. return $0.id.uuidString < $1.id.uuidString
  1080. }
  1081. return $0.createdAt < $1.createdAt
  1082. }
  1083. }
  1084. private var chronoFeedItems: [ChronoFeedItem] {
  1085. let sortedEvents = playbackVM.sortedEvents(from: session)
  1086. var includedPhotoTimes = Set<Int64>()
  1087. return sortedEvents.compactMap { event in
  1088. guard event.eventType == "PHOTO" else {
  1089. return ChronoFeedItem(event: event, photoEvents: [])
  1090. }
  1091. guard includedPhotoTimes.insert(event.relativeTimeMs).inserted else {
  1092. return nil
  1093. }
  1094. return ChronoFeedItem(
  1095. event: event,
  1096. photoEvents: photoEvents(at: event.relativeTimeMs)
  1097. )
  1098. }
  1099. }
  1100. }
  1101. private struct PhotoPointSelection: Identifiable {
  1102. let eventID: UUID
  1103. let relativeTimeMs: Int64
  1104. var id: UUID { eventID }
  1105. }
  1106. private struct ChronoFeedItem: Identifiable {
  1107. let event: CelestiaTimelineEvent
  1108. let photoEvents: [CelestiaTimelineEvent]
  1109. var id: UUID { event.id }
  1110. }
  1111. private struct TimelineImagePresentation: Identifiable {
  1112. let id = UUID()
  1113. let image: UIImage
  1114. let capturedAt: Date
  1115. let note: String
  1116. }
  1117. private struct TimelineEventEditDraft: Equatable {
  1118. var text: String
  1119. var location: TimelineLocation?
  1120. }
  1121. private struct PhotoPointDetailSheet: View {
  1122. @Environment(\.dismiss) private var dismiss
  1123. let events: [CelestiaTimelineEvent]
  1124. let onSaveEdits: ([UUID: String], TimelineLocation?) -> Bool
  1125. let onDelete: (CelestiaTimelineEvent) -> Void
  1126. @State private var noteDrafts: [UUID: String]
  1127. @State private var locationDraft: TimelineLocation?
  1128. @State private var pendingDeletionEvent: CelestiaTimelineEvent?
  1129. @State private var fullScreenImage: TimelineImagePresentation?
  1130. init(
  1131. events: [CelestiaTimelineEvent],
  1132. onSaveEdits: @escaping ([UUID: String], TimelineLocation?) -> Bool,
  1133. onDelete: @escaping (CelestiaTimelineEvent) -> Void
  1134. ) {
  1135. self.events = events
  1136. self.onSaveEdits = onSaveEdits
  1137. self.onDelete = onDelete
  1138. _noteDrafts = State(
  1139. initialValue: Dictionary(
  1140. uniqueKeysWithValues: events.map {
  1141. ($0.id, $0.textContent ?? "")
  1142. }
  1143. )
  1144. )
  1145. _locationDraft = State(
  1146. initialValue: events.compactMap(\.location).first
  1147. )
  1148. }
  1149. var body: some View {
  1150. NavigationStack {
  1151. ZStack {
  1152. Color.spaceBlack.ignoresSafeArea()
  1153. if events.isEmpty {
  1154. ContentUnavailableView(
  1155. "照片不可用",
  1156. systemImage: "photo.badge.exclamationmark",
  1157. description: Text("这个记录点没有可查看的本地照片。")
  1158. )
  1159. } else {
  1160. ScrollView {
  1161. VStack(alignment: .leading, spacing: 16) {
  1162. HStack {
  1163. Label(
  1164. events[0].relativeTimeFormatted,
  1165. systemImage: "clock"
  1166. )
  1167. .font(.system(size: 12, weight: .medium, design: .monospaced))
  1168. .foregroundStyle(Color.secondary)
  1169. Spacer()
  1170. Text("\(events.count) 张照片")
  1171. .font(.system(size: 12, design: .monospaced))
  1172. .foregroundStyle(Color.secondary)
  1173. }
  1174. TimelineLocationButton(location: $locationDraft)
  1175. LazyVStack(spacing: 28) {
  1176. ForEach(Array(events.enumerated()), id: \.element.id) { index, event in
  1177. photoListItem(event, index: index)
  1178. }
  1179. }
  1180. }
  1181. .padding(20)
  1182. }
  1183. }
  1184. }
  1185. .navigationTitle(events.count > 1 ? "查看照片(\(events.count) 张)" : "查看照片")
  1186. .navigationBarTitleDisplayMode(.inline)
  1187. .toolbar {
  1188. ToolbarItem(placement: .cancellationAction) {
  1189. Button("完成") {
  1190. dismiss()
  1191. }
  1192. }
  1193. ToolbarItem(placement: .confirmationAction) {
  1194. Button("保存") {
  1195. if onSaveEdits(noteDrafts, locationDraft) {
  1196. dismiss()
  1197. }
  1198. }
  1199. .disabled(!hasChanges)
  1200. }
  1201. }
  1202. .confirmationDialog(
  1203. "确定删除这张照片吗?",
  1204. isPresented: Binding(
  1205. get: { pendingDeletionEvent != nil },
  1206. set: { if !$0 { pendingDeletionEvent = nil } }
  1207. ),
  1208. titleVisibility: .visible
  1209. ) {
  1210. Button("删除", role: .destructive) {
  1211. guard let event = pendingDeletionEvent else { return }
  1212. onDelete(event)
  1213. dismiss()
  1214. }
  1215. Button("取消", role: .cancel) {}
  1216. } message: {
  1217. Text("本地图片文件和这张照片的备注都会被删除,此操作无法撤销。")
  1218. }
  1219. }
  1220. .fullScreenCover(item: $fullScreenImage) { item in
  1221. FullScreenTimelineImageView(
  1222. image: item.image,
  1223. capturedAt: item.capturedAt,
  1224. note: item.note
  1225. )
  1226. }
  1227. .presentationDetents([.large])
  1228. .presentationDragIndicator(.visible)
  1229. .presentationBackground(Color.spaceBlack)
  1230. }
  1231. private func photoListItem(
  1232. _ event: CelestiaTimelineEvent,
  1233. index: Int
  1234. ) -> some View {
  1235. VStack(alignment: .leading, spacing: 10) {
  1236. HStack {
  1237. Text("照片 \(index + 1)")
  1238. .font(.system(size: 11, weight: .medium))
  1239. .foregroundStyle(Color.secondary)
  1240. Spacer()
  1241. Button(role: .destructive) {
  1242. pendingDeletionEvent = event
  1243. } label: {
  1244. Image(systemName: "trash")
  1245. .font(.system(size: 11, weight: .medium))
  1246. .frame(width: 24, height: 24)
  1247. }
  1248. .buttonStyle(.plain)
  1249. .foregroundStyle(Color.red)
  1250. .accessibilityLabel("删除照片 \(index + 1)")
  1251. }
  1252. if let image = resolvedImage(for: event) {
  1253. Button {
  1254. fullScreenImage = TimelineImagePresentation(
  1255. image: image,
  1256. capturedAt: event.createdAt,
  1257. note: noteDrafts[event.id] ?? ""
  1258. )
  1259. } label: {
  1260. Image(uiImage: image)
  1261. .resizable()
  1262. .scaledToFit()
  1263. .frame(maxWidth: .infinity, maxHeight: 420)
  1264. .contentShape(Rectangle())
  1265. }
  1266. .buttonStyle(.plain)
  1267. .accessibilityLabel("全屏查看照片 \(index + 1)")
  1268. } else {
  1269. ContentUnavailableView(
  1270. "图片不可用",
  1271. systemImage: "photo.badge.exclamationmark",
  1272. description: Text("本地图片文件可能已被移动或删除。")
  1273. )
  1274. .frame(maxWidth: .infinity, minHeight: 180)
  1275. }
  1276. ZStack(alignment: .topLeading) {
  1277. if (noteDrafts[event.id] ?? "").isEmpty {
  1278. Text("照片备注")
  1279. .font(.system(size: 14))
  1280. .foregroundStyle(Color.secondary.opacity(0.55))
  1281. .padding(.horizontal, 15)
  1282. .padding(.vertical, 17)
  1283. .allowsHitTesting(false)
  1284. }
  1285. TextEditor(text: noteBinding(for: event))
  1286. .font(.system(size: 14))
  1287. .foregroundStyle(Color.primary)
  1288. .scrollContentBackground(.hidden)
  1289. .padding(8)
  1290. .frame(height: 72)
  1291. }
  1292. .background(Color.cardBackground.opacity(0.45))
  1293. .businessBorder(cornerRadius: 8)
  1294. if index < events.count - 1 {
  1295. Divider()
  1296. .overlay(Color.lineBorder.opacity(0.55))
  1297. .padding(.top, 8)
  1298. }
  1299. }
  1300. }
  1301. private func noteBinding(for event: CelestiaTimelineEvent) -> Binding<String> {
  1302. Binding(
  1303. get: { noteDrafts[event.id] ?? "" },
  1304. set: { newValue in
  1305. noteDrafts[event.id] = newValue
  1306. }
  1307. )
  1308. }
  1309. private var hasChanges: Bool {
  1310. events.contains { event in
  1311. let original = (event.textContent ?? "")
  1312. .trimmingCharacters(in: .whitespacesAndNewlines)
  1313. let draft = (noteDrafts[event.id] ?? "")
  1314. .trimmingCharacters(in: .whitespacesAndNewlines)
  1315. return original != draft || event.location != locationDraft
  1316. }
  1317. }
  1318. private func resolvedImage(for event: CelestiaTimelineEvent) -> UIImage? {
  1319. guard let url = AudioPathHelper.resolveURL(for: event.localFilePath) else {
  1320. return nil
  1321. }
  1322. return UIImage(contentsOfFile: url.path)
  1323. }
  1324. }
  1325. private struct TimelineEventDetailSheet: View {
  1326. @Environment(\.dismiss) private var dismiss
  1327. let event: CelestiaTimelineEvent
  1328. let onSaveNote: (String, TimelineLocation?) -> Bool
  1329. let onDelete: () -> Void
  1330. @State private var noteDraft: String
  1331. @State private var locationDraft: TimelineLocation?
  1332. @State private var showDeleteConfirmation = false
  1333. @State private var showFullScreenImage = false
  1334. init(
  1335. event: CelestiaTimelineEvent,
  1336. onSaveNote: @escaping (String, TimelineLocation?) -> Bool,
  1337. onDelete: @escaping () -> Void
  1338. ) {
  1339. self.event = event
  1340. self.onSaveNote = onSaveNote
  1341. self.onDelete = onDelete
  1342. _noteDraft = State(initialValue: event.textContent ?? "")
  1343. _locationDraft = State(initialValue: event.location)
  1344. }
  1345. var body: some View {
  1346. NavigationStack {
  1347. ZStack {
  1348. Color.spaceBlack.ignoresSafeArea()
  1349. VStack(alignment: .leading, spacing: 16) {
  1350. Label(event.relativeTimeFormatted, systemImage: "clock")
  1351. .font(.system(size: 12, weight: .medium, design: .monospaced))
  1352. .foregroundStyle(Color.secondary)
  1353. eventContent
  1354. Spacer(minLength: 0)
  1355. HStack {
  1356. Spacer()
  1357. Button(role: .destructive) {
  1358. showDeleteConfirmation = true
  1359. } label: {
  1360. Image(systemName: "trash")
  1361. .font(.system(size: 13, weight: .medium))
  1362. .frame(width: 30, height: 30)
  1363. }
  1364. .buttonStyle(.bordered)
  1365. .buttonBorderShape(.circle)
  1366. .tint(.red)
  1367. .accessibilityLabel("删除这条记录")
  1368. }
  1369. }
  1370. .padding(20)
  1371. }
  1372. .navigationTitle(detailTitle)
  1373. .navigationBarTitleDisplayMode(.inline)
  1374. .toolbar {
  1375. ToolbarItem(placement: .cancellationAction) {
  1376. Button("完成") {
  1377. dismiss()
  1378. }
  1379. }
  1380. if event.eventType == "NOTE" {
  1381. ToolbarItem(placement: .confirmationAction) {
  1382. Button("保存") {
  1383. if onSaveNote(noteDraft, locationDraft) {
  1384. dismiss()
  1385. }
  1386. }
  1387. .disabled(
  1388. trimmedNoteDraft.isEmpty
  1389. || !hasChanges
  1390. )
  1391. }
  1392. }
  1393. }
  1394. .confirmationDialog(
  1395. "确定删除这条记录吗?",
  1396. isPresented: $showDeleteConfirmation,
  1397. titleVisibility: .visible
  1398. ) {
  1399. Button("删除", role: .destructive) {
  1400. onDelete()
  1401. dismiss()
  1402. }
  1403. Button("取消", role: .cancel) {}
  1404. } message: {
  1405. Text(event.eventType == "PHOTO" ? "本地图片文件也会被删除,此操作无法撤销。" : "此操作无法撤销。")
  1406. }
  1407. }
  1408. .fullScreenCover(isPresented: $showFullScreenImage) {
  1409. if let image = resolvedImage {
  1410. FullScreenTimelineImageView(
  1411. image: image,
  1412. capturedAt: event.createdAt,
  1413. note: event.textContent ?? ""
  1414. )
  1415. }
  1416. }
  1417. .presentationDetents(event.eventType == "PHOTO" ? [.medium, .large] : [.medium])
  1418. .presentationDragIndicator(.visible)
  1419. .presentationBackground(Color.spaceBlack)
  1420. }
  1421. @ViewBuilder
  1422. private var eventContent: some View {
  1423. if event.eventType == "PHOTO" {
  1424. if let image = resolvedImage {
  1425. Button {
  1426. showFullScreenImage = true
  1427. } label: {
  1428. Image(uiImage: image)
  1429. .resizable()
  1430. .scaledToFit()
  1431. .frame(maxWidth: .infinity)
  1432. .clipShape(RoundedRectangle(cornerRadius: 10))
  1433. .contentShape(Rectangle())
  1434. }
  1435. .buttonStyle(.plain)
  1436. .accessibilityLabel("全屏查看图片")
  1437. } else {
  1438. ContentUnavailableView(
  1439. "图片不可用",
  1440. systemImage: "photo.badge.exclamationmark",
  1441. description: Text("本地图片文件可能已被移动或删除。")
  1442. )
  1443. .frame(maxWidth: .infinity, minHeight: 180)
  1444. }
  1445. } else if event.eventType == "NOTE" {
  1446. VStack(alignment: .leading, spacing: 14) {
  1447. ZStack(alignment: .topLeading) {
  1448. if noteDraft.isEmpty {
  1449. Text("这一刻的想法")
  1450. .font(.system(size: 15))
  1451. .foregroundStyle(Color.secondary.opacity(0.55))
  1452. .padding(.horizontal, 18)
  1453. .padding(.vertical, 16)
  1454. .allowsHitTesting(false)
  1455. }
  1456. TextEditor(text: $noteDraft)
  1457. .font(.system(size: 15))
  1458. .foregroundStyle(Color.primary)
  1459. .scrollContentBackground(.hidden)
  1460. .padding(10)
  1461. .frame(maxWidth: .infinity, minHeight: 150, alignment: .topLeading)
  1462. }
  1463. .background(Color.cardBackground.opacity(0.5))
  1464. .businessBorder(cornerRadius: 8)
  1465. TimelineLocationButton(location: $locationDraft)
  1466. }
  1467. } else {
  1468. Text(event.textContent ?? "暂无记录内容")
  1469. .font(.system(size: 15))
  1470. .foregroundStyle(Color.primary)
  1471. .frame(maxWidth: .infinity, alignment: .leading)
  1472. .padding(16)
  1473. .background(Color.cardBackground.opacity(0.5))
  1474. .businessBorder(cornerRadius: 8)
  1475. }
  1476. }
  1477. private var resolvedImage: UIImage? {
  1478. guard let url = AudioPathHelper.resolveURL(for: event.localFilePath) else {
  1479. return nil
  1480. }
  1481. return UIImage(contentsOfFile: url.path)
  1482. }
  1483. private var trimmedNoteDraft: String {
  1484. noteDraft.trimmingCharacters(in: .whitespacesAndNewlines)
  1485. }
  1486. private var hasChanges: Bool {
  1487. trimmedNoteDraft
  1488. != (event.textContent ?? "")
  1489. .trimmingCharacters(in: .whitespacesAndNewlines)
  1490. || locationDraft != event.location
  1491. }
  1492. private var detailTitle: String {
  1493. if event.isContinuationMarker {
  1494. return "续录详情"
  1495. }
  1496. return event.eventType == "PHOTO" ? "查看图片" : "查看笔记"
  1497. }
  1498. }
  1499. private struct FullScreenTimelineImageView: View {
  1500. @Environment(\.dismiss) private var dismiss
  1501. let image: UIImage
  1502. let capturedAt: Date
  1503. let note: String
  1504. var body: some View {
  1505. ZStack {
  1506. Color.black.ignoresSafeArea()
  1507. ZoomableTimelineImage(image: image)
  1508. .ignoresSafeArea()
  1509. VStack(spacing: 0) {
  1510. HStack {
  1511. Spacer()
  1512. Button {
  1513. dismiss()
  1514. } label: {
  1515. Image(systemName: "xmark")
  1516. .font(.system(size: 14, weight: .semibold))
  1517. .foregroundStyle(.white)
  1518. .frame(width: 34, height: 34)
  1519. .background(.black.opacity(0.55), in: Circle())
  1520. }
  1521. .accessibilityLabel("关闭全屏图片")
  1522. }
  1523. .padding(.top, 12)
  1524. .padding(.trailing, 16)
  1525. Spacer()
  1526. VStack(alignment: .leading, spacing: 6) {
  1527. Text(Self.photoDateFormatter.string(from: capturedAt))
  1528. .font(.system(size: 12, weight: .medium))
  1529. .foregroundStyle(.white.opacity(0.78))
  1530. if !trimmedNote.isEmpty {
  1531. Text(trimmedNote)
  1532. .font(.system(size: 15))
  1533. .foregroundStyle(.white)
  1534. .fixedSize(horizontal: false, vertical: true)
  1535. }
  1536. }
  1537. .frame(maxWidth: .infinity, alignment: .leading)
  1538. .padding(.horizontal, 20)
  1539. .padding(.top, 54)
  1540. .padding(.bottom, 28)
  1541. .background(
  1542. LinearGradient(
  1543. colors: [.clear, .black.opacity(0.82)],
  1544. startPoint: .top,
  1545. endPoint: .bottom
  1546. )
  1547. )
  1548. }
  1549. }
  1550. .statusBarHidden()
  1551. }
  1552. private var trimmedNote: String {
  1553. note.trimmingCharacters(in: .whitespacesAndNewlines)
  1554. }
  1555. private static let photoDateFormatter: DateFormatter = {
  1556. let formatter = DateFormatter()
  1557. formatter.locale = Locale(identifier: "zh_CN")
  1558. formatter.dateFormat = "yyyy年M月d日 HH:mm:ss"
  1559. return formatter
  1560. }()
  1561. }
  1562. private struct ZoomableTimelineImage: View {
  1563. let image: UIImage
  1564. @State private var scale: CGFloat = 1
  1565. @State private var settledScale: CGFloat = 1
  1566. @State private var offset: CGSize = .zero
  1567. @State private var settledOffset: CGSize = .zero
  1568. private let maximumScale: CGFloat = 5
  1569. var body: some View {
  1570. GeometryReader { proxy in
  1571. Image(uiImage: image)
  1572. .resizable()
  1573. .scaledToFit()
  1574. .frame(width: proxy.size.width, height: proxy.size.height)
  1575. .scaleEffect(scale)
  1576. .offset(offset)
  1577. .contentShape(Rectangle())
  1578. .gesture(magnificationGesture(in: proxy.size))
  1579. .simultaneousGesture(dragGesture(in: proxy.size))
  1580. }
  1581. .clipped()
  1582. .accessibilityLabel("全屏图片,可双指缩放并拖动")
  1583. }
  1584. private func magnificationGesture(in containerSize: CGSize) -> some Gesture {
  1585. MagnifyGesture()
  1586. .onChanged { value in
  1587. scale = min(max(settledScale * value.magnification, 1), maximumScale)
  1588. offset = clampedOffset(offset, scale: scale, in: containerSize)
  1589. }
  1590. .onEnded { _ in
  1591. if scale <= 1 {
  1592. scale = 1
  1593. offset = .zero
  1594. } else {
  1595. offset = clampedOffset(offset, scale: scale, in: containerSize)
  1596. }
  1597. settledScale = scale
  1598. settledOffset = offset
  1599. }
  1600. }
  1601. private func dragGesture(in containerSize: CGSize) -> some Gesture {
  1602. DragGesture()
  1603. .onChanged { value in
  1604. guard scale > 1 else {
  1605. offset = .zero
  1606. return
  1607. }
  1608. let proposedOffset = CGSize(
  1609. width: settledOffset.width + value.translation.width,
  1610. height: settledOffset.height + value.translation.height
  1611. )
  1612. offset = clampedOffset(proposedOffset, scale: scale, in: containerSize)
  1613. }
  1614. .onEnded { _ in
  1615. offset = clampedOffset(offset, scale: scale, in: containerSize)
  1616. settledOffset = offset
  1617. }
  1618. }
  1619. private func clampedOffset(
  1620. _ proposedOffset: CGSize,
  1621. scale: CGFloat,
  1622. in containerSize: CGSize
  1623. ) -> CGSize {
  1624. let fittedSize = aspectFitSize(for: image.size, in: containerSize)
  1625. let horizontalLimit = max(0, (fittedSize.width * scale - containerSize.width) / 2)
  1626. let verticalLimit = max(0, (fittedSize.height * scale - containerSize.height) / 2)
  1627. return CGSize(
  1628. width: min(max(proposedOffset.width, -horizontalLimit), horizontalLimit),
  1629. height: min(max(proposedOffset.height, -verticalLimit), verticalLimit)
  1630. )
  1631. }
  1632. private func aspectFitSize(for imageSize: CGSize, in containerSize: CGSize) -> CGSize {
  1633. guard imageSize.width > 0,
  1634. imageSize.height > 0,
  1635. containerSize.width > 0,
  1636. containerSize.height > 0 else {
  1637. return .zero
  1638. }
  1639. let fitScale = min(
  1640. containerSize.width / imageSize.width,
  1641. containerSize.height / imageSize.height
  1642. )
  1643. return CGSize(
  1644. width: imageSize.width * fitScale,
  1645. height: imageSize.height * fitScale
  1646. )
  1647. }
  1648. }
  1649. private struct AudioShareItem: Identifiable {
  1650. let id = UUID()
  1651. let fileURL: URL
  1652. }
  1653. private struct AudioShareSheet: UIViewControllerRepresentable {
  1654. let fileURL: URL
  1655. func makeUIViewController(context: Context) -> UIActivityViewController {
  1656. UIActivityViewController(activityItems: [fileURL], applicationActivities: nil)
  1657. }
  1658. func updateUIViewController(
  1659. _ uiViewController: UIActivityViewController,
  1660. context: Context
  1661. ) {}
  1662. }
  1663. // MARK: - Preview
  1664. #Preview {
  1665. NavigationStack {
  1666. SessionDetailView(session: {
  1667. let s = CelestiaSession(title: "Preview 现场记录 · 6.24 14:30")
  1668. s.endTime = Date().addingTimeInterval(3600)
  1669. return s
  1670. }())
  1671. }
  1672. .modelContainer(for: CelestiaSession.self, inMemory: true)
  1673. }