fix: update integration_test.go to match server.New signature (version, commit args)
All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
This commit is contained in:
235
ios/LibNovelV2/Views/Common/CommonViews.swift
Normal file
235
ios/LibNovelV2/Views/Common/CommonViews.swift
Normal file
@@ -0,0 +1,235 @@
|
||||
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<Data?, Never>] = [:]
|
||||
|
||||
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<Data?, Never> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user