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
116 lines
3.2 KiB
Swift
116 lines
3.2 KiB
Swift
import Foundation
|
|
|
|
// MARK: - SearchViewModel
|
|
// Debounced live search (300 ms) + recent searches persisted in UserDefaults.
|
|
// Uses @Observable (iOS 17+).
|
|
|
|
@Observable
|
|
@MainActor
|
|
final class SearchViewModel {
|
|
var query: String = ""
|
|
var results: [BrowseNovel] = []
|
|
var localCount: Int = 0
|
|
var remoteCount: Int = 0
|
|
var isLoading = false
|
|
var error: String?
|
|
|
|
// Persisted recent searches (max 10, prefixed with "v2.")
|
|
var recentSearches: [String] = []
|
|
|
|
private let recentKey = "v2.searchRecentTerms"
|
|
private var searchTask: Task<Void, Never>?
|
|
|
|
init() {
|
|
recentSearches = UserDefaults.standard.stringArray(forKey: recentKey) ?? []
|
|
}
|
|
|
|
// MARK: - Query change (debounced)
|
|
|
|
func onQueryChange(_ newValue: String) {
|
|
searchTask?.cancel()
|
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else {
|
|
results = []
|
|
localCount = 0
|
|
remoteCount = 0
|
|
return
|
|
}
|
|
searchTask = Task {
|
|
try? await Task.sleep(nanoseconds: 300_000_000) // 300 ms debounce
|
|
guard !Task.isCancelled else { return }
|
|
await runSearch(trimmed)
|
|
}
|
|
}
|
|
|
|
// MARK: - Submit (immediate, saves to recent)
|
|
|
|
func submitSearch() {
|
|
let term = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !term.isEmpty else { return }
|
|
saveRecent(term)
|
|
searchTask?.cancel()
|
|
searchTask = Task { await runSearch(term) }
|
|
}
|
|
|
|
// MARK: - Recent search tap
|
|
|
|
func selectRecent(_ term: String) {
|
|
query = term
|
|
searchTask?.cancel()
|
|
searchTask = Task { await runSearch(term) }
|
|
}
|
|
|
|
// MARK: - Clear
|
|
|
|
func clear() {
|
|
query = ""
|
|
results = []
|
|
localCount = 0
|
|
remoteCount = 0
|
|
error = nil
|
|
searchTask?.cancel()
|
|
}
|
|
|
|
func clearRecent() {
|
|
recentSearches = []
|
|
UserDefaults.standard.removeObject(forKey: recentKey)
|
|
}
|
|
|
|
// MARK: - Core search
|
|
|
|
private func runSearch(_ term: String) async {
|
|
guard !term.isEmpty else {
|
|
results = []
|
|
return
|
|
}
|
|
isLoading = true
|
|
error = nil
|
|
do {
|
|
let response = try await APIClient.shared.search(query: term)
|
|
// Only update if the query hasn't changed since we started
|
|
let currentTrimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if currentTrimmed == term || currentTrimmed.isEmpty {
|
|
results = response.results
|
|
localCount = response.localCount
|
|
remoteCount = response.remoteCount
|
|
}
|
|
} catch {
|
|
if !(error is CancellationError) {
|
|
self.error = error.localizedDescription
|
|
results = []
|
|
}
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
// MARK: - Persist recent
|
|
|
|
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)
|
|
}
|
|
}
|