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] 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 { ZStack { Color.spaceBlack.ignoresSafeArea() if filteredSessions.isEmpty { emptyState } else { sessionList } } .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) } } .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)」后,相关录音和图片也会被删除,且无法恢复。") } } } // 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 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") } .tint(.red) } } } .listStyle(.plain) .scrollContentBackground(.hidden) .contentMargins(.bottom, 18, for: .scrollContent) } 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) }) 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 { if sessions.isEmpty { Button(action: onStartRecording) { emptyStateContent .contentShape(Rectangle()) } .buttonStyle(.plain) } else { emptyStateContent } } 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) } } 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 { 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.recordingRed.opacity(0.6)) .frame(width: 6, height: 6) 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) } 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) }