Răsfoiți Sursa

feat(history): 优化历史会话列表布局、侧滑删除与空状态提示

bob.yuxinyang 1 lună în urmă
părinte
comite
b6287f9615

+ 3 - 1
CelestiaTrace/Views/ContentView.swift

@@ -18,7 +18,9 @@ struct ContentView: View {
                 }
                 .tag(Tab.home)
 
-            SessionListView()
+            SessionListView {
+                selectedTab = .home
+            }
                 .tabItem {
                     Label(Tab.history.title, systemImage: Tab.history.icon)
                 }

+ 125 - 79
CelestiaTrace/Views/History/SessionListView.swift

@@ -9,9 +9,15 @@ struct SessionListView: View {
     @Environment(\.modelContext) private var modelContext
     @Query(sort: \CelestiaSession.startTime, order: .reverse) private var sessions: [CelestiaSession]
 
+    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
 
@@ -26,35 +32,33 @@ struct SessionListView: View {
                     sessionList
                 }
             }
-            .navigationTitle("会话档案")
+            .navigationTitle("历史记录")
             .navigationBarTitleDisplayMode(.inline)
             .toolbar {
-                ToolbarItem(placement: .topBarTrailing) {
-                    Button {
-                        guard let userID = authManager.currentUser?.id else {
-                            showSyncAuthPrompt = true
-                            return
-                        }
-                        Task {
-                            _ = await syncManager.sync(sessions: sessions, modelContext: modelContext, userID: userID)
+                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)
                         }
-                    } 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)
                     }
-                    .disabled(syncManager.isSyncing)
                 }
             }
