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