// 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 groupés par univers, filtres, historique local, // fiche détaillée dans le panneau de droite. Adapté de l'app iOS KA. import SwiftUI struct UniversalSearchView: View { @EnvironmentObject private var state: AppState @Environment(\.colorScheme) private var scheme @FocusState private var focused: Bool @State private var query = "" @State private var submitted = "" @State private var sections: [(universe: Universe, items: [KAItem])] = [] @State private var searching = false @State private var selectedFilters: Set = [] // vide = tous @State private var selected: KAItem? @AppStorage("ka.search.history") private var historyRaw = "" 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 { VStack(spacing: 0) { header Divider().opacity(0.4) HStack(spacing: 0) { results .frame(maxWidth: .infinity) Divider().opacity(0.4) Group { if let item = selected { ItemDetailPane(item: item) } else { KAEmptyState(symbol: "magnifyingglass", title: "Recherche universelle", message: "Une seule barre pour les \(searchables.count) univers interrogeables du Groupe KA.") .frame(maxHeight: .infinity) .background(KATheme.paper(scheme)) } } .frame(width: 440) } } .onChange(of: state.searchFocusToken) { focused = true } .onAppear { focused = true } } private var header: some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) TextField("appartement Rouyn, resto italien, Corolla, emploi infirmière…", text: $query) .textFieldStyle(.plain) .font(.title3) .focused($focused) .onSubmit { Task { await search() } } if searching { ProgressView().controlSize(.small) } Text("⌘⇧K") .font(.system(.caption2, design: .monospaced).weight(.bold)) .foregroundStyle(.tertiary) } .padding(.horizontal, 13).padding(.vertical, 10) .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 11, style: .continuous) .strokeBorder(KATheme.ink(scheme).opacity(0.55), lineWidth: 1.3)) filterChips } .padding(.horizontal, 16).padding(.vertical, 12) } private var filterChips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 7) { ForEach(searchables) { u in let on = selectedFilters.isEmpty || selectedFilters.contains(u.id) Button { if selectedFilters.contains(u.id) { selectedFilters.remove(u.id) } else { selectedFilters.insert(u.id) } if selectedFilters.count == searchables.count { selectedFilters = [] } if !submitted.isEmpty { Task { await search(submitted) } } } label: { Text(u.wordmark) .font(.system(.caption, design: .monospaced).weight(.bold)) .padding(.horizontal, 10).padding(.vertical, 5) .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)) } .buttonStyle(.plain) .help("\(on ? "Masquer" : "Afficher") \(u.name)") } } .padding(.vertical, 2) } } @ViewBuilder private var results: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 16) { if searching { HStack(spacing: 10) { ProgressView().controlSize(.small) Text("KA interroge \(selectedFilters.isEmpty ? searchables.count : selectedFilters.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 { ForEach(sections.filter { !$0.items.isEmpty }, id: \.universe.id) { section in VStack(alignment: .leading, spacing: 8) { HStack { KAWordmark(universe: section.universe, size: 15) Spacer() Text("\(section.items.count)") .font(.system(.caption, design: .monospaced).weight(.bold)) .foregroundStyle(section.universe.accent) } ForEach(section.items.prefix(5)) { item in Button { selected = item } label: { KAItemRow(item: item, compact: true) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(section.universe.accent, lineWidth: selected?.id == item.id ? 2.2 : 0) .padding(.trailing, 4).padding(.bottom, 4) ) } .buttonStyle(KAPressStyle()) } Button { state.openUniverse(section.universe.id, query: submitted) } label: { Text("Tout voir dans \(section.universe.name) →") .font(.subheadline.weight(.semibold)) .foregroundStyle(section.universe.accent) } .buttonStyle(.plain) } } } } .padding(16) } .background(KATheme.paper(scheme)) } private var suggestions: some View { VStack(alignment: .leading, spacing: 12) { 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(11).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(11).kaCard() } .buttonStyle(.plain) } } } private func search(_ text: String? = nil) async { let q = (text ?? query).trimmingCharacters(in: .whitespaces) guard !q.isEmpty else { return } submitted = q searching = true selected = nil var hist = history.filter { $0 != q } hist.insert(q, at: 0) historyRaw = hist.prefix(10).joined(separator: "\n") let targets = searchables.filter { selectedFilters.isEmpty || selectedFilters.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 } }