| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891 |
- 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)
- }
|