| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- import SwiftUI
- // MARK: - ChronoFeedRow
- /// A card component for the chronological event feed.
- /// Displays a timeline event with time badge, type icon, content,
- /// and a clean, wireframe bordered layout suitable for business scenarios.
- struct ChronoFeedRow: View {
- let event: CelestiaTimelineEvent
- var photoEvents: [CelestiaTimelineEvent] = []
- var onTap: (() -> Void)?
- var body: some View {
- Button {
- HapticManager.trigger(.tapFeedback)
- onTap?()
- } label: {
- HStack(spacing: 12) {
- // Left: Time Badge (Minimalist outline)
- timeBadge
- // Center: Content
- eventContent
- Spacer(minLength: 4)
- // Right: Chevron
- Image(systemName: "chevron.right")
- .font(.system(size: 9, weight: .light))
- .foregroundStyle(Color.secondary.opacity(0.5))
- }
- .padding(12)
- .background(Color.cardBackground.opacity(0.15))
- .businessBorder(cornerRadius: 8)
- }
- .buttonStyle(.plain)
- }
- // MARK: - Time Badge
- private var timeBadge: some View {
- Text(event.relativeTimeFormatted)
- .font(.system(size: 11, weight: .regular, design: .monospaced))
- .foregroundStyle(Color.primary)
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .businessBorder(cornerRadius: 4)
- }
- // MARK: - Event Content
- private var eventContent: some View {
- HStack(spacing: 10) {
- // Event icon (Clean monochrome outline)
- Image(systemName: eventIconName)
- .font(.system(size: 12, weight: .light))
- .foregroundStyle(Color.primary)
- .frame(width: 24, height: 24)
- .businessBorder(cornerRadius: 12)
- VStack(alignment: .leading, spacing: 3) {
- // Type label
- Text(typeLabel)
- .font(.system(size: 9, weight: .medium))
- .foregroundStyle(Color.secondary)
- // Content
- switch event.eventType {
- case "PHOTO":
- photoView
- case "NOTE", "MARKER":
- if let text = event.textContent {
- Text(text)
- .font(.system(size: 13, weight: .regular))
- .foregroundStyle(Color.primary.opacity(0.9))
- .lineLimit(2)
- .multilineTextAlignment(.leading)
- }
- default:
- Text("事件")
- .font(.system(size: 13))
- .foregroundStyle(Color.secondary)
- }
- if let location = displayLocation {
- Label(location.name, systemImage: "location.fill")
- .font(.system(size: 10, weight: .medium))
- .foregroundStyle(Color.secondary)
- .lineLimit(1)
- }
- }
- }
- }
- // MARK: - Photo Placeholder
- private var photoPlaceholder: some View {
- HStack(spacing: 6) {
- Image(systemName: "photo")
- .font(.system(size: 10, weight: .light))
- .foregroundStyle(Color.secondary)
- Text("照片片段")
- .font(.system(size: 11))
- .foregroundStyle(Color.secondary)
- }
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .businessBorder(cornerRadius: 4)
- }
- @ViewBuilder
- private var photoView: some View {
- let events = displayPhotoEvents
- let images = events.compactMap(resolveImage(for:))
- if !images.isEmpty {
- HStack(spacing: 8) {
- ZStack(alignment: .leading) {
- ForEach(Array(images.prefix(3).enumerated()), id: \.offset) { index, image in
- Image(uiImage: image)
- .resizable()
- .scaledToFill()
- .frame(width: 48, height: 40)
- .clipShape(RoundedRectangle(cornerRadius: 4))
- .overlay {
- RoundedRectangle(cornerRadius: 4)
- .stroke(Color.lineBorder, lineWidth: 1)
- }
- .offset(x: CGFloat(index) * 12)
- }
- }
- .frame(width: images.count > 1 ? 72 : 48, height: 40, alignment: .leading)
- VStack(alignment: .leading, spacing: 3) {
- Text(events.count > 1 ? "\(events.count) 张照片" : "照片记录")
- .font(.system(size: 11))
- .foregroundStyle(Color.secondary)
- if let note = firstPhotoNote {
- Text(note)
- .font(.system(size: 12))
- .foregroundStyle(Color.primary.opacity(0.9))
- .lineLimit(1)
- }
- }
- }
- } else {
- photoPlaceholder
- }
- }
- // MARK: - Computed Properties
- private var eventIconName: String {
- if event.isContinuationMarker {
- return "record.circle"
- }
- switch event.eventType {
- case "PHOTO": return "camera"
- case "NOTE": return "doc.text"
- case "MARKER": return "flag"
- case "VOICE": return "waveform"
- default: return "circle"
- }
- }
- private var typeLabel: String {
- if event.isContinuationMarker {
- return "续录"
- }
- switch event.eventType {
- case "PHOTO": return "照片"
- case "NOTE": return "笔记"
- case "MARKER": return "标记"
- case "VOICE": return "音频"
- default: return "事件"
- }
- }
- private var displayPhotoEvents: [CelestiaTimelineEvent] {
- photoEvents.isEmpty ? [event] : photoEvents
- }
- private var firstPhotoNote: String? {
- displayPhotoEvents
- .compactMap(\.textContent)
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .first { !$0.isEmpty }
- }
- private var displayLocation: TimelineLocation? {
- if event.eventType == "PHOTO" {
- return displayPhotoEvents.compactMap(\.location).first
- }
- return event.location
- }
- private func resolveImage(for event: CelestiaTimelineEvent) -> UIImage? {
- guard let url = AudioPathHelper.resolveURL(for: event.localFilePath) else {
- return nil
- }
- return UIImage(contentsOfFile: url.path)
- }
- }
- // MARK: - Preview
- #Preview {
- VStack(spacing: 12) {
- let photoEvent: CelestiaTimelineEvent = {
- let e = CelestiaTimelineEvent(relativeTimeMs: 32000, eventType: "PHOTO")
- return e
- }()
- let noteEvent: CelestiaTimelineEvent = {
- let e = CelestiaTimelineEvent(relativeTimeMs: 65000, eventType: "NOTE")
- e.textContent = "看到了非常壮观的星空,银河清晰可见"
- return e
- }()
- let markerEvent: CelestiaTimelineEvent = {
- let e = CelestiaTimelineEvent(relativeTimeMs: 120000, eventType: "MARKER")
- e.textContent = "重要时刻标记"
- return e
- }()
- ChronoFeedRow(event: photoEvent)
- ChronoFeedRow(event: noteEvent)
- ChronoFeedRow(event: markerEvent)
- }
- .padding(16)
- .background(Color.spaceBlack)
- }
|