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
256 lines
8.5 KiB
Swift
256 lines
8.5 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - SearchView
|
|
// Full-screen search tab.
|
|
// Idle: recent searches list (or prompt if empty).
|
|
// Active: debounced live results in a 2-col grid with local/remote count header.
|
|
|
|
struct SearchView: View {
|
|
@State private var vm = SearchViewModel()
|
|
@EnvironmentObject private var networkMonitor: NetworkMonitor
|
|
|
|
private let columns = [
|
|
GridItem(.flexible(), spacing: 14),
|
|
GridItem(.flexible(), spacing: 14),
|
|
]
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
OfflineBanner()
|
|
|
|
Group {
|
|
if vm.isLoading {
|
|
loadingState
|
|
} else if !vm.query.isEmpty && vm.results.isEmpty {
|
|
emptyResultsState
|
|
} else if !vm.results.isEmpty {
|
|
resultsGrid
|
|
} else {
|
|
idleState
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.appNavigationDestination()
|
|
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
|
.navigationTitle("Search")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.searchable(
|
|
text: $vm.query,
|
|
placement: .navigationBarDrawer(displayMode: .always),
|
|
prompt: "Search novels, authors…"
|
|
)
|
|
.onChange(of: vm.query) { _, newValue in
|
|
guard networkMonitor.isConnected else { return }
|
|
vm.onQueryChange(newValue)
|
|
}
|
|
.onSubmit(of: .search) {
|
|
guard networkMonitor.isConnected else { return }
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
vm.submitSearch()
|
|
}
|
|
.errorAlert($vm.error)
|
|
}
|
|
}
|
|
|
|
// MARK: - Idle state
|
|
|
|
@ViewBuilder
|
|
private var idleState: some View {
|
|
if vm.recentSearches.isEmpty {
|
|
emptyIdleState
|
|
} else {
|
|
recentSearchesList
|
|
}
|
|
}
|
|
|
|
private var emptyIdleState: some View {
|
|
VStack(spacing: 16) {
|
|
Spacer()
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: 60))
|
|
.foregroundStyle(.tertiary)
|
|
Text("Search for novels")
|
|
.font(.title3.bold())
|
|
.foregroundStyle(.primary)
|
|
Text("Find books by title, author, or genre")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 40)
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
private var recentSearchesList: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack {
|
|
Text("Recent Searches")
|
|
.font(.subheadline.bold())
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Button {
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
vm.clearRecent()
|
|
} label: {
|
|
Text("Clear")
|
|
.font(.subheadline)
|
|
.foregroundStyle(Color.amber)
|
|
}
|
|
.frame(minWidth: 44, minHeight: 44)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 12)
|
|
.padding(.bottom, 4)
|
|
|
|
ForEach(vm.recentSearches, id: \.self) { term in
|
|
Button {
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
vm.selectRecent(term)
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "clock")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.tertiary)
|
|
.frame(width: 24)
|
|
Text(term)
|
|
.font(.body)
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
Image(systemName: "arrow.up.left")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.frame(minHeight: 44)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.contentShape(Rectangle())
|
|
|
|
Divider()
|
|
.padding(.leading, 52)
|
|
}
|
|
}
|
|
|
|
Color.clear.frame(height: 120)
|
|
}
|
|
}
|
|
|
|
// MARK: - Loading state
|
|
|
|
private var loadingState: some View {
|
|
VStack {
|
|
Spacer()
|
|
ProgressView()
|
|
.tint(Color.amber)
|
|
.scaleEffect(1.4)
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
// MARK: - Empty results state
|
|
|
|
private var emptyResultsState: some View {
|
|
VStack {
|
|
Spacer()
|
|
EmptyStateView(
|
|
icon: "magnifyingglass",
|
|
title: "No results",
|
|
message: "Nothing matched \"\(vm.query)\". Try a different term."
|
|
)
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
// MARK: - Results grid
|
|
|
|
private var resultsGrid: some View {
|
|
ScrollView {
|
|
// Count header
|
|
HStack(spacing: 6) {
|
|
Text("\(vm.results.count) results")
|
|
.font(.subheadline.bold())
|
|
.foregroundStyle(.primary)
|
|
|
|
if vm.localCount > 0 || vm.remoteCount > 0 {
|
|
Text("·")
|
|
.foregroundStyle(.tertiary)
|
|
if vm.localCount > 0 {
|
|
Text("\(vm.localCount) in library")
|
|
.font(.caption)
|
|
.foregroundStyle(Color.amber)
|
|
}
|
|
if vm.localCount > 0 && vm.remoteCount > 0 {
|
|
Text("+")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
if vm.remoteCount > 0 {
|
|
Text("\(vm.remoteCount) online")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 12)
|
|
.padding(.bottom, 4)
|
|
|
|
LazyVGrid(columns: columns, spacing: 14) {
|
|
ForEach(vm.results) { novel in
|
|
NavigationLink(value: NavDestination.book(novel.slug)) {
|
|
SearchNovelCard(novel: novel)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 4)
|
|
|
|
Color.clear.frame(height: 120)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - SearchNovelCard
|
|
|
|
private struct SearchNovelCard: View {
|
|
let novel: BrowseNovel
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
AsyncCoverImage(url: novel.cover)
|
|
.frame(maxWidth: .infinity)
|
|
.aspectRatio(2/3, contentMode: .fit)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
.bookCoverZoomSource(slug: novel.slug)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(novel.title)
|
|
.font(.subheadline.bold())
|
|
.lineLimit(2)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
if !novel.author.isEmpty {
|
|
Text(novel.author)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 10)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
.shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2)
|
|
}
|
|
}
|