SessionListView.swift 11 KB

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