// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // SearchView.swift — la recherche UNIVERSELLE : une seule barre qui interroge // tous les univers en parallèle (fan-out sur leurs API + web québécois via // Trouve·Ka), résultats mélangés groupés par univers, filtres par univers, // historique local. import SwiftUI struct SearchView: View { @State private var query = "" @State private var submitted = "" @State private var sections: [(universe: Universe, items: [KAItem])] = [] @State private var searching = false @State private var selected: Set = [] // filtres univers (vide = tous) @State private var sort: SortMode = .pertinence @State private var showSave = false @State private var saveName = "" @AppStorage("ka.search.history") private var historyRaw = "" @EnvironmentObject private var recents: RecentsStore @Environment(\.colorScheme) private var scheme enum SortMode: String, CaseIterable { case pertinence = "Pertinence" case prixAsc = "Prix ↑" case prixDesc = "Prix ↓" } private func sorted(_ items: [KAItem]) -> [KAItem] { switch sort { case .pertinence: return items case .prixAsc: return items.sorted { price($0) < price($1) } case .prixDesc: return items.sorted { price($0) > price($1) } } } private func price(_ i: KAItem) -> Double { Double(i.priceLabel?.filter { $0.isNumber } ?? "") ?? (sort == .prixAsc ? .greatestFiniteMagnitude : 0) } private var history: [String] { historyRaw.split(separator: "\n").map(String.init) } private var searchables: [Universe] { Ecosystem.all.filter { $0.map != nil } } var body: some View { NavigationStack { ScrollView { LazyVStack(alignment: .leading, spacing: 16) { filterChips if searching { HStack(spacing: 10) { ProgressView() Text("KA interroge \(selected.isEmpty ? searchables.count : selected.count) univers…") .font(.subheadline).foregroundStyle(.secondary) } .padding(24).frame(maxWidth: .infinity) } else if submitted.isEmpty { suggestions } else if sections.allSatisfy({ $0.items.isEmpty }) { KAEmptyState(symbol: "magnifyingglass", title: "Rien trouvé pour « \(submitted) »", message: "Essayez un autre mot, ou élargissez les univers filtrés.") } else { results } } .padding(16) } .background(KATheme.paper(scheme)) .navigationTitle("Recherche") .searchable(text: $query, prompt: "appartement Rouyn, resto italien, Corolla…") .onSubmit(of: .search) { Task { await search() } } .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } .toolbar { if !submitted.isEmpty { ToolbarItemGroup(placement: .topBarTrailing) { Menu { Picker("Tri", selection: $sort) { ForEach(SortMode.allCases, id: \.self) { Text($0.rawValue).tag($0) } } } label: { Image(systemName: "arrow.up.arrow.down") } .accessibilityLabel("Trier les résultats") Button { saveName = submitted; showSave = true } label: { Image(systemName: "bell.badge") } .accessibilityLabel("Sauvegarder cette recherche et créer une alerte") ShareLink(item: URL(string: "https://www.trouve-ka.com/search?q=\(submitted.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")")!, subject: Text("Recherche KA : \(submitted)")) } } } .alert("Sauvegarder la recherche", isPresented: $showSave) { TextField("Nom", text: $saveName) Button("Sauvegarder + alerte") { let uid = selected.count == 1 ? selected.first! : (sections.max { $0.items.count < $1.items.count }?.universe.id ?? "lou-ka") recents.saveSearch(name: saveName, universeID: uid, query: submitted, params: [:]) } Button("Annuler", role: .cancel) {} } message: { Text("KA recomptera les résultats à chaque ouverture et vous montrera les nouveautés (+N).") } } } private var filterChips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(searchables) { u in let on = selected.isEmpty || selected.contains(u.id) Button { Haptics.tap() if selected.contains(u.id) { selected.remove(u.id) } else { selected.insert(u.id) } if selected.count == searchables.count { selected = [] } if !submitted.isEmpty { Task { await search(submitted) } } } label: { Text(u.wordmark) .font(.system(.caption, design: .monospaced).weight(.bold)) .padding(.horizontal, 11).padding(.vertical, 7) .background(on ? u.accent.opacity(0.9) : KATheme.surface(scheme), in: Capsule()) .foregroundStyle(on ? .white : .secondary) .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1)) } .accessibilityLabel("\(on ? "Masquer" : "Afficher") \(u.name)") } } } } private var suggestions: some View { VStack(alignment: .leading, spacing: 14) { if !history.isEmpty { Text("Récemment cherché").font(.headline) ForEach(history.prefix(6), id: \.self) { h in Button { query = h Task { await search(h) } } label: { HStack { Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary) Text(h) Spacer() } .padding(12).kaCard() } .buttonStyle(.plain) } } Text("Idées").font(.headline) ForEach(["4½ à Québec", "resto italien Montréal", "Toyota Corolla", "emploi infirmière", "spectacle ce week-end"], id: \.self) { s in Button { query = s; Task { await search(s) } } label: { HStack { Image(systemName: "sparkle.magnifyingglass").foregroundStyle(KATheme.green) Text(s); Spacer() } .padding(12).kaCard() } .buttonStyle(.plain) } } } private var results: some View { ForEach(sections.filter { !$0.items.isEmpty }, id: \.universe.id) { section in VStack(alignment: .leading, spacing: 10) { HStack { KAWordmark(universe: section.universe, size: 16) Spacer() Text("\(section.items.count)") .font(.system(.caption, design: .monospaced).weight(.bold)) .foregroundStyle(section.universe.accent) } ForEach(sorted(section.items).prefix(5)) { item in NavigationLink(value: item) { KAItemRow(item: item) } .buttonStyle(KAPressStyle()) } NavigationLink(value: section.universe.id) { Text("Tout voir dans \(section.universe.name) →") .font(.subheadline.weight(.semibold)) .foregroundStyle(section.universe.accent) } } } .navigationDestination(for: String.self) { id in if let u = Ecosystem.universe(id) { UniverseHomeView(universe: u) } } } private func search(_ text: String? = nil) async { let q = (text ?? query).trimmingCharacters(in: .whitespaces) guard !q.isEmpty else { return } submitted = q searching = true Haptics.rigid() var hist = history.filter { $0 != q } hist.insert(q, at: 0) historyRaw = hist.prefix(10).joined(separator: "\n") let targets = searchables.filter { selected.isEmpty || selected.contains($0.id) } var found: [String: [KAItem]] = [:] await withTaskGroup(of: (String, [KAItem]).self) { group in for u in targets { group.addTask { let items = (try? await UniverseService.fetch(u, query: q, limit: 8)) ?? [] return (u.id, items) } } for await (id, items) in group { found[id] = items } } // ordre : univers avec le plus de résultats d'abord, web québécois à la fin sections = targets .map { ($0, found[$0.id] ?? []) } .sorted { if $0.0.id == "trouve-ka" { return false } if $1.0.id == "trouve-ka" { return true } return $0.1.count > $1.1.count } searching = false if sections.contains(where: { !$0.items.isEmpty }) { Haptics.success() } } }