SessionListView.swift 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import SwiftUI
  2. import SwiftData
  3. // MARK: - SessionListView
  4. /// The session archive screen showing all recorded sessions
  5. /// in a searchable, card-style list with sync status indicators.
  6. /// Redesigned with a minimalist wireframe business style.
  7. struct SessionListView: View {
  8. @Environment(\.modelContext) private var modelContext
  9. @Query(sort: \CelestiaSession.startTime, order: .reverse) private var sessions: [CelestiaSession]
  10. var onStartRecording: () -> Void = {}
  11. @State private var searchText = ""
  12. @State private var deletionError: String?
  13. @State private var sessionPendingDeletion: CelestiaSession?
  14. @State private var selectedSession: CelestiaSession?
  15. @State private var showSessionDetail = false
  16. var body: some View {
  17. NavigationStack {
  18. ZStack {
  19. Color.spaceBlack.ignoresSafeArea()
  20. if filteredSessions.isEmpty {
  21. emptyState
  22. } else {
  23. sessionList
  24. }
  25. }
  26. .navigationTitle("历史记录")
  27. .navigationBarTitleDisplayMode(.inline)
  28. .modifier(SessionSearchModifier(isEnabled: !sessions.isEmpty, searchText: $searchText))
  29. .navigationDestination(isPresented: $showSessionDetail) {
  30. if let selectedSession {
  31. SessionDetailView(session: selectedSession)
  32. }
  33. }
  34. .alert("删除失败", isPresented: Binding(
  35. get: { deletionError != nil },
  36. set: { if !$0 { deletionError = nil } }
  37. )) {
  38. Button("好", role: .cancel) {}
  39. } message: {
  40. Text(deletionError ?? "请稍后重试。")
  41. }
  42. .alert(
  43. "确认删除?",
  44. isPresented: Binding(
  45. get: { sessionPendingDeletion != nil },
  46. set: { if !$0 { sessionPendingDeletion = nil } }
  47. ),
  48. presenting: sessionPendingDeletion
  49. ) { session in
  50. Button("取消", role: .cancel) {}
  51. Button("删除", role: .destructive) {
  52. deleteSession(session)
  53. sessionPendingDeletion = nil
  54. }
  55. } message: { session in
  56. Text("删除「\(session.title)」后,相关录音和图片也会被删除,且无法恢复。")
  57. }
  58. }
  59. }
  60. // MARK: - Filtered Sessions
  61. private var filteredSessions: [CelestiaSession] {
  62. guard !searchText.isEmpty else { return sessions }
  63. let query = searchText.lowercased()
  64. return sessions.filter { session in
  65. if session.title.lowercased().contains(query) { return true }
  66. return session.events.contains { event in
  67. event.textContent?.lowercased().contains(query) == true
  68. }
  69. }
  70. }
  71. // MARK: - Session List
  72. private var sessionList: some View {
  73. List {
  74. ForEach(filteredSessions) { session in
  75. Button {
  76. selectedSession = session
  77. showSessionDetail = true
  78. } label: {
  79. SessionRowCard(session: session)
  80. .contentShape(Rectangle())
  81. }
  82. .buttonStyle(.plain)
  83. .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
  84. .listRowBackground(Color.clear)
  85. .listRowSeparator(.hidden)
  86. .swipeActions(edge: .trailing, allowsFullSwipe: false) {
  87. Button {
  88. sessionPendingDeletion = session
  89. } label: {
  90. Label("删除", systemImage: "trash")
  91. }
  92. .tint(.red)
  93. }
  94. }
  95. }
  96. .listStyle(.plain)
  97. .scrollContentBackground(.hidden)
  98. .contentMargins(.bottom, 18, for: .scrollContent)
  99. }
  100. private func deleteSession(_ session: CelestiaSession) {
  101. let storedPaths =
  102. [session.localAudioPath].compactMap { $0 } +
  103. session.audioChunks.map(\.localFilePath) +
  104. session.events.compactMap(\.localFilePath)
  105. let fileURLs = Set(storedPaths.compactMap { AudioPathHelper.resolveURL(for: $0) })
  106. modelContext.delete(session)
  107. do {
  108. try modelContext.save()
  109. for fileURL in fileURLs {
  110. try? FileManager.default.removeItem(at: fileURL)
  111. }
  112. } catch {
  113. modelContext.rollback()
  114. deletionError = error.localizedDescription
  115. }
  116. }
  117. // MARK: - Empty State
  118. @ViewBuilder
  119. private var emptyState: some View {
  120. if sessions.isEmpty {
  121. Button(action: onStartRecording) {
  122. emptyStateContent
  123. .contentShape(Rectangle())
  124. }
  125. .buttonStyle(.plain)
  126. } else {
  127. emptyStateContent
  128. }
  129. }
  130. private var emptyStateContent: some View {
  131. VStack(spacing: 16) {
  132. Image(systemName: "square.dashed")
  133. .font(.system(size: 36))
  134. .foregroundStyle(Color.secondary.opacity(0.4))
  135. Text("暂无记录")
  136. .font(.system(size: 14, weight: .medium))
  137. .foregroundStyle(Color.primary)
  138. if !searchText.isEmpty {
  139. Text("未找到与「\(searchText)」匹配的会话")
  140. .font(.system(size: 12))
  141. .foregroundStyle(Color.secondary)
  142. } else {
  143. Text("先试试去开始一次现场记录")
  144. .font(.system(size: 12))
  145. .foregroundStyle(Color.secondary.opacity(0.7))
  146. }
  147. }
  148. .frame(maxWidth: .infinity, maxHeight: .infinity)
  149. .padding(.bottom, 60)
  150. }
  151. }
  152. private struct SessionSearchModifier: ViewModifier {
  153. let isEnabled: Bool
  154. @Binding var searchText: String
  155. @ViewBuilder
  156. func body(content: Content) -> some View {
  157. if isEnabled {
  158. content.searchable(
  159. text: $searchText,
  160. placement: .navigationBarDrawer(displayMode: .always),
  161. prompt: "搜索会话或笔记..."
  162. )
  163. } else {
  164. content
  165. }
  166. }
  167. }
  168. // MARK: - Session Row Card
  169. private struct SessionRowCard: View {
  170. let session: CelestiaSession
  171. var body: some View {
  172. HStack(spacing: 12) {
  173. // Sync status indicator (plain circular dot)
  174. Circle()
  175. .fill(session.isSynced ? Color.secondary.opacity(0.4) : Color.lessCosmosGold)
  176. .frame(width: 6, height: 6)
  177. VStack(alignment: .leading, spacing: 4) {
  178. Text(session.title)
  179. .font(.system(size: 14, weight: .medium))
  180. .foregroundStyle(Color.primary)
  181. .lineLimit(1)
  182. HStack(spacing: 6) {
  183. Text(session.startTime.formatted(.dateTime.month().day().hour().minute()))
  184. .font(.system(size: 11))
  185. .foregroundStyle(Color.secondary)
  186. Text("·")
  187. .foregroundStyle(Color.secondary.opacity(0.4))
  188. Text(session.durationFormatted)
  189. .font(.system(size: 11, weight: .regular, design: .monospaced))
  190. .foregroundStyle(Color.secondary)
  191. }
  192. }
  193. Spacer()
  194. // Count badges (plain line style)
  195. HStack(spacing: 8) {
  196. countBadge(icon: "camera", count: session.photoCount)
  197. countBadge(icon: "doc.text", count: session.noteCount)
  198. }
  199. .padding(.trailing, 4)
  200. }
  201. .padding(14)
  202. .background(Color.cardBackground.opacity(0.2))
  203. .businessBorder(cornerRadius: 8)
  204. }
  205. private func countBadge(icon: String, count: Int) -> some View {
  206. HStack(spacing: 3) {
  207. Image(systemName: icon)
  208. .font(.system(size: 10, weight: .light))
  209. Text("\(count)")
  210. .font(.system(size: 11, weight: .regular, design: .monospaced))
  211. }
  212. .foregroundStyle(Color.secondary)
  213. .padding(.horizontal, 6)
  214. .padding(.vertical, 2)
  215. .businessBorder(cornerRadius: 4)
  216. }
  217. }
  218. // MARK: - Preview
  219. #Preview {
  220. SessionListView()
  221. .modelContainer(for: CelestiaSession.self, inMemory: true)
  222. }