SessionListView.swift 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  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. @State private var searchText = ""
  11. @State private var showSyncAuthPrompt = false
  12. @State private var showAuthModal = false
  13. @ObservedObject private var authManager: AuthManager = .shared
  14. @ObservedObject private var syncManager: SyncManager = .shared
  15. var body: some View {
  16. NavigationStack {
  17. ZStack {
  18. Color.spaceBlack.ignoresSafeArea()
  19. if filteredSessions.isEmpty {
  20. emptyState
  21. } else {
  22. sessionList
  23. }
  24. }
  25. .navigationTitle("会话档案")
  26. .navigationBarTitleDisplayMode(.inline)
  27. .toolbar {
  28. ToolbarItem(placement: .topBarTrailing) {
  29. Button {
  30. guard let userID = authManager.currentUser?.id else {
  31. showSyncAuthPrompt = true
  32. return
  33. }
  34. Task {
  35. _ = await syncManager.sync(sessions: sessions, modelContext: modelContext, userID: userID)
  36. }
  37. } label: {
  38. HStack(spacing: 4) {
  39. Image(systemName: "icloud.and.arrow.up")
  40. .font(.system(size: 13))
  41. Text(syncManager.isSyncing ? "同步中" : "同步")
  42. .font(.system(size: 13, weight: .medium))
  43. }
  44. .foregroundStyle(Color.primary)
  45. }
  46. .disabled(syncManager.isSyncing)
  47. }
  48. }
  49. .searchable(
  50. text: $searchText,
  51. placement: .navigationBarDrawer(displayMode: .always),
  52. prompt: "搜索会话或笔记..."
  53. )
  54. .sheet(isPresented: $showSyncAuthPrompt) {
  55. SyncAuthPromptModal(onLoginClick: {
  56. showAuthModal = true
  57. })
  58. }
  59. .sheet(isPresented: $showAuthModal) {
  60. AuthModalView()
  61. }
  62. }
  63. }
  64. // MARK: - Filtered Sessions
  65. private var filteredSessions: [CelestiaSession] {
  66. guard !searchText.isEmpty else { return sessions }
  67. let query = searchText.lowercased()
  68. return sessions.filter { session in
  69. if session.title.lowercased().contains(query) { return true }
  70. return session.events.contains { event in
  71. event.textContent?.lowercased().contains(query) == true
  72. }
  73. }
  74. }
  75. // MARK: - Session List
  76. private var sessionList: some View {
  77. ScrollView {
  78. LazyVStack(spacing: 12) {
  79. // Stats header
  80. statsHeader
  81. .padding(.horizontal, 16)
  82. .padding(.top, 12)
  83. ForEach(filteredSessions) { session in
  84. NavigationLink(destination: SessionDetailView(session: session)) {
  85. SessionRowCard(session: session)
  86. }
  87. .buttonStyle(.plain)
  88. .padding(.horizontal, 16)
  89. }
  90. }
  91. .padding(.bottom, 24)
  92. }
  93. }
  94. // MARK: - Stats Header
  95. private var statsHeader: some View {
  96. HStack(spacing: 20) {
  97. statBadge(
  98. value: "\(filteredSessions.count)",
  99. label: "总会话",
  100. icon: "waveform"
  101. )
  102. statBadge(
  103. value: "\(filteredSessions.reduce(0) { $0 + $1.photoCount })",
  104. label: "已获图片",
  105. icon: "camera"
  106. )
  107. statBadge(
  108. value: "\(filteredSessions.filter { $0.isSynced }.count)",
  109. label: "已同步",
  110. icon: "icloud"
  111. )
  112. }
  113. .padding(.vertical, 14)
  114. .background(Color.cardBackground.opacity(0.3))
  115. .businessBorder(cornerRadius: 10)
  116. }
  117. private func statBadge(value: String, label: String, icon: String) -> some View {
  118. VStack(spacing: 4) {
  119. Image(systemName: icon)
  120. .font(.system(size: 13, weight: .light))
  121. .foregroundStyle(Color.secondary)
  122. Text(value)
  123. .font(.system(size: 16, weight: .medium, design: .monospaced))
  124. .foregroundStyle(Color.primary)
  125. Text(label)
  126. .font(.system(size: 10, weight: .regular))
  127. .foregroundStyle(Color.secondary.opacity(0.8))
  128. }
  129. .frame(maxWidth: .infinity)
  130. }
  131. // MARK: - Empty State
  132. private var emptyState: some View {
  133. VStack(spacing: 16) {
  134. Image(systemName: "square.dashed")
  135. .font(.system(size: 36))
  136. .foregroundStyle(Color.secondary.opacity(0.4))
  137. Text("暂无记录")
  138. .font(.system(size: 14, weight: .medium))
  139. .foregroundStyle(Color.primary)
  140. if !searchText.isEmpty {
  141. Text("未找到与「\(searchText)」匹配的会话")
  142. .font(.system(size: 12))
  143. .foregroundStyle(Color.secondary)
  144. } else {
  145. Text("启动全局录音来保存你的第一条记录")
  146. .font(.system(size: 12))
  147. .foregroundStyle(Color.secondary.opacity(0.7))
  148. }
  149. }
  150. .padding(.bottom, 60)
  151. }
  152. }
  153. // MARK: - Session Row Card
  154. private struct SessionRowCard: View {
  155. let session: CelestiaSession
  156. var body: some View {
  157. HStack(spacing: 12) {
  158. // Sync status indicator (plain circular dot)
  159. Circle()
  160. .fill(session.isSynced ? Color.secondary.opacity(0.4) : Color.recordingRed.opacity(0.6))
  161. .frame(width: 6, height: 6)
  162. VStack(alignment: .leading, spacing: 4) {
  163. Text(session.title)
  164. .font(.system(size: 14, weight: .medium))
  165. .foregroundStyle(Color.primary)
  166. .lineLimit(1)
  167. HStack(spacing: 6) {
  168. Text(session.startTime.formatted(.dateTime.month().day().hour().minute()))
  169. .font(.system(size: 11))
  170. .foregroundStyle(Color.secondary)
  171. Text("·")
  172. .foregroundStyle(Color.secondary.opacity(0.4))
  173. Text(session.durationFormatted)
  174. .font(.system(size: 11, weight: .regular, design: .monospaced))
  175. .foregroundStyle(Color.secondary)
  176. }
  177. }
  178. Spacer()
  179. // Count badges (plain line style)
  180. HStack(spacing: 8) {
  181. countBadge(icon: "camera", count: session.photoCount)
  182. countBadge(icon: "doc.text", count: session.noteCount)
  183. }
  184. .padding(.trailing, 4)
  185. // Chevron
  186. Image(systemName: "chevron.right")
  187. .font(.system(size: 10, weight: .light))
  188. .foregroundStyle(Color.secondary.opacity(0.5))
  189. }
  190. .padding(14)
  191. .background(Color.cardBackground.opacity(0.2))
  192. .businessBorder(cornerRadius: 8)
  193. }
  194. private func countBadge(icon: String, count: Int) -> some View {
  195. HStack(spacing: 3) {
  196. Image(systemName: icon)
  197. .font(.system(size: 10, weight: .light))
  198. Text("\(count)")
  199. .font(.system(size: 11, weight: .regular, design: .monospaced))
  200. }
  201. .foregroundStyle(Color.secondary)
  202. .padding(.horizontal, 6)
  203. .padding(.vertical, 2)
  204. .businessBorder(cornerRadius: 4)
  205. }
  206. }
  207. // MARK: - Preview
  208. #Preview {
  209. SessionListView()
  210. .modelContainer(for: CelestiaSession.self, inMemory: true)
  211. }