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