import SwiftUI // MARK: - CommonViews // Shared reusable components used across multiple screens. // No external dependencies — images are loaded via URLSession with an in-memory cache. // MARK: - Color extensions (design system tokens) extension Color { /// Amber-400 accent — #f59e0b static let amber = Color(red: 0.961, green: 0.620, blue: 0.043) } // MARK: - AsyncCoverImage // URLSession-backed cover image loader with in-memory cache. // Displays a zinc-800 placeholder skeleton while loading, book-closed icon on failure. private actor ImageCache { static let shared = ImageCache() private var cache: [URL: Data] = [:] private var inFlight: [URL: Task] = [:] func data(for url: URL) async -> Data? { if let cached = cache[url] { return cached } if let existing = inFlight[url] { return await existing.value } let task = Task { do { let (d, _) = try await URLSession.shared.data(from: url) return d } catch { return nil } } inFlight[url] = task let result = await task.value inFlight.removeValue(forKey: url) if let result { cache[url] = result } return result } } struct AsyncCoverImage: View { let url: String? /// When true the placeholder is a plain colour fill (used for blurred hero backgrounds). var isBackground: Bool = false @State private var image: UIImage? @State private var hasFailed = false var body: some View { Group { if let image { Image(uiImage: image) .resizable() .scaledToFill() } else if hasFailed { placeholder } else { placeholder .task(id: url) { await load() } } } } @ViewBuilder private var placeholder: some View { if isBackground { Color(uiColor: UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1)) } else { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color(uiColor: UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1))) .overlay( Image(systemName: "book.closed") .font(.title3) .foregroundStyle(.tertiary) ) } } private func load() async { guard let urlString = url, let parsedURL = URL(string: urlString) else { hasFailed = true return } guard let data = await ImageCache.shared.data(for: parsedURL), let loaded = UIImage(data: data) else { hasFailed = true return } image = loaded } } // MARK: - EmptyStateView struct EmptyStateView: View { let icon: String let title: String let message: String var ctaLabel: String? = nil var ctaAction: (() -> Void)? = nil var body: some View { VStack(spacing: 16) { Image(systemName: icon) .font(.system(size: 52)) .foregroundStyle(.tertiary) .symbolEffect(.pulse) Text(title) .font(.headline) .foregroundStyle(.primary) Text(message) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 40) if let label = ctaLabel, let action = ctaAction { Button(action: action) { Text(label) .font(.subheadline.bold()) .foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1))) .padding(.horizontal, 24) .frame(height: 44) .background(Color.amber) .clipShape(Capsule()) } .padding(.top, 4) } } } } // MARK: - ShelfHeader // Amber accent-bar + bold title. Used by Home, Profile, Browse shelves. struct ShelfHeader: View { let title: String var body: some View { HStack(spacing: 10) { RoundedRectangle(cornerRadius: 2, style: .continuous) .fill(Color.amber) .frame(width: 3, height: 18) Text(title) .font(.title3.bold()) } .padding(.horizontal, 16) .padding(.bottom, 10) } } // MARK: - ChipButton // Unified selection chip (filled or outlined style). enum ChipButtonStyle { case filled, outlined } struct ChipButton: View { let label: String let isSelected: Bool var style: ChipButtonStyle = .filled let action: () -> Void var body: some View { Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() action() } label: { Text(label) .font(style == .filled ? .caption.weight(isSelected ? .semibold : .regular) : .subheadline.weight(isSelected ? .semibold : .regular)) .padding(.horizontal, style == .filled ? 12 : 14) .padding(.vertical, 6) .foregroundStyle(isSelected ? (style == .filled ? Color.white : Color.amber) : Color.primary) .background(chipBackground) } .buttonStyle(.plain) .frame(minWidth: 44, minHeight: 44) .accessibilityAddTraits(isSelected ? [.isSelected] : []) } @ViewBuilder private var chipBackground: some View { switch style { case .filled: Capsule().fill(isSelected ? Color.amber : Color(.systemGray5)) case .outlined: Capsule() .fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6)) .overlay(Capsule().stroke(isSelected ? Color.amber : Color.clear, lineWidth: 1.5)) } } } // MARK: - TagChip (read-only label) struct TagChip: View { let label: String var body: some View { Text(label) .font(.caption2.bold()) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color(.systemGray5), in: Capsule()) } } // MARK: - OfflineBanner // Shown at the top of any view when the device is offline. struct OfflineBanner: View { @EnvironmentObject var networkMonitor: NetworkMonitor var body: some View { if !networkMonitor.isConnected { HStack(spacing: 8) { Image(systemName: "wifi.slash") .font(.caption.bold()) Text("You're offline — showing cached content") .font(.caption) Spacer() } .foregroundStyle(.primary) .padding(.horizontal, 16) .padding(.vertical, 10) .background(.regularMaterial) .transition(.move(edge: .top).combined(with: .opacity)) } } }