Browse Source

feat(ui): 增加会话详情页自动同步、大文件传输提示与同步状态UI

bob.yuxinyang 1 month ago
parent
commit
34df40c131

+ 3 - 0
CelestiaTrace/App/ColorTheme.swift

@@ -24,6 +24,9 @@ extension Color {
     
     /// Precise red indicator for recording
     static let recordingRed = Color(red: 229/255, green: 57/255, blue: 53/255)
+
+    /// Less and Cosmos brand gold (#E6C687)
+    static let lessCosmosGold = Color(red: 230/255, green: 198/255, blue: 135/255)
 }
 
 /// A no-op glow that replaces NeonGlow to avoid compile errors while removing glows.

+ 258 - 36
CelestiaTrace/Views/Detail/SessionDetailView.swift

@@ -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 {

+ 1 - 37
CelestiaTrace/Views/History/SessionListView.swift

@@ -12,14 +12,10 @@ struct SessionListView: View {
     var onStartRecording: () -> Void = {}
 
     @State private var searchText = ""
-    @State private var showSyncAuthPrompt = false
-    @State private var showAuthModal = false
     @State private var deletionError: String?
     @State private var sessionPendingDeletion: CelestiaSession?
     @State private var selectedSession: CelestiaSession?
     @State private var showSessionDetail = false
-    @ObservedObject private var authManager: AuthManager = .shared
-    @ObservedObject private var syncManager: SyncManager = .shared
 
     var body: some View {
         NavigationStack {
@@ -34,39 +30,7 @@ struct SessionListView: View {
             }
             .navigationTitle("历史记录")
             .navigationBarTitleDisplayMode(.inline)
-            .toolbar {
-                if !sessions.isEmpty {
-                    ToolbarItem(placement: .topBarTrailing) {
-                        Button {
-                            guard let userID = authManager.currentUser?.id else {
-                                showSyncAuthPrompt = true
-                                return
-                            }
-                            Task {
-                                _ = await syncManager.sync(sessions: sessions, modelContext: modelContext, userID: userID)
-                            }
-                        } label: {
-                            HStack(spacing: 4) {
-                                Image(systemName: "icloud.and.arrow.up")
-                                    .font(.system(size: 13))
-                                Text(syncManager.isSyncing ? "同步中" : "同步")
-                                    .font(.system(size: 13, weight: .medium))
-                            }
-                            .foregroundStyle(Color.primary)
-                        }
-                        .disabled(syncManager.isSyncing)
-                    }
-                }
-            }
             .modifier(SessionSearchModifier(isEnabled: !sessions.isEmpty, searchText: $searchText))
-            .sheet(isPresented: $showSyncAuthPrompt) {
-                SyncAuthPromptModal(onLoginClick: {
-                    showAuthModal = true
-                })
-            }
-            .sheet(isPresented: $showAuthModal) {
-                AuthModalView()
-            }
             .navigationDestination(isPresented: $showSessionDetail) {
                 if let selectedSession {
                     SessionDetailView(session: selectedSession)
@@ -230,7 +194,7 @@ private struct SessionRowCard: View {
         HStack(spacing: 12) {
             // Sync status indicator (plain circular dot)
             Circle()
-                .fill(session.isSynced ? Color.secondary.opacity(0.4) : Color.recordingRed.opacity(0.6))
+                .fill(session.isSynced ? Color.secondary.opacity(0.4) : Color.lessCosmosGold)
                 .frame(width: 6, height: 6)
 
             VStack(alignment: .leading, spacing: 4) {

+ 6 - 0
CelestiaTrace/Views/Recording/ActiveRecordingView.swift

@@ -607,6 +607,8 @@ struct ActiveRecordingView: View {
                 let mergedURL = await AudioMerger.mergeAudioFiles(firstURL: existingURL, secondURL: newRecordedURL)
                 session.localAudioPath = AudioPathHelper.relativePath(from: mergedURL.path)
                 session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds)
+                session.isSynced = false
+                session.syncState = .pending
                 try? modelContext.save()
                 
                 HapticManager.trigger(.recordStop)
@@ -617,9 +619,13 @@ struct ActiveRecordingView: View {
         } else {
             if existingURL != nil {
                 session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds)
+                session.isSynced = false
+                session.syncState = .pending
                 try? modelContext.save()
             } else {
                 session.endTime = Date()
+                session.isSynced = false
+                session.syncState = .pending
                 try? modelContext.save()
             }
             HapticManager.trigger(.recordStop)