ソースを参照

feat(ui): 增加会话详情云端信息面板、列表同步中动画与自动同步对接

bob.yuxinyang 1 ヶ月 前
コミット
f0ad681fbf

+ 90 - 22
CelestiaTrace/Views/Detail/SessionDetailView.swift

@@ -40,8 +40,8 @@ struct SessionDetailView: View {
     @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
-    @State private var autoSyncTask: Task<Void, Never>?
 
     var body: some View {
         ZStack {
@@ -203,18 +203,16 @@ 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 {
+            if session.needsCloudSync {
                 scheduleAutoSync()
             }
         }
         .onChange(of: session.syncStateRaw) { _, newValue in
             guard let state = SessionSyncState(rawValue: newValue),
-                  state == .pending || state == .localOnly else { return }
+                  state == .pending || state == .localOnly,
+                  session.needsCloudSync else { return }
             scheduleAutoSync()
         }
-        .onDisappear {
-            autoSyncTask?.cancel()
-        }
     }
 
     private var detailHeader: some View {
@@ -269,8 +267,7 @@ struct SessionDetailView: View {
         recordName = trimmedName
         if session.title != trimmedName {
             session.title = trimmedName
-            session.isSynced = false
-            session.syncState = .pending
+            session.markContentModified()
             try? modelContext.save()
             scheduleAutoSync()
         }
@@ -480,6 +477,8 @@ struct SessionDetailView: View {
             Button {
                 if isThisSessionSyncing {
                     syncManager.pauseSync(sessionID: session.id)
+                } else if isDisplayedSynced {
+                    showSyncInfo = true
                 } else {
                     requestSessionSync(isAutomatic: false)
                 }
@@ -527,6 +526,12 @@ struct SessionDetailView: View {
                     ? "\(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))
@@ -534,23 +539,84 @@ struct SessionDetailView: View {
         }
     }
 
+    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 || session.syncState == .syncing
+        isThisSessionSyncing
     }
 
     private var isDisplayedSynced: Bool {
-        !isDisplayedSyncing && session.isSynced && session.syncState == .synced
+        !isDisplayedSyncing && session.hasConfirmedCloudSync
     }
 
     private var sessionSyncProgress: Double {
         if isThisSessionSyncing {
             return min(max(syncManager.syncProgress, 0), 1)
         }
-        return session.syncState == .synced ? 1 : 0
+        return session.hasConfirmedCloudSync ? 1 : 0
     }
 
     private var syncStatusText: String {
@@ -568,6 +634,14 @@ struct SessionDetailView: View {
         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)
@@ -610,15 +684,10 @@ struct SessionDetailView: View {
     }
 
     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)
-        }
+        syncManager.scheduleAutomaticSync(
+            for: session,
+            modelContext: modelContext
+        )
     }
 
     private func infoStat(icon: String, label: String, value: String) -> some View {
@@ -991,8 +1060,7 @@ struct SessionDetailView: View {
     }
 
     private func saveTimelineChanges() throws {
-        session.isSynced = false
-        session.syncState = .pending
+        session.markContentModified()
         try modelContext.save()
         scheduleAutoSync()
     }

+ 29 - 4
CelestiaTrace/Views/History/SessionListView.swift

@@ -188,14 +188,13 @@ private struct SessionSearchModifier: ViewModifier {
 // MARK: - Session Row Card
 
 private struct SessionRowCard: View {
+    @ObservedObject private var syncManager: SyncManager = .shared
+
     let session: CelestiaSession
 
     var body: some View {
         HStack(spacing: 12) {
-            // Sync status indicator (plain circular dot)
-            Circle()
-                .fill(session.isSynced ? Color.secondary.opacity(0.4) : Color.lessCosmosGold)
-                .frame(width: 6, height: 6)
+            syncStatusIndicator
 
             VStack(alignment: .leading, spacing: 4) {
                 Text(session.title)
@@ -232,6 +231,32 @@ private struct SessionRowCard: View {
         .businessBorder(cornerRadius: 8)
     }
 
+    @ViewBuilder
+    private var syncStatusIndicator: some View {
+        if isThisSessionSyncing {
+            ProgressView()
+                .controlSize(.mini)
+                .tint(Color.lessCosmosGold)
+                .scaleEffect(0.7)
+                .frame(width: 10, height: 10)
+                .accessibilityLabel("正在同步")
+        } else {
+            Circle()
+                .fill(
+                    session.hasConfirmedCloudSync
+                        ? Color.lessCosmosGold
+                        : Color.secondary.opacity(0.4)
+                )
+                .frame(width: 6, height: 6)
+                .frame(width: 10, height: 10)
+                .accessibilityLabel(session.hasConfirmedCloudSync ? "已同步" : "未同步")
+        }
+    }
+
+    private var isThisSessionSyncing: Bool {
+        syncManager.isSyncing && syncManager.activeSessionID == session.id
+    }
+
     private func countBadge(icon: String, count: Int) -> some View {
         HStack(spacing: 3) {
             Image(systemName: icon)

+ 14 - 8
CelestiaTrace/Views/Recording/ActiveRecordingView.swift

@@ -8,6 +8,7 @@ import SwiftData
 struct ActiveRecordingView: View {
     @Environment(\.modelContext) private var modelContext
     @Environment(\.dismiss) private var dismiss
+    @ObservedObject private var syncManager: SyncManager = .shared
 
     let session: CelestiaSession
     var initialDuration: TimeInterval = 0
@@ -607,9 +608,9 @@ 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
+                session.markContentModified()
                 try? modelContext.save()
+                scheduleAutomaticSync()
                 
                 HapticManager.trigger(.recordStop)
                 isEndingRecording = false
@@ -619,15 +620,14 @@ struct ActiveRecordingView: View {
         } else {
             if existingURL != nil {
                 session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds)
-                session.isSynced = false
-                session.syncState = .pending
+                session.markContentModified()
                 try? modelContext.save()
             } else {
                 session.endTime = Date()
-                session.isSynced = false
-                session.syncState = .pending
+                session.markContentModified()
                 try? modelContext.save()
             }
+            scheduleAutomaticSync()
             HapticManager.trigger(.recordStop)
             isEndingRecording = false
             dismiss()
@@ -676,11 +676,17 @@ struct ActiveRecordingView: View {
 
         recordName = trimmedName
         session.title = trimmedName
-        session.isSynced = false
-        session.syncState = .pending
+        session.markContentModified()
         try? modelContext.save()
     }
 
+    private func scheduleAutomaticSync() {
+        syncManager.scheduleAutomaticSync(
+            for: session,
+            modelContext: modelContext
+        )
+    }
+
     private func saveRecordNameAndCloseEditor() {
         commitRecordName()
         guard !recordName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }