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 @AppStorage("ka.search.history") private var historyRaw = ""15 @Environment(\.colorScheme) private var scheme1617 private var history: [String] { historyRaw.split(separator: "\n").map(String.init) }18 private var searchables: [Universe] { Ecosystem.all.filter { $0.map != nil } }1920 var body: some View {21 NavigationStack {22 ScrollView {23 LazyVStack(alignment: .leading, spacing: 16) {24 filterChips25 if searching {26 HStack(spacing: 10) {27 ProgressView()28 Text("KA interroge \(selected.isEmpty ? searchables.count : selected.count) univers…")29 .font(.subheadline).foregroundStyle(.secondary)30 }31 .padding(24).frame(maxWidth: .infinity)32 } else if submitted.isEmpty {33 suggestions34 } else if sections.allSatisfy({ $0.items.isEmpty }) {35 KAEmptyState(symbol: "magnifyingglass", title: "Rien trouvé pour « \(submitted) »",36 message: "Essayez un autre mot, ou élargissez les univers filtrés.")37 } else {38 results39 }40 }41 .padding(16)42 }43 .background(KATheme.paper(scheme))44 .navigationTitle("Recherche")45 .searchable(text: $query, prompt: "appartement Rouyn, resto italien, Corolla…")46 .onSubmit(of: .search) { Task { await search() } }47 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }48 }49 }5051 private var filterChips: some View {52 ScrollView(.horizontal, showsIndicators: false) {53 HStack(spacing: 8) {54 ForEach(searchables) { u in55 let on = selected.isEmpty || selected.contains(u.id)56 Button {57 Haptics.tap()58 if selected.contains(u.id) { selected.remove(u.id) }59 else { selected.insert(u.id) }60 if selected.count == searchables.count { selected = [] }61 if !submitted.isEmpty { Task { await search(submitted) } }62 } label: {63 Text(u.wordmark)64 .font(.system(.caption, design: .monospaced).weight(.bold))65 .padding(.horizontal, 11).padding(.vertical, 7)66 .background(on ? u.accent.opacity(0.9) : KATheme.surface(scheme), in: Capsule())67 .foregroundStyle(on ? .white : .secondary)68 .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))69 }70 .accessibilityLabel("\(on ? "Masquer" : "Afficher") \(u.name)")71 }72 }73 }74 }7576 private var suggestions: some View {77 VStack(alignment: .leading, spacing: 14) {78 if !history.isEmpty {79 Text("Récemment cherché").font(.headline)80 ForEach(history.prefix(6), id: \.self) { h in81 Button {82 query = h83 Task { await search(h) }84 } label: {85 HStack {86 Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary)87 Text(h)88 Spacer()89 }90 .padding(12).kaCard()91 }92 .buttonStyle(.plain)93 }94 }95 Text("Idées").font(.headline)96 ForEach(["4½ à Québec", "resto italien Montréal", "Toyota Corolla", "emploi infirmière", "spectacle ce week-end"], id: \.self) { s in97 Button { query = s; Task { await search(s) } } label: {98 HStack {99 Image(systemName: "sparkle.magnifyingglass").foregroundStyle(KATheme.green)100 Text(s); Spacer()101 }102 .padding(12).kaCard()103 }104 .buttonStyle(.plain)105 }106 }107 }108109 private var results: some View {110 ForEach(sections.filter { !$0.items.isEmpty }, id: \.universe.id) { section in111 VStack(alignment: .leading, spacing: 10) {112 HStack {113 KAWordmark(universe: section.universe, size: 16)114 Spacer()115 Text("\(section.items.count)")116 .font(.system(.caption, design: .monospaced).weight(.bold))117 .foregroundStyle(section.universe.accent)118 }119 ForEach(section.items.prefix(5)) { item in120 NavigationLink(value: item) { KAItemRow(item: item) }121 .buttonStyle(.plain)122 }123 NavigationLink(value: section.universe.id) {124 Text("Tout voir dans \(section.universe.name) →")125 .font(.subheadline.weight(.semibold))126 .foregroundStyle(section.universe.accent)127 }128 }129 }130 .navigationDestination(for: String.self) { id in131 if let u = Ecosystem.universe(id) { UniverseHomeView(universe: u) }132 }133 }134135 private func search(_ text: String? = nil) async {136 let q = (text ?? query).trimmingCharacters(in: .whitespaces)137 guard !q.isEmpty else { return }138 submitted = q139 searching = true140 Haptics.rigid()141 var hist = history.filter { $0 != q }142 hist.insert(q, at: 0)143 historyRaw = hist.prefix(10).joined(separator: "\n")144145 let targets = searchables.filter { selected.isEmpty || selected.contains($0.id) }146 var found: [String: [KAItem]] = [:]147 await withTaskGroup(of: (String, [KAItem]).self) { group in148 for u in targets {149 group.addTask {150 let items = (try? await UniverseService.fetch(u, query: q, limit: 8)) ?? []151 return (u.id, items)152 }153 }154 for await (id, items) in group { found[id] = items }155 }156 // ordre : univers avec le plus de résultats d'abord, web québécois à la fin157 sections = targets158 .map { ($0, found[$0.id] ?? []) }159 .sorted {160 if $0.0.id == "trouve-ka" { return false }161 if $1.0.id == "trouve-ka" { return true }162 return $0.1.count > $1.1.count163 }164 searching = false165 if sections.contains(where: { !$0.items.isEmpty }) { Haptics.success() }166 }167}168