1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// SearchView.swift — la recherche UNIVERSELLE : une seule barre qui interroge3// tous les univers en parallèle (fan-out sur leurs API + web québécois via4// Trouve·Ka), résultats mélangés groupés par univers, filtres par univers,5// historique local.6import SwiftUI78struct SearchView: View {9 @State private var query = ""10 @State private var submitted = ""11 @State private var sections: [(universe: Universe, items: [KAItem])] = []12 @State private var searching = false13 @State private var selected: Set<String> = [] // filtres univers (vide = tous)14 @State private var sort: SortMode = .pertinence15 @State private var showSave = false16 @State private var saveName = ""17 @AppStorage("ka.search.history") private var historyRaw = ""18 @EnvironmentObject private var recents: RecentsStore19 @Environment(\.colorScheme) private var scheme2021 enum SortMode: String, CaseIterable {22 case pertinence = "Pertinence"23 case prixAsc = "Prix ↑"24 case prixDesc = "Prix ↓"25 }2627 private func sorted(_ items: [KAItem]) -> [KAItem] {28 switch sort {29 case .pertinence: return items30 case .prixAsc: return items.sorted { price($0) < price($1) }31 case .prixDesc: return items.sorted { price($0) > price($1) }32 }33 }34 private func price(_ i: KAItem) -> Double {35 Double(i.priceLabel?.filter { $0.isNumber } ?? "") ?? (sort == .prixAsc ? .greatestFiniteMagnitude : 0)36 }3738 private var history: [String] { historyRaw.split(separator: "\n").map(String.init) }39 private var searchables: [Universe] { Ecosystem.all.filter { $0.map != nil } }4041 var body: some View {42 NavigationStack {43 ScrollView {44 LazyVStack(alignment: .leading, spacing: 16) {45 filterChips46 if searching {47 HStack(spacing: 10) {48 ProgressView()49 Text("KA interroge \(selected.isEmpty ? searchables.count : selected.count) univers…")50 .font(.subheadline).foregroundStyle(.secondary)51 }52 .padding(24).frame(maxWidth: .infinity)53 } else if submitted.isEmpty {54 suggestions55 } else if sections.allSatisfy({ $0.items.isEmpty }) {56 KAEmptyState(symbol: "magnifyingglass", title: "Rien trouvé pour « \(submitted) »",57 message: "Essayez un autre mot, ou élargissez les univers filtrés.")58 } else {59 results60 }61 }62 .padding(16)63 }64 .background(KATheme.paper(scheme))65 .navigationTitle("Recherche")66 .searchable(text: $query, prompt: "appartement Rouyn, resto italien, Corolla…")67 .onSubmit(of: .search) { Task { await search() } }68 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }69 .toolbar {70 if !submitted.isEmpty {71 ToolbarItemGroup(placement: .topBarTrailing) {72 Menu {73 Picker("Tri", selection: $sort) {74 ForEach(SortMode.allCases, id: \.self) { Text($0.rawValue).tag($0) }75 }76 } label: { Image(systemName: "arrow.up.arrow.down") }77 .accessibilityLabel("Trier les résultats")78 Button { saveName = submitted; showSave = true } label: {79 Image(systemName: "bell.badge")80 }81 .accessibilityLabel("Sauvegarder cette recherche et créer une alerte")82 ShareLink(item: URL(string: "https://www.trouve-ka.com/search?q=\(submitted.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")")!,83 subject: Text("Recherche KA : \(submitted)"))84 }85 }86 }87 .alert("Sauvegarder la recherche", isPresented: $showSave) {88 TextField("Nom", text: $saveName)89 Button("Sauvegarder + alerte") {90 let uid = selected.count == 1 ? selected.first! : (sections.max { $0.items.count < $1.items.count }?.universe.id ?? "lou-ka")91 recents.saveSearch(name: saveName, universeID: uid, query: submitted, params: [:])92 }93 Button("Annuler", role: .cancel) {}94 } message: {95 Text("KA recomptera les résultats à chaque ouverture et vous montrera les nouveautés (+N).")96 }97 }98 }99100 private var filterChips: some View {101 ScrollView(.horizontal, showsIndicators: false) {102 HStack(spacing: 8) {103 ForEach(searchables) { u in104 let on = selected.isEmpty || selected.contains(u.id)105 Button {106 Haptics.tap()107 if selected.contains(u.id) { selected.remove(u.id) }108 else { selected.insert(u.id) }109 if selected.count == searchables.count { selected = [] }110 if !submitted.isEmpty { Task { await search(submitted) } }111 } label: {112 Text(u.wordmark)113 .font(.system(.caption, design: .monospaced).weight(.bold))114 .padding(.horizontal, 11).padding(.vertical, 7)115 .background(on ? u.accent.opacity(0.9) : KATheme.surface(scheme), in: Capsule())116 .foregroundStyle(on ? .white : .secondary)117 .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))118 }119 .accessibilityLabel("\(on ? "Masquer" : "Afficher") \(u.name)")120 }121 }122 }123 }124125 private var suggestions: some View {126 VStack(alignment: .leading, spacing: 14) {127 if !history.isEmpty {128 Text("Récemment cherché").font(.headline)129 ForEach(history.prefix(6), id: \.self) { h in130 Button {131 query = h132 Task { await search(h) }133 } label: {134 HStack {135 Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary)136 Text(h)137 Spacer()138 }139 .padding(12).kaCard()140 }141 .buttonStyle(.plain)142 }143 }144 Text("Idées").font(.headline)145 ForEach(["4½ à Québec", "resto italien Montréal", "Toyota Corolla", "emploi infirmière", "spectacle ce week-end"], id: \.self) { s in146 Button { query = s; Task { await search(s) } } label: {147 HStack {148 Image(systemName: "sparkle.magnifyingglass").foregroundStyle(KATheme.green)149 Text(s); Spacer()150 }151 .padding(12).kaCard()152 }153 .buttonStyle(.plain)154 }155 }156 }157158 private var results: some View {159 ForEach(sections.filter { !$0.items.isEmpty }, id: \.universe.id) { section in160 VStack(alignment: .leading, spacing: 10) {161 HStack {162 KAWordmark(universe: section.universe, size: 16)163 Spacer()164 Text("\(section.items.count)")165 .font(.system(.caption, design: .monospaced).weight(.bold))166 .foregroundStyle(section.universe.accent)167 }168 ForEach(sorted(section.items).prefix(5)) { item in169 NavigationLink(value: item) { KAItemRow(item: item) }170 .buttonStyle(KAPressStyle())171 }172 NavigationLink(value: section.universe.id) {173 Text("Tout voir dans \(section.universe.name) →")174 .font(.subheadline.weight(.semibold))175 .foregroundStyle(section.universe.accent)176 }177 }178 }179 .navigationDestination(for: String.self) { id in180 if let u = Ecosystem.universe(id) { UniverseHomeView(universe: u) }181 }182 }183184 private func search(_ text: String? = nil) async {185 let q = (text ?? query).trimmingCharacters(in: .whitespaces)186 guard !q.isEmpty else { return }187 submitted = q188 searching = true189 Haptics.rigid()190 var hist = history.filter { $0 != q }191 hist.insert(q, at: 0)192 historyRaw = hist.prefix(10).joined(separator: "\n")193194 let targets = searchables.filter { selected.isEmpty || selected.contains($0.id) }195 var found: [String: [KAItem]] = [:]196 await withTaskGroup(of: (String, [KAItem]).self) { group in197 for u in targets {198 group.addTask {199 let items = (try? await UniverseService.fetch(u, query: q, limit: 8)) ?? []200 return (u.id, items)201 }202 }203 for await (id, items) in group { found[id] = items }204 }205 // ordre : univers avec le plus de résultats d'abord, web québécois à la fin206 sections = targets207 .map { ($0, found[$0.id] ?? []) }208 .sorted {209 if $0.0.id == "trouve-ka" { return false }210 if $1.0.id == "trouve-ka" { return true }211 return $0.1.count > $1.1.count212 }213 searching = false214 if sections.contains(where: { !$0.items.isEmpty }) { Haptics.success() }215 }216}217