|
|
@@ -1,5 +1,6 @@
|
|
|
import SwiftUI
|
|
|
import SwiftData
|
|
|
+import UIKit
|
|
|
|
|
|
// MARK: - SessionDetailView
|
|
|
/// The session detail & playback screen.
|
|
|
@@ -20,6 +21,20 @@ struct SessionDetailView: View {
|
|
|
@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 {
|
|
|
@@ -27,20 +42,16 @@ struct SessionDetailView: View {
|
|
|
|
|
|
ScrollView {
|
|
|
VStack(spacing: 20) {
|
|
|
- // Section 1: Session Info Card
|
|
|
- sessionInfoCard
|
|
|
- .padding(.horizontal, 16)
|
|
|
- .padding(.top, 12)
|
|
|
-
|
|
|
- // Section 2: Multi-Track Timeline
|
|
|
+ // Section 1: Multi-Track Timeline
|
|
|
timelineSection
|
|
|
.padding(.horizontal, 16)
|
|
|
+ .padding(.top, 12)
|
|
|
|
|
|
- // Section 3: Playback Controls
|
|
|
+ // Section 2: Playback Controls
|
|
|
playbackControls
|
|
|
.padding(.horizontal, 16)
|
|
|
|
|
|
- // Section 4: Chrono Feed
|
|
|
+ // Section 3: Chrono Feed
|
|
|
chronoFeedSection
|
|
|
.padding(.horizontal, 16)
|
|
|
.padding(.bottom, 32)
|
|
|
@@ -50,28 +61,64 @@ struct SessionDetailView: View {
|
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
|
.toolbar {
|
|
|
ToolbarItem(placement: .principal) {
|
|
|
- Text(session.title)
|
|
|
- .font(.system(size: 14, weight: .semibold))
|
|
|
- .foregroundStyle(Color.primary)
|
|
|
- .lineLimit(1)
|
|
|
- }
|
|
|
- ToolbarItem(placement: .topBarTrailing) {
|
|
|
Button {
|
|
|
- prepareContinuation()
|
|
|
+ recordName = session.title
|
|
|
+ showRecordNameEditor = true
|
|
|
} label: {
|
|
|
- HStack(spacing: 4) {
|
|
|
- Image(systemName: "plus")
|
|
|
- .font(.system(size: 11, weight: .medium))
|
|
|
- Text("续录")
|
|
|
- .font(.system(size: 12, weight: .regular))
|
|
|
+ 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)
|
|
|
- .padding(.horizontal, 10)
|
|
|
- .padding(.vertical, 4)
|
|
|
- .background(Color.primary.opacity(0.02))
|
|
|
- .businessBorder(cornerRadius: 6)
|
|
|
+ .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) {
|
|
|
@@ -99,6 +146,48 @@ struct SessionDetailView: View {
|
|
|
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))
|
|
|
@@ -125,6 +214,69 @@ struct SessionDetailView: View {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ 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)
|
|
|
@@ -148,7 +300,7 @@ struct SessionDetailView: View {
|
|
|
.foregroundStyle(Color.secondary)
|
|
|
}
|
|
|
|
|
|
- Text(session.startTime.formatted(.dateTime.month().day().hour().minute()))
|
|
|
+ Text(Self.chineseDateTimeFormatter.string(from: session.startTime))
|
|
|
.font(.system(size: 13, weight: .medium))
|
|
|
.foregroundStyle(Color.primary)
|
|
|
}
|
|
|
@@ -166,7 +318,7 @@ struct SessionDetailView: View {
|
|
|
.foregroundStyle(Color.secondary)
|
|
|
}
|
|
|
|
|
|
- Text(session.endTime?.formatted(.dateTime.month().day().hour().minute()) ?? "进行中")
|
|
|
+ Text(session.endTime.map { Self.chineseDateTimeFormatter.string(from: $0) } ?? "进行中")
|
|
|
.font(.system(size: 13, weight: .medium))
|
|
|
.foregroundStyle(Color.primary)
|
|
|
}
|
|
|
@@ -213,6 +365,14 @@ struct SessionDetailView: View {
|
|
|
.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)
|
|
|
@@ -234,7 +394,6 @@ struct SessionDetailView: View {
|
|
|
|
|
|
private var timelineSection: some View {
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
|
- sectionHeader(icon: "waveform", title: "三轨时间线")
|
|
|
|
|
|
MultiTrackTimeline(
|
|
|
events: session.events,
|
|
|
@@ -244,7 +403,13 @@ struct SessionDetailView: View {
|
|
|
),
|
|
|
totalDurationMs: playbackVM.totalDurationMs,
|
|
|
waveformSamples: playbackVM.waveformSamples,
|
|
|
- silentRanges: playbackVM.silentRanges
|
|
|
+ silentRanges: playbackVM.silentRanges,
|
|
|
+ onEmptyTrackTap: handleEmptyTrackTap,
|
|
|
+ onEventTap: { event in
|
|
|
+ playbackVM.seekTo(timeMs: Double(event.relativeTimeMs))
|
|
|
+ selectedTimelineEvent = event
|
|
|
+ HapticManager.trigger(.tapFeedback)
|
|
|
+ }
|
|
|
)
|
|
|
.frame(height: 140)
|
|
|
}
|
|
|
@@ -325,7 +490,7 @@ struct SessionDetailView: View {
|
|
|
|
|
|
HStack {
|
|
|
Label {
|
|
|
- Text("智能跳过静音")
|
|
|
+ Text("跳过静音")
|
|
|
.font(.system(size: 12, weight: .medium))
|
|
|
.foregroundStyle(Color.primary)
|
|
|
} icon: {
|
|
|
@@ -387,6 +552,11 @@ struct SessionDetailView: View {
|
|
|
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)
|
|
|
}
|
|
|
}
|
|
|
@@ -418,6 +588,293 @@ struct SessionDetailView: View {
|
|
|
}
|
|
|
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
|