Swift 98.3%
Shell 1.7%
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