SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%
13.3 KB · 277 lines swift
Raw Blame History
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