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 @ObservedObject private var bleManager: BLEManager = .shared @ObservedObject private var authManager: AuthManager = .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 showNoteEditor = false @State private var showPhotoActionDialog = false @State private var showImagePicker = false @State private var imagePickerSource: UIImagePickerController.SourceType = .photoLibrary @State private var selectedTimelineEvent: CelestiaTimelineEvent? @State private var timelineEditError: String? var body: some View { ZStack { Color.spaceBlack.ignoresSafeArea() ScrollView { VStack(spacing: 20) { // Section 1: Multi-Track Timeline timelineSection .padding(.horizontal, 16) .padding(.top, 12) // Section 2: Playback Controls playbackControls .padding(.horizontal, 16) // Section 3: Chrono Feed chronoFeedSection .padding(.horizontal, 16) .padding(.bottom, 32) } } } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { Button { recordName = session.title showRecordNameEditor = true } label: { HStack(spacing: 5) { Text(session.title) .font(.system(size: 14, weight: .semibold)) .lineLimit(1) Image(systemName: "pencil") .font(.system(size: 10, weight: .semibold)) } .foregroundStyle(Color.primary) .contentShape(Rectangle()) } .buttonStyle(.plain) } 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: 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)) } } .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: $showImagePicker) { ImagePicker(sourceType: imagePickerSource) { image in addPhoto(image, at: pendingTimelineTimeMs) } } .sheet(item: $selectedTimelineEvent) { event in TimelineEventDetailSheet(event: event) { deleteTimelineEvent(event) } } .confirmationDialog( "在 \(formattedTimelineTime(pendingTimelineTimeMs)) 添加图片", isPresented: $showPhotoActionDialog, titleVisibility: .visible ) { if UIImagePickerController.isSourceTypeAvailable(.camera) { Button("拍照") { imagePickerSource = .camera showImagePicker = true } } Button("从相册选择") { imagePickerSource = .photoLibrary showImagePicker = true } Button("取消", role: .cancel) {} } message: { Text("图片会添加到当前时间位置。") } .onAppear { playbackVM.totalDurationMs = Double(session.durationMs) playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs)) playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath)) } } private func prepareContinuation() { 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.isSynced = false session.syncState = .pending try? modelContext.save() } 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) } Text(Self.chineseDateTimeFormatter.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) } Text(session.endTime.map { Self.chineseDateTimeFormatter.string(from: $0) } ?? "进行中") .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)" ) // Sync status VStack(spacing: 4) { Image(systemName: session.isSynced ? "cloud.fill" : "cloud") .font(.system(size: 14, weight: .light)) .foregroundStyle(Color.secondary) Text(session.isSynced ? "已同步" : "未同步") .font(.system(size: 10, weight: .regular)) .foregroundStyle(Color.secondary) } .frame(maxWidth: .infinity) } } .padding(14) .background(Color.cardBackground.opacity(0.15)) .businessBorder(cornerRadius: 10) } private static let chineseDateTimeFormatter: 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 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.seekTo(timeMs: $0) } ), totalDurationMs: playbackVM.totalDurationMs, waveformSamples: playbackVM.waveformSamples, silentRanges: playbackVM.silentRanges, onEmptyTrackTap: handleEmptyTrackTap, onEventTap: { event in playbackVM.seekTo(timeMs: Double(event.relativeTimeMs)) selectedTimelineEvent = event HapticManager.trigger(.tapFeedback) } ) .frame(height: 140) } } // 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.seekTo(timeMs: newValue * playbackVM.totalDurationMs) } ), in: 0...1 ) .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 = playbackVM.sortedEvents(from: session) 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) { event in ChronoFeedRow(event: event) { playbackVM.seekTo(timeMs: event.relativeTimeMs) if event.eventType == "PHOTO" || event.eventType == "NOTE" || event.eventType == "MARKER" { selectedTimelineEvent = 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) } Spacer() } .padding(20) } .navigationTitle("添加笔记") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { noteText = "" 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: showPhotoActionDialog = true case .note: noteText = "" 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 session.events.append(event) do { try saveTimelineChanges() noteText = "" showNoteEditor = false } catch { modelContext.rollback() timelineEditError = "笔记保存失败:\(error.localizedDescription)" } } private func addPhoto(_ image: UIImage, at timeMs: Double) { guard let data = image.jpegData(compressionQuality: 0.85), let documentsURL = FileManager.default.urls( for: .documentDirectory, in: .userDomainMask ).first else { timelineEditError = "无法读取所选图片。" return } let filename = "photo_\(UUID().uuidString).jpg" let fileURL = documentsURL.appendingPathComponent(filename) do { try data.write(to: fileURL, options: .atomic) let event = CelestiaTimelineEvent( relativeTimeMs: Int64(timeMs.rounded()), eventType: "PHOTO" ) event.localFilePath = filename session.events.append(event) do { try saveTimelineChanges() } catch { modelContext.rollback() try? FileManager.default.removeItem(at: fileURL) throw error } } catch { timelineEditError = "图片保存失败:\(error.localizedDescription)" } } 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 } catch { modelContext.rollback() timelineEditError = "删除失败:\(error.localizedDescription)" } } private func saveTimelineChanges() throws { session.isSynced = false session.syncState = .pending try modelContext.save() } 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 struct TimelineEventDetailSheet: View { @Environment(\.dismiss) private var dismiss let event: CelestiaTimelineEvent let onDelete: () -> Void @State private var showDeleteConfirmation = false var body: some View { NavigationStack { ZStack { Color.spaceBlack.ignoresSafeArea() ScrollView { VStack(alignment: .leading, spacing: 16) { Label(event.relativeTimeFormatted, systemImage: "clock") .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(Color.secondary) eventContent Button(role: .destructive) { showDeleteConfirmation = true } label: { Label("删除这条记录", systemImage: "trash") .font(.system(size: 14, weight: .medium)) .frame(maxWidth: .infinity) .padding(.vertical, 12) } .buttonStyle(.bordered) .tint(.red) } .padding(20) } } .navigationTitle(event.eventType == "PHOTO" ? "查看图片" : "查看笔记") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } } } .confirmationDialog( "确定删除这条记录吗?", isPresented: $showDeleteConfirmation, titleVisibility: .visible ) { Button("删除", role: .destructive) { onDelete() dismiss() } Button("取消", role: .cancel) {} } message: { Text(event.eventType == "PHOTO" ? "本地图片文件也会被删除,此操作无法撤销。" : "此操作无法撤销。") } } .presentationDetents(event.eventType == "PHOTO" ? [.medium, .large] : [.medium]) .presentationDragIndicator(.visible) .presentationBackground(Color.spaceBlack) } @ViewBuilder private var eventContent: some View { if event.eventType == "PHOTO" { if let url = AudioPathHelper.resolveURL(for: event.localFilePath), let image = UIImage(contentsOfFile: url.path) { Image(uiImage: image) .resizable() .scaledToFit() .frame(maxWidth: .infinity) .clipShape(RoundedRectangle(cornerRadius: 10)) .overlay { RoundedRectangle(cornerRadius: 10) .stroke(Color.lineBorder, lineWidth: 1) } } else { ContentUnavailableView( "图片不可用", systemImage: "photo.badge.exclamationmark", description: Text("本地图片文件可能已被移动或删除。") ) .frame(maxWidth: .infinity, minHeight: 180) } } 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 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) }