| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876 |
- 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
- @ObservedObject private var syncManager: SyncManager = .shared
- @ObservedObject private var networkMonitor: NetworkStatusMonitor = .shared
- let session: CelestiaSession
- @State private var playbackVM = PlaybackViewModel()
- @State private var navigateToRecording = false
- @State private var detector = RecordingEnvironmentDetector()
- @State private var showPrepAlert = false
- @State private var activeWarningMsg = ""
- @State private var showRecordingSourcePicker = false
- @State private var selectedRecordingSource: RecordingSourceChoice = .iPhone
- @State private var recordName = ""
- @State private var showRecordNameEditor = false
- @State private var showSessionInfo = false
- @State private var showShareUnavailableAlert = false
- @State private var audioShareItem: AudioShareItem?
- @State private var preparedShareDirectoryURL: URL?
- @State private var pendingTimelineTimeMs: Double = 0
- @State private var noteText = ""
- @State private var noteLocation: TimelineLocation?
- @State private var showNoteEditor = false
- @State private var showPhotoEditor = false
- @State private var selectedTimelineEvent: CelestiaTimelineEvent?
- @State private var selectedPhotoPoint: PhotoPointSelection?
- @State private var timelineEditError: String?
- @State private var showSyncAuthPrompt = false
- @State private var showAuthModal = false
- @State private var showLargeSyncConfirmation = false
- @State private var showSyncInfo = false
- @State private var hasApprovedLargeTransfer = false
- var body: some View {
- ZStack {
- Color.spaceBlack.ignoresSafeArea()
- ScrollView {
- VStack(spacing: 20) {
- detailHeader
- .padding(.horizontal, 16)
- .padding(.top, 12)
- // Section 1: Multi-Track Timeline
- timelineSection
- .padding(.horizontal, 16)
- // Section 2: Playback Controls
- playbackControls
- .padding(.horizontal, 16)
- // Section 3: Chrono Feed
- chronoFeedSection
- .padding(.horizontal, 16)
- .padding(.bottom, 32)
- }
- }
- }
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItemGroup(placement: .topBarTrailing) {
- Button {
- showSessionInfo = true
- } label: {
- Image(systemName: "info.circle")
- .font(.system(size: 16, weight: .regular))
- .foregroundStyle(Color.primary)
- }
- .accessibilityLabel("记录信息")
- .popover(isPresented: $showSessionInfo, arrowEdge: .top) {
- sessionInfoCard
- .padding(16)
- .frame(idealWidth: 360)
- .presentationCompactAdaptation(.popover)
- }
- Button {
- prepareAudioForSharing()
- } label: {
- Image(systemName: "square.and.arrow.up")
- .font(.system(size: 16, weight: .regular))
- .foregroundStyle(Color.primary)
- }
- .accessibilityLabel("分享录音")
- }
- }
- .sheet(item: $audioShareItem, onDismiss: cleanupSharedAudioCopy) { item in
- AudioShareSheet(fileURL: item.fileURL)
- }
- .alert("无法分享录音", isPresented: $showShareUnavailableAlert) {
- Button("知道了", role: .cancel) {}
- } message: {
- Text("当前记录没有可用的本地录音文件,请先完成录制或同步录音。")
- }
- .alert("无法完成编辑", isPresented: Binding(
- get: { timelineEditError != nil },
- set: { if !$0 { timelineEditError = nil } }
- )) {
- Button("知道了", role: .cancel) {}
- } message: {
- Text(timelineEditError ?? "请稍后重试。")
- }
- .alert("录音环境提醒", isPresented: $showPrepAlert) {
- Button("继续录制", role: .none) {
- chooseSourceAndContinue()
- }
- Button("取消", role: .cancel) {}
- } message: {
- Text(activeWarningMsg + "\n\n另外请手动确认:\n1. 手机侧边静音开关已拨至红色(静音状态)\n2. 已关闭可能在此期间响起的系统闹钟")
- }
- .fullScreenCover(isPresented: $navigateToRecording) {
- ActiveRecordingView(
- session: session,
- initialDuration: Double(session.durationMs) / 1000.0,
- recordingSource: selectedRecordingSource
- ) {
- playbackVM.totalDurationMs = Double(session.durationMs)
- playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
- playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
- scheduleAutoSync()
- }
- }
- .sheet(isPresented: $showRecordingSourcePicker) {
- RecordingSourcePickerView(devices: connectedSparkDevices) { source in
- selectedRecordingSource = source
- showRecordingSourcePicker = false
- navigateToRecording = true
- }
- }
- .sheet(isPresented: $showRecordNameEditor) {
- RecordNameEditorSheet(recordName: $recordName) {
- saveRecordName()
- } onCancel: {
- recordName = session.title
- showRecordNameEditor = false
- }
- }
- .sheet(isPresented: $showNoteEditor) {
- timelineNoteEditor
- }
- .sheet(isPresented: $showPhotoEditor) {
- PhotoRecordEditorSheet(
- timeLabel: formattedTimelineTime(pendingTimelineTimeMs)
- ) { drafts, location in
- addPhotos(
- drafts,
- location: location,
- at: pendingTimelineTimeMs
- )
- }
- }
- .sheet(item: $selectedTimelineEvent) { event in
- TimelineEventDetailSheet(
- event: event,
- onSaveNote: { updatedText, updatedLocation in
- updateTimelineNote(
- event,
- text: updatedText,
- location: updatedLocation
- )
- },
- onDelete: {
- deleteTimelineEvent(event)
- }
- )
- }
- .sheet(item: $selectedPhotoPoint) { selection in
- PhotoPointDetailSheet(
- events: photoEvents(at: selection.relativeTimeMs),
- onSaveEdits: updatePhotoEdits,
- onDelete: deleteTimelineEvent
- )
- }
- .sheet(isPresented: $showSyncAuthPrompt) {
- SyncAuthPromptModal {
- showSyncAuthPrompt = false
- showAuthModal = true
- }
- }
- .sheet(isPresented: $showAuthModal) {
- AuthModalView()
- }
- .alert("同步较大文件", isPresented: $showLargeSyncConfirmation) {
- Button("开始同步") {
- hasApprovedLargeTransfer = true
- performSessionSync()
- }
- Button("取消", role: .cancel) {}
- } message: {
- Text(largeTransferMessage)
- }
- .onAppear {
- playbackVM.totalDurationMs = Double(session.durationMs)
- playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
- playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
- if session.needsCloudSync {
- scheduleAutoSync()
- }
- }
- .onChange(of: session.syncStateRaw) { _, newValue in
- guard let state = SessionSyncState(rawValue: newValue),
- state == .pending || state == .localOnly,
- session.needsCloudSync else { return }
- scheduleAutoSync()
- }
- }
- private var detailHeader: some View {
- HStack(spacing: 12) {
- Button {
- recordName = session.title
- showRecordNameEditor = true
- } label: {
- Text(session.title)
- .font(.system(size: 22, weight: .semibold))
- .lineLimit(1)
- .minimumScaleFactor(0.75)
- .foregroundStyle(Color.primary)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .accessibilityLabel("编辑记录名称")
- .layoutPriority(1)
- Spacer(minLength: 0)
- compactSyncButton
- .fixedSize()
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- private func prepareContinuation() {
- playbackVM.pause()
- detector.checkEnvironment()
- if let firstWarning = detector.activeWarnings.first {
- activeWarningMsg = firstWarning
- showPrepAlert = true
- } else {
- chooseSourceAndContinue()
- }
- }
- private func chooseSourceAndContinue() {
- if connectedSparkDevices.isEmpty {
- selectedRecordingSource = .iPhone
- navigateToRecording = true
- } else {
- showRecordingSourcePicker = true
- }
- }
- private func saveRecordName() {
- let trimmedName = recordName.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmedName.isEmpty else { return }
- recordName = trimmedName
- if session.title != trimmedName {
- session.title = trimmedName
- session.markContentModified()
- try? modelContext.save()
- scheduleAutoSync()
- }
- showRecordNameEditor = false
- }
- private func prepareAudioForSharing() {
- guard let sourceURL = AudioPathHelper.resolveURL(for: session.localAudioPath) else {
- showShareUnavailableAlert = true
- return
- }
- let shareDirectory = FileManager.default.temporaryDirectory
- .appendingPathComponent("CelestiaTraceShare", isDirectory: true)
- .appendingPathComponent(UUID().uuidString, isDirectory: true)
- let fileExtension = sourceURL.pathExtension
- let fileName = sanitizedShareFileName(from: session.title)
- let destinationURL = shareDirectory
- .appendingPathComponent(fileName)
- .appendingPathExtension(fileExtension)
- do {
- try FileManager.default.createDirectory(
- at: shareDirectory,
- withIntermediateDirectories: true
- )
- try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
- preparedShareDirectoryURL = shareDirectory
- audioShareItem = AudioShareItem(fileURL: destinationURL)
- } catch {
- audioShareItem = nil
- preparedShareDirectoryURL = nil
- showShareUnavailableAlert = true
- }
- }
- private func sanitizedShareFileName(from title: String) -> String {
- let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
- .union(.controlCharacters)
- .union(.newlines)
- let components = title.components(separatedBy: invalidCharacters)
- let sanitized = components
- .joined(separator: "-")
- .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines.union(
- CharacterSet(charactersIn: ".")
- ))
- return sanitized.isEmpty ? "现场记录" : sanitized
- }
- private func cleanupSharedAudioCopy() {
- guard let preparedShareDirectoryURL else { return }
- try? FileManager.default.removeItem(at: preparedShareDirectoryURL)
- self.preparedShareDirectoryURL = nil
- }
- private var connectedSparkDevices: [BoundDevice] {
- guard let userID = authManager.currentUser?.id else { return [] }
- return bleManager.connectedDevices(forUserId: userID)
- .sorted { $0.boundAt < $1.boundAt }
- }
- // MARK: - Session Info Card
- private var sessionInfoCard: some View {
- VStack(spacing: 14) {
- // Date range
- HStack {
- VStack(alignment: .leading, spacing: 4) {
- Label {
- Text("开始")
- .font(.system(size: 10, weight: .medium))
- .foregroundStyle(Color.secondary)
- } icon: {
- Image(systemName: "play.circle")
- .font(.system(size: 11))
- .foregroundStyle(Color.secondary)
- }
- VStack(alignment: .leading, spacing: 1) {
- Text(Self.chineseDateFormatter.string(from: session.startTime))
- Text(Self.chineseTimeFormatter.string(from: session.startTime))
- }
- .font(.system(size: 13, weight: .medium))
- .foregroundStyle(Color.primary)
- }
- Spacer()
- VStack(alignment: .trailing, spacing: 4) {
- Label {
- Text("结束")
- .font(.system(size: 10, weight: .medium))
- .foregroundStyle(Color.secondary)
- } icon: {
- Image(systemName: "stop.circle")
- .font(.system(size: 11))
- .foregroundStyle(Color.secondary)
- }
- if let endTime = session.endTime {
- VStack(alignment: .trailing, spacing: 1) {
- Text(Self.chineseDateFormatter.string(from: endTime))
- Text(Self.chineseTimeFormatter.string(from: endTime))
- }
- .font(.system(size: 13, weight: .medium))
- .foregroundStyle(Color.primary)
- } else {
- Text("进行中")
- .font(.system(size: 13, weight: .medium))
- .foregroundStyle(Color.primary)
- }
- }
- }
- Divider()
- .background(Color.lineBorder)
- // Stats row
- HStack(spacing: 0) {
- infoStat(
- icon: "timer",
- label: "总时长",
- value: session.durationFormatted
- )
- infoStat(
- icon: "camera",
- label: "图片",
- value: "\(session.photoCount)"
- )
- infoStat(
- icon: "doc.text",
- label: "笔记",
- value: "\(session.noteCount)"
- )
- }
- Divider()
- .background(Color.lineBorder)
- HStack(spacing: 8) {
- Label {
- Text("版本号")
- .font(.system(size: 11, weight: .medium))
- } icon: {
- Image(systemName: "number")
- .font(.system(size: 11, weight: .medium))
- }
- .foregroundStyle(Color.secondary)
- Spacer()
- Text("v\(session.localRevision)")
- .font(.system(size: 12, weight: .semibold, design: .monospaced))
- .foregroundStyle(Color.primary)
- }
- Divider()
- .background(Color.lineBorder)
- Button {
- showSessionInfo = false
- DispatchQueue.main.async {
- prepareContinuation()
- }
- } label: {
- HStack(spacing: 8) {
- Image(systemName: "record.circle")
- .font(.system(size: 14, weight: .medium))
- Text("续录")
- .font(.system(size: 13, weight: .semibold))
- }
- .foregroundStyle(Color.primary)
- .frame(maxWidth: .infinity)
- .padding(.vertical, 10)
- .background(Color.cardBackground.opacity(0.2))
- .businessBorder(cornerRadius: 8)
- }
- .buttonStyle(.plain)
- .accessibilityLabel("继续现场记录")
- }
- .padding(14)
- .background(Color.cardBackground.opacity(0.15))
- .businessBorder(cornerRadius: 10)
- }
- private static let chineseDateFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "zh_CN")
- formatter.calendar = Calendar(identifier: .gregorian)
- formatter.dateFormat = "yyyy年M月d日"
- return formatter
- }()
- private static let chineseTimeFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "zh_CN")
- formatter.calendar = Calendar(identifier: .gregorian)
- formatter.dateFormat = "HH:mm:ss"
- return formatter
- }()
- private var compactSyncButton: some View {
- VStack(spacing: 3) {
- Button {
- if isThisSessionSyncing {
- syncManager.pauseSync(sessionID: session.id)
- } else if isDisplayedSynced {
- showSyncInfo = true
- } else {
- requestSessionSync(isAutomatic: false)
- }
- } label: {
- Group {
- if isDisplayedSyncing {
- ZStack {
- Circle()
- .stroke(Color.secondary.opacity(0.22), lineWidth: 2)
- Circle()
- .trim(from: 0, to: max(sessionSyncProgress, 0.04))
- .stroke(
- Color.lessCosmosGold,
- style: StrokeStyle(lineWidth: 2, lineCap: .round)
- )
- .rotationEffect(.degrees(-90))
- TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
- Image(systemName: "arrow.triangle.2.circlepath")
- .font(.system(size: 9, weight: .semibold))
- .foregroundStyle(Color.lessCosmosGold)
- .rotationEffect(syncRotation(at: context.date))
- }
- }
- } else if isDisplayedSynced {
- Image(systemName: "checkmark.icloud.fill")
- .symbolRenderingMode(.hierarchical)
- .foregroundStyle(Color.lessCosmosGold)
- } else {
- Image(systemName: "icloud.and.arrow.up")
- .symbolRenderingMode(.hierarchical)
- .foregroundStyle(Color.secondary)
- }
- }
- .font(.system(size: 17, weight: .medium))
- .frame(width: 32, height: 32)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .disabled(syncManager.isSyncing && !isThisSessionSyncing)
- .accessibilityLabel(isThisSessionSyncing ? "暂停同步" : "同步记录")
- .accessibilityValue(
- isDisplayedSyncing
- ? "\(syncStatusText),\(Int((sessionSyncProgress * 100).rounded()))%"
- : syncStatusText
- )
- .popover(isPresented: $showSyncInfo, arrowEdge: .top) {
- syncInfoCard
- .padding(16)
- .frame(idealWidth: 320)
- .presentationCompactAdaptation(.popover)
- }
- Text(syncStatusText)
- .font(.system(size: 9, weight: .medium))
- .foregroundStyle(syncStatusColor)
- }
- }
- private var syncInfoCard: some View {
- VStack(alignment: .leading, spacing: 12) {
- HStack(spacing: 8) {
- Image(systemName: "checkmark.icloud.fill")
- .foregroundStyle(Color.lessCosmosGold)
- Text("同步信息")
- .font(.system(size: 15, weight: .semibold))
- .foregroundStyle(Color.primary)
- }
- Divider()
- .background(Color.lineBorder)
- syncInfoRow(
- label: "同步时间",
- value: session.lastSyncedAt.map {
- Self.syncDateTimeFormatter.string(from: $0)
- } ?? "现场记录未保存"
- )
- syncInfoRow(
- label: "云端版本",
- value: "v\(max(session.serverRevision, 1))",
- monospaced: true
- )
- if let cloudSessionID = session.cloudSessionId {
- syncInfoRow(
- label: "云端记录",
- value: cloudSessionID,
- monospaced: true
- )
- }
- }
- .padding(14)
- .background(Color.cardBackground.opacity(0.15))
- .businessBorder(cornerRadius: 10)
- }
- private func syncInfoRow(
- label: String,
- value: String,
- monospaced: Bool = false
- ) -> some View {
- HStack(alignment: .firstTextBaseline, spacing: 14) {
- Text(label)
- .font(.system(size: 11, weight: .medium))
- .foregroundStyle(Color.secondary)
- Spacer(minLength: 12)
- Text(value)
- .font(.system(size: 12, weight: .medium, design: monospaced ? .monospaced : .default))
- .foregroundStyle(Color.primary)
- .multilineTextAlignment(.trailing)
- .lineLimit(2)
- .textSelection(.enabled)
- }
- }
- private var isThisSessionSyncing: Bool {
- syncManager.isSyncing && syncManager.activeSessionID == session.id
- }
- private var isDisplayedSyncing: Bool {
- isThisSessionSyncing
- }
- private var isDisplayedSynced: Bool {
- !isDisplayedSyncing && session.hasConfirmedCloudSync
- }
- private var sessionSyncProgress: Double {
- if isThisSessionSyncing {
- return min(max(syncManager.syncProgress, 0), 1)
- }
- return session.hasConfirmedCloudSync ? 1 : 0
- }
- private var syncStatusText: String {
- if isDisplayedSyncing { return "正在同步" }
- return isDisplayedSynced ? "已同步" : "未同步"
- }
- private var syncStatusColor: Color {
- isDisplayedSynced || isDisplayedSyncing ? Color.lessCosmosGold : Color.secondary
- }
- private func syncRotation(at date: Date) -> Angle {
- let cycle = date.timeIntervalSinceReferenceDate
- .truncatingRemainder(dividingBy: 1.2)
- return .degrees(cycle / 1.2 * 360)
- }
- private static let syncDateTimeFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "zh_CN")
- formatter.calendar = Calendar(identifier: .gregorian)
- formatter.dateFormat = "yyyy年M月d日 HH:mm:ss"
- return formatter
- }()
- private var largeTransferMessage: String {
- let bytes = syncManager.estimatedUploadBytes(for: session)
- let size = ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
- return "预计上传约 \(size),将占用约 \(size) \(networkMonitor.connectionName)流量。是否继续?"
- }
- private func requestSessionSync(isAutomatic: Bool) {
- guard !syncManager.isSyncing else { return }
- guard networkMonitor.isConnected else {
- if !isAutomatic {
- timelineEditError = "当前没有可用网络,请连接网络后重试。"
- }
- return
- }
- guard authManager.currentUser?.id != nil else {
- if !isAutomatic {
- showSyncAuthPrompt = true
- }
- return
- }
- let largeTransferThreshold: Int64 = 70 * 1_024 * 1_024
- if syncManager.estimatedUploadBytes(for: session) >= largeTransferThreshold,
- !hasApprovedLargeTransfer {
- showLargeSyncConfirmation = true
- return
- }
- performSessionSync()
- }
- private func performSessionSync() {
- guard let userID = authManager.currentUser?.id else { return }
- Task {
- _ = await syncManager.sync(
- sessions: [session],
- modelContext: modelContext,
- userID: userID
- )
- }
- }
- private func scheduleAutoSync() {
- syncManager.scheduleAutomaticSync(
- for: session,
- modelContext: modelContext
- )
- }
- private func infoStat(icon: String, label: String, value: String) -> some View {
- VStack(spacing: 4) {
- Image(systemName: icon)
- .font(.system(size: 14, weight: .light))
- .foregroundStyle(Color.secondary)
- Text(value)
- .font(.system(size: 13, weight: .medium, design: .monospaced))
- .foregroundStyle(Color.primary)
- Text(label)
- .font(.system(size: 9, weight: .regular))
- .foregroundStyle(Color.secondary.opacity(0.8))
- }
- .frame(maxWidth: .infinity)
- }
- // MARK: - Timeline Section
- private var timelineSection: some View {
- VStack(alignment: .leading, spacing: 8) {
- MultiTrackTimeline(
- events: session.events,
- currentTimeMs: Binding(
- get: { playbackVM.currentPlaybackTimeMs },
- set: { playbackVM.seekTo(timeMs: $0) }
- ),
- totalDurationMs: playbackVM.totalDurationMs,
- waveformSamples: playbackVM.waveformSamples,
- silentRanges: playbackVM.silentRanges,
- onEmptyTrackTap: handleEmptyTrackTap,
- onEventTap: { event in
- playbackVM.seekTo(timeMs: Double(event.relativeTimeMs))
- openTimelineEvent(event)
- HapticManager.trigger(.tapFeedback)
- }
- )
- .frame(height: 220)
- }
- }
- // MARK: - Playback Controls
- private var playbackControls: some View {
- VStack(spacing: 12) {
- // Time display
- HStack {
- Text(playbackVM.currentTimeFormatted)
- .font(.system(size: 12, weight: .regular, design: .monospaced))
- .foregroundStyle(Color.primary)
- Spacer()
- Text(totalTimeFormatted)
- .font(.system(size: 12, weight: .regular, design: .monospaced))
- .foregroundStyle(Color.secondary)
- }
- // Seek slider
- Slider(
- value: Binding(
- get: { playbackVM.progress },
- set: { newValue in
- playbackVM.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 = chronoFeedItems
- if sorted.isEmpty {
- HStack {
- Spacer()
- VStack(spacing: 8) {
- Image(systemName: "tray")
- .font(.system(size: 24, weight: .light))
- .foregroundStyle(Color.secondary.opacity(0.3))
- Text("暂无会话事件")
- .font(.system(size: 12))
- .foregroundStyle(Color.secondary.opacity(0.5))
- }
- .padding(.vertical, 24)
- Spacer()
- }
- } else {
- LazyVStack(spacing: 8) {
- ForEach(sorted) { item in
- ChronoFeedRow(
- event: item.event,
- photoEvents: item.photoEvents
- ) {
- playbackVM.seekTo(timeMs: item.event.relativeTimeMs)
- openTimelineEvent(item.event)
- HapticManager.trigger(.tapFeedback)
- }
- }
- }
- }
- }
- }
- // MARK: - Helpers
- private func sectionHeader(icon: String, title: String) -> some View {
- HStack(spacing: 6) {
- Image(systemName: icon)
- .font(.system(size: 12, weight: .light))
- .foregroundStyle(Color.secondary)
- Text(title)
- .font(.system(size: 13, weight: .semibold))
- .foregroundStyle(Color.primary.opacity(0.8))
- }
- }
- private var totalTimeFormatted: String {
- let totalSeconds = Int(playbackVM.totalDurationMs / 1000)
- let hours = totalSeconds / 3600
- let minutes = (totalSeconds % 3600) / 60
- let seconds = totalSeconds % 60
- if hours > 0 {
- return String(format: "%d:%02d:%02d", hours, minutes, seconds)
- }
- return String(format: "%02d:%02d", minutes, seconds)
- }
- // MARK: - Timeline Editing
- private var timelineNoteEditor: some View {
- NavigationStack {
- ZStack {
- Color.spaceBlack.ignoresSafeArea()
- VStack(alignment: .leading, spacing: 16) {
- Label(
- "添加到 \(formattedTimelineTime(pendingTimelineTimeMs))",
- systemImage: "clock"
- )
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(Color.secondary)
- ZStack(alignment: .topLeading) {
- if noteText.isEmpty {
- Text("这一刻的想法")
- .font(.system(size: 15))
- .foregroundStyle(Color.secondary.opacity(0.6))
- .padding(.horizontal, 18)
- .padding(.vertical, 16)
- .allowsHitTesting(false)
- }
- TextEditor(text: $noteText)
- .font(.system(size: 15))
- .foregroundStyle(Color.primary)
- .scrollContentBackground(.hidden)
- .padding(10)
- .frame(minHeight: 160)
- .background(Color.cardBackground.opacity(0.5))
- .businessBorder(cornerRadius: 8)
- }
- TimelineLocationButton(location: $noteLocation)
- Spacer()
- }
- .padding(20)
- }
- .navigationTitle("添加笔记")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("取消") {
- noteText = ""
- noteLocation = nil
- showNoteEditor = false
- }
- }
- ToolbarItem(placement: .confirmationAction) {
- Button("保存") {
- addNote(at: pendingTimelineTimeMs)
- }
- .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
- }
- }
- }
- .presentationDetents([.medium])
- .presentationDragIndicator(.visible)
- .presentationBackground(Color.spaceBlack)
- }
- private func handleEmptyTrackTap(
- _ track: MultiTrackTimeline.EditableTrack,
- timeMs: Double
- ) {
- pendingTimelineTimeMs = min(max(timeMs, 0), playbackVM.totalDurationMs)
- playbackVM.seekTo(timeMs: pendingTimelineTimeMs)
- HapticManager.trigger(.tapFeedback)
- switch track {
- case .photo:
- showPhotoEditor = true
- case .note:
- noteText = ""
- noteLocation = nil
- showNoteEditor = true
- }
- }
- private func addNote(at timeMs: Double) {
- let trimmedText = noteText.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmedText.isEmpty else { return }
- let event = CelestiaTimelineEvent(
- relativeTimeMs: Int64(timeMs.rounded()),
- eventType: "NOTE"
- )
- event.textContent = trimmedText
- event.location = noteLocation
- session.events.append(event)
- do {
- try saveTimelineChanges()
- noteText = ""
- noteLocation = nil
- showNoteEditor = false
- } catch {
- modelContext.rollback()
- timelineEditError = "笔记保存失败:\(error.localizedDescription)"
- }
- }
- private func addPhotos(
- _ drafts: [PhotoRecordDraft],
- location: TimelineLocation?,
- at timeMs: Double
- ) -> Bool {
- guard !drafts.isEmpty,
- let documentsURL = FileManager.default.urls(
- for: .documentDirectory,
- in: .userDomainMask
- ).first else {
- timelineEditError = "无法访问本地照片目录。"
- return false
- }
- var storedPhotos: [
- (
- filename: String,
- fileURL: URL,
- note: String
- )
- ] = []
- do {
- for draft in drafts {
- guard let data = draft.image.jpegData(compressionQuality: 0.85) else {
- throw CocoaError(.fileWriteUnknown)
- }
- let filename = "photo_\(UUID().uuidString).jpg"
- let fileURL = documentsURL.appendingPathComponent(filename)
- try data.write(to: fileURL, options: .atomic)
- storedPhotos.append(
- (filename, fileURL, draft.note)
- )
- }
- let relativeTimeMs = Int64(timeMs.rounded())
- for storedPhoto in storedPhotos {
- let event = CelestiaTimelineEvent(
- relativeTimeMs: relativeTimeMs,
- eventType: "PHOTO"
- )
- event.localFilePath = storedPhoto.filename
- let trimmedNote = storedPhoto.note.trimmingCharacters(in: .whitespacesAndNewlines)
- event.textContent = trimmedNote.isEmpty ? nil : trimmedNote
- event.location = location
- session.events.append(event)
- }
- do {
- try saveTimelineChanges()
- return true
- } catch {
- modelContext.rollback()
- for storedPhoto in storedPhotos {
- try? FileManager.default.removeItem(at: storedPhoto.fileURL)
- }
- throw error
- }
- } catch {
- for storedPhoto in storedPhotos {
- try? FileManager.default.removeItem(at: storedPhoto.fileURL)
- }
- timelineEditError = "照片保存失败:\(error.localizedDescription)"
- return false
- }
- }
- private func updatePhotoEdits(
- _ notes: [UUID: String],
- location: TimelineLocation?
- ) -> Bool {
- let photoEventsByID = Dictionary(
- uniqueKeysWithValues: session.events
- .filter { $0.eventType == "PHOTO" }
- .map { ($0.id, $0) }
- )
- let previousValues = notes.reduce(
- into: [UUID: TimelineEventEditDraft]()
- ) { result, item in
- guard let event = photoEventsByID[item.key] else { return }
- result[item.key] = TimelineEventEditDraft(
- text: event.textContent ?? "",
- location: event.location
- )
- let trimmedNote = item.value
- .trimmingCharacters(in: .whitespacesAndNewlines)
- event.textContent = trimmedNote.isEmpty ? nil : trimmedNote
- event.location = location
- }
- do {
- try saveTimelineChanges()
- return true
- } catch {
- for (eventID, previousValue) in previousValues {
- photoEventsByID[eventID]?.textContent = previousValue.text.isEmpty
- ? nil
- : previousValue.text
- photoEventsByID[eventID]?.location = previousValue.location
- }
- modelContext.rollback()
- timelineEditError = "照片信息保存失败:\(error.localizedDescription)"
- return false
- }
- }
- private func updateTimelineNote(
- _ event: CelestiaTimelineEvent,
- text: String,
- location: TimelineLocation?
- ) -> Bool {
- let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmedText.isEmpty else { return false }
- let previousText = event.textContent
- let previousLocation = event.location
- event.textContent = trimmedText
- event.location = location
- do {
- try saveTimelineChanges()
- return true
- } catch {
- event.textContent = previousText
- event.location = previousLocation
- modelContext.rollback()
- timelineEditError = "笔记保存失败:\(error.localizedDescription)"
- return false
- }
- }
- private func deleteTimelineEvent(_ event: CelestiaTimelineEvent) {
- let photoURL = event.eventType == "PHOTO"
- ? AudioPathHelper.resolveURL(for: event.localFilePath)
- : nil
- modelContext.delete(event)
- do {
- try saveTimelineChanges()
- if let photoURL {
- try? FileManager.default.removeItem(at: photoURL)
- }
- selectedTimelineEvent = nil
- selectedPhotoPoint = nil
- } catch {
- modelContext.rollback()
- timelineEditError = "删除失败:\(error.localizedDescription)"
- }
- }
- private func saveTimelineChanges() throws {
- session.markContentModified()
- try modelContext.save()
- scheduleAutoSync()
- }
- private func formattedTimelineTime(_ timeMs: Double) -> String {
- let totalSeconds = max(0, Int(timeMs / 1_000))
- let hours = totalSeconds / 3_600
- let minutes = (totalSeconds % 3_600) / 60
- let seconds = totalSeconds % 60
- if hours > 0 {
- return String(format: "%d:%02d:%02d", hours, minutes, seconds)
- }
- return String(format: "%02d:%02d", minutes, seconds)
- }
- private func openTimelineEvent(_ event: CelestiaTimelineEvent) {
- if event.eventType == "PHOTO" {
- selectedPhotoPoint = PhotoPointSelection(
- eventID: event.id,
- relativeTimeMs: event.relativeTimeMs
- )
- } else if event.eventType == "NOTE" || event.eventType == "MARKER" {
- selectedTimelineEvent = event
- }
- }
- private func photoEvents(at relativeTimeMs: Int64) -> [CelestiaTimelineEvent] {
- session.events
- .filter {
- $0.eventType == "PHOTO"
- && $0.relativeTimeMs == relativeTimeMs
- }
- .sorted {
- if $0.createdAt == $1.createdAt {
- return $0.id.uuidString < $1.id.uuidString
- }
- return $0.createdAt < $1.createdAt
- }
- }
- private var chronoFeedItems: [ChronoFeedItem] {
- let sortedEvents = playbackVM.sortedEvents(from: session)
- var includedPhotoTimes = Set<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()
- VStack(alignment: .leading, spacing: 16) {
- Label(event.relativeTimeFormatted, systemImage: "clock")
- .font(.system(size: 12, weight: .medium, design: .monospaced))
- .foregroundStyle(Color.secondary)
- eventContent
- Spacer(minLength: 0)
- HStack {
- Spacer()
- Button(role: .destructive) {
- showDeleteConfirmation = true
- } label: {
- Image(systemName: "trash")
- .font(.system(size: 13, weight: .medium))
- .frame(width: 30, height: 30)
- }
- .buttonStyle(.bordered)
- .buttonBorderShape(.circle)
- .tint(.red)
- .accessibilityLabel("删除这条记录")
- }
- }
- .padding(20)
- }
- .navigationTitle(detailTitle)
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("完成") {
- dismiss()
- }
- }
- if event.eventType == "NOTE" {
- ToolbarItem(placement: .confirmationAction) {
- Button("保存") {
- if onSaveNote(noteDraft, locationDraft) {
- dismiss()
- }
- }
- .disabled(
- trimmedNoteDraft.isEmpty
- || !hasChanges
- )
- }
- }
- }
- .confirmationDialog(
- "确定删除这条记录吗?",
- isPresented: $showDeleteConfirmation,
- titleVisibility: .visible
- ) {
- Button("删除", role: .destructive) {
- onDelete()
- dismiss()
- }
- Button("取消", role: .cancel) {}
- } message: {
- Text(event.eventType == "PHOTO" ? "本地图片文件也会被删除,此操作无法撤销。" : "此操作无法撤销。")
- }
- }
- .fullScreenCover(isPresented: $showFullScreenImage) {
- if let image = resolvedImage {
- FullScreenTimelineImageView(
- image: image,
- capturedAt: event.createdAt,
- note: event.textContent ?? ""
- )
- }
- }
- .presentationDetents(event.eventType == "PHOTO" ? [.medium, .large] : [.medium])
- .presentationDragIndicator(.visible)
- .presentationBackground(Color.spaceBlack)
- }
- @ViewBuilder
- private var eventContent: some View {
- if event.eventType == "PHOTO" {
- if let image = resolvedImage {
- Button {
- showFullScreenImage = true
- } label: {
- Image(uiImage: image)
- .resizable()
- .scaledToFit()
- .frame(maxWidth: .infinity)
- .clipShape(RoundedRectangle(cornerRadius: 10))
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .accessibilityLabel("全屏查看图片")
- } else {
- ContentUnavailableView(
- "图片不可用",
- systemImage: "photo.badge.exclamationmark",
- description: Text("本地图片文件可能已被移动或删除。")
- )
- .frame(maxWidth: .infinity, minHeight: 180)
- }
- } else if event.eventType == "NOTE" {
- VStack(alignment: .leading, spacing: 14) {
- ZStack(alignment: .topLeading) {
- if noteDraft.isEmpty {
- Text("这一刻的想法")
- .font(.system(size: 15))
- .foregroundStyle(Color.secondary.opacity(0.55))
- .padding(.horizontal, 18)
- .padding(.vertical, 16)
- .allowsHitTesting(false)
- }
- TextEditor(text: $noteDraft)
- .font(.system(size: 15))
- .foregroundStyle(Color.primary)
- .scrollContentBackground(.hidden)
- .padding(10)
- .frame(maxWidth: .infinity, minHeight: 150, alignment: .topLeading)
- }
- .background(Color.cardBackground.opacity(0.5))
- .businessBorder(cornerRadius: 8)
- TimelineLocationButton(location: $locationDraft)
- }
- } else {
- Text(event.textContent ?? "暂无记录内容")
- .font(.system(size: 15))
- .foregroundStyle(Color.primary)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(16)
- .background(Color.cardBackground.opacity(0.5))
- .businessBorder(cornerRadius: 8)
- }
- }
- private var resolvedImage: UIImage? {
- guard let url = AudioPathHelper.resolveURL(for: event.localFilePath) else {
- return nil
- }
- return UIImage(contentsOfFile: url.path)
- }
- private var trimmedNoteDraft: String {
- noteDraft.trimmingCharacters(in: .whitespacesAndNewlines)
- }
- private var hasChanges: Bool {
- trimmedNoteDraft
- != (event.textContent ?? "")
- .trimmingCharacters(in: .whitespacesAndNewlines)
- || locationDraft != event.location
- }
- private var detailTitle: String {
- if event.isContinuationMarker {
- return "续录详情"
- }
- return event.eventType == "PHOTO" ? "查看图片" : "查看笔记"
- }
- }
- private struct FullScreenTimelineImageView: View {
- @Environment(\.dismiss) private var dismiss
- let image: UIImage
- let capturedAt: Date
- let note: String
- var body: some View {
- ZStack {
- Color.black.ignoresSafeArea()
- ZoomableTimelineImage(image: image)
- .ignoresSafeArea()
- VStack(spacing: 0) {
- HStack {
- Spacer()
- Button {
- dismiss()
- } label: {
- Image(systemName: "xmark")
- .font(.system(size: 14, weight: .semibold))
- .foregroundStyle(.white)
- .frame(width: 34, height: 34)
- .background(.black.opacity(0.55), in: Circle())
- }
- .accessibilityLabel("关闭全屏图片")
- }
- .padding(.top, 12)
- .padding(.trailing, 16)
- Spacer()
- VStack(alignment: .leading, spacing: 6) {
- Text(Self.photoDateFormatter.string(from: capturedAt))
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(.white.opacity(0.78))
- if !trimmedNote.isEmpty {
- Text(trimmedNote)
- .font(.system(size: 15))
- .foregroundStyle(.white)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 20)
- .padding(.top, 54)
- .padding(.bottom, 28)
- .background(
- LinearGradient(
- colors: [.clear, .black.opacity(0.82)],
- startPoint: .top,
- endPoint: .bottom
- )
- )
- }
- }
- .statusBarHidden()
- }
- private var trimmedNote: String {
- note.trimmingCharacters(in: .whitespacesAndNewlines)
- }
- private static let photoDateFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "zh_CN")
- formatter.dateFormat = "yyyy年M月d日 HH:mm:ss"
- return formatter
- }()
- }
- private struct ZoomableTimelineImage: View {
- let image: UIImage
- @State private var scale: CGFloat = 1
- @State private var settledScale: CGFloat = 1
- @State private var offset: CGSize = .zero
- @State private var settledOffset: CGSize = .zero
- private let maximumScale: CGFloat = 5
- var body: some View {
- GeometryReader { proxy in
- Image(uiImage: image)
- .resizable()
- .scaledToFit()
- .frame(width: proxy.size.width, height: proxy.size.height)
- .scaleEffect(scale)
- .offset(offset)
- .contentShape(Rectangle())
- .gesture(magnificationGesture(in: proxy.size))
- .simultaneousGesture(dragGesture(in: proxy.size))
- }
- .clipped()
- .accessibilityLabel("全屏图片,可双指缩放并拖动")
- }
- private func magnificationGesture(in containerSize: CGSize) -> some Gesture {
- MagnifyGesture()
- .onChanged { value in
- scale = min(max(settledScale * value.magnification, 1), maximumScale)
- offset = clampedOffset(offset, scale: scale, in: containerSize)
- }
- .onEnded { _ in
- if scale <= 1 {
- scale = 1
- offset = .zero
- } else {
- offset = clampedOffset(offset, scale: scale, in: containerSize)
- }
- settledScale = scale
- settledOffset = offset
- }
- }
- private func dragGesture(in containerSize: CGSize) -> some Gesture {
- DragGesture()
- .onChanged { value in
- guard scale > 1 else {
- offset = .zero
- return
- }
- let proposedOffset = CGSize(
- width: settledOffset.width + value.translation.width,
- height: settledOffset.height + value.translation.height
- )
- offset = clampedOffset(proposedOffset, scale: scale, in: containerSize)
- }
- .onEnded { _ in
- offset = clampedOffset(offset, scale: scale, in: containerSize)
- settledOffset = offset
- }
- }
- private func clampedOffset(
- _ proposedOffset: CGSize,
- scale: CGFloat,
- in containerSize: CGSize
- ) -> CGSize {
- let fittedSize = aspectFitSize(for: image.size, in: containerSize)
- let horizontalLimit = max(0, (fittedSize.width * scale - containerSize.width) / 2)
- let verticalLimit = max(0, (fittedSize.height * scale - containerSize.height) / 2)
- return CGSize(
- width: min(max(proposedOffset.width, -horizontalLimit), horizontalLimit),
- height: min(max(proposedOffset.height, -verticalLimit), verticalLimit)
- )
- }
- private func aspectFitSize(for imageSize: CGSize, in containerSize: CGSize) -> CGSize {
- guard imageSize.width > 0,
- imageSize.height > 0,
- containerSize.width > 0,
- containerSize.height > 0 else {
- return .zero
- }
- let fitScale = min(
- containerSize.width / imageSize.width,
- containerSize.height / imageSize.height
- )
- return CGSize(
- width: imageSize.width * fitScale,
- height: imageSize.height * fitScale
- )
- }
- }
- private struct AudioShareItem: Identifiable {
- let id = UUID()
- let fileURL: URL
- }
- private struct AudioShareSheet: UIViewControllerRepresentable {
- let fileURL: URL
- func makeUIViewController(context: Context) -> UIActivityViewController {
- UIActivityViewController(activityItems: [fileURL], applicationActivities: nil)
- }
- func updateUIViewController(
- _ uiViewController: UIActivityViewController,
- context: Context
- ) {}
- }
- // MARK: - Preview
- #Preview {
- NavigationStack {
- SessionDetailView(session: {
- let s = CelestiaSession(title: "Preview 现场记录 · 6.24 14:30")
- s.endTime = Date().addingTimeInterval(3600)
- return s
- }())
- }
- .modelContainer(for: CelestiaSession.self, inMemory: true)
- }
|