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() 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 { 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) }