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
287 lines
9.8 KiB
Swift
287 lines
9.8 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - SearchView
|
|
// Dedicated search tab for intentional, fuzzy search.
|
|
// Live search as you type, shows recent searches when idle.
|
|
|
|
struct SearchView: View {
|
|
@StateObject private var vm = SearchViewModel()
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
OfflineBanner()
|
|
|
|
Group {
|
|
// ── Content ─────────────────────────────────────────────────
|
|
if vm.query.isEmpty && vm.results.isEmpty {
|
|
idleContent
|
|
} else if vm.isLoading && vm.results.isEmpty {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if vm.results.isEmpty && !vm.query.isEmpty {
|
|
EmptyStateView(
|
|
icon: "magnifyingglass",
|
|
title: "No results",
|
|
message: "Try a different title or author name."
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
resultsGrid
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Search")
|
|
.searchable(
|
|
text: $vm.query,
|
|
placement: .navigationBarDrawer(displayMode: .always),
|
|
prompt: "Search novels, authors…"
|
|
)
|
|
.autocorrectionDisabled()
|
|
.onChange(of: vm.query) { _, newValue in
|
|
vm.onQueryChange(newValue)
|
|
}
|
|
.onSubmit(of: .search) {
|
|
vm.submitSearch()
|
|
}
|
|
.appNavigationDestination()
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
HStack(spacing: 16) {
|
|
DownloadQueueButton()
|
|
AvatarToolbarButton()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Idle screen (recent searches)
|
|
|
|
@ViewBuilder
|
|
private var idleContent: some View {
|
|
if vm.recentSearches.isEmpty {
|
|
// Empty state - prompt to search
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: 56))
|
|
.foregroundStyle(.secondary.opacity(0.5))
|
|
Text("Search for novels")
|
|
.font(.title2.bold())
|
|
.foregroundStyle(.primary)
|
|
Text("Find your next favorite book by title, author, or genre")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 40)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
// Recent searches list
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack {
|
|
Text("Recent Searches")
|
|
.font(.title3.bold())
|
|
Spacer()
|
|
Button("Clear") { vm.clearRecent() }
|
|
.font(.subheadline)
|
|
.foregroundStyle(.amber)
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 12)
|
|
|
|
ForEach(vm.recentSearches, id: \.self) { term in
|
|
Button {
|
|
vm.query = term
|
|
vm.submitSearch()
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "clock")
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 20)
|
|
Text(term)
|
|
.foregroundStyle(.primary)
|
|
Spacer()
|
|
Image(systemName: "arrow.up.left")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 12)
|
|
}
|
|
|
|
if term != vm.recentSearches.last {
|
|
Divider()
|
|
.padding(.leading, 44)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Results grid
|
|
|
|
@ViewBuilder
|
|
private var resultsGrid: some View {
|
|
ScrollView {
|
|
VStack(spacing: 8) {
|
|
// Result count
|
|
HStack {
|
|
Text("\(vm.results.count) result\(vm.results.count == 1 ? "" : "s")")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.top, 8)
|
|
|
|
LazyVGrid(
|
|
columns: [
|
|
GridItem(.flexible(), spacing: 14),
|
|
GridItem(.flexible(), spacing: 14)
|
|
],
|
|
spacing: 14
|
|
) {
|
|
ForEach(vm.results) { novel in
|
|
NavigationLink(value: NavDestination.book(novel.slug)) {
|
|
SearchNovelCard(novel: novel)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.bottom, 100)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Search novel card (compact 2-column)
|
|
|
|
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))
|
|
.bookCoverZoomSource(slug: novel.slug)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(novel.title)
|
|
.font(.subheadline.bold())
|
|
.lineLimit(2)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.multilineTextAlignment(.leading)
|
|
|
|
if !novel.author.isEmpty {
|
|
Text(novel.author)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 10)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(.secondarySystemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
|
|
}
|
|
}
|
|
|
|
// MARK: - SearchViewModel
|
|
|
|
@MainActor
|
|
final class SearchViewModel: ObservableObject {
|
|
@Published var query: String = ""
|
|
@Published var results: [BrowseNovel] = []
|
|
@Published var isLoading = false
|
|
|
|
// Persisted in UserDefaults (max 10 recent terms)
|
|
@Published var recentSearches: [String] = []
|
|
|
|
private let recentKey = "searchRecentTerms"
|
|
private var searchTask: Task<Void, Never>?
|
|
|
|
init() {
|
|
recentSearches = (UserDefaults.standard.stringArray(forKey: recentKey) ?? [])
|
|
}
|
|
|
|
/// Called when query changes - implements debounced live search
|
|
func onQueryChange(_ newValue: String) {
|
|
// Cancel previous search task
|
|
searchTask?.cancel()
|
|
|
|
// If query is empty, clear results
|
|
guard !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
results = []
|
|
return
|
|
}
|
|
|
|
// Debounce: wait 300ms before searching
|
|
searchTask = Task {
|
|
try? await Task.sleep(nanoseconds: 300_000_000) // 300ms
|
|
guard !Task.isCancelled else { return }
|
|
await runSearch(newValue)
|
|
}
|
|
}
|
|
|
|
func submitSearch() {
|
|
let term = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !term.isEmpty else { return }
|
|
saveRecent(term)
|
|
// Cancel debounce and search immediately
|
|
searchTask?.cancel()
|
|
Task { await runSearch(term) }
|
|
}
|
|
|
|
func clear() {
|
|
query = ""
|
|
results = []
|
|
searchTask?.cancel()
|
|
}
|
|
|
|
func clearRecent() {
|
|
recentSearches = []
|
|
UserDefaults.standard.removeObject(forKey: recentKey)
|
|
}
|
|
|
|
private func runSearch(_ term: String) async {
|
|
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else {
|
|
results = []
|
|
return
|
|
}
|
|
|
|
isLoading = true
|
|
do {
|
|
let result = try await APIClient.shared.search(query: trimmed)
|
|
// Only update results if query hasn't changed
|
|
if query.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed {
|
|
results = result.results
|
|
}
|
|
} catch {
|
|
if !(error is CancellationError) {
|
|
results = []
|
|
}
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
private func saveRecent(_ term: String) {
|
|
var list = recentSearches.filter { $0 != term }
|
|
list.insert(term, at: 0)
|
|
if list.count > 10 { list = Array(list.prefix(10)) }
|
|
recentSearches = list
|
|
UserDefaults.standard.set(list, forKey: recentKey)
|
|
}
|
|
}
|