|
|
@@ -11,6 +11,8 @@ 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
|
|
|
|
|
|
@@ -35,6 +37,11 @@ struct SessionDetailView: View {
|
|
|
@State private var imagePickerSource: UIImagePickerController.SourceType = .photoLibrary
|
|
|
@State private var selectedTimelineEvent: CelestiaTimelineEvent?
|
|
|
@State private var timelineEditError: String?
|
|
|
+ @State private var showSyncAuthPrompt = false
|
|
|
+ @State private var showAuthModal = false
|
|
|
+ @State private var showLargeSyncConfirmation = false
|
|
|
+ @State private var hasApprovedLargeTransfer = false
|
|
|
+ @State private var autoSyncTask: Task<Void, Never>?
|
|
|
|
|
|
var body: some View {
|
|
|
ZStack {
|
|
|
@@ -42,10 +49,13 @@ struct SessionDetailView: View {
|
|
|
|
|
|
ScrollView {
|
|
|
VStack(spacing: 20) {
|
|
|
+ detailHeader
|
|
|
+ .padding(.horizontal, 16)
|
|
|
+ .padding(.top, 12)
|
|
|
+
|
|
|
// Section 1: Multi-Track Timeline
|
|
|
timelineSection
|
|
|
.padding(.horizontal, 16)
|
|
|
- .padding(.top, 12)
|
|
|
|
|
|
// Section 2: Playback Controls
|
|
|
playbackControls
|
|
|
@@ -60,24 +70,6 @@ struct SessionDetailView: View {
|
|
|
}
|
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
|
.toolbar {
|
|
|
- ToolbarItem(placement: .principal) {
|
|
|
- Button {
|
|
|
- recordName = session.title
|
|
|
- showRecordNameEditor = true
|
|
|
- } label: {
|
|
|
- HStack(spacing: 5) {
|
|
|
- Text(session.title)
|
|
|
- .font(.system(size: 14, weight: .semibold))
|
|
|
- .lineLimit(1)
|
|
|
-
|
|
|
- Image(systemName: "pencil")
|
|
|
- .font(.system(size: 10, weight: .semibold))
|
|
|
- }
|
|
|
- .foregroundStyle(Color.primary)
|
|
|
- .contentShape(Rectangle())
|
|
|
- }
|
|
|
- .buttonStyle(.plain)
|
|
|
- }
|
|
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
|
|
Button {
|
|
|
showSessionInfo = true
|
|
|
@@ -137,6 +129,7 @@ struct SessionDetailView: View {
|
|
|
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) {
|
|
|
@@ -167,6 +160,24 @@ struct SessionDetailView: View {
|
|
|
deleteTimelineEvent(event)
|
|
|
}
|
|
|
}
|
|
|
+ .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)
|
|
|
+ }
|
|
|
.confirmationDialog(
|
|
|
"在 \(formattedTimelineTime(pendingTimelineTimeMs)) 添加图片",
|
|
|
isPresented: $showPhotoActionDialog,
|
|
|
@@ -192,7 +203,43 @@ struct SessionDetailView: View {
|
|
|
playbackVM.totalDurationMs = Double(session.durationMs)
|
|
|
playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
|
|
|
playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
|
|
|
+ if session.syncState == .pending || session.syncState == .localOnly {
|
|
|
+ scheduleAutoSync()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .onChange(of: session.syncStateRaw) { _, newValue in
|
|
|
+ guard let state = SessionSyncState(rawValue: newValue),
|
|
|
+ state == .pending || state == .localOnly else { return }
|
|
|
+ scheduleAutoSync()
|
|
|
+ }
|
|
|
+ .onDisappear {
|
|
|
+ autoSyncTask?.cancel()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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() {
|
|
|
@@ -225,6 +272,7 @@ struct SessionDetailView: View {
|
|
|
session.isSynced = false
|
|
|
session.syncState = .pending
|
|
|
try? modelContext.save()
|
|
|
+ scheduleAutoSync()
|
|
|
}
|
|
|
showRecordNameEditor = false
|
|
|
}
|
|
|
@@ -301,9 +349,12 @@ struct SessionDetailView: View {
|
|
|
.foregroundStyle(Color.secondary)
|
|
|
}
|
|
|
|
|
|
- Text(Self.chineseDateTimeFormatter.string(from: session.startTime))
|
|
|
- .font(.system(size: 13, weight: .medium))
|
|
|
- .foregroundStyle(Color.primary)
|
|
|
+ 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()
|
|
|
@@ -319,9 +370,18 @@ struct SessionDetailView: View {
|
|
|
.foregroundStyle(Color.secondary)
|
|
|
}
|
|
|
|
|
|
- Text(session.endTime.map { Self.chineseDateTimeFormatter.string(from: $0) } ?? "进行中")
|
|
|
+ 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)
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -347,18 +407,26 @@ struct SessionDetailView: View {
|
|
|
label: "笔记",
|
|
|
value: "\(session.noteCount)"
|
|
|
)
|
|
|
+ }
|
|
|
|
|
|
- // Sync status
|
|
|
- VStack(spacing: 4) {
|
|
|
- Image(systemName: session.isSynced ? "cloud.fill" : "cloud")
|
|
|
- .font(.system(size: 14, weight: .light))
|
|
|
- .foregroundStyle(Color.secondary)
|
|
|
+ Divider()
|
|
|
+ .background(Color.lineBorder)
|
|
|
|
|
|
- Text(session.isSynced ? "已同步" : "未同步")
|
|
|
- .font(.system(size: 10, weight: .regular))
|
|
|
- .foregroundStyle(Color.secondary)
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ Label {
|
|
|
+ Text("版本号")
|
|
|
+ .font(.system(size: 11, weight: .medium))
|
|
|
+ } icon: {
|
|
|
+ Image(systemName: "number")
|
|
|
+ .font(.system(size: 11, weight: .medium))
|
|
|
}
|
|
|
- .frame(maxWidth: .infinity)
|
|
|
+ .foregroundStyle(Color.secondary)
|
|
|
+
|
|
|
+ Spacer()
|
|
|
+
|
|
|
+ Text("v\(session.localRevision)")
|
|
|
+ .font(.system(size: 12, weight: .semibold, design: .monospaced))
|
|
|
+ .foregroundStyle(Color.primary)
|
|
|
}
|
|
|
|
|
|
Divider()
|
|
|
@@ -391,14 +459,168 @@ struct SessionDetailView: View {
|
|
|
.businessBorder(cornerRadius: 10)
|
|
|
}
|
|
|
|
|
|
- private static let chineseDateTimeFormatter: DateFormatter = {
|
|
|
+ private static let chineseDateFormatter: DateFormatter = {
|
|
|
let formatter = DateFormatter()
|
|
|
formatter.locale = Locale(identifier: "zh_CN")
|
|
|
formatter.calendar = Calendar(identifier: .gregorian)
|
|
|
- formatter.dateFormat = "yyyy年M月d日 HH:mm:ss"
|
|
|
+ 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 {
|
|
|
+ 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
|
|
|
+ )
|
|
|
+
|
|
|
+ Text(syncStatusText)
|
|
|
+ .font(.system(size: 9, weight: .medium))
|
|
|
+ .foregroundStyle(syncStatusColor)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var isThisSessionSyncing: Bool {
|
|
|
+ syncManager.isSyncing && syncManager.activeSessionID == session.id
|
|
|
+ }
|
|
|
+
|
|
|
+ private var isDisplayedSyncing: Bool {
|
|
|
+ isThisSessionSyncing || session.syncState == .syncing
|
|
|
+ }
|
|
|
+
|
|
|
+ private var isDisplayedSynced: Bool {
|
|
|
+ !isDisplayedSyncing && session.isSynced && session.syncState == .synced
|
|
|
+ }
|
|
|
+
|
|
|
+ private var sessionSyncProgress: Double {
|
|
|
+ if isThisSessionSyncing {
|
|
|
+ return min(max(syncManager.syncProgress, 0), 1)
|
|
|
+ }
|
|
|
+ return session.syncState == .synced ? 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 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() {
|
|
|
+ guard session.endTime != nil,
|
|
|
+ session.syncState != .conflict,
|
|
|
+ authManager.currentUser?.id != nil else { return }
|
|
|
+ autoSyncTask?.cancel()
|
|
|
+ autoSyncTask = Task { @MainActor in
|
|
|
+ try? await Task.sleep(for: .seconds(1.5))
|
|
|
+ guard !Task.isCancelled else { return }
|
|
|
+ requestSessionSync(isAutomatic: true)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private func infoStat(icon: String, label: String, value: String) -> some View {
|
|
|
VStack(spacing: 4) {
|
|
|
Image(systemName: icon)
|
|
|
@@ -420,7 +642,6 @@ struct SessionDetailView: View {
|
|
|
|
|
|
private var timelineSection: some View {
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
|
-
|
|
|
MultiTrackTimeline(
|
|
|
events: session.events,
|
|
|
currentTimeMs: Binding(
|
|
|
@@ -555,7 +776,7 @@ struct SessionDetailView: View {
|
|
|
|
|
|
private var chronoFeedSection: some View {
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
|
- sectionHeader(icon: "list.dash", title: "时序事件列表")
|
|
|
+ sectionHeader(icon: "list.dash", title: "事件")
|
|
|
|
|
|
let sorted = playbackVM.sortedEvents(from: session)
|
|
|
|
|
|
@@ -773,6 +994,7 @@ struct SessionDetailView: View {
|
|
|
session.isSynced = false
|
|
|
session.syncState = .pending
|
|
|
try modelContext.save()
|
|
|
+ scheduleAutoSync()
|
|
|
}
|
|
|
|
|
|
private func formattedTimelineTime(_ timeMs: Double) -> String {
|