Swift 100%
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 i.price ?? Double(i.priceLabel?.filter { $0.isNumber } ?? "")36 ?? (sort == .prixAsc ? .greatestFiniteMagnitude : 0)37 }3839 private var history: [String] { historyRaw.split(separator: "\n").map(String.init) }40 private var searchables: [Universe] { Ecosystem.all.filter { $0.map != nil } }4142 var body: some View {43 NavigationStack {44 ScrollView {45 LazyVStack(alignment: .leading, spacing: 16) {46 filterChips47 if searching {48 HStack(spacing: 10) {49 ProgressView()50 Text("KA interroge \(selected.isEmpty ? searchables.count : selected.count) univers…")51 .font(.subheadline).foregroundStyle(.secondary)52 }53 .padding(24).frame(maxWidth: .infinity)54 } else if submitted.isEmpty {55 suggestions56 } else if sections.allSatisfy({ $0.items.isEmpty }) {57 KAEmptyState(symbol: "magnifyingglass", title: "Rien trouvé pour « \(submitted) »",58 message: "Essayez un autre mot, ou élargissez les univers filtrés.")59 } else {60 results61 }62 }63 .padding(16)64 }65 .background(KATheme.paper(scheme))66 .navigationTitle("")67 .navigationBarTitleDisplayMode(.inline)68 .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always),69 prompt: "appartement Rouyn, resto italien, Corolla…")70 .searchSuggestions {71 // complétions pendant la frappe : l'historique d'abord72 if !query.isEmpty {73 ForEach(history.filter { $0.localizedCaseInsensitiveContains(query) && $0 != query }.prefix(4), id: \.self) { h in74 Label(h, systemImage: "clock.arrow.circlepath").searchCompletion(h)75 }76 }77 }78 .onSubmit(of: .search) { Task { await search() } }79 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }80 .toolbar {81 if !submitted.isEmpty {82 ToolbarItemGroup(placement: .topBarTrailing) {83 Menu {84 Picker("Tri", selection: $sort) {85 ForEach(SortMode.allCases, id: \.self) { Text($0.rawValue).tag($0) }86 }87 } label: { Image(systemName: "arrow.up.arrow.down") }88 .accessibilityLabel("Trier les résultats")89 Button { saveName = submitted; showSave = true } label: {90 Image(systemName: "bell.badge")91 }92 .accessibilityLabel("Sauvegarder cette recherche et créer une alerte")93 ShareLink(item: URL(string: "https://www.trouve-ka.com/search?q=\(submitted.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")")!,94 subject: Text("Recherche KA : \(submitted)"))95 }96 }97 }98 .alert("Sauvegarder la recherche", isPresented: $showSave) {99 TextField("Nom", text: $saveName)100 Button("Sauvegarder + alerte") {101 let uid = selected.count == 1 ? selected.first! : (sections.max { $0.items.count < $1.items.count }?.universe.id ?? "lou-ka")102 recents.saveSearch(name: saveName, universeID: uid, query: submitted, params: [:])103 }104 Button("Annuler", role: .cancel) {}105 } message: {106 Text("KA recomptera les résultats à chaque ouverture et vous montrera les nouveautés (+N).")107 }108 }109 }110111 private var filterChips: some View {112 ScrollView(.horizontal, showsIndicators: false) {113 HStack(spacing: 8) {114 ForEach(searchables) { u in115 let on = selected.isEmpty || selected.contains(u.id)116 Button {117 Haptics.tap()118 if selected.contains(u.id) { selected.remove(u.id) }119 else { selected.insert(u.id) }120 if selected.count == searchables.count { selected = [] }121 if !submitted.isEmpty { Task { await search(submitted) } }122 } label: {123 Text(u.wordmark)124 .font(.system(.caption, design: .monospaced).weight(.bold))125 .padding(.horizontal, 11).padding(.vertical, 7)126 .background(on ? u.accent.opacity(0.9) : KATheme.surface(scheme), in: Capsule())127 .foregroundStyle(on ? .white : .secondary)128 .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))129 }130 .accessibilityLabel("\(on ? "Masquer" : "Afficher") \(u.name)")131 }132 }133 }134 }135136 /// Idées de départ : chaque tuile porte l'ADN visuel de son univers.137 private static let ideas: [(query: String, universeID: String, symbol: String)] = [138 ("4½ à Québec", "lou-ka", "key.fill"),139 ("resto italien Montréal", "resto-ka", "fork.knife"),140 ("Toyota Corolla", "auto-ka", "car.fill"),141 ("emploi infirmière", "job-ka", "briefcase.fill"),142 ("spectacle ce week-end", "sorti-ka", "ticket.fill"),143 ("maison Gatineau", "immo-ka", "house.fill"),144 ]145146 private var suggestions: some View {147 VStack(alignment: .leading, spacing: 14) {148 // manchette éditoriale149 VStack(alignment: .leading, spacing: 4) {150 HStack(spacing: 8) {151 Rectangle().fill(KATheme.lime).frame(width: 8, height: 8)152 Text("UNE BARRE · \(searchables.count) UNIVERS INTERROGÉS D'UN COUP")153 .font(KAFont.mono(9)).kerning(1.0)154 .foregroundStyle(KATheme.ink2(scheme))155 .lineLimit(1).minimumScaleFactor(0.8)156 }157 Text("Chercher dans\ntout le Québec")158 .font(KAFont.display(32))159 .foregroundStyle(KATheme.ink(scheme))160 }161 if !history.isEmpty {162 UniverseSectionHeader(title: "Récemment cherché", accent: KATheme.green)163 KAFlow(spacing: 8) {164 ForEach(history.prefix(6), id: \.self) { h in165 Button {166 query = h167 Task { await search(h) }168 } label: {169 HStack(spacing: 6) {170 Image(systemName: "clock.arrow.circlepath")171 .font(.caption2).foregroundStyle(.secondary)172 Text(h).font(.caption.weight(.semibold)).lineLimit(1)173 }174 .padding(.horizontal, 12).padding(.vertical, 9)175 .background(KATheme.surface(scheme), in: Capsule())176 .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))177 .foregroundStyle(KATheme.ink(scheme))178 }179 .buttonStyle(KAPressStyle())180 }181 }182 }183 UniverseSectionHeader(title: "Idées de départ", accent: KATheme.green)184 LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),185 GridItem(.flexible(), spacing: 12)], spacing: 12) {186 ForEach(Self.ideas, id: \.query) { idea in187 let u = Ecosystem.universe(idea.universeID)188 Button { query = idea.query; Task { await search(idea.query) } } label: {189 VStack(alignment: .leading, spacing: 8) {190 Image(systemName: idea.symbol)191 .font(.headline)192 .foregroundStyle(.white)193 .frame(width: 34, height: 34)194 .background(u?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 10, style: .continuous))195 Text(idea.query)196 .font(.subheadline.weight(.bold))197 .foregroundStyle(KATheme.ink(scheme))198 .lineLimit(2, reservesSpace: true)199 .multilineTextAlignment(.leading)200 if let u {201 Text(u.wordmark.uppercased())202 .font(KAFont.mono(8))203 .foregroundStyle(u.accent)204 }205 }206 .padding(13)207 .frame(maxWidth: .infinity, alignment: .topLeading)208 .kaCard(accent: u?.accent)209 }210 .buttonStyle(KAPressStyle())211 .kaScrollPop(axis: .vertical)212 }213 }214 }215 }216217 private var results: some View {218 ForEach(sections.filter { !$0.items.isEmpty }, id: \.universe.id) { section in219 VStack(alignment: .leading, spacing: 10) {220 HStack {221 KAWordmark(universe: section.universe, size: 16)222 Spacer()223 Text("\(section.items.count)")224 .font(.system(.caption, design: .monospaced).weight(.bold))225 .foregroundStyle(section.universe.accent)226 }227 ForEach(sorted(section.items).prefix(5)) { item in228 NavigationLink(value: item) { KAItemRow(item: item) }229 .buttonStyle(KAPressStyle())230 }231 NavigationLink(value: section.universe.id) {232 Text("Tout voir dans \(section.universe.name) →")233 .font(.subheadline.weight(.semibold))234 .foregroundStyle(section.universe.accent)235 }236 }237 }238 .navigationDestination(for: String.self) { id in239 if let u = Ecosystem.universe(id) { UniverseHomeView(universe: u) }240 }241 }242243 private func search(_ text: String? = nil) async {244 let q = (text ?? query).trimmingCharacters(in: .whitespaces)245 guard !q.isEmpty else { return }246 submitted = q247 searching = true248 Haptics.rigid()249 var hist = history.filter { $0 != q }250 hist.insert(q, at: 0)251 historyRaw = hist.prefix(10).joined(separator: "\n")252 PersonalizationStore.shared.recordSearch(q)253254 let targets = searchables.filter { selected.isEmpty || selected.contains($0.id) }255 var found: [String: [KAItem]] = [:]256 await withTaskGroup(of: (String, [KAItem]).self) { group in257 for u in targets {258 group.addTask {259 let items = (try? await UniverseService.fetch(u, query: q, limit: 8)) ?? []260 return (u.id, items)261 }262 }263 for await (id, items) in group { found[id] = items }264 }265 // ordre : univers avec le plus de résultats d'abord, web québécois à la fin266 sections = targets267 .map { ($0, found[$0.id] ?? []) }268 .sorted {269 if $0.0.id == "trouve-ka" { return false }270 if $1.0.id == "trouve-ka" { return true }271 return $0.1.count > $1.1.count272 }273 searching = false274 if sections.contains(where: { !$0.items.isEmpty }) { Haptics.success() }275 }276}277