import SwiftUI import SwiftData // MARK: - SessionListView /// The session archive screen showing all recorded sessions /// in a searchable, card-style list with sync status indicators. /// Redesigned with a minimalist wireframe business style. struct SessionListView: View { @Environment(\.modelContext) private var modelContext @Query(sort: \CelestiaSession.startTime, order: .reverse) private var sessions: [CelestiaSession] @ObservedObject private var authManager = AuthManager.shared @Binding var selectedSession: CelestiaSession? var onStartRecording: () -> Void = {} @State private var searchText = "" @State private var deletionError: String? @State private var sessionPendingDeletion: CelestiaSession? @State private var deletingSessionID: UUID? init(selectedSession: Binding = .constant(nil), onStartRecording: @escaping () -> Void = {}) { self._selectedSession = selectedSession self.onStartRecording = onStartRecording } var body: some View { NavigationStack { ZStack { Color.spaceBlack.ignoresSafeArea() if filteredSessions.isEmpty { emptyState } else { sessionList } } .navigationTitle("现场记录") .navigationBarTitleDisplayMode(.inline) .modifier(SessionSearchModifier(isEnabled: !sessions.isEmpty, searchText: $searchText)) .navigationDestination(item: $selectedSession) { session in SessionDetailView(session: session) } .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(session.cloudSessionId == nil ? "删除" : "同时删除", role: .destructive) { sessionPendingDeletion = nil Task { await deleteSession(session) } } .disabled(deletingSessionID != nil) } message: { session in if session.cloudSessionId != nil { Text("删除「\(session.title)」后,本地录音、图片以及已经同步到云端的记录都会一并删除,且无法恢复。") } else { Text("删除「\(session.title)」后,相关录音和图片也会被删除,且无法恢复。") } } } } // MARK: - Filtered Sessions private var filteredSessions: [CelestiaSession] { guard !searchText.isEmpty else { return sessions } let query = searchText.lowercased() return sessions.filter { session in if session.title.lowercased().contains(query) { return true } return session.events.contains { event in event.textContent?.lowercased().contains(query) == true } } } // MARK: - Session List private var sessionList: some View { List { ForEach(filteredSessions) { session in Button { selectedSession = session } 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") } .tint(.red) } } } .listStyle(.plain) .scrollContentBackground(.hidden) .contentMargins(.bottom, 18, for: .scrollContent) .refreshable { await synchronizeFieldRecords() } } @MainActor private func deleteSession(_ session: CelestiaSession) async { guard deletingSessionID == nil else { return } deletingSessionID = session.id defer { deletingSessionID = nil } let storedPaths = [session.localAudioPath].compactMap { $0 } + session.audioChunks.map(\.localFilePath) + session.events.compactMap(\.localFilePath) let fileURLs = Set(storedPaths.compactMap { AudioPathHelper.resolveURL(for: $0) }) if let cloudSessionID = session.cloudSessionId { do { try await RemoteNetworkService().deleteSession(sessionID: cloudSessionID) } catch APIError.server(let code, _) where code == 404 { // The remote record is already gone, so local cleanup can continue. } catch { deletionError = "云端记录删除失败,本地记录未删除:\(error.localizedDescription)" return } } modelContext.delete(session) do { try modelContext.save() for fileURL in fileURLs { try? FileManager.default.removeItem(at: fileURL) } } catch { modelContext.rollback() deletionError = error.localizedDescription } } // MARK: - Empty State @ViewBuilder private var emptyState: some View { GeometryReader { proxy in ScrollView { Group { if sessions.isEmpty { Button(action: onStartRecording) { emptyStateContent .contentShape(Rectangle()) } .buttonStyle(.plain) } else { emptyStateContent } } .frame(minHeight: proxy.size.height) } .refreshable { await synchronizeFieldRecords() } } } private var emptyStateContent: some View { VStack(spacing: 16) { Image(systemName: "square.dashed") .font(.system(size: 36)) .foregroundStyle(Color.secondary.opacity(0.4)) Text("暂无记录") .font(.system(size: 14, weight: .medium)) .foregroundStyle(Color.primary) if !searchText.isEmpty { Text("未找到与「\(searchText)」匹配的会话") .font(.system(size: 12)) .foregroundStyle(Color.secondary) } else { Text("先试试去开始一次现场记录") .font(.system(size: 12)) .foregroundStyle(Color.secondary.opacity(0.7)) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.bottom, 60) } @MainActor private func synchronizeFieldRecords() async { guard let userID = authManager.currentUser?.id else { return } _ = await SyncManager.shared.sync( sessions: sessions, modelContext: modelContext, userID: userID ) } } 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 { @ObservedObject private var syncManager: SyncManager = .shared let session: CelestiaSession var body: some View { HStack(spacing: 12) { syncStatusIndicator VStack(alignment: .leading, spacing: 4) { Text(session.title) .font(.system(size: 14, weight: .medium)) .foregroundStyle(Color.primary) .lineLimit(1) HStack(spacing: 6) { Text(session.startTime.formatted(.dateTime.month().day().hour().minute())) .font(.system(size: 11)) .foregroundStyle(Color.secondary) Text("·") .foregroundStyle(Color.secondary.opacity(0.4)) Text(session.durationFormatted) .font(.system(size: 11, weight: .regular, design: .monospaced)) .foregroundStyle(Color.secondary) } } Spacer() // Count badges (plain line style) HStack(spacing: 8) { countBadge(icon: "camera", count: session.photoCount) countBadge(icon: "doc.text", count: session.noteCount) } .padding(.trailing, 4) } .padding(14) .background(Color.cardBackground.opacity(0.2)) .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) .font(.system(size: 10, weight: .light)) Text("\(count)") .font(.system(size: 11, weight: .regular, design: .monospaced)) } .foregroundStyle(Color.secondary) .padding(.horizontal, 6) .padding(.vertical, 2) .businessBorder(cornerRadius: 4) } } // MARK: - Preview #Preview { SessionListView() .modelContainer(for: CelestiaSession.self, inMemory: true) }