Files
libnovel/ios/LibNovelV2/ViewModels/SearchViewModel.swift
Admin 7413313100
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
fix: update integration_test.go to match server.New signature (version, commit args)
2026-03-14 14:25:46 +05:00

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)
}
}