SessionListView.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  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. @State private var deletingSessionID: UUID?
  17. var body: some View {
  18. NavigationStack {
  19. ZStack {
  20. Color.spaceBlack.ignoresSafeArea()
  21. if filteredSessions.isEmpty {
  22. emptyState
  23. } else {
  24. sessionList
  25. }
  26. }
  27. .navigationTitle("历史记录")
  28. .navigationBarTitleDisplayMode(.inline)
  29. .modifier(SessionSearchModifier(isEnabled: !sessions.isEmpty, searchText: $searchText))
  30. .navigationDestination(isPresented: $showSessionDetail) {
  31. if let selectedSession {
  32. SessionDetailView(session: selectedSession)
  33. }
  34. }
  35. .alert("删除失败", isPresented: Binding(
  36. get: { deletionError != nil },
  37. set: { if !$0 { deletionError = nil } }
  38. )) {
  39. Button("好", role: .cancel) {}
  40. } message: {
  41. Text(deletionError ?? "请稍后重试。")
  42. }
  43. .alert(
  44. "确认删除?",
  45. isPresented: Binding(
  46. get: { sessionPendingDeletion != nil },
  47. set: { if !$0 { sessionPendingDeletion = nil } }
  48. ),
  49. presenting: sessionPendingDeletion
  50. ) { session in
  51. Button("取消", role: .cancel) {}
  52. Button(session.cloudSessionId == nil ? "删除" : "同时删除", role: .destructive) {
  53. sessionPendingDeletion = nil
  54. Task {
  55. await deleteSession(session)
  56. }
  57. }
  58. .disabled(deletingSessionID != nil)
  59. } message: { session in
  60. if session.cloudSessionId != nil {
  61. Text("删除「\(session.title)」后,本地录音、图片以及已经同步到云端的记录都会一并删除,且无法恢复。")
  62. } else {
  63. Text("删除「\(session.title)」后,相关录音和图片也会被删除,且无法恢复。")
  64. }
  65. }
  66. }
  67. }
  68. // MARK: - Filtered Sessions
  69. private var filteredSessions: [CelestiaSession] {
  70. guard !searchText.isEmpty else { return sessions }
  71. let query = searchText.lowercased()
  72. return sessions.filter { session in
  73. if session.title.lowercased().contains(query) { return true }
  74. return session.events.contains { event in
  75. event.textContent?.lowercased().contains(query) == true
  76. }
  77. }
  78. }
  79. // MARK: - Session List
  80. private var sessionList: some View {
  81. List {
  82. ForEach(filteredSessions) { session in
  83. Button {
  84. selectedSession = session
  85. showSessionDetail = true
  86. } label: {
  87. SessionRowCard(session: session)
  88. .contentShape(Rectangle())
  89. }
  90. .buttonStyle(.plain)
  91. .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
  92. .listRowBackground(Color.clear)
  93. .listRowSeparator(.hidden)
  94. .swipeActions(edge: .trailing, allowsFullSwipe: false) {
  95. Button {
  96. sessionPendingDeletion = session
  97. } label: {
  98. Label("删除", systemImage: "trash")
  99. }
  100. .tint(.red)
  101. }
  102. }
  103. }
  104. .listStyle(.plain)
  105. .scrollContentBackground(.hidden)
  106. .contentMargins(.bottom, 18, for: .scrollContent)
  107. }
  108. @MainActor
  109. private func deleteSession(_ session: CelestiaSession) async {
  110. guard deletingSessionID == nil else { return }
  111. deletingSessionID = session.id
  112. defer { deletingSessionID = nil }
  113. let storedPaths =
  114. [session.localAudioPath].compactMap { $0 } +
  115. session.audioChunks.map(\.localFilePath) +
  116. session.events.compactMap(\.localFilePath)
  117. let fileURLs = Set(storedPaths.compactMap { AudioPathHelper.resolveURL(for: $0) })
  118. if let cloudSessionID = session.cloudSessionId {
  119. do {
  120. try await RemoteNetworkService().deleteSession(sessionID: cloudSessionID)
  121. } catch APIError.server(let code, _) where code == 404 {
  122. // The remote record is already gone, so local cleanup can continue.
  123. } catch {
  124. deletionError = "云端记录删除失败,本地记录未删除:\(error.localizedDescription)"
  125. return
  126. }
  127. }
  128. modelContext.delete(session)
  129. do {
  130. try modelContext.save()
  131. for fileURL in fileURLs {
  132. try? FileManager.default.removeItem(at: fileURL)
  133. }
  134. } catch {
  135. modelContext.rollback()
  136. deletionError = error.localizedDescription
  137. }
  138. }
  139. // MARK: - Empty State
  140. @ViewBuilder
  141. private var emptyState: some View {
  142. if sessions.isEmpty {
  143. Button(action: onStartRecording) {
  144. emptyStateContent
  145. .contentShape(Rectangle())
  146. }
  147. .buttonStyle(.plain)
  148. } else {
  149. emptyStateContent
  150. }
  151. }
  152. private var emptyStateContent: some View {
  153. VStack(spacing: 16) {
  154. Image(systemName: "square.dashed")
  155. .font(.system(size: 36))
  156. .foregroundStyle(Color.secondary.opacity(0.4))
  157. Text("暂无记录")
  158. .font(.system(size: 14, weight: .medium))
  159. .foregroundStyle(Color.primary)
  160. if !searchText.isEmpty {
  161. Text("未找到与「\(searchText)」匹配的会话")
  162. .font(.system(size: 12))
  163. .foregroundStyle(Color.secondary)
  164. } else {
  165. Text("先试试去开始一次现场记录")
  166. .font(.system(size: 12))
  167. .foregroundStyle(Color.secondary.opacity(0.7))
  168. }
  169. }
  170. .frame(maxWidth: .infinity, maxHeight: .infinity)
  171. .padding(.bottom, 60)
  172. }
  173. }
  174. private struct SessionSearchModifier: ViewModifier {
  175. let isEnabled: Bool
  176. @Binding var searchText: String
  177. @ViewBuilder
  178. func body(content: Content) -> some View {
  179. if isEnabled {
  180. content.searchable(
  181. text: $searchText,
  182. placement: .navigationBarDrawer(displayMode: .always),
  183. prompt: "搜索会话或笔记..."
  184. )
  185. } else {
  186. content
  187. }
  188. }
  189. }
  190. // MARK: - Session Row Card
  191. private struct SessionRowCard: View {
  192. @ObservedObject private var syncManager: SyncManager = .shared
  193. let session: CelestiaSession
  194. var body: some View {
  195. HStack(spacing: 12) {
  196. syncStatusIndicator
  197. VStack(alignment: .leading, spacing: 4) {
  198. Text(session.title)
  199. .font(.system(size: 14, weight: .medium))
  200. .foregroundStyle(Color.primary)
  201. .lineLimit(1)
  202. HStack(spacing: 6) {
  203. Text(session.startTime.formatted(.dateTime.month().day().hour().minute()))
  204. .font(.system(size: 11))
  205. .foregroundStyle(Color.secondary)
  206. Text("·")
  207. .foregroundStyle(Color.secondary.opacity(0.4))
  208. Text(session.durationFormatted)
  209. .font(.system(size: 11, weight: .regular, design: .monospaced))
  210. .foregroundStyle(Color.secondary)
  211. }
  212. }
  213. Spacer()
  214. // Count badges (plain line style)
  215. HStack(spacing: 8) {
  216. countBadge(icon: "camera", count: session.photoCount)
  217. countBadge(icon: "doc.text", count: session.noteCount)
  218. }
  219. .padding(.trailing, 4)
  220. }
  221. .padding(14)
  222. .background(Color.cardBackground.opacity(0.2))
  223. .businessBorder(cornerRadius: 8)
  224. }
  225. @ViewBuilder
  226. private var syncStatusIndicator: some View {
  227. if isThisSessionSyncing {
  228. ProgressView()
  229. .controlSize(.mini)
  230. .tint(Color.lessCosmosGold)
  231. .scaleEffect(0.7)
  232. .frame(width: 10, height: 10)
  233. .accessibilityLabel("正在同步")
  234. } else {
  235. Circle()
  236. .fill(
  237. session.hasConfirmedCloudSync
  238. ? Color.lessCosmosGold
  239. : Color.secondary.opacity(0.4)
  240. )
  241. .frame(width: 6, height: 6)
  242. .frame(width: 10, height: 10)
  243. .accessibilityLabel(session.hasConfirmedCloudSync ? "已同步" : "未同步")
  244. }
  245. }
  246. private var isThisSessionSyncing: Bool {
  247. syncManager.isSyncing && syncManager.activeSessionID == session.id
  248. }
  249. private func countBadge(icon: String, count: Int) -> some View {
  250. HStack(spacing: 3) {
  251. Image(systemName: icon)
  252. .font(.system(size: 10, weight: .light))
  253. Text("\(count)")
  254. .font(.system(size: 11, weight: .regular, design: .monospaced))
  255. }
  256. .foregroundStyle(Color.secondary)
  257. .padding(.horizontal, 6)
  258. .padding(.vertical, 2)
  259. .businessBorder(cornerRadius: 4)
  260. }
  261. }
  262. // MARK: - Preview
  263. #Preview {
  264. SessionListView()
  265. .modelContainer(for: CelestiaSession.self, inMemory: true)
  266. }