ChronoFeedRow.swift 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import SwiftUI
  2. // MARK: - ChronoFeedRow
  3. /// A card component for the chronological event feed.
  4. /// Displays a timeline event with time badge, type icon, content,
  5. /// and a clean, wireframe bordered layout suitable for business scenarios.
  6. struct ChronoFeedRow: View {
  7. let event: CelestiaTimelineEvent
  8. var photoEvents: [CelestiaTimelineEvent] = []
  9. var onTap: (() -> Void)?
  10. var body: some View {
  11. Button {
  12. HapticManager.trigger(.tapFeedback)
  13. onTap?()
  14. } label: {
  15. HStack(spacing: 12) {
  16. // Left: Time Badge (Minimalist outline)
  17. timeBadge
  18. // Center: Content
  19. eventContent
  20. Spacer(minLength: 4)
  21. // Right: Chevron
  22. Image(systemName: "chevron.right")
  23. .font(.system(size: 9, weight: .light))
  24. .foregroundStyle(Color.secondary.opacity(0.5))
  25. }
  26. .padding(12)
  27. .background(Color.cardBackground.opacity(0.15))
  28. .businessBorder(cornerRadius: 8)
  29. }
  30. .buttonStyle(.plain)
  31. }
  32. // MARK: - Time Badge
  33. private var timeBadge: some View {
  34. Text(event.relativeTimeFormatted)
  35. .font(.system(size: 11, weight: .regular, design: .monospaced))
  36. .foregroundStyle(Color.primary)
  37. .padding(.horizontal, 8)
  38. .padding(.vertical, 4)
  39. .businessBorder(cornerRadius: 4)
  40. }
  41. // MARK: - Event Content
  42. private var eventContent: some View {
  43. HStack(spacing: 10) {
  44. // Event icon (Clean monochrome outline)
  45. Image(systemName: eventIconName)
  46. .font(.system(size: 12, weight: .light))
  47. .foregroundStyle(Color.primary)
  48. .frame(width: 24, height: 24)
  49. .businessBorder(cornerRadius: 12)
  50. VStack(alignment: .leading, spacing: 3) {
  51. // Type label
  52. Text(typeLabel)
  53. .font(.system(size: 9, weight: .medium))
  54. .foregroundStyle(Color.secondary)
  55. // Content
  56. switch event.eventType {
  57. case "PHOTO":
  58. photoView
  59. case "NOTE", "MARKER":
  60. if let text = event.textContent {
  61. Text(text)
  62. .font(.system(size: 13, weight: .regular))
  63. .foregroundStyle(Color.primary.opacity(0.9))
  64. .lineLimit(2)
  65. .multilineTextAlignment(.leading)
  66. }
  67. default:
  68. Text("事件")
  69. .font(.system(size: 13))
  70. .foregroundStyle(Color.secondary)
  71. }
  72. if let location = displayLocation {
  73. Label(location.name, systemImage: "location.fill")
  74. .font(.system(size: 10, weight: .medium))
  75. .foregroundStyle(Color.secondary)
  76. .lineLimit(1)
  77. }
  78. }
  79. }
  80. }
  81. // MARK: - Photo Placeholder
  82. private var photoPlaceholder: some View {
  83. HStack(spacing: 6) {
  84. Image(systemName: "photo")
  85. .font(.system(size: 10, weight: .light))
  86. .foregroundStyle(Color.secondary)
  87. Text("照片片段")
  88. .font(.system(size: 11))
  89. .foregroundStyle(Color.secondary)
  90. }
  91. .padding(.horizontal, 8)
  92. .padding(.vertical, 4)
  93. .businessBorder(cornerRadius: 4)
  94. }
  95. @ViewBuilder
  96. private var photoView: some View {
  97. let events = displayPhotoEvents
  98. let images = events.compactMap(resolveImage(for:))
  99. if !images.isEmpty {
  100. HStack(spacing: 8) {
  101. ZStack(alignment: .leading) {
  102. ForEach(Array(images.prefix(3).enumerated()), id: \.offset) { index, image in
  103. Image(uiImage: image)
  104. .resizable()
  105. .scaledToFill()
  106. .frame(width: 48, height: 40)
  107. .clipShape(RoundedRectangle(cornerRadius: 4))
  108. .overlay {
  109. RoundedRectangle(cornerRadius: 4)
  110. .stroke(Color.lineBorder, lineWidth: 1)
  111. }
  112. .offset(x: CGFloat(index) * 12)
  113. }
  114. }
  115. .frame(width: images.count > 1 ? 72 : 48, height: 40, alignment: .leading)
  116. VStack(alignment: .leading, spacing: 3) {
  117. Text(events.count > 1 ? "\(events.count) 张照片" : "照片记录")
  118. .font(.system(size: 11))
  119. .foregroundStyle(Color.secondary)
  120. if let note = firstPhotoNote {
  121. Text(note)
  122. .font(.system(size: 12))
  123. .foregroundStyle(Color.primary.opacity(0.9))
  124. .lineLimit(1)
  125. }
  126. }
  127. }
  128. } else {
  129. photoPlaceholder
  130. }
  131. }
  132. // MARK: - Computed Properties
  133. private var eventIconName: String {
  134. if event.isContinuationMarker {
  135. return "record.circle"
  136. }
  137. switch event.eventType {
  138. case "PHOTO": return "camera"
  139. case "NOTE": return "doc.text"
  140. case "MARKER": return "flag"
  141. case "VOICE": return "waveform"
  142. default: return "circle"
  143. }
  144. }
  145. private var typeLabel: String {
  146. if event.isContinuationMarker {
  147. return "续录"
  148. }
  149. switch event.eventType {
  150. case "PHOTO": return "照片"
  151. case "NOTE": return "笔记"
  152. case "MARKER": return "标记"
  153. case "VOICE": return "音频"
  154. default: return "事件"
  155. }
  156. }
  157. private var displayPhotoEvents: [CelestiaTimelineEvent] {
  158. photoEvents.isEmpty ? [event] : photoEvents
  159. }
  160. private var firstPhotoNote: String? {
  161. displayPhotoEvents
  162. .compactMap(\.textContent)
  163. .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
  164. .first { !$0.isEmpty }
  165. }
  166. private var displayLocation: TimelineLocation? {
  167. if event.eventType == "PHOTO" {
  168. return displayPhotoEvents.compactMap(\.location).first
  169. }
  170. return event.location
  171. }
  172. private func resolveImage(for event: CelestiaTimelineEvent) -> UIImage? {
  173. guard let url = AudioPathHelper.resolveURL(for: event.localFilePath) else {
  174. return nil
  175. }
  176. return UIImage(contentsOfFile: url.path)
  177. }
  178. }
  179. // MARK: - Preview
  180. #Preview {
  181. VStack(spacing: 12) {
  182. let photoEvent: CelestiaTimelineEvent = {
  183. let e = CelestiaTimelineEvent(relativeTimeMs: 32000, eventType: "PHOTO")
  184. return e
  185. }()
  186. let noteEvent: CelestiaTimelineEvent = {
  187. let e = CelestiaTimelineEvent(relativeTimeMs: 65000, eventType: "NOTE")
  188. e.textContent = "看到了非常壮观的星空,银河清晰可见"
  189. return e
  190. }()
  191. let markerEvent: CelestiaTimelineEvent = {
  192. let e = CelestiaTimelineEvent(relativeTimeMs: 120000, eventType: "MARKER")
  193. e.textContent = "重要时刻标记"
  194. return e
  195. }()
  196. ChronoFeedRow(event: photoEvent)
  197. ChronoFeedRow(event: noteEvent)
  198. ChronoFeedRow(event: markerEvent)
  199. }
  200. .padding(16)
  201. .background(Color.spaceBlack)
  202. }