Swift 98.3%
Shell 1.7%
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 groupés par univers, filtres, historique local,5// fiche détaillée dans le panneau de droite. Adapté de l'app iOS KA.6import SwiftUI78struct UniversalSearchView: View {9 @EnvironmentObject private var state: AppState10 @Environment(\.colorScheme) private var scheme11 @FocusState private var focused: Bool1213 @State private var query = ""14 @State private var submitted = ""15 @State private var sections: [(universe: Universe, items: [KAItem])] = []16 @State private var searching = false17 @State private var selectedFilters: Set<String> = [] // vide = tous18 @State private var selected: KAItem?19 @AppStorage("ka.search.history") private var historyRaw = ""2021 private var history: [String] { historyRaw.split(separator: "\n").map(String.init) }22 private var searchables: [Universe] { Ecosystem.all.filter { $0.map != nil } }2324 var body: some View {25 VStack(spacing: 0) {26 header27 Divider().opacity(0.4)28 HStack(spacing: 0) {29 results30 .frame(maxWidth: .infinity)31 Divider().opacity(0.4)32 Group {33 if let item = selected {34 ItemDetailPane(item: item)35 } else {36 KAEmptyState(symbol: "magnifyingglass",37 title: "Recherche universelle",38 message: "Une seule barre pour les \(searchables.count) univers interrogeables du Groupe KA.")39 .frame(maxHeight: .infinity)40 .background(KATheme.paper(scheme))41 }42 }43 .frame(width: 440)44 }45 }46 .onChange(of: state.searchFocusToken) { focused = true }47 .onAppear { focused = true }48 }4950 private var header: some View {51 VStack(alignment: .leading, spacing: 10) {52 HStack(spacing: 8) {53 Image(systemName: "magnifyingglass").foregroundStyle(.secondary)54 TextField("appartement Rouyn, resto italien, Corolla, emploi infirmière…", text: $query)55 .textFieldStyle(.plain)56 .font(.title3)57 .focused($focused)58 .onSubmit { Task { await search() } }59 if searching { ProgressView().controlSize(.small) }60 Text("⌘⇧K")61 .font(.system(.caption2, design: .monospaced).weight(.bold))62 .foregroundStyle(.tertiary)63 }64 .padding(.horizontal, 13).padding(.vertical, 10)65 .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 11, style: .continuous))66 .overlay(RoundedRectangle(cornerRadius: 11, style: .continuous)67 .strokeBorder(KATheme.ink(scheme).opacity(0.55), lineWidth: 1.3))68 filterChips69 }70 .padding(.horizontal, 16).padding(.vertical, 12)71 }7273 private var filterChips: some View {74 ScrollView(.horizontal, showsIndicators: false) {75 HStack(spacing: 7) {76 ForEach(searchables) { u in77 let on = selectedFilters.isEmpty || selectedFilters.contains(u.id)78 Button {79 if selectedFilters.contains(u.id) { selectedFilters.remove(u.id) }80 else { selectedFilters.insert(u.id) }81 if selectedFilters.count == searchables.count { selectedFilters = [] }82 if !submitted.isEmpty { Task { await search(submitted) } }83 } label: {84 Text(u.wordmark)85 .font(.system(.caption, design: .monospaced).weight(.bold))86 .padding(.horizontal, 10).padding(.vertical, 5)87 .background(on ? u.accent.opacity(0.9) : KATheme.surface(scheme), in: Capsule())88 .foregroundStyle(on ? .white : .secondary)89 .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))90 }91 .buttonStyle(.plain)92 .help("\(on ? "Masquer" : "Afficher") \(u.name)")93 }94 }95 .padding(.vertical, 2)96 }97 }9899 @ViewBuilder100 private var results: some View {101 ScrollView {102 LazyVStack(alignment: .leading, spacing: 16) {103 if searching {104 HStack(spacing: 10) {105 ProgressView().controlSize(.small)106 Text("KA interroge \(selectedFilters.isEmpty ? searchables.count : selectedFilters.count) univers…")107 .font(.subheadline).foregroundStyle(.secondary)108 }109 .padding(24).frame(maxWidth: .infinity)110 } else if submitted.isEmpty {111 suggestions112 } else if sections.allSatisfy({ $0.items.isEmpty }) {113 KAEmptyState(symbol: "magnifyingglass", title: "Rien trouvé pour « \(submitted) »",114 message: "Essayez un autre mot, ou élargissez les univers filtrés.")115 } else {116 ForEach(sections.filter { !$0.items.isEmpty }, id: \.universe.id) { section in117 VStack(alignment: .leading, spacing: 8) {118 HStack {119 KAWordmark(universe: section.universe, size: 15)120 Spacer()121 Text("\(section.items.count)")122 .font(.system(.caption, design: .monospaced).weight(.bold))123 .foregroundStyle(section.universe.accent)124 }125 ForEach(section.items.prefix(5)) { item in126 Button { selected = item } label: {127 KAItemRow(item: item, compact: true)128 .overlay(129 RoundedRectangle(cornerRadius: 12, style: .continuous)130 .strokeBorder(section.universe.accent,131 lineWidth: selected?.id == item.id ? 2.2 : 0)132 .padding(.trailing, 4).padding(.bottom, 4)133 )134 }135 .buttonStyle(KAPressStyle())136 }137 Button {138 state.openUniverse(section.universe.id, query: submitted)139 } label: {140 Text("Tout voir dans \(section.universe.name) →")141 .font(.subheadline.weight(.semibold))142 .foregroundStyle(section.universe.accent)143 }144 .buttonStyle(.plain)145 }146 }147 }148 }149 .padding(16)150 }151 .background(KATheme.paper(scheme))152 }153154 private var suggestions: some View {155 VStack(alignment: .leading, spacing: 12) {156 if !history.isEmpty {157 Text("Récemment cherché").font(.headline)158 ForEach(history.prefix(6), id: \.self) { h in159 Button {160 query = h161 Task { await search(h) }162 } label: {163 HStack {164 Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary)165 Text(h)166 Spacer()167 }168 .padding(11).kaCard()169 }170 .buttonStyle(.plain)171 }172 }173 Text("Idées").font(.headline)174 ForEach(["4½ à Québec", "resto italien Montréal", "Toyota Corolla", "emploi infirmière", "spectacle ce week-end"], id: \.self) { s in175 Button { query = s; Task { await search(s) } } label: {176 HStack {177 Image(systemName: "sparkle.magnifyingglass").foregroundStyle(KATheme.green)178 Text(s); Spacer()179 }180 .padding(11).kaCard()181 }182 .buttonStyle(.plain)183 }184 }185 }186187 private func search(_ text: String? = nil) async {188 let q = (text ?? query).trimmingCharacters(in: .whitespaces)189 guard !q.isEmpty else { return }190 submitted = q191 searching = true192 selected = nil193 var hist = history.filter { $0 != q }194 hist.insert(q, at: 0)195 historyRaw = hist.prefix(10).joined(separator: "\n")196197 let targets = searchables.filter { selectedFilters.isEmpty || selectedFilters.contains($0.id) }198 var found: [String: [KAItem]] = [:]199 await withTaskGroup(of: (String, [KAItem]).self) { group in200 for u in targets {201 group.addTask {202 let items = (try? await UniverseService.fetch(u, query: q, limit: 8)) ?? []203 return (u.id, items)204 }205 }206 for await (id, items) in group { found[id] = items }207 }208 // ordre : univers avec le plus de résultats d'abord, web québécois à la fin209 sections = targets210 .map { ($0, found[$0.id] ?? []) }211 .sorted {212 if $0.0.id == "trouve-ka" { return false }213 if $1.0.id == "trouve-ka" { return true }214 return $0.1.count > $1.1.count215 }216 searching = false217 }218}219