SPB Git

spb/ka-macos

Public
21.1 KB · 459 lines swift
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 = false2526    var body: some View {27        Group {28            if universe.id == "vrai-prix" {29                VraiPrixExplorer(universe: universe)30            } else if universe.id == "api-ka" {31                ApiPlaygroundView(universe: universe)32            } else if universe.listPath == nil || universe.map == nil {33                webUniverse34            } else {35                explorer36            }37        }38    }3940    // MARK: univers sans liste native → renvoi au site4142    private var webUniverse: some View {43        VStack(spacing: 18) {44            Image(systemName: universe.symbol).font(.system(size: 52)).foregroundStyle(universe.accent)45            KAWordmark(universe: universe, size: 30)46            Text(universe.tagline).font(.title3).foregroundStyle(.secondary)47                .multilineTextAlignment(.center)48            if let n = pulse.totals[universe.id] {49                Text("\(n.fr) \(universe.unit)")50                    .font(.system(.subheadline, design: .monospaced).weight(.bold))51                    .foregroundStyle(universe.accent)52            }53            Link(destination: universe.baseURL) {54                Label("Ouvrir \(universe.name)", systemImage: "arrow.up.right.square")55                    .font(.headline).padding(.horizontal, 22).padding(.vertical, 12)56                    .background(universe.accent, in: Capsule())57                    .foregroundStyle(.white)58            }59            .buttonStyle(.plain)60        }61        .padding(30)62        .frame(maxWidth: .infinity, maxHeight: .infinity)63    }6465    // MARK: explorateur grille/liste + fiche6667    private var explorer: some View {68        VStack(spacing: 0) {69            hero70            Divider().opacity(0.4)71            HStack(spacing: 0) {72                results73                    .frame(maxWidth: .infinity)74                Divider().opacity(0.4)75                Group {76                    if let item = selected {77                        ItemDetailPane(item: item)78                    } else {79                        KAEmptyState(symbol: universe.symbol,80                                     title: "Choisissez un élément",81                                     message: "La fiche détaillée s'affichera ici.")82                            .frame(maxHeight: .infinity)83                            .background(KATheme.paper(scheme))84                    }85                }86                .frame(width: 440)87            }88        }89        .task {90            consumePrefill()91            await load()92        }93        .onChange(of: state.prefill?.universeID) {94            if state.prefill?.universeID == universe.id {95                consumePrefill()96                Task { await load() }97            }98        }99    }100101    private var hero: some View {102        VStack(alignment: .leading, spacing: 10) {103            HStack(alignment: .firstTextBaseline, spacing: 12) {104                KAWordmark(universe: universe, size: 24)105                Text(universe.tagline)106                    .font(.system(.subheadline, design: .rounded).weight(.bold))107                    .foregroundStyle(KATheme.ink2(scheme))108                Spacer()109                if let n = pulse.totals[universe.id] {110                    Text("\(n.fr) \(universe.unit)")111                        .font(.system(.caption, design: .monospaced).weight(.bold))112                        .padding(.horizontal, 9).padding(.vertical, 4)113                        .background(universe.accent.opacity(0.15), in: Capsule())114                        .foregroundStyle(universe.accent)115                        .contentTransition(.numericText())116                }117                Picker("Présentation", selection: $asGrid) {118                    Image(systemName: "square.grid.2x2").tag(true)119                    Image(systemName: "list.bullet").tag(false)120                }121                .pickerStyle(.segmented)122                .labelsHidden()123                .frame(width: 90)124                Link(destination: universe.baseURL) {125                    Label("Ouvrir le site", systemImage: "safari")126                        .font(.caption.weight(.semibold))127                }128                .foregroundStyle(universe.accent)129            }130            HStack(spacing: 8) {131                KASearchField(prompt: "Chercher dans \(universe.name)…", text: $query) {132                    Task { await load() }133                }134                FilterBar(universe: universe, params: $filterParams) {135                    Task { await load() }136                }137                if !query.isEmpty || !filterParams.isEmpty {138                    Button {139                        recents.saveSearch(name: "", universeID: universe.id,140                                           query: query, params: filterParams)141                        savedFlash = true142                        Task { try? await Task.sleep(for: .seconds(2)); savedFlash = false }143                    } label: {144                        Label(savedFlash ? "Alerte créée ✓" : "Sauvegarder + alerte",145                              systemImage: savedFlash ? "bell.badge.fill" : "bell.badge")146                            .font(.caption.weight(.bold))147                            .padding(.horizontal, 10).padding(.vertical, 7)148                            .background(savedFlash ? KATheme.green : universe.accent, in: Capsule())149                            .foregroundStyle(.white)150                    }151                    .buttonStyle(.plain)152                    .help("Sauvegarder cette recherche — KA recompte le total à chaque ouverture et signale les nouveautés")153                }154            }155        }156        .padding(.horizontal, 16).padding(.vertical, 12)157    }158159    @ViewBuilder160    private var results: some View {161        ScrollView {162            Group {163                if universe.id == "trouve-ka" && query.isEmpty {164                    KAEmptyState(symbol: "magnifyingglass",165                                 title: "Cherchez le web québécois",166                                 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.")167                } else if loading {168                    LazyVStack(spacing: 10) {169                        ForEach(0..<6, id: \.self) { _ in KASkeletonRow() }170                    }171                } else if let e = errorText, items.isEmpty {172                    KAEmptyState(symbol: "wifi.exclamationmark", title: "Impossible de charger", message: e)173                } else if items.isEmpty {174                    KAEmptyState(symbol: "tray", title: "Aucun résultat",175                                 message: "Essayez d'autres mots-clés ou élargissez les filtres.")176                } else if asGrid {177                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 230), spacing: 12)], spacing: 12) {178                        ForEach(items) { item in179                            Button { selected = item } label: {180                                KAItemCard(item: item, selected: selected?.id == item.id)181                            }182                            .buttonStyle(KAPressStyle())183                        }184                    }185                } else {186                    LazyVStack(spacing: 10) {187                        ForEach(items) { item in188                            Button { selected = item } label: {189                                KAItemRow(item: item)190                                    .overlay(191                                        RoundedRectangle(cornerRadius: 12, style: .continuous)192                                            .strokeBorder(universe.accent,193                                                          lineWidth: selected?.id == item.id ? 2.4 : 0)194                                            .padding(.trailing, 4).padding(.bottom, 4)195                                    )196                            }197                            .buttonStyle(KAPressStyle())198                        }199                    }200                }201            }202            .padding(14)203            .animation(.snappy(duration: 0.3), value: items)204        }205        .background(KATheme.paper(scheme))206    }207208    private func consumePrefill() {209        if let p = state.prefill, p.universeID == universe.id {210            query = p.query211            filterParams = p.params212            state.prefill = nil213        }214    }215216    private func load() async {217        // Trouve·Ka est un moteur : pas de liste sans requête218        if universe.id == "trouve-ka" && query.isEmpty {219            items = []; selected = nil; loading = false; errorText = nil220            return221        }222        loading = items.isEmpty223        errorText = nil224        do {225            // Trouve·Ka (FastAPI) plafonne limit à 50 — 422 au-delà226            let cap = universe.id == "trouve-ka" ? 50 : 60227            items = try await UniverseService.fetch(universe, query: query.isEmpty ? nil : query,228                                                    limit: cap, params: filterParams)229            if let sel = selected, !items.contains(where: { $0.id == sel.id }) { selected = nil }230            if selected == nil { selected = items.first }231        } catch {232            errorText = (error as? URLError)?.code == .notConnectedToInternet233                ? "Vous êtes hors ligne." : "Le service ne répond pas."234        }235        loading = false236    }237}238239// MARK: - Fiche universelle (panneau de droite)240241struct ItemDetailPane: View {242    let item: KAItem243    @Environment(\.colorScheme) private var scheme244    @EnvironmentObject private var favorites: FavoritesStore245    @EnvironmentObject private var recents: RecentsStore246    @State private var galleryIndex = 0247    @State private var menu: [RestoMenuSection] = []248249    private var universe: Universe? { Ecosystem.universe(item.universeID) }250251    var body: some View {252        ScrollView {253            VStack(alignment: .leading, spacing: 16) {254                gallery255                HStack {256                    if let u = universe { KAChip(text: u.wordmark, accent: u.accent) }257                    Spacer()258                    if favorites.collections.count > 1 && !favorites.isFavorite(item) {259                        Menu {260                            ForEach(favorites.collections) { c in261                                Button(c.name) { favorites.toggle(item, in: c.id) }262                            }263                        } label: {264                            Image(systemName: "heart")265                        }266                        .menuStyle(.borderlessButton)267                        .fixedSize()268                        .help("Ajouter à une collection")269                    } else {270                        Button {271                            favorites.toggle(item)272                        } label: {273                            Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart")274                                .foregroundStyle(favorites.isFavorite(item) ? .red : .primary)275                        }276                        .buttonStyle(.plain)277                        .help(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris")278                    }279                    if let url = item.url {280                        ShareLink(item: url, subject: Text(item.title)) {281                            Image(systemName: "square.and.arrow.up")282                        }283                        .buttonStyle(.plain)284                        .help("Partager le lien")285                    }286                }287                .font(.title3)288                Text(item.title).font(.title2.weight(.bold)).textSelection(.enabled)289                if let sub = item.subtitle, !sub.isEmpty {290                    Text(sub).font(.body).foregroundStyle(KATheme.ink2(scheme)).textSelection(.enabled)291                }292                HStack(spacing: 10) {293                    if let p = item.priceLabel {294                        Text(p).font(.system(.title3, design: .rounded).weight(.bold))295                            .foregroundStyle(universe?.accent ?? .primary)296                    }297                    if let c = item.city, !c.isEmpty { KAChip(text: c) }298                }299300                if let detail = item.detail, !detail.isEmpty {301                    VStack(alignment: .leading, spacing: 6) {302                        Text("À propos").font(.headline)303                        Text(detail)304                            .font(.subheadline)305                            .foregroundStyle(KATheme.ink2(scheme))306                            .textSelection(.enabled)307                    }308                    .padding(14)309                    .frame(maxWidth: .infinity, alignment: .leading)310                    .kaCard()311                }312313                if !item.facts.isEmpty {314                    VStack(spacing: 0) {315                        ForEach(item.facts, id: \.self) { f in316                            HStack(alignment: .top) {317                                Text(f.label).font(.subheadline).foregroundStyle(.secondary)318                                Spacer()319                                Text(f.value).font(.subheadline.weight(.semibold))320                                    .multilineTextAlignment(.trailing)321                                    .textSelection(.enabled)322                            }323                            .padding(.vertical, 9).padding(.horizontal, 14)324                            if f != item.facts.last { Divider() }325                        }326                    }327                    .kaCard()328                }329330                if !item.links.isEmpty {331                    VStack(alignment: .leading, spacing: 8) {332                        Text("Comptes & liens").font(.headline)333                        ForEach(item.links, id: \.self) { l in334                            if let u = URL(string: l.value) {335                                Link(destination: u) {336                                    HStack {337                                        Image(systemName: "link")338                                        Text(l.label).font(.subheadline.weight(.semibold))339                                        Spacer()340                                        Image(systemName: "arrow.up.right").font(.caption)341                                    }342                                    .padding(11)343                                    .background((universe?.accent ?? .gray).opacity(0.1),344                                                in: RoundedRectangle(cornerRadius: 10, style: .continuous))345                                }346                                .foregroundStyle(universe?.accent ?? .primary)347                            }348                        }349                    }350                }351352                if !menu.isEmpty {353                    VStack(alignment: .leading, spacing: 10) {354                        HStack {355                            Text("Menu & prix réels").font(.headline)356                            Spacer()357                            KAChip(text: "\(menu.reduce(0) { $0 + $1.items.count }) plats", accent: universe?.accent)358                        }359                        ForEach(menu.prefix(8)) { section in360                            DisclosureGroup {361                                VStack(spacing: 0) {362                                    ForEach(section.items, id: \.name) { dish in363                                        HStack(alignment: .top) {364                                            Text(dish.name).font(.subheadline)365                                            Spacer()366                                            if let p = dish.price {367                                                Text(p).font(.system(.subheadline, design: .rounded).weight(.bold))368                                                    .foregroundStyle(universe?.accent ?? .primary)369                                            }370                                        }371                                        .padding(.vertical, 6)372                                        Divider().opacity(dish.name == section.items.last?.name ? 0 : 0.6)373                                    }374                                }375                                .padding(.top, 4)376                            } label: {377                                Text(section.name).font(.subheadline.weight(.bold))378                            }379                            .padding(.horizontal, 14).padding(.vertical, 8)380                            .kaCard()381                        }382                    }383                }384385                if let url = item.url {386                    Link(destination: url) {387                        Label("Voir à la source", systemImage: "arrow.up.right.square")388                            .font(.headline)389                            .frame(maxWidth: .infinity).padding(.vertical, 13)390                            .background(universe?.accent ?? KATheme.green,391                                        in: RoundedRectangle(cornerRadius: 11, style: .continuous))392                            .foregroundStyle(.white)393                    }394                    .buttonStyle(.plain)395                    Text("Groupe KA est un agrégateur : la transaction se fait chez la source originale.")396                        .font(.caption2).foregroundStyle(.tertiary)397                }398            }399            .padding(18)400        }401        .background(KATheme.paper(scheme))402        .task(id: item.id) {403            galleryIndex = 0404            menu = []405            recents.record(item) // historique consulté406            if item.universeID == "resto-ka" {407                let uid = String(item.id.dropFirst("resto-ka:".count))408                menu = await RestoMenuLoader.load(uid: uid)409            }410        }411    }412413    // MARK: galerie (image principale + vignettes cliquables)414415    @ViewBuilder416    private var gallery: some View {417        if !item.imageURLs.isEmpty {418            VStack(spacing: 8) {419                ZStack(alignment: .bottomTrailing) {420                    KAImage(url: item.imageURLs[min(galleryIndex, item.imageURLs.count - 1)],421                            accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")422                        .frame(maxWidth: .infinity)423                        .frame(height: 250)424                        .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))425                        .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous)426                            .strokeBorder(.primary.opacity(0.3), lineWidth: 1.2))427                        .accessibilityLabel("Photo \(galleryIndex + 1) sur \(item.imageURLs.count) : \(item.title)")428                    if item.imageURLs.count > 1 {429                        Text("\(galleryIndex + 1)/\(item.imageURLs.count)")430                            .font(.system(.caption2, design: .monospaced).weight(.bold))431                            .padding(.horizontal, 8).padding(.vertical, 4)432                            .background(.ultraThinMaterial, in: Capsule())433                            .padding(8)434                    }435                }436                if item.imageURLs.count > 1 {437                    ScrollView(.horizontal, showsIndicators: false) {438                        HStack(spacing: 6) {439                            ForEach(Array(item.imageURLs.enumerated()), id: \.offset) { i, url in440                                Button { galleryIndex = i } label: {441                                    KAImage(url: url, accent: universe?.accent ?? .gray)442                                        .frame(width: 64, height: 44)443                                        .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))444                                        .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous)445                                            .strokeBorder(i == galleryIndex ? (universe?.accent ?? .blue) : .primary.opacity(0.2),446                                                          lineWidth: i == galleryIndex ? 2.2 : 1))447                                }448                                .buttonStyle(.plain)449                                .accessibilityLabel("Photo \(i + 1)")450                            }451                        }452                        .padding(2)453                    }454                }455            }456        }457    }458}459
Files · e2e01af1478899e68831e7da506cabe4fc68c49a
Menu

Theme