SPB Git

spb/ka-ios

Public
9.8 KB · 217 lines swift
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        Double(i.priceLabel?.filter { $0.isNumber } ?? "") ?? (sort == .prixAsc ? .greatestFiniteMagnitude : 0)36    }3738    private var history: [String] { historyRaw.split(separator: "\n").map(String.init) }39    private var searchables: [Universe] { Ecosystem.all.filter { $0.map != nil } }4041    var body: some View {42        NavigationStack {43            ScrollView {44                LazyVStack(alignment: .leading, spacing: 16) {45                    filterChips46                    if searching {47                        HStack(spacing: 10) {48                            ProgressView()49                            Text("KA interroge \(selected.isEmpty ? searchables.count : selected.count) univers…")50                                .font(.subheadline).foregroundStyle(.secondary)51                        }52                        .padding(24).frame(maxWidth: .infinity)53                    } else if submitted.isEmpty {54                        suggestions55                    } else if sections.allSatisfy({ $0.items.isEmpty }) {56                        KAEmptyState(symbol: "magnifyingglass", title: "Rien trouvé pour « \(submitted) »",57                                     message: "Essayez un autre mot, ou élargissez les univers filtrés.")58                    } else {59                        results60                    }61                }62                .padding(16)63            }64            .background(KATheme.paper(scheme))65            .navigationTitle("Recherche")66            .searchable(text: $query, prompt: "appartement Rouyn, resto italien, Corolla…")67            .onSubmit(of: .search) { Task { await search() } }68            .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }69            .toolbar {70                if !submitted.isEmpty {71                    ToolbarItemGroup(placement: .topBarTrailing) {72                        Menu {73                            Picker("Tri", selection: $sort) {74                                ForEach(SortMode.allCases, id: \.self) { Text($0.rawValue).tag($0) }75                            }76                        } label: { Image(systemName: "arrow.up.arrow.down") }77                            .accessibilityLabel("Trier les résultats")78                        Button { saveName = submitted; showSave = true } label: {79                            Image(systemName: "bell.badge")80                        }81                        .accessibilityLabel("Sauvegarder cette recherche et créer une alerte")82                        ShareLink(item: URL(string: "https://www.trouve-ka.com/search?q=\(submitted.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")")!,83                                  subject: Text("Recherche KA : \(submitted)"))84                    }85                }86            }87            .alert("Sauvegarder la recherche", isPresented: $showSave) {88                TextField("Nom", text: $saveName)89                Button("Sauvegarder + alerte") {90                    let uid = selected.count == 1 ? selected.first! : (sections.max { $0.items.count < $1.items.count }?.universe.id ?? "lou-ka")91                    recents.saveSearch(name: saveName, universeID: uid, query: submitted, params: [:])92                }93                Button("Annuler", role: .cancel) {}94            } message: {95                Text("KA recomptera les résultats à chaque ouverture et vous montrera les nouveautés (+N).")96            }97        }98    }99100    private var filterChips: some View {101        ScrollView(.horizontal, showsIndicators: false) {102            HStack(spacing: 8) {103                ForEach(searchables) { u in104                    let on = selected.isEmpty || selected.contains(u.id)105                    Button {106                        Haptics.tap()107                        if selected.contains(u.id) { selected.remove(u.id) }108                        else { selected.insert(u.id) }109                        if selected.count == searchables.count { selected = [] }110                        if !submitted.isEmpty { Task { await search(submitted) } }111                    } label: {112                        Text(u.wordmark)113                            .font(.system(.caption, design: .monospaced).weight(.bold))114                            .padding(.horizontal, 11).padding(.vertical, 7)115                            .background(on ? u.accent.opacity(0.9) : KATheme.surface(scheme), in: Capsule())116                            .foregroundStyle(on ? .white : .secondary)117                            .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))118                    }119                    .accessibilityLabel("\(on ? "Masquer" : "Afficher") \(u.name)")120                }121            }122        }123    }124125    private var suggestions: some View {126        VStack(alignment: .leading, spacing: 14) {127            if !history.isEmpty {128                Text("Récemment cherché").font(.headline)129                ForEach(history.prefix(6), id: \.self) { h in130                    Button {131                        query = h132                        Task { await search(h) }133                    } label: {134                        HStack {135                            Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary)136                            Text(h)137                            Spacer()138                        }139                        .padding(12).kaCard()140                    }141                    .buttonStyle(.plain)142                }143            }144            Text("Idées").font(.headline)145            ForEach(["4½ à Québec", "resto italien Montréal", "Toyota Corolla", "emploi infirmière", "spectacle ce week-end"], id: \.self) { s in146                Button { query = s; Task { await search(s) } } label: {147                    HStack {148                        Image(systemName: "sparkle.magnifyingglass").foregroundStyle(KATheme.green)149                        Text(s); Spacer()150                    }151                    .padding(12).kaCard()152                }153                .buttonStyle(.plain)154            }155        }156    }157158    private var results: some View {159        ForEach(sections.filter { !$0.items.isEmpty }, id: \.universe.id) { section in160            VStack(alignment: .leading, spacing: 10) {161                HStack {162                    KAWordmark(universe: section.universe, size: 16)163                    Spacer()164                    Text("\(section.items.count)")165                        .font(.system(.caption, design: .monospaced).weight(.bold))166                        .foregroundStyle(section.universe.accent)167                }168                ForEach(sorted(section.items).prefix(5)) { item in169                    NavigationLink(value: item) { KAItemRow(item: item) }170                        .buttonStyle(KAPressStyle())171                }172                NavigationLink(value: section.universe.id) {173                    Text("Tout voir dans \(section.universe.name) →")174                        .font(.subheadline.weight(.semibold))175                        .foregroundStyle(section.universe.accent)176                }177            }178        }179        .navigationDestination(for: String.self) { id in180            if let u = Ecosystem.universe(id) { UniverseHomeView(universe: u) }181        }182    }183184    private func search(_ text: String? = nil) async {185        let q = (text ?? query).trimmingCharacters(in: .whitespaces)186        guard !q.isEmpty else { return }187        submitted = q188        searching = true189        Haptics.rigid()190        var hist = history.filter { $0 != q }191        hist.insert(q, at: 0)192        historyRaw = hist.prefix(10).joined(separator: "\n")193194        let targets = searchables.filter { selected.isEmpty || selected.contains($0.id) }195        var found: [String: [KAItem]] = [:]196        await withTaskGroup(of: (String, [KAItem]).self) { group in197            for u in targets {198                group.addTask {199                    let items = (try? await UniverseService.fetch(u, query: q, limit: 8)) ?? []200                    return (u.id, items)201                }202            }203            for await (id, items) in group { found[id] = items }204        }205        // ordre : univers avec le plus de résultats d'abord, web québécois à la fin206        sections = targets207            .map { ($0, found[$0.id] ?? []) }208            .sorted {209                if $0.0.id == "trouve-ka" { return false }210                if $1.0.id == "trouve-ka" { return true }211                return $0.1.count > $1.1.count212            }213        searching = false214        if sections.contains(where: { !$0.items.isEmpty }) { Haptics.success() }215    }216}217
Files · ce73f14a9dd1fe80ae09bf638fcba97f1a74e08a
Menu

Theme