| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991 |
- import SwiftUI
- import SwiftData
- import UIKit
- // MARK: - SessionDetailView
- /// The session detail & playback screen.
- /// Shows session info, multi-track timeline, playback controls,
- /// and a chronological feed of all events.
- /// Redesigned to use a minimalist wireframe business style.
- struct SessionDetailView: View {
- @Environment(\.modelContext) private var modelContext
- @Environment(\.dismiss) private var dismiss
- @ObservedObject private var bleManager: BLEManager = .shared
- @ObservedObject private var authManager: AuthManager = .shared
- @ObservedObject private var syncManager: SyncManager = .shared
- @ObservedObject private var networkMonitor: NetworkStatusMonitor = .shared
- let session: CelestiaSession
- @State private var playbackVM = PlaybackViewModel()
- @State private var navigateToRecording = false
- @State private var detector = RecordingEnvironmentDetector()
- @State private var showPrepAlert = false
- @State private var activeWarningMsg = ""
- @State private var showRecordingSourcePicker = false
- @State private var selectedRecordingSource: RecordingSourceChoice = .iPhone
- @State private var recordName = ""
- @State private var showRecordNameEditor = false
- @State private var showSessionInfo = false
- @State private var showShareUnavailableAlert = false
- @State private var audioShareItem: AudioShareItem?
- @State private var preparedShareDirectoryURL: URL?
- @State private var pendingTimelineTimeMs: Double = 0
- @State private var noteText = ""
- @State private var noteLocation: TimelineLocation?
- @State private var showNoteEditor = false
- @State private var showPhotoEditor = false
- @State private var selectedTimelineEvent: CelestiaTimelineEvent?
- @State private var selectedPhotoPoint: PhotoPointSelection?
- @State private var timelineEditError: String?
- @State private var showSyncAuthPrompt = false
- @State private var showAuthModal = false
- @State private var showLargeSyncConfirmation = false
- @State private var showSyncInfo = false
- @State private var hasApprovedLargeTransfer = false
- @State private var showDeleteConfirmation = false
- @State private var deletionError: String?
- @State private var isDeletingSession = false
- var body: some View {
- ZStack {
- Color.spaceBlack.ignoresSafeArea()
- ScrollView {
- VStack(spacing: 20) {
- detailHeader
- .padding(.horizontal, 16)
- .padding(.top, 12)
- // Section 1: Multi-Track Timeline
- timelineSection
- .padding(.horizontal, 16)
- // Section 2: Playback Controls
- playbackControls
- .padding(.horizontal, 16)
- // Section 3: Chrono Feed
- if !chronoFeedItems.isEmpty {
- chronoFeedSection
- .padding(.horizontal, 16)
- .padding(.bottom, 32)
- }
- }
- }
- }
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItemGroup(placement: .topBarTrailing) {
- Button {
- showSessionInfo = true
- } label: {
- Image(systemName: "info.circle")
- .font(.system(size: 16, weight: .regular))
- .foregroundStyle(Color.primary)
- }
- .accessibilityLabel("记录信息")
- .popover(isPresented: $showSessionInfo, arrowEdge: .top) {
- sessionInfoCard
- .padding(16)
- .frame(idealWidth: 360)
- .presentationCompactAdaptation(.popover)
- }
- Button {
- prepareAudioForSharing()
- } label: {
- Image(systemName: "square.and.arrow.up")
- .font(.system(size: 16, weight: .regular))
- .foregroundStyle(Color.primary)
- }
- .accessibilityLabel("分享录音")
- }
- }
- .sheet(item: $audioShareItem, onDismiss: cleanupSharedAudioCopy) { item in
- AudioShareSheet(fileURL: item.fileURL)
- }
- .alert("无法分享录音", isPresented: $showShareUnavailableAlert) {
- Button("知道了", role: .cancel) {}
- } message: {
- Text("当前记录没有可用的本地录音文件,请先完成录制或同步录音。")
- }
- .alert("确认删除?", isPresented: $showDeleteConfirmation) {
- Button("取消", role: .cancel) {}
- Button(session.cloudSessionId == nil ? "删除" : "同时删除", role: .destructive) {
- Task {
- await deleteSession()
- }
- }
- .disabled(isDeletingSession)
- } message: {
- if session.cloudSessionId != nil {
- Text("删除「\(session.title)」后,本地录音、图片以及已经同步到云端的记录都会一并删除,且无法恢复。")
- } else {
- Text("删除「\(session.title)」后,相关录音和图片也会被删除,且无法恢复。")
- }
- }
- .alert("删除失败", isPresented: Binding(
- get: { deletionError != nil },
- set: { if !$0 { deletionError = nil } }
- )) {
- Button("好", role: .cancel) {}
- } message: {
- Text(deletionError ?? "请稍后重试。")
- }
- .alert("无法完成编辑", isPresented: Binding(
- get: { timelineEditError != nil },
- set: { if !$0 { timelineEditError = nil } }
- )) {
- Button("知道了", role: .cancel) {}
- } message: {
- Text(timelineEditError ?? "请稍后重试。")
- }
- .alert("录音环境提醒", isPresented: $showPrepAlert) {
- Button("继续录制", role: .none) {
- chooseSourceAndContinue()
- }
- Button("取消", role: .cancel) {}
- } message: {
- Text(activeWarningMsg + "\n\n另外请手动确认:\n1. 手机侧边静音开关已拨至红色(静音状态)\n2. 已关闭可能在此期间响起的系统闹钟")
- }
- .fullScreenCover(isPresented: $navigateToRecording) {
- ActiveRecordingView(
- session: session,
- initialDuration: Double(session.durationMs) / 1000.0,
- recordingSource: selectedRecordingSource
- ) {
- playbackVM.totalDurationMs = Double(session.durationMs)
- playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
- playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
- scheduleAutoSync()
- }
- }
- .sheet(isPresented: $showRecordingSourcePicker) {
- RecordingSourcePickerView(devices: connectedSparkDevices) { source in
- selectedRecordingSource = source
- showRecordingSourcePicker = false
- navigateToRecording = true
- }
- }
- .sheet(isPresented: $showRecordNameEditor) {
- RecordNameEditorSheet(recordName: $recordName) {
- saveRecordName()
- } onCancel: {
- recordName = session.title
- showRecordNameEditor = false
- }
- }
- .sheet(isPresented: $showNoteEditor) {
- timelineNoteEditor
- }
- .sheet(isPresented: $showPhotoEditor) {
- PhotoRecordEditorSheet(
- timeLabel: formattedTimelineTime(pendingTimelineTimeMs)
- ) { drafts, location in
- addPhotos(
- drafts,
- location: location,
- at: pendingTimelineTimeMs
- )
- }
- }
- .sheet(item: $selectedTimelineEvent) { event in
- TimelineEventDetailSheet(
- event: event,
- onSaveNote: { updatedText, updatedLocation in
- updateTimelineNote(
- event,
- text: updatedText,
- location: updatedLocation
- )
- },
- onDelete: {
- deleteTimelineEvent(event)
- }
- )
- }
- .sheet(item: $selectedPhotoPoint) { selection in
- PhotoPointDetailSheet(
- events: photoEvents(at: selection.relativeTimeMs),
- onSaveEdits: updatePhotoEdits,
- onDelete: deleteTimelineEvent
- )
- }
- .sheet(isPresented: $showSyncAuthPrompt) {
- SyncAuthPromptModal {
- showSyncAuthPrompt = false
- showAuthModal = true
- }
- }
- .sheet(isPresented: $showAuthModal) {
- AuthModalView()
- }
- .alert("同步较大文件", isPresented: $showLargeSyncConfirmation) {
- Button("开始同步") {
- hasApprovedLargeTransfer = true
- performSessionSync()
- }
- Button("取消", role: .cancel) {}
- } message: {
- Text(largeTransferMessage)
- }
- .onAppear {
- playbackVM.totalDurationMs = Double(session.durationMs)
- playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
- playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
- if session.needsCloudSync {
- scheduleAutoSync()
- }
- }
- .onDisappear {
- playbackVM.pause()
- playbackVM.cancelAudioAnalysis()
- }
- .onChange(of: session.syncStateRaw) { _, newValue in
- guard let state = SessionSyncState(rawValue: newValue),
- state == .pending || state == .localOnly,
- session.needsCloudSync else { return }
- scheduleAutoSync()
- }
- }
- private var detailHeader: some View {
- HStack(spacing: 12) {
- Button {
- recordName = session.title
- showRecordNameEditor = true
- } label: {
- Text(session.title)
- .font(.system(size: 22, weight: .semibold))
- .lineLimit(1)
- .minimumScaleFactor(0.75)
- .foregroundStyle(Color.primary)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .accessibilityLabel("编辑记录名称")
- .layoutPriority(1)
- Spacer(minLength: 0)
- compactSyncButton
- .fixedSize()
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- private func prepareContinuation() {
- playbackVM.pause()
- detector.checkEnvironment()
- if let firstWarning = detector.activeWarnings.first {
- activeWarningMsg = firstWarning
- showPrepAlert = true
- } else {
- chooseSourceAndContinue()
- }
- }
- private func chooseSourceAndContinue() {
- if connectedSparkDevices.isEmpty {
- selectedRecordingSource = .iPhone
- navigateToRecording = true
- } else {
- showRecordingSourcePicker = true
- }
- }
- private func saveRecordName() {
- let trimmedName = recordName.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmedName.isEmpty else { return }
- recordName = trimmedName
- if session.title != trimmedName {
- session.title = trimmedName
- session.markContentModified()
- try? modelContext.save()
- scheduleAutoSync()
- }
- showRecordNameEditor = false
- }
- private func prepareAudioForSharing() {
- guard let sourceURL = AudioPathHelper.resolveURL(for: session.localAudioPath) else {
- showShareUnavailableAlert = true
- return
- }
- let shareDirectory = FileManager.default.temporaryDirectory
- .appendingPathComponent("CelestiaTraceShare", isDirectory: true)
- .appendingPathComponent(UUID().uuidString, isDirectory: true)
- let fileExtension = sourceURL.pathExtension
- let fileName = sanitizedShareFileName(from: session.title)
- let destinationURL = shareDirectory
- .appendingPathComponent(fileName)
- .appendingPathExtension(fileExtension)
- do {
- try FileManager.default.createDirectory(
- at: shareDirectory,
- withIntermediateDirectories: true
- )
- try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
- preparedShareDirectoryURL = shareDirectory
- audioShareItem = AudioShareItem(fileURL: destinationURL)
- } catch {
- audioShareItem = nil
- preparedShareDirectoryURL = nil
- showShareUnavailableAlert = true
- }
- }
- private func sanitizedShareFileName(from title: String) -> String {
- let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
- .union(.controlCharacters)
- .union(.newlines)
- let components = title.components(separatedBy: invalidCharacters)
- let sanitized = components
- .joined(separator: "-")
- .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines.union(
- CharacterSet(charactersIn: ".")
- ))
- return sanitized.isEmpty ? "现场记录" : sanitized
- }
- private func cleanupSharedAudioCopy() {
- guard let preparedShareDirectoryURL else { return }
- try? FileManager.default.removeItem(at: preparedShareDirectoryURL)
- self.preparedShareDirectoryURL = nil
- }
- private var connectedSparkDevices: [BoundDevice] {
- guard let userID = authManager.currentUser?.id else { return [] }
- return bleManager.connectedDevices(forUserId: userID)
- .sorted { $0.boundAt < $1.boundAt }
- }
- // MARK: - Session Info Card
- private var sessionInfoCard: some View {
- VStack(spacing: 14) {
- // Date range
- HStack {
- VStack(alignment: .leading, spacing: 4) {
- Label {
- Text("开始")
- .font(.system(size: 10, weight: .medium))
- .foregroundStyle(Color.secondary)
- } icon: {
- Image(systemName: "play.circle")
- .font(.system(size: 11))
- .foregroundStyle(Color.secondary)
- }
- VStack(alignment: .leading, spacing: 1) {
- Text(Self.chineseDateFormatter.string(from: session.startTime))
- Text(Self.chineseTimeFormatter.string(from: session.startTime))
- }
- .font(.system(size: 13, weight: .medium))
- .foregroundStyle(Color.primary)
- }
- Spacer()
- VStack(alignment: .trailing, spacing: 4) {
- Label {
- Text("结束")
- .font(.system(size: 10, weight: .medium))
- .foregroundStyle(Color.secondary)
- } icon: {
- Image(systemName: "stop.circle")
- .font(.system(size: 11))
- .foregroundStyle(Color.secondary)
- }
- if let endTime = session.endTime {
- VStack(alignment: .trailing, spacing: 1) {
- Text(Self.chineseDateFormatter.string(from: endTime))
- Text(Self.chineseTimeFormatter.string(from: endTime))
- }
- .font(.system(size: 13, weight: .medium))
- .foregroundStyle(Color.primary)
- } else {
- Text("进行中")
- .font(.system(size: 13, weight: .medium))
- .foregroundStyle(Color.primary)
- }
- }
- }
- Divider()
- .background(Color.lineBorder)
- // Stats row
- HStack(spacing: 0) {
- infoStat(
- icon: "timer",
- label: "总时长",
- value: session.durationFormatted
- )
- infoStat(
- icon: "camera",
- label: "图片",
- value: "\(session.photoCount)"
- )
- infoStat(
- icon: "doc.text",
- label: "笔记",
- value: "\(session.noteCount)"
- )
- }
- Divider()
- .background(Color.lineBorder)
- HStack(spacing: 8) {
- Label {
- Text("版本号")
- .font(.system(size: 11, weight: .medium))
- } icon: {
- Image(systemName: "number")
- .font(.system(size: 11, weight: .medium))
- }
- .foregroundStyle(Color.secondary)
- Spacer()
- Text("v\(session.localRevision)")
- .font(.system(size: 12, weight: .semibold, design: .monospaced))
- .foregroundStyle(Color.primary)
- }
- Divider()
- .background(Color.lineBorder)
- Button {
- showSessionInfo = false
- DispatchQueue.main.async {
- prepareContinuation()
- }
- } label: {
- HStack(spacing: 8) {
- Image(systemName: "record.circle")
- .font(.system(size: 14, weight: .medium))
- Text("续录")
- .font(.system(size: 13, weight: .semibold))
- }
- .foregroundStyle(Color.primary)
- .frame(maxWidth: .infinity)
- .padding(.vertical, 10)
- .background(Color.cardBackground.opacity(0.2))
- .businessBorder(cornerRadius: 8)
- }
- .buttonStyle(.plain)
- .accessibilityLabel("继续现场记录")
- Button(role: .destructive) {
- showSessionInfo = false
- DispatchQueue.main.async {
- showDeleteConfirmation = true
- }
- } label: {
- HStack(spacing: 8) {
- Image(systemName: "trash")
- .font(.system(size: 14, weight: .medium))
- Text("删除")
- .font(.system(size: 13, weight: .semibold))
- }
- .foregroundStyle(Color.red)
- .frame(maxWidth: .infinity)
- .padding(.vertical, 10)
- .background(Color.red.opacity(0.06))
- .overlay {
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color.red.opacity(0.45), lineWidth: 1)
- }
- }
- .buttonStyle(.plain)
- .disabled(isDeletingSession)
- .accessibilityLabel("删除现场记录")
- }
- .padding(14)
- .background(Color.cardBackground.opacity(0.15))
- .businessBorder(cornerRadius: 10)
- }
- @MainActor
- private func deleteSession() async {
- guard !isDeletingSession else { return }
- isDeletingSession = true
- defer { isDeletingSession = false }
- let storedPaths =
- [session.localAudioPath].compactMap { $0 } +
- session.audioChunks.map(\.localFilePath) +
- session.events.compactMap(\.localFilePath)
- let fileURLs = Set(storedPaths.compactMap { AudioPathHelper.resolveURL(for: $0) })
- if let cloudSessionID = session.cloudSessionId {
- do {
- try await RemoteNetworkService().deleteSession(sessionID: cloudSessionID)
- } catch APIError.server(let code, _) where code == 404 {
- // The remote record is already gone, so local cleanup can continue.
- } catch {
- deletionError = "云端记录删除失败,本地记录未删除:\(error.localizedDescription)"
- return
- }
- }
- playbackVM.pause()
- modelContext.delete(session)
- do {
- try modelContext.save()
- for fileURL in fileURLs {
- try? FileManager.default.removeItem(at: fileURL)
- }
- dismiss()
- } catch {
- modelContext.rollback()
- deletionError = error.localizedDescription
- }
- }
- private static let chineseDateFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "zh_CN")
- formatter.calendar = Calendar(identifier: .gregorian)
- formatter.dateFormat = "yyyy年M月d日"
- return formatter
- }()
- private static let chineseTimeFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "zh_CN")
- formatter.calendar = Calendar(identifier: .gregorian)
- formatter.dateFormat = "HH:mm:ss"
- return formatter
- }()
- private var compactSyncButton: some View {
- VStack(spacing: 3) {
- Button {
- if isThisSessionSyncing {
- syncManager.pauseSync(sessionID: session.id)
- } else if isDisplayedSynced {
- showSyncInfo = true
- } else {
- requestSessionSync(isAutomatic: false)
- }
- } label: {
- Group {
- if isDisplayedSyncing {
- ZStack {
- Circle()
- .stroke(Color.secondary.opacity(0.22), lineWidth: 2)
- Circle()
- .trim(from: 0, to: max(sessionSyncProgress, 0.04))
- .stroke(
- Color.lessCosmosGold,
- style: StrokeStyle(lineWidth: 2, lineCap: .round)
- )
- .rotationEffect(.degrees(-90))
- TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
- Image(systemName: "arrow.triangle.2.circlepath")
- .font(.system(size: 9, weight: .semibold))
- .foregroundStyle(Color.lessCosmosGold)
- .rotationEffect(syncRotation(at: context.date))
- }
- }
- } else if isDisplayedSynced {
- Image(systemName: "checkmark.icloud.fill")
- .symbolRenderingMode(.hierarchical)
- .foregroundStyle(Color.lessCosmosGold)
- } else {
- Image(systemName: "icloud.and.arrow.up")
- .symbolRenderingMode(.hierarchical)
- .foregroundStyle(Color.secondary)
- }
- }
- .font(.system(size: 17, weight: .medium))
- .frame(width: 32, height: 32)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .disabled(syncManager.isSyncing && !isThisSessionSyncing)
- .accessibilityLabel(isThisSessionSyncing ? "暂停同步" : "同步记录")
- .accessibilityValue(
- isDisplayedSyncing
- ? "\(syncStatusText),\(Int((sessionSyncProgress * 100).rounded()))%"
- : syncStatusText
- )
- .popover(isPresented: $showSyncInfo, arrowEdge: .top) {
- syncInfoCard
- .padding(16)
- .frame(idealWidth: 320)
- .presentationCompactAdaptation(.popover)
- }
- Text(syncStatusText)
- .font(.system(size: 9, weight: .medium))
- .foregroundStyle(syncStatusColor)
- }
- }
- private var syncInfoCard: some View {
- VStack(alignment: .leading, spacing: 12) {
- HStack(spacing: 8) {
- Image(systemName: "checkmark.icloud.fill")
- .foregroundStyle(Color.lessCosmosGold)
- Text("同步信息")
- .font(.system(size: 15, weight: .semibold))
- .foregroundStyle(Color.primary)
- }
- Divider()
- .background(Color.lineBorder)
- syncInfoRow(
- label: "同步时间",
- value: session.lastSyncedAt.map {
- Self.syncDateTimeFormatter.string(from: $0)
- } ?? "现场记录未保存"
- )
- syncInfoRow(
- label: "云端版本",
- value: "v\(max(session.serverRevision, 1))",
- monospaced: true
- )
- if let cloudSessionID = session.cloudSessionId {
- syncInfoRow(
- label: "云端记录",
- value: cloudSessionID,
- monospaced: true
- )
- }
- }
- .padding(14)
- .background(Color.cardBackground.opacity(0.15))
- .businessBorder(cornerRadius: 10)
- }
- private func syncInfoRow(
- label: String,
- value: String,
- monospaced: Bool = false
- ) -> some View {
- HStack(alignment: .firstTextBaseline, spacing: 14) {
- Text(label)
- .font(.system(size: 11, weight: .medium))
- .foregroundStyle(Color.secondary)
- Spacer(minLength: 12)
- Text(value)
- .font(.system(size: 12, weight: .medium, design: monospaced ? .monospaced : .default))
- .foregroundStyle(Color.primary)
- .multilineTextAlignment(.trailing)
- .lineLimit(2)
- .textSelection(.enabled)
- }
- }
- private var isThisSessionSyncing: Bool {
- session.syncState == .syncing
- || (syncManager.isSyncing && syncManager.activeSessionID == session.id)
- }
- private var isDisplayedSyncing: Bool {
- isThisSessionSyncing
- }
- private var isDisplayedSynced: Bool {
- !isDisplayedSyncing
- && session.hasConfirmedCloudSync
- && session.syncState != .failed
- && session.syncState != .conflict
- }
- private var sessionSyncProgress: Double {
- if isThisSessionSyncing {
- return min(max(syncManager.syncProgress, 0), 1)
- }
- return session.hasConfirmedCloudSync ? 1 : 0
- }
- private var syncStatusText: String {
- if isDisplayedSyncing { return "正在同步" }
- return isDisplayedSynced ? "已同步" : "未同步"
- }
- private var syncStatusColor: Color {
- isDisplayedSynced || isDisplayedSyncing ? Color.lessCosmosGold : Color.secondary
- }
- private func syncRotation(at date: Date) -> Angle {
- let cycle = date.timeIntervalSinceReferenceDate
- .truncatingRemainder(dividingBy: 1.2)
- return .degrees(cycle / 1.2 * 360)
- }
- private static let syncDateTimeFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "zh_CN")
- formatter.calendar = Calendar(identifier: .gregorian)
- formatter.dateFormat = "yyyy年M月d日 HH:mm:ss"
- return formatter
- }()
- private var largeTransferMessage: String {
- let bytes = syncManager.estimatedUploadBytes(for: session)
- let size = ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
- return "预计上传约 \(size),将占用约 \(size) \(networkMonitor.connectionName)流量。是否继续?"
- }
- private func requestSessionSync(isAutomatic: Bool) {
- guard !syncManager.isSyncing else { return }
- guard networkMonitor.isConnected else {
- if !isAutomatic {
- timelineEditError = "当前没有可用网络,请连接网络后重试。"
- }
- return
- }
- guard authManager.currentUser?.id != nil else {
- if !isAutomatic {
- showSyncAuthPrompt = true
- }
- return
- }
- let largeTransferThreshold: Int64 = 70 * 1_024 * 1_024
- if syncManager.estimatedUploadBytes(for: session) >= largeTransferThreshold,
- !hasApprovedLargeTransfer {
- showLargeSyncConfirmation = true
- return
- }
- performSessionSync()
- }
- private func performSessionSync() {
- guard let userID = authManager.currentUser?.id else { return }
- Task {
- _ = await syncManager.sync(
- sessions: [session],
- modelContext: modelContext,
- userID: userID
- )
- }
- }
- private func scheduleAutoSync() {
- syncManager.scheduleAutomaticSync(
- for: session,
- modelContext: modelContext
- )
- }
- private func infoStat(icon: String, label: String, value: String) -> some View {
- VStack(spacing: 4) {
- Image(systemName: icon)
- .font(.system(size: 14, weight: .light))
- .foregroundStyle(Color.secondary)
- Text(value)
- .font(.system(size: 13, weight: .medium, design: .monospaced))
- .foregroundStyle(Color.primary)
- Text(label)
- .font(.system(size: 9, weight: .regular))
- .foregroundStyle(Color.secondary.opacity(0.8))
- }
- .frame(maxWidth: .infinity)
- }
- // MARK: - Timeline Section
- private var timelineSection: some View {
- VStack(alignment: .leading, spacing: 8) {
- MultiTrackTimeline(
- events: session.events,
- currentTimeMs: Binding(
- get: { playbackVM.currentPlaybackTimeMs },
- set: { playbackVM.previewScrub(to: $0) }
- ),
- totalDurationMs: playbackVM.totalDurationMs,
- waveformSamples: playbackVM.waveformSamples,
- waveformRevision: playbackVM.waveformRevision,
- silentRanges: playbackVM.silentRanges,
- onEmptyTrackTap: handleEmptyTrackTap,
- onEventTap: { event in
- playbackVM.seekTo(timeMs: Double(event.relativeTimeMs))
- openTimelineEvent(event)
- HapticManager.trigger(.tapFeedback)
- },
- onScrubBegan: playbackVM.beginScrubbing,
- onScrubEnded: playbackVM.endScrubbing
- )
- .frame(height: 220)
- }
- }
- // MARK: - Playback Controls
- private var playbackControls: some View {
- VStack(spacing: 12) {
- // Time display
- HStack {
- Text(playbackVM.currentTimeFormatted)
- .font(.system(size: 12, weight: .regular, design: .monospaced))
- .foregroundStyle(Color.primary)
- Spacer()
- Text(totalTimeFormatted)
- .font(.system(size: 12, weight: .regular, design: .monospaced))
- .foregroundStyle(Color.secondary)
- }
- // Seek slider
- Slider(
- value: Binding(
- get: { playbackVM.progress },
- set: { newValue in
- playbackVM.previewScrub(
- to: newValue * playbackVM.totalDurationMs
- )
- }
- ),
- in: 0...1,
- onEditingChanged: { isEditing in
- if isEditing {
- playbackVM.beginScrubbing()
- } else {
- playbackVM.endScrubbing(
- at: playbackVM.currentPlaybackTimeMs
- )
- }
- }
- )
- .tint(Color.primary)
- // Play/Pause button
- HStack(spacing: 36) {
- // Rewind 10s
- Button {
- let target = max(0, playbackVM.currentPlaybackTimeMs - 10_000)
- playbackVM.seekTo(timeMs: target)
- } label: {
- Image(systemName: "gobackward.10")
- .font(.system(size: 18, weight: .light))
- .foregroundStyle(Color.secondary)
- }
- // Play/Pause (High-contrast minimalist button)
- Button {
- playbackVM.togglePlayback()
- HapticManager.trigger(.tapFeedback)
- } label: {
- ZStack {
- Circle()
- .fill(Color.primary)
- .frame(width: 48, height: 48)
- Image(systemName: playbackVM.isPlaying ? "pause.fill" : "play.fill")
- .font(.system(size: 16, weight: .bold))
- .foregroundStyle(Color.spaceBlack)
- }
- }
- // Forward 10s
- Button {
- let target = min(playbackVM.totalDurationMs, playbackVM.currentPlaybackTimeMs + 10_000)
- playbackVM.seekTo(timeMs: target)
- } label: {
- Image(systemName: "goforward.10")
- .font(.system(size: 18, weight: .light))
- .foregroundStyle(Color.secondary)
- }
- }
- .padding(.top, 4)
-
- Divider()
- .background(Color.lineBorder)
- .padding(.vertical, 4)
-
- HStack {
- Label {
- Text("跳过静音")
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(Color.primary)
- } icon: {
- Image(systemName: playbackVM.isSilenceSkipEnabled ? "waveform.badge.minus" : "waveform")
- .font(.system(size: 13))
- .foregroundStyle(playbackVM.isSilenceSkipEnabled ? Color.primary : Color.secondary)
- }
-
- Spacer()
-
- if playbackVM.isAnalyzingSilence {
- ProgressView()
- .scaleEffect(0.7)
- .frame(width: 16, height: 16)
- } else if !playbackVM.silentRanges.isEmpty {
- Text("检测到 \(playbackVM.silentRanges.count) 处静音")
- .font(.system(size: 11))
- .foregroundStyle(Color.secondary)
- }
-
- Toggle("", isOn: Binding(
- get: { playbackVM.isSilenceSkipEnabled },
- set: { playbackVM.isSilenceSkipEnabled = $0 }
- ))
- .toggleStyle(SwitchToggleStyle(tint: Color.primary))
- .labelsHidden()
- .scaleEffect(0.8)
- }
- }
- .padding(14)
- .background(Color.cardBackground.opacity(0.15))
- .businessBorder(cornerRadius: 10)
- }
- // MARK: - Chrono Feed
- private var chronoFeedSection: some View {
- VStack(alignment: .leading, spacing: 10) {
- sectionHeader(icon: "list.dash", title: "事件")
- let sorted = chronoFeedItems
- if sorted.isEmpty {
- HStack {
- Spacer()
- VStack(spacing: 8) {
- Image(systemName: "tray")
- .font(.system(size: 24, weight: .light))
- .foregroundStyle(Color.secondary.opacity(0.3))
- Text("暂无会话事件")
- .font(.system(size: 12))
- .foregroundStyle(Color.secondary.opacity(0.5))
- }
- .padding(.vertical, 24)
- Spacer()
- }
- } else {
- LazyVStack(spacing: 8) {
- ForEach(sorted) { item in
- ChronoFeedRow(
- event: item.event,
- photoEvents: item.photoEvents
- ) {
- playbackVM.seekTo(timeMs: item.event.relativeTimeMs)
- openTimelineEvent(item.event)
- HapticManager.trigger(.tapFeedback)
- }
- }
- }
- }
- }
- }
- // MARK: - Helpers
- private func sectionHeader(icon: String, title: String) -> some View {
- HStack(spacing: 6) {
- Image(systemName: icon)
- .font(.system(size: 12, weight: .light))
- .foregroundStyle(Color.secondary)
- Text(title)
- .font(.system(size: 13, weight: .semibold))
- .foregroundStyle(Color.primary.opacity(0.8))
- }
- }
- private var totalTimeFormatted: String {
- let totalSeconds = Int(playbackVM.totalDurationMs / 1000)
- let hours = totalSeconds / 3600
- let minutes = (totalSeconds % 3600) / 60
- let seconds = totalSeconds % 60
- if hours > 0 {
- return String(format: "%d:%02d:%02d", hours, minutes, seconds)
- }
- return String(format: "%02d:%02d", minutes, seconds)
- }
- // MARK: - Timeline Editing
- private var timelineNoteEditor: some View {
- NavigationStack {
- ZStack {
- Color.spaceBlack.ignoresSafeArea()
- VStack(alignment: .leading, spacing: 16) {
- Label(
- "添加到 \(formattedTimelineTime(pendingTimelineTimeMs))",
- systemImage: "clock"
- )
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(Color.secondary)
- ZStack(alignment: .topLeading) {
- if noteText.isEmpty {
- Text("这一刻的想法")
- .font(.system(size: 15))
- .foregroundStyle(Color.secondary.opacity(0.6))
- .padding(.horizontal, 18)
- .padding(.vertical, 16)
- .allowsHitTesting(false)
- }
- TextEditor(text: $noteText)
- .font(.system(size: 15))
- .foregroundStyle(Color.primary)
- .scrollContentBackground(.hidden)
- .padding(10)
- .frame(minHeight: 160)
- .background(Color.cardBackground.opacity(0.5))
- .businessBorder(cornerRadius: 8)
- }
- TimelineLocationButton(location: $noteLocation)
- Spacer()
- }
- .padding(20)
- }
- .navigationTitle("添加笔记")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("取消") {
- noteText = ""
- noteLocation = nil
- showNoteEditor = false
- }
- }
- ToolbarItem(placement: .confirmationAction) {
- Button("保存") {
- addNote(at: pendingTimelineTimeMs)
- }
- .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
- }
- }
- }
- .presentationDetents([.medium])
- .presentationDragIndicator(.visible)
- .presentationBackground(Color.spaceBlack)
- }
- private func handleEmptyTrackTap(
- _ track: MultiTrackTimeline.EditableTrack,
- timeMs: Double
- ) {
- pendingTimelineTimeMs = min(max(timeMs, 0), playbackVM.totalDurationMs)
- playbackVM.seekTo(timeMs: pendingTimelineTimeMs)
- HapticManager.trigger(.tapFeedback)
- switch track {
- case .photo:
- showPhotoEditor = true
- case .note:
- noteText = ""
- noteLocation = nil
- showNoteEditor = true
- }
- }
- private func addNote(at timeMs: Double) {
- let trimmedText = noteText.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmedText.isEmpty else { return }
- let event = CelestiaTimelineEvent(
- relativeTimeMs: Int64(timeMs.rounded()),
- eventType: "NOTE"
- )
- event.textContent = trimmedText
- event.location = noteLocation
- session.events.append(event)
- do {
- try saveTimelineChanges()
- noteText = ""
- noteLocation = nil
- showNoteEditor = false
- } catch {
- modelContext.rollback()
- timelineEditError = "笔记保存失败:\(error.localizedDescription)"
- }
- }
- private func addPhotos(
- _ drafts: [PhotoRecordDraft],
- location: TimelineLocation?,
- at timeMs: Double
- ) -> Bool {
- guard !drafts.isEmpty,
- let documentsURL = FileManager.default.urls(
- for: .documentDirectory,
- in: .userDomainMask
- ).first else {
- timelineEditError = "无法访问本地照片目录。"
- return false
- }
- var storedPhotos: [
- (
- filename: String,
- fileURL: URL,
- note: String
- )
- ] = []
- do {
- for draft in drafts {
- guard let data = draft.image.jpegData(compressionQuality: 0.85) else {
- throw CocoaError(.fileWriteUnknown)
- }
- let filename = "photo_\(UUID().uuidString).jpg"
- let fileURL = documentsURL.appendingPathComponent(filename)
- try data.write(to: fileURL, options: .atomic)
- storedPhotos.append(
- (filename, fileURL, draft.note)
- )
- }
- let relativeTimeMs = Int64(timeMs.rounded())
- for storedPhoto in storedPhotos {
- let event = CelestiaTimelineEvent(
- relativeTimeMs: relativeTimeMs,
- eventType: "PHOTO"
- )
- event.localFilePath = storedPhoto.filename
- let trimmedNote = storedPhoto.note.trimmingCharacters(in: .whitespacesAndNewlines)
- event.textContent = trimmedNote.isEmpty ? nil : trimmedNote
- event.location = location
- session.events.append(event)
- }
- do {
- try saveTimelineChanges()
- return true
- } catch {
- modelContext.rollback()
- for storedPhoto in storedPhotos {
- try? FileManager.default.removeItem(at: storedPhoto.fileURL)
- }
- throw error
- }
- } catch {
- for storedPhoto in storedPhotos {
- try? FileManager.default.removeItem(at: storedPhoto.fileURL)
- }
- timelineEditError = "照片保存失败:\(error.localizedDescription)"
- return false
- }
- }
- private func updatePhotoEdits(
- _ notes: [UUID: String],
- location: TimelineLocation?
- ) -> Bool {
- let photoEventsByID = Dictionary(
- uniqueKeysWithValues: session.events
- .filter { $0.eventType == "PHOTO" }
- .map { ($0.id, $0) }
- )
- let previousValues = notes.reduce(
- into: [UUID: TimelineEventEditDraft]()
- ) { result, item in
- guard let event = photoEventsByID[item.key] else { return }
- result[item.key] = TimelineEventEditDraft(
- text: event.textContent ?? "",
- location: event.location
- )
- let trimmedNote = item.value
- .trimmingCharacters(in: .whitespacesAndNewlines)
- event.textContent = trimmedNote.isEmpty ? nil : trimmedNote
- event.location = location
- }
- do {
- try saveTimelineChanges()
- return true
- } catch {
- for (eventID, previousValue) in previousValues {
- photoEventsByID[eventID]?.textContent = previousValue.text.isEmpty
- ? nil
- : previousValue.text
- photoEventsByID[eventID]?.location = previousValue.location
- }
- modelContext.rollback()
- timelineEditError = "照片信息保存失败:\(error.localizedDescription)"
- return false
- }
- }
- private func updateTimelineNote(
- _ event: CelestiaTimelineEvent,
- text: String,
- location: TimelineLocation?
- ) -> Bool {
- let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmedText.isEmpty else { return false }
- let previousText = event.textContent
- let previousLocation = event.location
- event.textContent = trimmedText
- event.location = location
- do {
- try saveTimelineChanges()
- return true
- } catch {
- event.textContent = previousText
- event.location = previousLocation
- modelContext.rollback()
- timelineEditError = "笔记保存失败:\(error.localizedDescription)"
- return false
- }
- }
- private func deleteTimelineEvent(_ event: CelestiaTimelineEvent) {
- let photoURL = event.eventType == "PHOTO"
- ? AudioPathHelper.resolveURL(for: event.localFilePath)
- : nil
- modelContext.delete(event)
- do {
- try saveTimelineChanges()
- if let photoURL {
- try? FileManager.default.removeItem(at: photoURL)
- }
- selectedTimelineEvent = nil
- selectedPhotoPoint = nil
- } catch {
- modelContext.rollback()
- timelineEditError = "删除失败:\(error.localizedDescription)"
- }
- }
- private func saveTimelineChanges() throws {
- session.markContentModified()
- try modelContext.save()
- scheduleAutoSync()
- }
- private func formattedTimelineTime(_ timeMs: Double) -> String {
- let totalSeconds = max(0, Int(timeMs / 1_000))
- let hours = totalSeconds / 3_600
- let minutes = (totalSeconds % 3_600) / 60
- let seconds = totalSeconds % 60
- if hours > 0 {
- return String(format: "%d:%02d:%02d", hours, minutes, seconds)
- }
- return String(format: "%02d:%02d", minutes, seconds)
- }
- private func openTimelineEvent(_ event: CelestiaTimelineEvent) {
- if event.eventType == "PHOTO" {
- selectedPhotoPoint = PhotoPointSelection(
- eventID: event.id,
- relativeTimeMs: event.relativeTimeMs
- )
- } else if event.eventType == "NOTE" || event.eventType == "MARKER" {
- selectedTimelineEvent = event
- }
- }
- private func photoEvents(at relativeTimeMs: Int64) -> [CelestiaTimelineEvent] {
- session.events
- .filter {
- $0.eventType == "PHOTO"
- && $0.relativeTimeMs == relativeTimeMs
- }
- .sorted {
- if $0.createdAt == $1.createdAt {
- return $0.id.uuidString < $1.id.uuidString
- }
- return $0.createdAt < $1.createdAt
- }
- }
- private var chronoFeedItems: [ChronoFeedItem] {
- let sortedEvents = playbackVM.sortedEvents(from: session)
- var includedPhotoTimes = Set<Int64>()
- return sortedEvents.compactMap { event in
- guard event.eventType == "PHOTO" else {
- return ChronoFeedItem(event: event, photoEvents: [])
- }
- guard includedPhotoTimes.insert(event.relativeTimeMs).inserted else {
- return nil
- }
- return ChronoFeedItem(
- event: event,
- photoEvents: photoEvents(at: event.relativeTimeMs)
- )
- }
- }
- }
- private struct PhotoPointSelection: Identifiable {
- let eventID: UUID
- let relativeTimeMs: Int64
- var id: UUID { eventID }
- }
- private struct ChronoFeedItem: Identifiable {
- let event: CelestiaTimelineEvent
- let photoEvents: [CelestiaTimelineEvent]
- var id: UUID { event.id }
- }
- private struct TimelineImagePresentation: Identifiable {
- let id = UUID()
- let image: UIImage
- let capturedAt: Date
- let note: String
- }
- private struct TimelineEventEditDraft: Equatable {
- var text: String
- var location: TimelineLocation?
- }
- private struct PhotoPointDetailSheet: View {
- @Environment(\.dismiss) private var dismiss
- let events: [CelestiaTimelineEvent]
- let onSaveEdits: ([UUID: String], TimelineLocation?) -> Bool
- let onDelete: (CelestiaTimelineEvent) -> Void
- @State private var noteDrafts: [UUID: String]
- @State private var locationDraft: TimelineLocation?
- @State private var pendingDeletionEvent: CelestiaTimelineEvent?
- @State private var fullScreenImage: TimelineImagePresentation?
- init(
- events: [CelestiaTimelineEvent],
- onSaveEdits: @escaping ([UUID: String], TimelineLocation?) -> Bool,
- onDelete: @escaping (CelestiaTimelineEvent) -> Void
- ) {
- self.events = events
- self.onSaveEdits = onSaveEdits
- self.onDelete = onDelete
- _noteDrafts = State(
- initialValue: Dictionary(
- uniqueKeysWithValues: events.map {
- ($0.id, $0.textContent ?? "")
- }
- )
- )
- _locationDraft = State(
- initialValue: events.compactMap(\.location).first
- )
- }
- var body: some View {
- NavigationStack {
- ZStack {
- Color.spaceBlack.ignoresSafeArea()
- if events.isEmpty {
- ContentUnavailableView(
- "照片不可用",
- systemImage: "photo.badge.exclamationmark",
- description: Text("这个记录点没有可查看的本地照片。")
- )
- } else {
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- HStack {
- Label(
- events[0].relativeTimeFormatted,
- systemImage: "clock"
- )
- .font(.system(size: 12, weight: .medium, design: .monospaced))
- .foregroundStyle(Color.secondary)
- Spacer()
- Text("\(events.count) 张照片")
- .font(.system(size: 12, design: .monospaced))
- .foregroundStyle(Color.secondary)
- }
- TimelineLocationButton(location: $locationDraft)
- LazyVStack(spacing: 28) {
- ForEach(Array(events.enumerated()), id: \.element.id) { index, event in
- photoListItem(event, index: index)
- }
- }
- }
- .padding(20)
- }
- }
- }
- .navigationTitle(events.count > 1 ? "查看照片(\(events.count) 张)" : "查看照片")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("完成") {
- dismiss()
- }
- }
- ToolbarItem(placement: .confirmationAction) {
- Button("保存") {
- if onSaveEdits(noteDrafts, locationDraft) {
- dismiss()
- }
- }
- .disabled(!hasChanges)
- }
- }
- .confirmationDialog(
- "确定删除这张照片吗?",
- isPresented: Binding(
- get: { pendingDeletionEvent != nil },
- set: { if !$0 { pendingDeletionEvent = nil } }
- ),
- titleVisibility: .visible
- ) {
- Button("删除", role: .destructive) {
- guard let event = pendingDeletionEvent else { return }
- onDelete(event)
- dismiss()
- }
- Button("取消", role: .cancel) {}
- } message: {
- Text("本地图片文件和这张照片的备注都会被删除,此操作无法撤销。")
- }
- }
- .fullScreenCover(item: $fullScreenImage) { item in
- FullScreenTimelineImageView(
- image: item.image,
- capturedAt: item.capturedAt,
- note: item.note
- )
- }
- .presentationDetents([.large])
- .presentationDragIndicator(.visible)
- .presentationBackground(Color.spaceBlack)
- }
- private func photoListItem(
- _ event: CelestiaTimelineEvent,
- index: Int
- ) -> some View {
- VStack(alignment: .leading, spacing: 10) {
- HStack {
- Text("照片 \(index + 1)")
- .font(.system(size: 11, weight: .medium))
- .foregroundStyle(Color.secondary)
- Spacer()
- Button(role: .destructive) {
- pendingDeletionEvent = event
- } label: {
- Image(systemName: "trash")
- .font(.system(size: 11, weight: .medium))
- .frame(width: 24, height: 24)
- }
- .buttonStyle(.plain)
- .foregroundStyle(Color.red)
- .accessibilityLabel("删除照片 \(index + 1)")
- }
- if let image = resolvedImage(for: event) {
- Button {
- fullScreenImage = TimelineImagePresentation(
- image: image,
- capturedAt: event.createdAt,
- note: noteDrafts[event.id] ?? ""
- )
- } label: {
- Image(uiImage: image)
- .resizable()
- .scaledToFit()
- .frame(maxWidth: .infinity, maxHeight: 420)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .accessibilityLabel("全屏查看照片 \(index + 1)")
- } else {
- ContentUnavailableView(
- "图片不可用",
- systemImage: "photo.badge.exclamationmark",
- description: Text("本地图片文件可能已被移动或删除。")
- )
- .frame(maxWidth: .infinity, minHeight: 180)
- }
- ZStack(alignment: .topLeading) {
- if (noteDrafts[event.id] ?? "").isEmpty {
- Text("照片备注")
- .font(.system(size: 14))
- .foregroundStyle(Color.secondary.opacity(0.55))
- .padding(.horizontal, 15)
- .padding(.vertical, 17)
- .allowsHitTesting(false)
- }
- TextEditor(text: noteBinding(for: event))
- .font(.system(size: 14))
- .foregroundStyle(Color.primary)
- .scrollContentBackground(.hidden)
- .padding(8)
- .frame(height: 72)
- }
- .background(Color.cardBackground.opacity(0.45))
- .businessBorder(cornerRadius: 8)
- if index < events.count - 1 {
- Divider()
- .overlay(Color.lineBorder.opacity(0.55))
- .padding(.top, 8)
- }
- }
- }
- private func noteBinding(for event: CelestiaTimelineEvent) -> Binding<String> {
- Binding(
- get: { noteDrafts[event.id] ?? "" },
- set: { newValue in
- noteDrafts[event.id] = newValue
- }
- )
- }
- private var hasChanges: Bool {
- events.contains { event in
- let original = (event.textContent ?? "")
- .trimmingCharacters(in: .whitespacesAndNewlines)
- let draft = (noteDrafts[event.id] ?? "")
- .trimmingCharacters(in: .whitespacesAndNewlines)
- return original != draft || event.location != locationDraft
- }
- }
- private func resolvedImage(for event: CelestiaTimelineEvent) -> UIImage? {
- guard let url = AudioPathHelper.resolveURL(for: event.localFilePath) else {
- return nil
- }
- return UIImage(contentsOfFile: url.path)
- }
- }
- private struct TimelineEventDetailSheet: View {
- @Environment(\.dismiss) private var dismiss
- let event: CelestiaTimelineEvent
- let onSaveNote: (String, TimelineLocation?) -> Bool
- let onDelete: () -> Void
- @State private var noteDraft: String
- @State private var locationDraft: TimelineLocation?
- @State private var showDeleteConfirmation = false
- @State private var showFullScreenImage = false
- init(
- event: CelestiaTimelineEvent,
- onSaveNote: @escaping (String, TimelineLocation?) -> Bool,
- onDelete: @escaping () -> Void
- ) {
- self.event = event
- self.onSaveNote = onSaveNote
- self.onDelete = onDelete
- _noteDraft = State(initialValue: event.textContent ?? "")
- _locationDraft = State(initialValue: event.location)
- }
- var body: some View {
- NavigationStack {
- ZStack {
- Color.spaceBlack.ignoresSafeArea()
- VStack(alignment: .leading, spacing: 16) {
- Label(event.relativeTimeFormatted, systemImage: "clock")
- .font(.system(size: 12, weight: .medium, design: .monospaced))
- .foregroundStyle(Color.secondary)
- eventContent
- Spacer(minLength: 0)
- HStack {
- Spacer()
- Button(role: .destructive) {
- showDeleteConfirmation = true
- } label: {
- Image(systemName: "trash")
- .font(.system(size: 13, weight: .medium))
- .frame(width: 30, height: 30)
- }
- .buttonStyle(.bordered)
- .buttonBorderShape(.circle)
- .tint(.red)
- .accessibilityLabel("删除这条记录")
- }
- }
- .padding(20)
- }
- .navigationTitle(detailTitle)
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("完成") {
- dismiss()
- }
- }
- if event.eventType == "NOTE" {
- ToolbarItem(placement: .confirmationAction) {
- Button("保存") {
- if onSaveNote(noteDraft, locationDraft) {
- dismiss()
- }
- }
- .disabled(
- trimmedNoteDraft.isEmpty
- || !hasChanges
- )
- }
- }
- }
- .confirmationDialog(
- "确定删除这条记录吗?",
- isPresented: $showDeleteConfirmation,
- titleVisibility: .visible
- ) {
- Button("删除", role: .destructive) {
- onDelete()
- dismiss()
- }
- Button("取消", role: .cancel) {}
- } message: {
- Text(event.eventType == "PHOTO" ? "本地图片文件也会被删除,此操作无法撤销。" : "此操作无法撤销。")
- }
- }
- .fullScreenCover(isPresented: $showFullScreenImage) {
- if let image = resolvedImage {
- FullScreenTimelineImageView(
- image: image,
- capturedAt: event.createdAt,
- note: event.textContent ?? ""
- )
- }
- }
- .presentationDetents(event.eventType == "PHOTO" ? [.medium, .large] : [.medium])
- .presentationDragIndicator(.visible)
- .presentationBackground(Color.spaceBlack)
- }
- @ViewBuilder
- private var eventContent: some View {
- if event.eventType == "PHOTO" {
- if let image = resolvedImage {
- Button {
- showFullScreenImage = true
- } label: {
- Image(uiImage: image)
- .resizable()
- .scaledToFit()
- .frame(maxWidth: .infinity)
- .clipShape(RoundedRectangle(cornerRadius: 10))
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .accessibilityLabel("全屏查看图片")
- } else {
- ContentUnavailableView(
- "图片不可用",
- systemImage: "photo.badge.exclamationmark",
- description: Text("本地图片文件可能已被移动或删除。")
- )
- .frame(maxWidth: .infinity, minHeight: 180)
- }
- } else if event.eventType == "NOTE" {
- VStack(alignment: .leading, spacing: 14) {
- ZStack(alignment: .topLeading) {
- if noteDraft.isEmpty {
- Text("这一刻的想法")
- .font(.system(size: 15))
- .foregroundStyle(Color.secondary.opacity(0.55))
- .padding(.horizontal, 18)
- .padding(.vertical, 16)
- .allowsHitTesting(false)
- }
- TextEditor(text: $noteDraft)
- .font(.system(size: 15))
- .foregroundStyle(Color.primary)
- .scrollContentBackground(.hidden)
- .padding(10)
- .frame(maxWidth: .infinity, minHeight: 150, alignment: .topLeading)
- }
- .background(Color.cardBackground.opacity(0.5))
- .businessBorder(cornerRadius: 8)
- TimelineLocationButton(location: $locationDraft)
- }
- } else {
- Text(event.textContent ?? "暂无记录内容")
- .font(.system(size: 15))
- .foregroundStyle(Color.primary)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(16)
- .background(Color.cardBackground.opacity(0.5))
- .businessBorder(cornerRadius: 8)
- }
- }
- private var resolvedImage: UIImage? {
- guard let url = AudioPathHelper.resolveURL(for: event.localFilePath) else {
- return nil
- }
- return UIImage(contentsOfFile: url.path)
- }
- private var trimmedNoteDraft: String {
- noteDraft.trimmingCharacters(in: .whitespacesAndNewlines)
- }
- private var hasChanges: Bool {
- trimmedNoteDraft
- != (event.textContent ?? "")
- .trimmingCharacters(in: .whitespacesAndNewlines)
- || locationDraft != event.location
- }
- private var detailTitle: String {
- if event.isContinuationMarker {
- return "续录详情"
- }
- return event.eventType == "PHOTO" ? "查看图片" : "查看笔记"
- }
- }
- private struct FullScreenTimelineImageView: View {
- @Environment(\.dismiss) private var dismiss
- let image: UIImage
- let capturedAt: Date
- let note: String
- var body: some View {
- ZStack {
- Color.black.ignoresSafeArea()
- ZoomableTimelineImage(image: image)
- .ignoresSafeArea()
- VStack(spacing: 0) {
- HStack {
- Spacer()
- Button {
- dismiss()
- } label: {
- Image(systemName: "xmark")
- .font(.system(size: 14, weight: .semibold))
- .foregroundStyle(.white)
- .frame(width: 34, height: 34)
- .background(.black.opacity(0.55), in: Circle())
- }
- .accessibilityLabel("关闭全屏图片")
- }
- .padding(.top, 12)
- .padding(.trailing, 16)
- Spacer()
- VStack(alignment: .leading, spacing: 6) {
- Text(Self.photoDateFormatter.string(from: capturedAt))
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(.white.opacity(0.78))
- if !trimmedNote.isEmpty {
- Text(trimmedNote)
- .font(.system(size: 15))
- .foregroundStyle(.white)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 20)
- .padding(.top, 54)
- .padding(.bottom, 28)
- .background(
- LinearGradient(
- colors: [.clear, .black.opacity(0.82)],
- startPoint: .top,
- endPoint: .bottom
- )
- )
- }
- }
- .statusBarHidden()
- }
- private var trimmedNote: String {
- note.trimmingCharacters(in: .whitespacesAndNewlines)
- }
- private static let photoDateFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "zh_CN")
- formatter.dateFormat = "yyyy年M月d日 HH:mm:ss"
- return formatter
- }()
- }
- private struct ZoomableTimelineImage: View {
- let image: UIImage
- @State private var scale: CGFloat = 1
- @State private var settledScale: CGFloat = 1
- @State private var offset: CGSize = .zero
- @State private var settledOffset: CGSize = .zero
- private let maximumScale: CGFloat = 5
- var body: some View {
- GeometryReader { proxy in
- Image(uiImage: image)
- .resizable()
- .scaledToFit()
- .frame(width: proxy.size.width, height: proxy.size.height)
- .scaleEffect(scale)
- .offset(offset)
- .contentShape(Rectangle())
- .gesture(magnificationGesture(in: proxy.size))
- .simultaneousGesture(dragGesture(in: proxy.size))
- }
- .clipped()
- .accessibilityLabel("全屏图片,可双指缩放并拖动")
- }
- private func magnificationGesture(in containerSize: CGSize) -> some Gesture {
- MagnifyGesture()
- .onChanged { value in
- scale = min(max(settledScale * value.magnification, 1), maximumScale)
- offset = clampedOffset(offset, scale: scale, in: containerSize)
- }
- .onEnded { _ in
- if scale <= 1 {
- scale = 1
- offset = .zero
- } else {
- offset = clampedOffset(offset, scale: scale, in: containerSize)
- }
- settledScale = scale
- settledOffset = offset
- }
- }
- private func dragGesture(in containerSize: CGSize) -> some Gesture {
- DragGesture()
- .onChanged { value in
- guard scale > 1 else {
- offset = .zero
- return
- }
- let proposedOffset = CGSize(
- width: settledOffset.width + value.translation.width,
- height: settledOffset.height + value.translation.height
- )
- offset = clampedOffset(proposedOffset, scale: scale, in: containerSize)
- }
- .onEnded { _ in
- offset = clampedOffset(offset, scale: scale, in: containerSize)
- settledOffset = offset
- }
- }
- private func clampedOffset(
- _ proposedOffset: CGSize,
- scale: CGFloat,
- in containerSize: CGSize
- ) -> CGSize {
- let fittedSize = aspectFitSize(for: image.size, in: containerSize)
- let horizontalLimit = max(0, (fittedSize.width * scale - containerSize.width) / 2)
- let verticalLimit = max(0, (fittedSize.height * scale - containerSize.height) / 2)
- return CGSize(
- width: min(max(proposedOffset.width, -horizontalLimit), horizontalLimit),
- height: min(max(proposedOffset.height, -verticalLimit), verticalLimit)
- )
- }
- private func aspectFitSize(for imageSize: CGSize, in containerSize: CGSize) -> CGSize {
- guard imageSize.width > 0,
- imageSize.height > 0,
- containerSize.width > 0,
- containerSize.height > 0 else {
- return .zero
- }
- let fitScale = min(
- containerSize.width / imageSize.width,
- containerSize.height / imageSize.height
- )
- return CGSize(
- width: imageSize.width * fitScale,
- height: imageSize.height * fitScale
- )
- }
- }
- private struct AudioShareItem: Identifiable {
- let id = UUID()
- let fileURL: URL
- }
- private struct AudioShareSheet: UIViewControllerRepresentable {
- let fileURL: URL
- func makeUIViewController(context: Context) -> UIActivityViewController {
- UIActivityViewController(activityItems: [fileURL], applicationActivities: nil)
- }
- func updateUIViewController(
- _ uiViewController: UIActivityViewController,
- context: Context
- ) {}
- }
- // MARK: - Preview
- #Preview {
- NavigationStack {
- SessionDetailView(session: {
- let s = CelestiaSession(title: "Preview 现场记录 · 6.24 14:30")
- s.endTime = Date().addingTimeInterval(3600)
- return s
- }())
- }
- .modelContainer(for: CelestiaSession.self, inMemory: true)
- }
|