SessionListView.swift 7.8 KB

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