SessionListView.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  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 showSyncAuthPrompt = false
  13. @State private var showAuthModal = false
  14. @State private var deletionError: String?
  15. @State private var sessionPendingDeletion: CelestiaSession?
  16. @State private var selectedSession: CelestiaSession?
  17. @State private var showSessionDetail = false
  18. @ObservedObject private var authManager: AuthManager = .shared
  19. @ObservedObject private var syncManager: SyncManager = .shared
  20. var body: some View {
  21. NavigationStack {
  22. ZStack {
  23. Color.spaceBlack.ignoresSafeArea()
  24. if filteredSessions.isEmpty {
  25. emptyState
  26. } else {
  27. sessionList
  28. }
  29. }
  30. .navigationTitle("历史记录")
  31. .navigationBarTitleDisplayMode(.inline)
  32. .toolbar {
  33. if !sessions.isEmpty {
  34. ToolbarItem(placement: .topBarTrailing) {
  35. Button {
  36. guard let userID = authManager.currentUser?.id else {
  37. showSyncAuthPrompt = true
  38. return
  39. }
  40. Task {
  41. _ = await syncManager.sync(sessions: sessions, modelContext: modelContext, userID: userID)
  42. }
  43. } label: {
  44. HStack(spacing: 4) {
  45. Image(systemName: "icloud.and.arrow.up")
  46. .font(.system(size: 13))
  47. Text(syncManager.isSyncing ? "同步中" : "同步")
  48. .font(.system(size: 13, weight: .medium))
  49. }
  50. .foregroundStyle(Color.primary)
  51. }
  52. .disabled(syncManager.isSyncing)
  53. }
  54. }
  55. }
  56. .modifier(SessionSearchModifier(isEnabled: !sessions.isEmpty, searchText: $searchText))
  57. .sheet(isPresented: $showSyncAuthPrompt) {
  58. SyncAuthPromptModal(onLoginClick: {
  59. showAuthModal = true
  60. })
  61. }
  62. .sheet(isPresented: $showAuthModal) {
  63. AuthModalView()
  64. }
  65. .navigationDestination(isPresented: $showSessionDetail) {
  66. if let selectedSession {
  67. SessionDetailView(session: selectedSession)
  68. }
  69. }
  70. .alert("删除失败", isPresented: Binding(
  71. get: { deletionError != nil },
  72. set: { if !$0 { deletionError = nil } }
  73. )) {
  74. Button("好", role: .cancel) {}
  75. } message: {
  76. Text(deletionError ?? "请稍后重试。")
  77. }
  78. .alert(
  79. "确认删除?",
  80. isPresented: Binding(
  81. get: { sessionPendingDeletion != nil },
  82. set: { if !$0 { sessionPendingDeletion = nil } }
  83. ),
  84. presenting: sessionPendingDeletion
  85. ) { session in
  86. Button("取消", role: .cancel) {}
  87. Button("删除", role: .destructive) {
  88. deleteSession(session)
  89. sessionPendingDeletion = nil
  90. }
  91. } message: { session in
  92. Text("删除「\(session.title)」后,相关录音和图片也会被删除,且无法恢复。")
  93. }
  94. }
  95. }
  96. // MARK: - Filtered Sessions
  97. private var filteredSessions: [CelestiaSession] {
  98. guard !searchText.isEmpty else { return sessions }
  99. let query = searchText.lowercased()
  100. return sessions.filter { session in
  101. if session.title.lowercased().contains(query) { return true }
  102. return session.events.contains { event in
  103. event.textContent?.lowercased().contains(query) == true
  104. }
  105. }
  106. }
  107. // MARK: - Session List
  108. private var sessionList: some View {
  109. List {
  110. ForEach(filteredSessions) { session in
  111. Button {
  112. selectedSession = session
  113. showSessionDetail = true
  114. } label: {
  115. SessionRowCard(session: session)
  116. .contentShape(Rectangle())
  117. }
  118. .buttonStyle(.plain)
  119. .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
  120. .listRowBackground(Color.clear)
  121. .listRowSeparator(.hidden)
  122. .swipeActions(edge: .trailing, allowsFullSwipe: false) {
  123. Button {
  124. sessionPendingDeletion = session
  125. } label: {
  126. Label("删除", systemImage: "trash")
  127. }
  128. .tint(.red)
  129. }
  130. }
  131. }
  132. .listStyle(.plain)
  133. .scrollContentBackground(.hidden)
  134. .contentMargins(.bottom, 18, for: .scrollContent)
  135. }
  136. private func deleteSession(_ session: CelestiaSession) {
  137. let storedPaths =
  138. [session.localAudioPath].compactMap { $0 } +
  139. session.audioChunks.map(\.localFilePath) +
  140. session.events.compactMap(\.localFilePath)
  141. let fileURLs = Set(storedPaths.compactMap { AudioPathHelper.resolveURL(for: $0) })
  142. modelContext.delete(session)
  143. do {
  144. try modelContext.save()
  145. for fileURL in fileURLs {
  146. try? FileManager.default.removeItem(at: fileURL)
  147. }
  148. } catch {
  149. modelContext.rollback()
  150. deletionError = error.localizedDescription
  151. }
  152. }
  153. // MARK: - Empty State
  154. @ViewBuilder
  155. private var emptyState: some View {
  156. if sessions.isEmpty {
  157. Button(action: onStartRecording) {
  158. emptyStateContent
  159. .contentShape(Rectangle())
  160. }
  161. .buttonStyle(.plain)
  162. } else {
  163. emptyStateContent
  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. }
  188. private struct SessionSearchModifier: ViewModifier {
  189. let isEnabled: Bool
  190. @Binding var searchText: String
  191. @ViewBuilder
  192. func body(content: Content) -> some View {
  193. if isEnabled {
  194. content.searchable(
  195. text: $searchText,
  196. placement: .navigationBarDrawer(displayMode: .always),
  197. prompt: "搜索会话或笔记..."
  198. )
  199. } else {
  200. content
  201. }
  202. }
  203. }
  204. // MARK: - Session Row Card
  205. private struct SessionRowCard: View {
  206. let session: CelestiaSession
  207. var body: some View {
  208. HStack(spacing: 12) {
  209. // Sync status indicator (plain circular dot)
  210. Circle()
  211. .fill(session.isSynced ? Color.secondary.opacity(0.4) : Color.recordingRed.opacity(0.6))
  212. .frame(width: 6, height: 6)
  213. VStack(alignment: .leading, spacing: 4) {
  214. Text(session.title)
  215. .font(.system(size: 14, weight: .medium))
  216. .foregroundStyle(Color.primary)
  217. .lineLimit(1)
  218. HStack(spacing: 6) {
  219. Text(session.startTime.formatted(.dateTime.month().day().hour().minute()))
  220. .font(.system(size: 11))
  221. .foregroundStyle(Color.secondary)
  222. Text("·")
  223. .foregroundStyle(Color.secondary.opacity(0.4))
  224. Text(session.durationFormatted)
  225. .font(.system(size: 11, weight: .regular, design: .monospaced))
  226. .foregroundStyle(Color.secondary)
  227. }
  228. }
  229. Spacer()
  230. // Count badges (plain line style)
  231. HStack(spacing: 8) {
  232. countBadge(icon: "camera", count: session.photoCount)
  233. countBadge(icon: "doc.text", count: session.noteCount)
  234. }
  235. .padding(.trailing, 4)
  236. }
  237. .padding(14)
  238. .background(Color.cardBackground.opacity(0.2))
  239. .businessBorder(cornerRadius: 8)
  240. }
  241. private func countBadge(icon: String, count: Int) -> some View {
  242. HStack(spacing: 3) {
  243. Image(systemName: icon)
  244. .font(.system(size: 10, weight: .light))
  245. Text("\(count)")
  246. .font(.system(size: 11, weight: .regular, design: .monospaced))
  247. }
  248. .foregroundStyle(Color.secondary)
  249. .padding(.horizontal, 6)
  250. .padding(.vertical, 2)
  251. .businessBorder(cornerRadius: 4)
  252. }
  253. }
  254. // MARK: - Preview
  255. #Preview {
  256. SessionListView()
  257. .modelContainer(for: CelestiaSession.self, inMemory: true)
  258. }