// 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 { i.price ?? 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("") .navigationBarTitleDisplayMode(.inline) .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always), prompt: "appartement Rouyn, resto italien, Corolla…") .searchSuggestions { // complétions pendant la frappe : l'historique d'abord if !query.isEmpty { ForEach(history.filter { $0.localizedCaseInsensitiveContains(query) && $0 != query }.prefix(4), id: \.self) { h in Label(h, systemImage: "clock.arrow.circlepath").searchCompletion(h) } } } .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)") } } } } /// Idées de départ : chaque tuile porte l'ADN visuel de son univers. private static let ideas: [(query: String, universeID: String, symbol: String)] = [ ("4½ à Québec", "lou-ka", "key.fill"), ("resto italien Montréal", "resto-ka", "fork.knife"), ("Toyota Corolla", "auto-ka", "car.fill"), ("emploi infirmière", "job-ka", "briefcase.fill"), ("spectacle ce week-end", "sorti-ka", "ticket.fill"), ("maison Gatineau", "immo-ka", "house.fill"), ] private var suggestions: some View { VStack(alignment: .leading, spacing: 14) { // manchette éditoriale VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Rectangle().fill(KATheme.lime).frame(width: 8, height: 8) Text("UNE BARRE · \(searchables.count) UNIVERS INTERROGÉS D'UN COUP") .font(KAFont.mono(9)).kerning(1.0) .foregroundStyle(KATheme.ink2(scheme)) .lineLimit(1).minimumScaleFactor(0.8) } Text("Chercher dans\ntout le Québec") .font(KAFont.display(32)) .foregroundStyle(KATheme.ink(scheme)) } if !history.isEmpty { UniverseSectionHeader(title: "Récemment cherché", accent: KATheme.green) KAFlow(spacing: 8) { ForEach(history.prefix(6), id: \.self) { h in Button { query = h Task { await search(h) } } label: { HStack(spacing: 6) { Image(systemName: "clock.arrow.circlepath") .font(.caption2).foregroundStyle(.secondary) Text(h).font(.caption.weight(.semibold)).lineLimit(1) } .padding(.horizontal, 12).padding(.vertical, 9) .background(KATheme.surface(scheme), in: Capsule()) .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1)) .foregroundStyle(KATheme.ink(scheme)) } .buttonStyle(KAPressStyle()) } } } UniverseSectionHeader(title: "Idées de départ", accent: KATheme.green) LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { ForEach(Self.ideas, id: \.query) { idea in let u = Ecosystem.universe(idea.universeID) Button { query = idea.query; Task { await search(idea.query) } } label: { VStack(alignment: .leading, spacing: 8) { Image(systemName: idea.symbol) .font(.headline) .foregroundStyle(.white) .frame(width: 34, height: 34) .background(u?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) Text(idea.query) .font(.subheadline.weight(.bold)) .foregroundStyle(KATheme.ink(scheme)) .lineLimit(2, reservesSpace: true) .multilineTextAlignment(.leading) if let u { Text(u.wordmark.uppercased()) .font(KAFont.mono(8)) .foregroundStyle(u.accent) } } .padding(13) .frame(maxWidth: .infinity, alignment: .topLeading) .kaCard(accent: u?.accent) } .buttonStyle(KAPressStyle()) .kaScrollPop(axis: .vertical) } } } } 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") PersonalizationStore.shared.recordSearch(q) 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() } } }