|
|
@@ -31,11 +31,11 @@ struct SessionDetailView: View {
|
|
|
@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 showPhotoActionDialog = false
|
|
|
- @State private var showImagePicker = false
|
|
|
- @State private var imagePickerSource: UIImagePickerController.SourceType = .photoLibrary
|
|
|
+ @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
|
|
|
@@ -150,15 +150,38 @@ struct SessionDetailView: View {
|
|
|
.sheet(isPresented: $showNoteEditor) {
|
|
|
timelineNoteEditor
|
|
|
}
|
|
|
- .sheet(isPresented: $showImagePicker) {
|
|
|
- ImagePicker(sourceType: imagePickerSource) { image in
|
|
|
- addPhoto(image, at: pendingTimelineTimeMs)
|
|
|
+ .sheet(isPresented: $showPhotoEditor) {
|
|
|
+ PhotoRecordEditorSheet(
|
|
|
+ timeLabel: formattedTimelineTime(pendingTimelineTimeMs)
|
|
|
+ ) { drafts, location in
|
|
|
+ addPhotos(
|
|
|
+ drafts,
|
|
|
+ location: location,
|
|
|
+ at: pendingTimelineTimeMs
|
|
|
+ )
|
|
|
}
|
|
|
}
|
|
|
.sheet(item: $selectedTimelineEvent) { event in
|
|
|
- TimelineEventDetailSheet(event: event) {
|
|
|
- deleteTimelineEvent(event)
|
|
|
- }
|
|
|
+ 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 {
|
|
|
@@ -178,27 +201,6 @@ struct SessionDetailView: View {
|
|
|
} message: {
|
|
|
Text(largeTransferMessage)
|
|
|
}
|
|
|
- .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))
|
|
|
@@ -557,7 +559,7 @@ struct SessionDetailView: View {
|
|
|
label: "同步时间",
|
|
|
value: session.lastSyncedAt.map {
|
|
|
Self.syncDateTimeFormatter.string(from: $0)
|
|
|
- } ?? "历史记录未保存"
|
|
|
+ } ?? "现场记录未保存"
|
|
|
)
|
|
|
|
|
|
syncInfoRow(
|
|
|
@@ -723,7 +725,7 @@ struct SessionDetailView: View {
|
|
|
onEmptyTrackTap: handleEmptyTrackTap,
|
|
|
onEventTap: { event in
|
|
|
playbackVM.seekTo(timeMs: Double(event.relativeTimeMs))
|
|
|
- selectedTimelineEvent = event
|
|
|
+ openTimelineEvent(event)
|
|
|
HapticManager.trigger(.tapFeedback)
|
|
|
}
|
|
|
)
|
|
|
@@ -847,7 +849,7 @@ struct SessionDetailView: View {
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
|
sectionHeader(icon: "list.dash", title: "事件")
|
|
|
|
|
|
- let sorted = playbackVM.sortedEvents(from: session)
|
|
|
+ let sorted = chronoFeedItems
|
|
|
|
|
|
if sorted.isEmpty {
|
|
|
HStack {
|
|
|
@@ -865,14 +867,13 @@ struct SessionDetailView: View {
|
|
|
}
|
|
|
} 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
|
|
|
- }
|
|
|
+ ForEach(sorted) { item in
|
|
|
+ ChronoFeedRow(
|
|
|
+ event: item.event,
|
|
|
+ photoEvents: item.photoEvents
|
|
|
+ ) {
|
|
|
+ playbackVM.seekTo(timeMs: item.event.relativeTimeMs)
|
|
|
+ openTimelineEvent(item.event)
|
|
|
HapticManager.trigger(.tapFeedback)
|
|
|
}
|
|
|
}
|
|
|
@@ -922,7 +923,7 @@ struct SessionDetailView: View {
|
|
|
|
|
|
ZStack(alignment: .topLeading) {
|
|
|
if noteText.isEmpty {
|
|
|
- Text("输入笔记内容…")
|
|
|
+ Text("这一刻的想法")
|
|
|
.font(.system(size: 15))
|
|
|
.foregroundStyle(Color.secondary.opacity(0.6))
|
|
|
.padding(.horizontal, 18)
|
|
|
@@ -940,6 +941,8 @@ struct SessionDetailView: View {
|
|
|
.businessBorder(cornerRadius: 8)
|
|
|
}
|
|
|
|
|
|
+ TimelineLocationButton(location: $noteLocation)
|
|
|
+
|
|
|
Spacer()
|
|
|
}
|
|
|
.padding(20)
|
|
|
@@ -950,6 +953,7 @@ struct SessionDetailView: View {
|
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
|
Button("取消") {
|
|
|
noteText = ""
|
|
|
+ noteLocation = nil
|
|
|
showNoteEditor = false
|
|
|
}
|
|
|
}
|
|
|
@@ -977,9 +981,10 @@ struct SessionDetailView: View {
|
|
|
|
|
|
switch track {
|
|
|
case .photo:
|
|
|
- showPhotoActionDialog = true
|
|
|
+ showPhotoEditor = true
|
|
|
case .note:
|
|
|
noteText = ""
|
|
|
+ noteLocation = nil
|
|
|
showNoteEditor = true
|
|
|
}
|
|
|
}
|
|
|
@@ -993,11 +998,13 @@ struct SessionDetailView: View {
|
|
|
eventType: "NOTE"
|
|
|
)
|
|
|
event.textContent = trimmedText
|
|
|
+ event.location = noteLocation
|
|
|
session.events.append(event)
|
|
|
|
|
|
do {
|
|
|
try saveTimelineChanges()
|
|
|
noteText = ""
|
|
|
+ noteLocation = nil
|
|
|
showNoteEditor = false
|
|
|
} catch {
|
|
|
modelContext.rollback()
|
|
|
@@ -1005,38 +1012,135 @@ struct SessionDetailView: View {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private func addPhoto(_ image: UIImage, at timeMs: Double) {
|
|
|
- guard let data = image.jpegData(compressionQuality: 0.85),
|
|
|
+ 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
|
|
|
+ timelineEditError = "无法访问本地照片目录。"
|
|
|
+ return false
|
|
|
}
|
|
|
|
|
|
- let filename = "photo_\(UUID().uuidString).jpg"
|
|
|
- let fileURL = documentsURL.appendingPathComponent(filename)
|
|
|
+ var storedPhotos: [
|
|
|
+ (
|
|
|
+ filename: String,
|
|
|
+ fileURL: URL,
|
|
|
+ note: String
|
|
|
+ )
|
|
|
+ ] = []
|
|
|
|
|
|
do {
|
|
|
- try data.write(to: fileURL, options: .atomic)
|
|
|
+ for draft in drafts {
|
|
|
+ guard let data = draft.image.jpegData(compressionQuality: 0.85) else {
|
|
|
+ throw CocoaError(.fileWriteUnknown)
|
|
|
+ }
|
|
|
|
|
|
- let event = CelestiaTimelineEvent(
|
|
|
- relativeTimeMs: Int64(timeMs.rounded()),
|
|
|
- eventType: "PHOTO"
|
|
|
- )
|
|
|
- event.localFilePath = filename
|
|
|
- session.events.append(event)
|
|
|
+ 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()
|
|
|
- try? FileManager.default.removeItem(at: fileURL)
|
|
|
+ for storedPhoto in storedPhotos {
|
|
|
+ try? FileManager.default.removeItem(at: storedPhoto.fileURL)
|
|
|
+ }
|
|
|
throw error
|
|
|
}
|
|
|
} catch {
|
|
|
- timelineEditError = "图片保存失败:\(error.localizedDescription)"
|
|
|
+ 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
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -1053,6 +1157,7 @@ struct SessionDetailView: View {
|
|
|
try? FileManager.default.removeItem(at: photoURL)
|
|
|
}
|
|
|
selectedTimelineEvent = nil
|
|
|
+ selectedPhotoPoint = nil
|
|
|
} catch {
|
|
|
modelContext.rollback()
|
|
|
timelineEditError = "删除失败:\(error.localizedDescription)"
|
|
|
@@ -1076,51 +1181,382 @@ struct SessionDetailView: View {
|
|
|
}
|
|
|
return String(format: "%02d:%02d", minutes, seconds)
|
|
|
}
|
|
|
+
|
|
|
+ private func openTimelineEvent(_ event: CelestiaTimelineEvent) {
|
|
|
+ if event.eventType == "PHOTO" {
|
|
|
+ selectedPhotoPoint = PhotoPointSelection(
|
|
|
+ eventID: event.id,
|
|
|
+ relativeTimeMs: event.relativeTimeMs
|
|
|
+ )
|
|
|
+ } else if event.eventType == "NOTE" || event.eventType == "MARKER" {
|
|
|
+ selectedTimelineEvent = event
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func photoEvents(at relativeTimeMs: Int64) -> [CelestiaTimelineEvent] {
|
|
|
+ session.events
|
|
|
+ .filter {
|
|
|
+ $0.eventType == "PHOTO"
|
|
|
+ && $0.relativeTimeMs == relativeTimeMs
|
|
|
+ }
|
|
|
+ .sorted {
|
|
|
+ if $0.createdAt == $1.createdAt {
|
|
|
+ return $0.id.uuidString < $1.id.uuidString
|
|
|
+ }
|
|
|
+ return $0.createdAt < $1.createdAt
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var chronoFeedItems: [ChronoFeedItem] {
|
|
|
+ let sortedEvents = playbackVM.sortedEvents(from: session)
|
|
|
+ var includedPhotoTimes = Set<Int64>()
|
|
|
+
|
|
|
+ return sortedEvents.compactMap { event in
|
|
|
+ guard event.eventType == "PHOTO" else {
|
|
|
+ return ChronoFeedItem(event: event, photoEvents: [])
|
|
|
+ }
|
|
|
+
|
|
|
+ guard includedPhotoTimes.insert(event.relativeTimeMs).inserted else {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ return ChronoFeedItem(
|
|
|
+ event: event,
|
|
|
+ photoEvents: photoEvents(at: event.relativeTimeMs)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct PhotoPointSelection: Identifiable {
|
|
|
+ let eventID: UUID
|
|
|
+ let relativeTimeMs: Int64
|
|
|
+
|
|
|
+ var id: UUID { eventID }
|
|
|
+}
|
|
|
+
|
|
|
+private struct ChronoFeedItem: Identifiable {
|
|
|
+ let event: CelestiaTimelineEvent
|
|
|
+ let photoEvents: [CelestiaTimelineEvent]
|
|
|
+
|
|
|
+ var id: UUID { event.id }
|
|
|
+}
|
|
|
+
|
|
|
+private struct TimelineImagePresentation: Identifiable {
|
|
|
+ let id = UUID()
|
|
|
+ let image: UIImage
|
|
|
+ let capturedAt: Date
|
|
|
+ let note: String
|
|
|
+}
|
|
|
+
|
|
|
+private struct TimelineEventEditDraft: Equatable {
|
|
|
+ var text: String
|
|
|
+ var location: TimelineLocation?
|
|
|
+}
|
|
|
+
|
|
|
+private struct PhotoPointDetailSheet: View {
|
|
|
+ @Environment(\.dismiss) private var dismiss
|
|
|
+
|
|
|
+ let events: [CelestiaTimelineEvent]
|
|
|
+ let onSaveEdits: ([UUID: String], TimelineLocation?) -> Bool
|
|
|
+ let onDelete: (CelestiaTimelineEvent) -> Void
|
|
|
+
|
|
|
+ @State private var noteDrafts: [UUID: String]
|
|
|
+ @State private var locationDraft: TimelineLocation?
|
|
|
+ @State private var pendingDeletionEvent: CelestiaTimelineEvent?
|
|
|
+ @State private var fullScreenImage: TimelineImagePresentation?
|
|
|
+
|
|
|
+ init(
|
|
|
+ events: [CelestiaTimelineEvent],
|
|
|
+ onSaveEdits: @escaping ([UUID: String], TimelineLocation?) -> Bool,
|
|
|
+ onDelete: @escaping (CelestiaTimelineEvent) -> Void
|
|
|
+ ) {
|
|
|
+ self.events = events
|
|
|
+ self.onSaveEdits = onSaveEdits
|
|
|
+ self.onDelete = onDelete
|
|
|
+ _noteDrafts = State(
|
|
|
+ initialValue: Dictionary(
|
|
|
+ uniqueKeysWithValues: events.map {
|
|
|
+ ($0.id, $0.textContent ?? "")
|
|
|
+ }
|
|
|
+ )
|
|
|
+ )
|
|
|
+ _locationDraft = State(
|
|
|
+ initialValue: events.compactMap(\.location).first
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ NavigationStack {
|
|
|
+ ZStack {
|
|
|
+ Color.spaceBlack.ignoresSafeArea()
|
|
|
+
|
|
|
+ if events.isEmpty {
|
|
|
+ ContentUnavailableView(
|
|
|
+ "照片不可用",
|
|
|
+ systemImage: "photo.badge.exclamationmark",
|
|
|
+ description: Text("这个记录点没有可查看的本地照片。")
|
|
|
+ )
|
|
|
+ } else {
|
|
|
+ ScrollView {
|
|
|
+ VStack(alignment: .leading, spacing: 16) {
|
|
|
+ HStack {
|
|
|
+ Label(
|
|
|
+ events[0].relativeTimeFormatted,
|
|
|
+ systemImage: "clock"
|
|
|
+ )
|
|
|
+ .font(.system(size: 12, weight: .medium, design: .monospaced))
|
|
|
+ .foregroundStyle(Color.secondary)
|
|
|
+
|
|
|
+ Spacer()
|
|
|
+
|
|
|
+ Text("\(events.count) 张照片")
|
|
|
+ .font(.system(size: 12, design: .monospaced))
|
|
|
+ .foregroundStyle(Color.secondary)
|
|
|
+ }
|
|
|
+
|
|
|
+ TimelineLocationButton(location: $locationDraft)
|
|
|
+
|
|
|
+ LazyVStack(spacing: 28) {
|
|
|
+ ForEach(Array(events.enumerated()), id: \.element.id) { index, event in
|
|
|
+ photoListItem(event, index: index)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .padding(20)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .navigationTitle(events.count > 1 ? "查看照片(\(events.count) 张)" : "查看照片")
|
|
|
+ .navigationBarTitleDisplayMode(.inline)
|
|
|
+ .toolbar {
|
|
|
+ ToolbarItem(placement: .cancellationAction) {
|
|
|
+ Button("完成") {
|
|
|
+ dismiss()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ ToolbarItem(placement: .confirmationAction) {
|
|
|
+ Button("保存") {
|
|
|
+ if onSaveEdits(noteDrafts, locationDraft) {
|
|
|
+ dismiss()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .disabled(!hasChanges)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .confirmationDialog(
|
|
|
+ "确定删除这张照片吗?",
|
|
|
+ isPresented: Binding(
|
|
|
+ get: { pendingDeletionEvent != nil },
|
|
|
+ set: { if !$0 { pendingDeletionEvent = nil } }
|
|
|
+ ),
|
|
|
+ titleVisibility: .visible
|
|
|
+ ) {
|
|
|
+ Button("删除", role: .destructive) {
|
|
|
+ guard let event = pendingDeletionEvent else { return }
|
|
|
+ onDelete(event)
|
|
|
+ dismiss()
|
|
|
+ }
|
|
|
+ Button("取消", role: .cancel) {}
|
|
|
+ } message: {
|
|
|
+ Text("本地图片文件和这张照片的备注都会被删除,此操作无法撤销。")
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .fullScreenCover(item: $fullScreenImage) { item in
|
|
|
+ FullScreenTimelineImageView(
|
|
|
+ image: item.image,
|
|
|
+ capturedAt: item.capturedAt,
|
|
|
+ note: item.note
|
|
|
+ )
|
|
|
+ }
|
|
|
+ .presentationDetents([.large])
|
|
|
+ .presentationDragIndicator(.visible)
|
|
|
+ .presentationBackground(Color.spaceBlack)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func photoListItem(
|
|
|
+ _ event: CelestiaTimelineEvent,
|
|
|
+ index: Int
|
|
|
+ ) -> some View {
|
|
|
+ VStack(alignment: .leading, spacing: 10) {
|
|
|
+ HStack {
|
|
|
+ Text("照片 \(index + 1)")
|
|
|
+ .font(.system(size: 11, weight: .medium))
|
|
|
+ .foregroundStyle(Color.secondary)
|
|
|
+
|
|
|
+ Spacer()
|
|
|
+
|
|
|
+ Button(role: .destructive) {
|
|
|
+ pendingDeletionEvent = event
|
|
|
+ } label: {
|
|
|
+ Image(systemName: "trash")
|
|
|
+ .font(.system(size: 11, weight: .medium))
|
|
|
+ .frame(width: 24, height: 24)
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ .foregroundStyle(Color.red)
|
|
|
+ .accessibilityLabel("删除照片 \(index + 1)")
|
|
|
+ }
|
|
|
+
|
|
|
+ if let image = resolvedImage(for: event) {
|
|
|
+ Button {
|
|
|
+ fullScreenImage = TimelineImagePresentation(
|
|
|
+ image: image,
|
|
|
+ capturedAt: event.createdAt,
|
|
|
+ note: noteDrafts[event.id] ?? ""
|
|
|
+ )
|
|
|
+ } label: {
|
|
|
+ Image(uiImage: image)
|
|
|
+ .resizable()
|
|
|
+ .scaledToFit()
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: 420)
|
|
|
+ .contentShape(Rectangle())
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ .accessibilityLabel("全屏查看照片 \(index + 1)")
|
|
|
+ } else {
|
|
|
+ ContentUnavailableView(
|
|
|
+ "图片不可用",
|
|
|
+ systemImage: "photo.badge.exclamationmark",
|
|
|
+ description: Text("本地图片文件可能已被移动或删除。")
|
|
|
+ )
|
|
|
+ .frame(maxWidth: .infinity, minHeight: 180)
|
|
|
+ }
|
|
|
+
|
|
|
+ ZStack(alignment: .topLeading) {
|
|
|
+ if (noteDrafts[event.id] ?? "").isEmpty {
|
|
|
+ Text("照片备注")
|
|
|
+ .font(.system(size: 14))
|
|
|
+ .foregroundStyle(Color.secondary.opacity(0.55))
|
|
|
+ .padding(.horizontal, 15)
|
|
|
+ .padding(.vertical, 17)
|
|
|
+ .allowsHitTesting(false)
|
|
|
+ }
|
|
|
+
|
|
|
+ TextEditor(text: noteBinding(for: event))
|
|
|
+ .font(.system(size: 14))
|
|
|
+ .foregroundStyle(Color.primary)
|
|
|
+ .scrollContentBackground(.hidden)
|
|
|
+ .padding(8)
|
|
|
+ .frame(height: 72)
|
|
|
+ }
|
|
|
+ .background(Color.cardBackground.opacity(0.45))
|
|
|
+ .businessBorder(cornerRadius: 8)
|
|
|
+
|
|
|
+ if index < events.count - 1 {
|
|
|
+ Divider()
|
|
|
+ .overlay(Color.lineBorder.opacity(0.55))
|
|
|
+ .padding(.top, 8)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func noteBinding(for event: CelestiaTimelineEvent) -> Binding<String> {
|
|
|
+ Binding(
|
|
|
+ get: { noteDrafts[event.id] ?? "" },
|
|
|
+ set: { newValue in
|
|
|
+ noteDrafts[event.id] = newValue
|
|
|
+ }
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ private var hasChanges: Bool {
|
|
|
+ events.contains { event in
|
|
|
+ let original = (event.textContent ?? "")
|
|
|
+ .trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ let draft = (noteDrafts[event.id] ?? "")
|
|
|
+ .trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ return original != draft || event.location != locationDraft
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func resolvedImage(for event: CelestiaTimelineEvent) -> UIImage? {
|
|
|
+ guard let url = AudioPathHelper.resolveURL(for: event.localFilePath) else {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ return UIImage(contentsOfFile: url.path)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
private struct TimelineEventDetailSheet: View {
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
|
|
let event: CelestiaTimelineEvent
|
|
|
+ let onSaveNote: (String, TimelineLocation?) -> Bool
|
|
|
let onDelete: () -> Void
|
|
|
|
|
|
+ @State private var noteDraft: String
|
|
|
+ @State private var locationDraft: TimelineLocation?
|
|
|
@State private var showDeleteConfirmation = false
|
|
|
+ @State private var showFullScreenImage = false
|
|
|
+
|
|
|
+ init(
|
|
|
+ event: CelestiaTimelineEvent,
|
|
|
+ onSaveNote: @escaping (String, TimelineLocation?) -> Bool,
|
|
|
+ onDelete: @escaping () -> Void
|
|
|
+ ) {
|
|
|
+ self.event = event
|
|
|
+ self.onSaveNote = onSaveNote
|
|
|
+ self.onDelete = onDelete
|
|
|
+ _noteDraft = State(initialValue: event.textContent ?? "")
|
|
|
+ _locationDraft = State(initialValue: event.location)
|
|
|
+ }
|
|
|
|
|
|
var body: some View {
|
|
|
NavigationStack {
|
|
|
ZStack {
|
|
|
Color.spaceBlack.ignoresSafeArea()
|
|
|
|
|
|
- ScrollView {
|
|
|
- VStack(alignment: .leading, spacing: 16) {
|
|
|
- Label(event.relativeTimeFormatted, systemImage: "clock")
|
|
|
- .font(.system(size: 12, weight: .medium, design: .monospaced))
|
|
|
- .foregroundStyle(Color.secondary)
|
|
|
+ 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)
|
|
|
|
|
|
- eventContent
|
|
|
+ HStack {
|
|
|
+ Spacer()
|
|
|
|
|
|
Button(role: .destructive) {
|
|
|
showDeleteConfirmation = true
|
|
|
} label: {
|
|
|
- Label("删除这条记录", systemImage: "trash")
|
|
|
- .font(.system(size: 14, weight: .medium))
|
|
|
- .frame(maxWidth: .infinity)
|
|
|
- .padding(.vertical, 12)
|
|
|
+ Image(systemName: "trash")
|
|
|
+ .font(.system(size: 13, weight: .medium))
|
|
|
+ .frame(width: 30, height: 30)
|
|
|
}
|
|
|
.buttonStyle(.bordered)
|
|
|
+ .buttonBorderShape(.circle)
|
|
|
.tint(.red)
|
|
|
+ .accessibilityLabel("删除这条记录")
|
|
|
}
|
|
|
- .padding(20)
|
|
|
}
|
|
|
+ .padding(20)
|
|
|
}
|
|
|
.navigationTitle(detailTitle)
|
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
|
.toolbar {
|
|
|
- ToolbarItem(placement: .confirmationAction) {
|
|
|
+ ToolbarItem(placement: .cancellationAction) {
|
|
|
Button("完成") {
|
|
|
dismiss()
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ if event.eventType == "NOTE" {
|
|
|
+ ToolbarItem(placement: .confirmationAction) {
|
|
|
+ Button("保存") {
|
|
|
+ if onSaveNote(noteDraft, locationDraft) {
|
|
|
+ dismiss()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .disabled(
|
|
|
+ trimmedNoteDraft.isEmpty
|
|
|
+ || !hasChanges
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
.confirmationDialog(
|
|
|
"确定删除这条记录吗?",
|
|
|
@@ -1136,6 +1572,15 @@ private struct TimelineEventDetailSheet: View {
|
|
|
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)
|
|
|
@@ -1144,17 +1589,19 @@ private struct TimelineEventDetailSheet: View {
|
|
|
@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)
|
|
|
- }
|
|
|
+ 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(
|
|
|
"图片不可用",
|
|
|
@@ -1163,8 +1610,32 @@ private struct TimelineEventDetailSheet: View {
|
|
|
)
|
|
|
.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 ?? "暂无笔记内容")
|
|
|
+ Text(event.textContent ?? "暂无记录内容")
|
|
|
.font(.system(size: 15))
|
|
|
.foregroundStyle(Color.primary)
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
@@ -1174,6 +1645,24 @@ private struct TimelineEventDetailSheet: View {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ 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 "续录详情"
|
|
|
@@ -1182,6 +1671,179 @@ private struct TimelineEventDetailSheet: View {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+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
|