-            .searchable(
-                text: $searchText,
-                placement: .navigationBarDrawer(displayMode: .always),
-                prompt: "搜索会话或笔记..."
-            )
+            .modifier(SessionSearchModifier(isEnabled: !sessions.isEmpty, searchText: $searchText))
             .sheet(isPresented: $showSyncAuthPrompt) {
                 SyncAuthPromptModal(onLoginClick: {
                     showAuthModal = true
@@ -63,6 +67,35 @@ struct SessionListView: View {
             .sheet(isPresented: $showAuthModal) {
                 AuthModalView()
             }
+            .navigationDestination(isPresented: $showSessionDetail) {
+                if let selectedSession {
+                    SessionDetailView(session: selectedSession)
+                }
+            }
+            .alert("删除失败", isPresented: Binding(
+                get: { deletionError != nil },
+                set: { if !$0 { deletionError = nil } }
+            )) {
+                Button("好", role: .cancel) {}
+            } message: {
+                Text(deletionError ?? "请稍后重试。")
+            }
+            .alert(
+                "确认删除?",
+                isPresented: Binding(
+                    get: { sessionPendingDeletion != nil },
+                    set: { if !$0 { sessionPendingDeletion = nil } }
+                ),
+                presenting: sessionPendingDeletion
+            ) { session in
+                Button("取消", role: .cancel) {}
+                Button("删除", role: .destructive) {
+                    deleteSession(session)
+                    sessionPendingDeletion = nil
+                }
+            } message: { session in
+                Text("删除「\(session.title)」后,相关录音和图片也会被删除,且无法恢复。")
+            }
         }
     }
 
@@ -82,72 +115,70 @@ struct SessionListView: View {
     // MARK: - Session List
 
     private var sessionList: some View {
-        ScrollView {
-            LazyVStack(spacing: 12) {
-                // Stats header
-                statsHeader
-                    .padding(.horizontal, 16)
-                    .padding(.top, 12)
-
-                ForEach(filteredSessions) { session in
-                    NavigationLink(destination: SessionDetailView(session: session)) {
-                        SessionRowCard(session: session)
+        List {
+            ForEach(filteredSessions) { session in
+                Button {
+                    selectedSession = session
+                    showSessionDetail = true
+                } label: {
+                    SessionRowCard(session: session)
+                        .contentShape(Rectangle())
+                }
+                .buttonStyle(.plain)
+                .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
+                .listRowBackground(Color.clear)
+                .listRowSeparator(.hidden)
+                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+                    Button {
+                        sessionPendingDeletion = session
+                    } label: {
+                        Label("删除", systemImage: "trash")
                     }
-                    .buttonStyle(.plain)
-                    .padding(.horizontal, 16)
+                    .tint(.red)
                 }
             }
-            .padding(.bottom, 24)
         }
+        .listStyle(.plain)
+        .scrollContentBackground(.hidden)
+        .contentMargins(.bottom, 18, for: .scrollContent)
     }
 
-    // MARK: - Stats Header
+    private func deleteSession(_ session: CelestiaSession) {
+        let storedPaths =
+            [session.localAudioPath].compactMap { $0 } +
+            session.audioChunks.map(\.localFilePath) +
+            session.events.compactMap(\.localFilePath)
+        let fileURLs = Set(storedPaths.compactMap { AudioPathHelper.resolveURL(for: $0) })
 
-    private var statsHeader: some View {
-        HStack(spacing: 20) {
-            statBadge(
-                value: "\(filteredSessions.count)",
-                label: "总会话",
-                icon: "waveform"
-            )
+        modelContext.delete(session)
 
-            statBadge(
-                value: "\(filteredSessions.reduce(0) { $0 + $1.photoCount })",
-                label: "已获图片",
-                icon: "camera"
-            )
-
-            statBadge(
-                value: "\(filteredSessions.filter { $0.isSynced }.count)",
-                label: "已同步",
-                icon: "icloud"
-            )
+        do {
+            try modelContext.save()
+            for fileURL in fileURLs {
+                try? FileManager.default.removeItem(at: fileURL)
+            }
+        } catch {
+            modelContext.rollback()
+            deletionError = error.localizedDescription
         }
-        .padding(.vertical, 14)
-        .background(Color.cardBackground.opacity(0.3))
-        .businessBorder(cornerRadius: 10)
     }
 
-    private func statBadge(value: String, label: String, icon: String) -> some View {
-        VStack(spacing: 4) {
-            Image(systemName: icon)
-                .font(.system(size: 13, weight: .light))
-                .foregroundStyle(Color.secondary)
-
-            Text(value)
-                .font(.system(size: 16, weight: .medium, design: .monospaced))
-                .foregroundStyle(Color.primary)
+    // MARK: - Empty State
 
-            Text(label)
-                .font(.system(size: 10, weight: .regular))
-                .foregroundStyle(Color.secondary.opacity(0.8))
+    @ViewBuilder
+    private var emptyState: some View {
+        if sessions.isEmpty {
+            Button(action: onStartRecording) {
+                emptyStateContent
+                    .contentShape(Rectangle())
+            }
+            .buttonStyle(.plain)
+        } else {
+            emptyStateContent
         }
-        .frame(maxWidth: .infinity)
     }
 
-    // MARK: - Empty State
-
-    private var emptyState: some View {
+    private var emptyStateContent: some View {
         VStack(spacing: 16) {
             Image(systemName: "square.dashed")
                 .font(.system(size: 36))
@@ -162,15 +193,34 @@ struct SessionListView: View {
                     .font(.system(size: 12))
                     .foregroundStyle(Color.secondary)
             } else {
-                Text("启动全局录音来保存你的第一条记录")
+                Text("先试试去开始一次现场记录")
                     .font(.system(size: 12))
                     .foregroundStyle(Color.secondary.opacity(0.7))
             }
         }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
         .padding(.bottom, 60)
     }
 }
 
+private struct SessionSearchModifier: ViewModifier {
+    let isEnabled: Bool
+    @Binding var searchText: String
+
+    @ViewBuilder
+    func body(content: Content) -> some View {
+        if isEnabled {
+            content.searchable(
+                text: $searchText,
+                placement: .navigationBarDrawer(displayMode: .always),
+                prompt: "搜索会话或笔记..."
+            )
+        } else {
+            content
+        }
+    }
+}
+
 // MARK: - Session Row Card
 
 private struct SessionRowCard: View {
@@ -212,10 +262,6 @@ private struct SessionRowCard: View {
             }
             .padding(.trailing, 4)
 
-            // Chevron
-            Image(systemName: "chevron.right")
-                .font(.system(size: 10, weight: .light))
-                .foregroundStyle(Color.secondary.opacity(0.5))
         }
         .padding(14)
         .background(Color.cardBackground.opacity(0.2))