SPB Git forge

spb/ka-macos

Public
3commits 1branches 0releases
6.1 MBsize
maindefault branch
1 mo agolast push
Swift 98.3% Shell 1.7%
25.3 KB · 557 lines swift
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// UniverseExplorerView.swift — l'explorateur premium d'un univers : héros3// (wordmark, tagline, compteur live, lien site), recherche + FILTRES MÉTIER,4// grille photo multi-colonnes (grandes fenêtres) ou liste, fiche détaillée5// riche (galerie, faits, description, liens, menu resto), sauvegarde de la6// recherche avec alerte. Vrai-Prix a son expérience NATIVE (search+estimate).7// Adapté de l'app iOS KA (UniversesView.swift + VraiPrixView.swift).8import SwiftUI910struct UniverseExplorerView: View {11    let universe: Universe12    @EnvironmentObject private var pulse: EcosystemPulse13    @EnvironmentObject private var state: AppState14    @EnvironmentObject private var recents: RecentsStore15    @Environment(\.colorScheme) private var scheme1617    @State private var items: [KAItem] = []18    @State private var selected: KAItem?19    @State private var query = ""20    @State private var filterParams: [String: String] = [:]21    @State private var loading = true22    @State private var errorText: String?23    @State private var asGrid = true24    @State private var savedFlash = false25    /// Bascule Données (explorateur natif) | Site (le vrai site web, intégré).26    enum Mode: String, CaseIterable { case donnees = "Données", site = "Site" }27    @State private var mode: Mode2829    init(universe: Universe) {30        self.universe = universe31        // Les univers sans liste native s'ouvrent directement sur leur site.32        let nativeless = universe.listPath == nil || universe.map == nil33        let hasCustom = universe.id == "vrai-prix" || universe.id == "api-ka"34        _mode = State(initialValue: nativeless && !hasCustom ? .site : .donnees)35    }3637    var body: some View {38        Group {39            if mode == .site {40                SiteView(universe: universe)41            } else if universe.id == "vrai-prix" {42                VraiPrixExplorer(universe: universe)43            } else if universe.id == "api-ka" {44                ApiPlaygroundView(universe: universe)45            } else if universe.listPath == nil || universe.map == nil {46                SiteView(universe: universe)47            } else {48                explorer49            }50        }51        .toolbar {52            ToolbarItem(placement: .principal) {53                Picker("Présentation de \(universe.name)", selection: $mode) {54                    ForEach(Mode.allCases, id: \.self) { m in55                        Text(m.rawValue).tag(m)56                    }57                }58                .pickerStyle(.segmented)59                .labelsHidden()60                .frame(width: 170)61                .help("Données = explorateur natif ; Site = le site web \(universe.domain), intégré")62            }63        }64    }6566    // MARK: explorateur grille/liste + fiche6768    private var explorer: some View {69        VStack(spacing: 0) {70            hero71            Divider().opacity(0.4)72            HStack(spacing: 0) {73                results74                    .frame(maxWidth: .infinity)75                Divider().opacity(0.4)76                Group {77                    if let item = selected {78                        ItemDetailPane(item: item)79                    } else {80                        KAEmptyState(symbol: universe.symbol,81                                     title: "Choisissez un élément",82                                     message: "La fiche détaillée s'affichera ici.")83                            .frame(maxHeight: .infinity)84                            .background(KATheme.paper(scheme))85                    }86                }87                .frame(width: 440)88            }89        }90        .task {91            consumePrefill()92            await load()93        }94        .onChange(of: state.prefill?.universeID) {95            if state.prefill?.universeID == universe.id {96                consumePrefill()97                Task { await load() }98            }99        }100    }101102    private var hero: some View {103        VStack(alignment: .leading, spacing: 12) {104            SiteHero(universe: universe, liveTotal: pulse.totals[universe.id]) { mode = .site }105            HStack(spacing: 8) {106                Picker("Présentation", selection: $asGrid) {107                    Image(systemName: "square.grid.2x2").tag(true)108                    Image(systemName: "list.bullet").tag(false)109                }110                .pickerStyle(.segmented)111                .labelsHidden()112                .frame(width: 90)113                KASearchField(prompt: "Chercher dans \(universe.name)…", text: $query) {114                    Task { await load() }115                }116                FilterBar(universe: universe, params: $filterParams) {117                    Task { await load() }118                }119                if !query.isEmpty || !filterParams.isEmpty {120                    Button {121                        recents.saveSearch(name: "", universeID: universe.id,122                                           query: query, params: filterParams)123                        savedFlash = true124                        Task { try? await Task.sleep(for: .seconds(2)); savedFlash = false }125                    } label: {126                        Label(savedFlash ? "Alerte créée ✓" : "Sauvegarder + alerte",127                              systemImage: savedFlash ? "bell.badge.fill" : "bell.badge")128                            .font(.caption.weight(.bold))129                            .padding(.horizontal, 10).padding(.vertical, 7)130                            .background(savedFlash ? KATheme.green : universe.accent, in: Capsule())131                            .foregroundStyle(.white)132                    }133                    .buttonStyle(.plain)134                    .help("Sauvegarder cette recherche — KA recompte le total à chaque ouverture et signale les nouveautés")135                }136            }137        }138        .padding(.horizontal, 16).padding(.vertical, 12)139    }140141    @ViewBuilder142    private var results: some View {143        ScrollView {144            Group {145                if universe.id == "trouve-ka" && query.isEmpty {146                    KAEmptyState(symbol: "magnifyingglass",147                                 title: "Cherchez le web québécois",148                                 message: "Trouve·Ka fouille \(pulse.totals[universe.id].map { $0.fr } ?? "plus d'un million de") pages indexées — tapez un mot-clé ci-dessus.")149                } else if loading {150                    LazyVStack(spacing: 10) {151                        ForEach(0..<6, id: \.self) { _ in KASkeletonRow() }152                    }153                } else if let e = errorText, items.isEmpty {154                    KAEmptyState(symbol: "wifi.exclamationmark", title: "Impossible de charger", message: e)155                } else if items.isEmpty {156                    KAEmptyState(symbol: "tray", title: "Aucun résultat",157                                 message: "Essayez d'autres mots-clés ou élargissez les filtres.")158                } else if asGrid {159                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 230), spacing: 12)], spacing: 12) {160                        ForEach(items) { item in161                            Button { selected = item } label: {162                                KAItemCard(item: item, selected: selected?.id == item.id)163                            }164                            .buttonStyle(KAPressStyle())165                        }166                    }167                } else {168                    LazyVStack(spacing: 10) {169                        ForEach(items) { item in170                            Button { selected = item } label: {171                                KAItemRow(item: item)172                                    .overlay(173                                        RoundedRectangle(cornerRadius: 10, style: .continuous)174                                            .strokeBorder(universe.accent,175                                                          lineWidth: selected?.id == item.id ? 2.4 : 0)176                                            .padding(.trailing, 6).padding(.bottom, 6)177                                    )178                            }179                            .buttonStyle(KAPressStyle())180                        }181                    }182                }183            }184            .padding(14)185            .animation(.snappy(duration: 0.3), value: items)186        }187        .background(KATheme.paper(scheme))188    }189190    private func consumePrefill() {191        if let p = state.prefill, p.universeID == universe.id {192            query = p.query193            filterParams = p.params194            state.prefill = nil195        }196    }197198    private func load() async {199        // Trouve·Ka est un moteur : pas de liste sans requête200        if universe.id == "trouve-ka" && query.isEmpty {201            items = []; selected = nil; loading = false; errorText = nil202            return203        }204        loading = items.isEmpty205        errorText = nil206        do {207            // Trouve·Ka (FastAPI) plafonne limit à 50 — 422 au-delà208            let cap = universe.id == "trouve-ka" ? 50 : 60209            items = try await UniverseService.fetch(universe, query: query.isEmpty ? nil : query,210                                                    limit: cap, params: filterParams)211            if let sel = selected, !items.contains(where: { $0.id == sel.id }) { selected = nil }212            if selected == nil { selected = items.first }213        } catch {214            errorText = (error as? URLError)?.code == .notConnectedToInternet215                ? "Vous êtes hors ligne." : "Le service ne répond pas."216        }217        loading = false218    }219}220221// MARK: - Héros de site (reproduction fidèle de la page d'accueil de l'univers)222// Spec : app iOS KA (UniversesView.swift, struct SiteHero) — adapté desktop.223224struct SiteHero: View {225    let universe: Universe226    let liveTotal: Int?227    var openSite: (() -> Void)? = nil228    @Environment(\.colorScheme) private var scheme229230    private var copy: HeroCopy? { KAHeroes.hero(universe.id) }231232    var body: some View {233        VStack(alignment: .leading, spacing: 10) {234            ticker235            if let c = copy {236                if let k = c.kicker {237                    HStack(spacing: 10) {238                        Rectangle().fill(KATheme.green).frame(width: 26, height: 2.5)239                        Text(k.uppercased())240                            .font(KAFont.mono(10))241                            .foregroundStyle(scheme == .dark ? KATheme.lime : KATheme.green)242                            .kerning(1.1)243                            .lineLimit(1)244                    }245                }246                HStack(alignment: .top, spacing: 18) {247                    VStack(alignment: .leading, spacing: 7) {248                        headline(c)249                        Text(c.sub)250                            .font(.subheadline)251                            .foregroundStyle(KATheme.ink2(scheme))252                            .lineLimit(3)253                    }254                    Spacer(minLength: 0)255                    VStack(alignment: .trailing, spacing: 9) {256                        statPill257                        if let openSite {258                            Button(action: openSite) {259                                Text("VOIR LE SITE ↗")260                                    .font(KAFont.mono(10))261                                    .foregroundStyle(universe.accent)262                                    .padding(.horizontal, 12).padding(.vertical, 8)263                                    .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 6, style: .continuous))264                            }265                            .buttonStyle(KAPressStyle())266                            .help("Ouvrir \(universe.domain) dans l'app")267                            .accessibilityLabel("Voir le site \(universe.name) dans l'app")268                        }269                    }270                }271            }272        }273    }274275    /// Bandeau ticker encre — la signature du haut des sites276    private var ticker: some View {277        HStack(spacing: 9) {278            KALogo(id: universe.id, size: 18,279                   fallbackSymbol: universe.symbol, fallbackAccent: universe.accent)280            Text(universe.unit.uppercased())281                .font(KAFont.mono(9.5))282                .foregroundStyle(.white.opacity(0.9))283            Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent)284            Text("EN DIRECT")285                .font(KAFont.mono(9.5))286                .foregroundStyle(universe.accent)287            Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent)288            Text(universe.domain.replacingOccurrences(of: "www.", with: "").uppercased())289                .font(KAFont.mono(9.5))290                .foregroundStyle(.white.opacity(0.75))291                .lineLimit(1)292            Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent)293            Text(universe.tagline.uppercased())294                .font(KAFont.mono(9.5))295                .foregroundStyle(.white.opacity(0.6))296                .lineLimit(1)297            Spacer(minLength: 0)298        }299        .padding(.horizontal, 12).padding(.vertical, 8)300        .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 8, style: .continuous))301    }302303    /// Titre display avec le segment surligné SUR l'accent (comme le h1 du site)304    private func headline(_ c: HeroCopy) -> some View {305        var attr = AttributedString(c.headline)306        attr.font = KAFont.display(24)307        attr.foregroundColor = KATheme.ink(scheme)308        if let hl = c.highlight,309           let range = attr.range(of: hl, options: .caseInsensitive) {310            attr[range].backgroundColor = universe.accent311            attr[range].foregroundColor = .white312        }313        return Text(attr).lineSpacing(2)314    }315316    private var statPill: some View {317        HStack(spacing: 7) {318            Circle().fill(universe.accent).frame(width: 8, height: 8)319            if let n = liveTotal {320                Text(n.fr)321                    .font(KAFont.display(14))322                    .contentTransition(.numericText())323                Text(universe.unit)324                    .font(.caption).foregroundStyle(KATheme.ink2(scheme))325            } else {326                Text("En direct").font(.caption).foregroundStyle(KATheme.ink2(scheme))327            }328        }329        .padding(.horizontal, 12).padding(.vertical, 7)330        .background(KATheme.surface(scheme), in: Capsule())331        .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5))332        .background(Capsule().fill(scheme == .dark ? Color.clear : KATheme.inkLight).offset(x: 4, y: 4))333        .padding(.trailing, 4).padding(.bottom, 4)334    }335}336337// MARK: - Fiche universelle (panneau de droite)338339struct ItemDetailPane: View {340    let item: KAItem341    @Environment(\.colorScheme) private var scheme342    @EnvironmentObject private var favorites: FavoritesStore343    @EnvironmentObject private var recents: RecentsStore344    @State private var galleryIndex = 0345    @State private var menu: [RestoMenuSection] = []346347    private var universe: Universe? { Ecosystem.universe(item.universeID) }348349    var body: some View {350        ScrollView {351            VStack(alignment: .leading, spacing: 16) {352                gallery353                HStack {354                    if let u = universe { KAChip(text: u.wordmark, accent: u.accent) }355                    Spacer()356                    if favorites.collections.count > 1 && !favorites.isFavorite(item) {357                        Menu {358                            ForEach(favorites.collections) { c in359                                Button(c.name) { favorites.toggle(item, in: c.id) }360                            }361                        } label: {362                            Image(systemName: "heart")363                        }364                        .menuStyle(.borderlessButton)365                        .fixedSize()366                        .help("Ajouter à une collection")367                    } else {368                        Button {369                            favorites.toggle(item)370                        } label: {371                            Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart")372                                .foregroundStyle(favorites.isFavorite(item) ? .red : .primary)373                        }374                        .buttonStyle(.plain)375                        .help(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris")376                    }377                    if let url = item.url {378                        ShareLink(item: url, subject: Text(item.title)) {379                            Image(systemName: "square.and.arrow.up")380                        }381                        .buttonStyle(.plain)382                        .help("Partager le lien")383                    }384                }385                .font(.title3)386                Text(item.title).font(.title2.weight(.bold)).textSelection(.enabled)387                if let sub = item.subtitle, !sub.isEmpty {388                    Text(sub).font(.body).foregroundStyle(KATheme.ink2(scheme)).textSelection(.enabled)389                }390                HStack(spacing: 10) {391                    if let p = item.priceLabel {392                        Text(p).font(.system(.title3, design: .rounded).weight(.bold))393                            .foregroundStyle(universe?.accent ?? .primary)394                    }395                    if let c = item.city, !c.isEmpty { KAChip(text: c) }396                }397398                if let detail = item.detail, !detail.isEmpty {399                    VStack(alignment: .leading, spacing: 6) {400                        Text("À propos").font(.headline)401                        Text(detail)402                            .font(.subheadline)403                            .foregroundStyle(KATheme.ink2(scheme))404                            .textSelection(.enabled)405                    }406                    .padding(14)407                    .frame(maxWidth: .infinity, alignment: .leading)408                    .kaCard()409                }410411                if !item.facts.isEmpty {412                    VStack(spacing: 0) {413                        ForEach(item.facts, id: \.self) { f in414                            HStack(alignment: .top) {415                                Text(f.label).font(.subheadline).foregroundStyle(.secondary)416                                Spacer()417                                Text(f.value).font(.subheadline.weight(.semibold))418                                    .multilineTextAlignment(.trailing)419                                    .textSelection(.enabled)420                            }421                            .padding(.vertical, 9).padding(.horizontal, 14)422                            if f != item.facts.last { Divider() }423                        }424                    }425                    .kaCard()426                }427428                if !item.links.isEmpty {429                    VStack(alignment: .leading, spacing: 8) {430                        Text("Comptes & liens").font(.headline)431                        ForEach(item.links, id: \.self) { l in432                            if let u = URL(string: l.value) {433                                Link(destination: u) {434                                    HStack {435                                        Image(systemName: "link")436                                        Text(l.label).font(.subheadline.weight(.semibold))437                                        Spacer()438                                        Image(systemName: "arrow.up.right").font(.caption)439                                    }440                                    .padding(11)441                                    .background((universe?.accent ?? .gray).opacity(0.1),442                                                in: RoundedRectangle(cornerRadius: 10, style: .continuous))443                                }444                                .foregroundStyle(universe?.accent ?? .primary)445                            }446                        }447                    }448                }449450                if !menu.isEmpty {451                    VStack(alignment: .leading, spacing: 10) {452                        HStack {453                            Text("Menu & prix réels").font(.headline)454                            Spacer()455                            KAChip(text: "\(menu.reduce(0) { $0 + $1.items.count }) plats", accent: universe?.accent)456                        }457                        ForEach(menu.prefix(8)) { section in458                            DisclosureGroup {459                                VStack(spacing: 0) {460                                    ForEach(section.items, id: \.name) { dish in461                                        HStack(alignment: .top) {462                                            Text(dish.name).font(.subheadline)463                                            Spacer()464                                            if let p = dish.price {465                                                Text(p).font(.system(.subheadline, design: .rounded).weight(.bold))466                                                    .foregroundStyle(universe?.accent ?? .primary)467                                            }468                                        }469                                        .padding(.vertical, 6)470                                        Divider().opacity(dish.name == section.items.last?.name ? 0 : 0.6)471                                    }472                                }473                                .padding(.top, 4)474                            } label: {475                                Text(section.name).font(.subheadline.weight(.bold))476                            }477                            .padding(.horizontal, 14).padding(.vertical, 8)478                            .kaCard()479                        }480                    }481                }482483                if let url = item.url {484                    Link(destination: url) {485                        Label("Voir à la source", systemImage: "arrow.up.right.square")486                            .font(.headline)487                            .frame(maxWidth: .infinity).padding(.vertical, 13)488                            .background(universe?.accent ?? KATheme.green,489                                        in: RoundedRectangle(cornerRadius: 11, style: .continuous))490                            .foregroundStyle(.white)491                    }492                    .buttonStyle(.plain)493                    Text("Groupe KA est un agrégateur : la transaction se fait chez la source originale.")494                        .font(.caption2).foregroundStyle(.tertiary)495                }496            }497            .padding(18)498        }499        .background(KATheme.paper(scheme))500        .task(id: item.id) {501            galleryIndex = 0502            menu = []503            recents.record(item) // historique consulté504            if item.universeID == "resto-ka" {505                let uid = String(item.id.dropFirst("resto-ka:".count))506                menu = await RestoMenuLoader.load(uid: uid)507            }508        }509    }510511    // MARK: galerie (image principale + vignettes cliquables)512513    @ViewBuilder514    private var gallery: some View {515        if !item.imageURLs.isEmpty {516            VStack(spacing: 8) {517                ZStack(alignment: .bottomTrailing) {518                    KAImage(url: item.imageURLs[min(galleryIndex, item.imageURLs.count - 1)],519                            accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")520                        .frame(maxWidth: .infinity)521                        .frame(height: 250)522                        .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))523                        .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous)524                            .strokeBorder(.primary.opacity(0.3), lineWidth: 1.2))525                        .accessibilityLabel("Photo \(galleryIndex + 1) sur \(item.imageURLs.count) : \(item.title)")526                    if item.imageURLs.count > 1 {527                        Text("\(galleryIndex + 1)/\(item.imageURLs.count)")528                            .font(.system(.caption2, design: .monospaced).weight(.bold))529                            .padding(.horizontal, 8).padding(.vertical, 4)530                            .background(.ultraThinMaterial, in: Capsule())531                            .padding(8)532                    }533                }534                if item.imageURLs.count > 1 {535                    ScrollView(.horizontal, showsIndicators: false) {536                        HStack(spacing: 6) {537                            ForEach(Array(item.imageURLs.enumerated()), id: \.offset) { i, url in538                                Button { galleryIndex = i } label: {539                                    KAImage(url: url, accent: universe?.accent ?? .gray)540                                        .frame(width: 64, height: 44)541                                        .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))542                                        .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous)543                                            .strokeBorder(i == galleryIndex ? (universe?.accent ?? .blue) : .primary.opacity(0.2),544                                                          lineWidth: i == galleryIndex ? 2.2 : 1))545                                }546                                .buttonStyle(.plain)547                                .accessibilityLabel("Photo \(i + 1)")548                            }549                        }550                        .padding(2)551                    }552                }553            }554        }555    }556}557