| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290 |
- 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)
- }
|