1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// UniversesView.swift — la grille des 12 univers (cartes accent + compteur en3// direct), l'explorateur générique d'un univers (liste + recherche) et la4// fiche universelle (faits, favori, partage, lien web).5import SwiftUI6import SafariServices7import MapKit89extension URL: @retroactive Identifiable { public var id: String { absoluteString } }1011/// Visionneuse plein écran avec zoom (pincement + double-tap)12struct ZoomableImageView: View {13 let url: URL14 let accent: Color15 @Environment(\.dismiss) private var dismiss16 @State private var scale: CGFloat = 117 @State private var lastScale: CGFloat = 11819 var body: some View {20 ZStack(alignment: .topTrailing) {21 Color.black.ignoresSafeArea()22 KAImage(url: url, accent: accent)23 .aspectRatio(contentMode: .fit)24 .scaleEffect(scale)25 .gesture(26 MagnifyGesture()27 .onChanged { v in scale = min(max(lastScale * v.magnification, 1), 5) }28 .onEnded { _ in lastScale = scale }29 )30 .onTapGesture(count: 2) {31 withAnimation(.snappy) { scale = scale > 1.5 ? 1 : 2.5; lastScale = scale }32 }33 .accessibilityLabel("Photo en plein écran, pincer pour zoomer")34 Button { dismiss() } label: {35 Image(systemName: "xmark.circle.fill")36 .font(.title2).foregroundStyle(.white.opacity(0.85))37 .padding(16)38 }39 .accessibilityLabel("Fermer la photo")40 }41 .statusBarHidden()42 }43}4445// MARK: - Grille des univers4647struct UniversesView: View {48 @State private var totals: [String: Int] = [:]49 @Environment(\.colorScheme) private var scheme5051 private let columns = [GridItem(.adaptive(minimum: 160), spacing: 14)]52 @State private var showMap = false53 @State private var path = NavigationPath()5455 var body: some View {56 NavigationStack(path: $path) {57 ScrollView {58 LazyVGrid(columns: columns, spacing: 14) {59 ForEach(Ecosystem.all) { u in60 NavigationLink(value: u.id) {61 UniverseCard(universe: u, total: totals[u.id])62 }63 .buttonStyle(KAPressStyle())64 }65 }66 .padding(16)67 }68 .background(KATheme.paper(scheme))69 .navigationTitle("Univers")70 .toolbar {71 ToolbarItem(placement: .topBarTrailing) {72 Button { showMap = true } label: { Image(systemName: "map.fill") }73 .accessibilityLabel("Carte unifiée de l'écosystème")74 }75 }76 .fullScreenCover(isPresented: $showMap) {77 UnifiedMapView()78 }79 .navigationDestination(for: String.self) { id in80 if let u = Ecosystem.universe(id) {81 UniverseHomeView(universe: u)82 }83 }84 .task {85 // pilotage debug/captures : ouvre directement un univers86 if let id = UserDefaults.standard.string(forKey: "ka.debug.universe"),87 Ecosystem.universe(id) != nil, path.isEmpty {88 path.append(id)89 }90 await withTaskGroup(of: (String, Int?).self) { group in91 for u in Ecosystem.all {92 group.addTask { (u.id, await UniverseService.liveTotal(u)) }93 }94 for await (id, n) in group {95 if let n { totals[id] = n }96 }97 }98 }99 }100 }101}102103struct UniverseCard: View {104 let universe: Universe105 let total: Int?106 @Environment(\.colorScheme) private var scheme107108 var body: some View {109 VStack(alignment: .leading, spacing: 10) {110 HStack {111 KALogo(universe: universe, size: 40)112 Spacer()113 Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary)114 }115 KAWordmark(universe: universe, size: 17)116 Text(universe.tagline)117 .font(.caption).foregroundStyle(KATheme.ink2(scheme))118 .lineLimit(2, reservesSpace: true)119 if let total {120 Text("\(total.formatted(.number.locale(Locale(identifier: "fr_CA")))) \(universe.unit)")121 .font(KAFont.mono(10))122 .foregroundStyle(universe.accent)123 .contentTransition(.numericText())124 } else {125 Text("—").font(KAFont.mono(10, bold: false))126 .foregroundStyle(.tertiary)127 }128 }129 .padding(14)130 .frame(maxWidth: .infinity, alignment: .leading)131 .kaCard(accent: universe.accent)132 .accessibilityElement(children: .combine)133 }134}135136// MARK: - Héros de site (reproduction fidèle de la page d'accueil de l'univers)137138struct SiteHero: View {139 let universe: Universe140 let liveTotal: Int?141 var openSite: (() -> Void)? = nil142 @Environment(\.colorScheme) private var scheme143144 private var copy: HeroCopy? { KAHeroes.hero(universe.id) }145146 var body: some View {147 VStack(alignment: .leading, spacing: 12) {148 ticker149 if let c = copy {150 if let k = c.kicker {151 HStack(spacing: 10) {152 Rectangle().fill(KATheme.green).frame(width: 26, height: 2.5)153 Text(k.uppercased())154 .font(KAFont.mono(10))155 .foregroundStyle(scheme == .dark ? KATheme.lime : KATheme.green)156 .kerning(1.1)157 .lineLimit(2)158 }159 }160 headline(c)161 Text(c.sub)162 .font(.subheadline)163 .foregroundStyle(KATheme.ink2(scheme))164 .lineLimit(4)165 }166 HStack(spacing: 10) {167 statPill168 Spacer()169 if let openSite {170 Button(action: { Haptics.tap(); openSite() }) {171 Text("VOIR LE SITE ↗")172 .font(KAFont.mono(10))173 .foregroundStyle(universe.accent)174 .padding(.horizontal, 12).padding(.vertical, 8)175 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 6, style: .continuous))176 }177 .buttonStyle(KAPressStyle())178 .accessibilityLabel("Voir le site \(universe.name) dans l'app")179 }180 }181 }182 }183184 /// Bandeau ticker encre — la signature du haut des sites185 private var ticker: some View {186 HStack(spacing: 8) {187 KALogo(universe: universe, size: 18)188 Text(universe.unit.uppercased())189 .font(KAFont.mono(9.5))190 .foregroundStyle(.white.opacity(0.9))191 Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent)192 Text("EN DIRECT")193 .font(KAFont.mono(9.5))194 .foregroundStyle(universe.accent)195 Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent)196 Text(universe.domain.replacingOccurrences(of: "www.", with: "").uppercased())197 .font(KAFont.mono(9.5))198 .foregroundStyle(.white.opacity(0.75))199 .lineLimit(1)200 Spacer(minLength: 0)201 }202 .padding(.horizontal, 12).padding(.vertical, 8)203 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 8, style: .continuous))204 }205206 /// Titre display avec le segment surligné SUR l'accent (comme le h1 du site)207 private func headline(_ c: HeroCopy) -> some View {208 var attr = AttributedString(c.headline)209 attr.font = KAFont.display(27)210 attr.foregroundColor = KATheme.ink(scheme)211 if let hl = c.highlight,212 let range = attr.range(of: hl, options: .caseInsensitive) {213 attr[range].backgroundColor = universe.accent214 attr[range].foregroundColor = .white215 }216 return Text(attr).lineSpacing(2)217 }218219 private var statPill: some View {220 HStack(spacing: 7) {221 Circle().fill(universe.accent).frame(width: 8, height: 8)222 if let n = liveTotal {223 KACountUp(value: n, font: KAFont.display(15),224 color: KATheme.ink(scheme))225 Text(universe.unit)226 .font(.caption).foregroundStyle(KATheme.ink2(scheme))227 } else {228 Text("En direct").font(.caption).foregroundStyle(KATheme.ink2(scheme))229 }230 }231 .padding(.horizontal, 13).padding(.vertical, 8)232 .background(KATheme.surface(scheme), in: Capsule())233 .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5))234 .background(Capsule().fill(scheme == .dark ? Color.clear : KATheme.inkLight).offset(x: 4, y: 4))235 }236}237238// MARK: - Explorateur d'un univers239240struct UniverseHomeView: View {241 let universe: Universe242 @State private var items: [KAItem] = []243 @State private var query = ""244 @State private var loading = true245 @State private var errorText: String?246 @State private var filterParams: [String: String] = [:]247 @State private var liveTotal: Int?248 @State private var showSite = false249 @State private var cityFilter: String?250 @State private var cityPool: [String] = [] // top villes du premier chargement251 @Environment(\.colorScheme) private var scheme252253 var body: some View {254 Group {255 if universe.id == "vrai-prix" {256 VraiPrixView(universe: universe)257 } else if universe.id == "api-ka" {258 APIPlaygroundView(universe: universe)259 } else if universe.listPath == nil {260 webUniverse261 } else {262 list263 }264 }265 .background(KATheme.paper(scheme))266 .navigationTitle(universe.name)267 .navigationBarTitleDisplayMode(.inline)268 .toolbar {269 ToolbarItem(placement: .topBarTrailing) {270 Button { Haptics.tap(); showSite = true } label: {271 Image(systemName: "globe").accessibilityLabel("Voir le site \(universe.name) dans l'app")272 }273 }274 }275 .fullScreenCover(isPresented: $showSite) {276 SiteBrowserCover(universe: universe)277 }278 .tint(universe.accent)279 }280281 /// Univers sans liste native : le site lui-même, embarqué282 private var webUniverse: some View {283 EmbeddedSite(universe: universe)284 }285286 /// En-tête FIDÈLE au site : bande teintée accent + logo filigrane autour287 /// du héros (ticker encre, kicker, titre surligné, pilule live roulante).288 private var hero: some View {289 VStack(alignment: .leading, spacing: 0) {290 UniverseHeroBand(universe: universe) {291 SiteHero(universe: universe, liveTotal: liveTotal) { showSite = true }292 }293 FilterBar(universe: universe, params: $filterParams) {294 Task { await load() }295 }296 .padding(.top, 12)297 }298 }299300 private var list: some View {301 ScrollView {302 LazyVStack(spacing: 14) {303 hero304 if loading {305 ForEach(0..<6, id: \.self) { _ in KASkeletonRow() }306 } else if let e = errorText, items.isEmpty {307 KAEmptyState(symbol: "wifi.exclamationmark", title: "Impossible de charger",308 message: e + "\nTirez pour réessayer.")309 } else if items.isEmpty {310 if universe.id == "trouve-ka" && query.isEmpty {311 KAEmptyState(symbol: "magnifyingglass",312 title: "Cherchez le web québécois",313 message: "1,2 M de pages d'ici indexées — tapez un mot dans la barre de recherche ci-dessus.")314 } else {315 KAEmptyState(symbol: "tray", title: "Aucun résultat",316 message: "Essayez d'autres mots-clés.")317 }318 } else {319 UniverseStatStrip(universe: universe, liveTotal: liveTotal,320 shown: items.count,321 cityCount: Set(items.compactMap(\.city)).count)322 if cityPool.count > 1 {323 UniverseCityChips(cities: cityPool, selected: $cityFilter,324 accent: universe.accent) {325 Task { await load() }326 }327 }328 magazine329 }330 }331 .padding(16)332 .animation(.snappy(duration: 0.3), value: items)333 }334 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }335 .searchable(text: $query, prompt: "Chercher dans \(universe.name)…")336 .onSubmit(of: .search) { Task { await load() } }337 .refreshable { await load() }338 .task {339 await load()340 liveTotal = await UniverseService.liveTotal(universe)341 }342 }343344 /// Mise en page MAGAZINE quand l'univers a des photos : vitrine pleine345 /// largeur → carrousel « En vedette » → mosaïque 2 colonnes. Sans photos346 /// (Job·Ka, Trouve·Ka…) : liste éditoriale classique.347 @ViewBuilder348 private var magazine: some View {349 let visuals = items.filter { $0.imageURL != nil }350 if visuals.count >= 3, let showcase = visuals.first {351 let featured = Array(visuals.dropFirst().prefix(6))352 let usedIDs = Set([showcase.id] + featured.map(\.id))353 let rest = items.filter { !usedIDs.contains($0.id) }354355 NavigationLink(value: showcase) {356 UniverseShowcaseCard(item: showcase, universe: universe)357 }358 .buttonStyle(KAPressStyle())359 .kaScrollPop(axis: .vertical)360361 if !featured.isEmpty {362 UniverseSectionHeader(title: "En vedette", accent: universe.accent)363 ScrollView(.horizontal, showsIndicators: false) {364 HStack(spacing: 12) {365 ForEach(featured) { it in366 NavigationLink(value: it) {367 UniverseFeaturedCard(item: it, universe: universe)368 }369 .buttonStyle(KAPressStyle())370 .kaScrollPop()371 }372 }373 .scrollTargetLayout()374 .padding(.vertical, 4)375 }376 .scrollTargetBehavior(.viewAligned)377 }378379 if !rest.isEmpty {380 UniverseSectionHeader(title: "Tout le flux", accent: universe.accent,381 detail: "\(rest.count)")382 LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),383 GridItem(.flexible(), spacing: 12)], spacing: 12) {384 ForEach(rest) { it in385 NavigationLink(value: it) {386 UniverseMosaicCell(item: it, universe: universe)387 }388 .buttonStyle(KAPressStyle())389 .kaScrollPop(axis: .vertical)390 }391 }392 }393 } else {394 ForEach(items) { item in395 NavigationLink(value: item) { KAItemRow(item: item) }396 .buttonStyle(KAPressStyle())397 .kaScrollPop(axis: .vertical)398 }399 }400 }401402 private func load() async {403 loading = items.isEmpty404 errorText = nil405 do {406 items = try await UniverseService.fetch(universe,407 query: query.isEmpty ? nil : query,408 city: cityFilter,409 limit: 40, params: filterParams)410 // top villes mémorisées sur un chargement NON filtré (chips stables)411 if query.isEmpty, cityFilter == nil, filterParams.isEmpty {412 let counts = Dictionary(grouping: items.compactMap(\.city), by: { $0 })413 .mapValues(\.count)414 cityPool = counts.sorted { $0.value > $1.value }.prefix(8).map(\.key)415 }416 } catch {417 errorText = (error as? URLError)?.code == .notConnectedToInternet418 ? "Vous êtes hors ligne." : "Le service ne répond pas."419 }420 loading = false421 }422}423424// MARK: - Fiche universelle425426struct ItemDetailView: View {427 let item: KAItem428 @EnvironmentObject private var favorites: FavoritesStore429 @Environment(\.colorScheme) private var scheme430 @State private var showCollections = false431 @State private var menu: [RestoMenuSection] = []432 @State private var similar: [KAItem] = []433 @State private var fullScreenImage: URL?434 /// Fiche COMPLÈTE chargée depuis l'API de détail du site (galerie entière,435 /// description longue, inclusions, caractéristiques, estimation Vrai-Prix…)436 @State private var enriched: KAItem?437 @State private var descExpanded = false438 @EnvironmentObject private var recents: RecentsStore439440 private var universe: Universe? { Ecosystem.universe(it.universeID) }441 /// L'item affiché : enrichi dès que l'API de détail répond, sinon la liste.442 private var it: KAItem { enriched ?? item }443 private var accent: Color { universe?.accent ?? KATheme.green }444445 var body: some View {446 ScrollView {447 VStack(alignment: .leading, spacing: 16) {448 if !it.imageURLs.isEmpty {449 TabView {450 ForEach(it.imageURLs, id: \.self) { img in451 KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")452 .onTapGesture { Haptics.tap(); fullScreenImage = img }453 .accessibilityLabel("Photo de \(it.title) — toucher pour agrandir")454 .accessibilityAddTraits(.isButton)455 }456 }457 .tabViewStyle(.page(indexDisplayMode: it.imageURLs.count > 1 ? .automatic : .never))458 .frame(height: 240)459 .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))460 .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous)461 .strokeBorder(.primary.opacity(0.3), lineWidth: 1.2))462 .overlay(alignment: .topTrailing) {463 if it.imageURLs.count > 1 {464 Text("\(it.imageURLs.count) photos")465 .font(.system(size: 10, design: .monospaced).weight(.bold))466 .padding(.horizontal, 8).padding(.vertical, 4)467 .background(.ultraThinMaterial, in: Capsule())468 .padding(8)469 }470 }471 }472 if let u = universe {473 HStack {474 KAChip(text: u.wordmark, accent: u.accent)475 Spacer()476 if enriched != nil {477 Text("FICHE COMPLÈTE")478 .font(KAFont.mono(8))479 .foregroundStyle(accent)480 .transition(.opacity)481 }482 }483 }484 Text(it.title).font(KAFont.display(23))485 if let sub = it.subtitle, !sub.isEmpty {486 Text(sub).font(.body).foregroundStyle(KATheme.ink2(scheme))487 }488 HStack(spacing: 10) {489 if let p = it.priceLabel {490 Text(p).font(KAFont.display(21))491 .foregroundStyle(accent)492 }493 if let c = it.city { KAChip(text: c) }494 }495496 // « En bref » — le digest de la fiche du site497 if let brief = it.brief, !brief.isEmpty {498 HStack(alignment: .top, spacing: 10) {499 Rectangle().fill(accent).frame(width: 3)500 VStack(alignment: .leading, spacing: 4) {501 Text("EN BREF")502 .font(KAFont.mono(9))503 .foregroundStyle(accent)504 Text(brief)505 .font(.subheadline)506 .foregroundStyle(KATheme.ink(scheme))507 }508 }509 .padding(13)510 .frame(maxWidth: .infinity, alignment: .leading)511 .background(accent.opacity(scheme == .dark ? 0.14 : 0.08),512 in: RoundedRectangle(cornerRadius: 12, style: .continuous))513 }514515 // étiquettes de la fiche (commodités, inclusions, équipements…)516 if let tags = it.tags, !tags.isEmpty {517 VStack(alignment: .leading, spacing: 8) {518 UniverseSectionHeader(title: "Caractéristiques", accent: accent,519 detail: "\(tags.count)")520 KAFlow(spacing: 7) {521 ForEach(tags, id: \.self) { t in522 Text(t)523 .font(.system(.caption2, design: .rounded, weight: .semibold))524 .padding(.horizontal, 10).padding(.vertical, 6)525 .background(accent.opacity(0.10), in: Capsule())526 .overlay(Capsule().strokeBorder(accent.opacity(0.35), lineWidth: 1))527 .foregroundStyle(KATheme.ink(scheme))528 }529 }530 }531 }532533 if let detail = it.detail, !detail.isEmpty {534 VStack(alignment: .leading, spacing: 6) {535 Text("À propos").font(.headline)536 Text(detail)537 .font(.subheadline)538 .foregroundStyle(KATheme.ink2(scheme))539 .lineLimit(descExpanded ? nil : 8)540 if detail.count > 350 {541 Button {542 withAnimation(.snappy) { descExpanded.toggle() }543 } label: {544 Text(descExpanded ? "Réduire ↑" : "Lire la suite ↓")545 .font(KAFont.mono(10))546 .foregroundStyle(accent)547 }548 }549 }550 .padding(14)551 .frame(maxWidth: .infinity, alignment: .leading)552 .kaCard()553 }554555 // fiche technique — grille 2 colonnes, comme le tableau du site556 if !it.facts.isEmpty {557 VStack(alignment: .leading, spacing: 8) {558 UniverseSectionHeader(title: "Détails", accent: accent,559 detail: "\(it.facts.count)")560 LazyVGrid(columns: [GridItem(.flexible(), spacing: 10),561 GridItem(.flexible(), spacing: 10)],562 alignment: .leading, spacing: 10) {563 ForEach(it.facts, id: \.self) { f in564 VStack(alignment: .leading, spacing: 3) {565 Text(f.label.uppercased())566 .font(KAFont.mono(8))567 .foregroundStyle(KATheme.ink2(scheme))568 .lineLimit(1)569 Text(f.value)570 .font(.subheadline.weight(.semibold))571 .foregroundStyle(KATheme.ink(scheme))572 .lineLimit(3)573 .multilineTextAlignment(.leading)574 }575 .padding(.horizontal, 11).padding(.vertical, 9)576 .frame(maxWidth: .infinity, minHeight: 54, alignment: .topLeading)577 .background(KATheme.surface(scheme),578 in: RoundedRectangle(cornerRadius: 10, style: .continuous))579 .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous)580 .strokeBorder(KATheme.ink(scheme).opacity(0.18), lineWidth: 1))581 .accessibilityElement(children: .combine)582 }583 }584 }585 }586587 // localisation sur la carte588 if let la = it.latitude, let lo = it.longitude {589 VStack(alignment: .leading, spacing: 8) {590 UniverseSectionHeader(title: "Sur la carte", accent: accent)591 Map(position: .constant(.region(MKCoordinateRegion(592 center: .init(latitude: la, longitude: lo),593 span: .init(latitudeDelta: 0.012, longitudeDelta: 0.012))))) {594 Marker(it.title, coordinate: .init(latitude: la, longitude: lo))595 .tint(accent)596 }597 .frame(height: 170)598 .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))599 .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous)600 .strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1.2))601 .allowsHitTesting(false)602 .accessibilityLabel("Emplacement de \(it.title) sur la carte")603 }604 }605606 if !it.links.isEmpty {607 VStack(alignment: .leading, spacing: 8) {608 Text("Comptes & liens").font(.headline)609 ForEach(it.links, id: \.self) { l in610 if let u = URL(string: l.value) {611 Link(destination: u) {612 HStack {613 Image(systemName: "link")614 Text(l.label).font(.subheadline.weight(.semibold))615 Spacer()616 Image(systemName: "arrow.up.right").font(.caption)617 }618 .padding(11)619 .background((universe?.accent ?? .gray).opacity(0.1), in: RoundedRectangle(cornerRadius: 10, style: .continuous))620 }621 .foregroundStyle(universe?.accent ?? .primary)622 }623 }624 }625 }626627 if !menu.isEmpty {628 VStack(alignment: .leading, spacing: 10) {629 HStack {630 Text("Menu & prix réels").font(.headline)631 Spacer()632 KAChip(text: "\(menu.reduce(0) { $0 + $1.items.count }) plats", accent: universe?.accent)633 }634 ForEach(menu.prefix(6)) { section in635 DisclosureGroup {636 VStack(spacing: 0) {637 ForEach(section.items, id: \.name) { dish in638 HStack(alignment: .top) {639 Text(dish.name).font(.subheadline)640 Spacer()641 if let p = dish.price {642 Text(p).font(.system(.subheadline, design: .rounded).weight(.bold))643 .foregroundStyle(universe?.accent ?? .primary)644 }645 }646 .padding(.vertical, 7)647 Divider().opacity(dish.name == section.items.last?.name ? 0 : 0.6)648 }649 }650 .padding(.top, 4)651 } label: {652 Text(section.name).font(.subheadline.weight(.bold))653 }654 .padding(.horizontal, 14).padding(.vertical, 8)655 .kaCard()656 }657 }658 }659660 HStack(spacing: 10) {661 if let url = it.url {662 Link(destination: url) {663 Label("Voir à la source", systemImage: "arrow.up.right.square")664 .font(.headline)665 .frame(maxWidth: .infinity).padding(.vertical, 14)666 .background(universe?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 12, style: .continuous))667 .foregroundStyle(.white)668 }669 }670 if let la = it.latitude, let lo = it.longitude {671 Button {672 Haptics.tap()673 let place = MKMapItem(placemark: MKPlacemark(coordinate: .init(latitude: la, longitude: lo)))674 place.name = it.title675 place.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault])676 } label: {677 Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill")678 .font(.headline)679 .padding(.horizontal, 16).padding(.vertical, 14)680 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous))681 .foregroundStyle(KATheme.lime)682 }683 .accessibilityLabel("Itinéraire vers \(it.title)")684 }685 }686 if let src = it.url?.host() {687 Text("Source : \(src) — Groupe KA est un agrégateur, la transaction se fait chez la source originale.")688 .font(.caption2).foregroundStyle(.tertiary)689 }690691 if !similar.isEmpty {692 VStack(alignment: .leading, spacing: 10) {693 Text("Semblables\(it.city.map { " à \($0)" } ?? "")").font(.headline)694 ScrollView(.horizontal, showsIndicators: false) {695 HStack(spacing: 10) {696 ForEach(similar) { s in697 NavigationLink(value: s) {698 VStack(alignment: .leading, spacing: 0) {699 KAImage(url: s.imageURL, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")700 .frame(width: 150, height: 84).clipped()701 VStack(alignment: .leading, spacing: 2) {702 Text(s.title).font(.caption.weight(.semibold))703 .lineLimit(2, reservesSpace: true)704 if let p = s.priceLabel {705 Text(p).font(.system(.caption2, design: .rounded).weight(.bold))706 .foregroundStyle(universe?.accent ?? .primary)707 }708 }709 .padding(8)710 }711 .frame(width: 150, alignment: .topLeading)712 .kaCard(accent: universe?.accent)713 }714 .buttonStyle(KAPressStyle())715 }716 }717 .padding(.vertical, 4)718 }719 }720 }721 }722 .padding(16)723 }724 .background(KATheme.paper(scheme))725 .navigationBarTitleDisplayMode(.inline)726 .toolbar {727 ToolbarItemGroup(placement: .topBarTrailing) {728 Button {729 if favorites.isFavorite(item) { favorites.toggle(item) }730 else if favorites.collections.count > 1 { showCollections = true }731 else { favorites.toggle(item) }732 } label: {733 Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart")734 .foregroundStyle(favorites.isFavorite(item) ? .red : .primary)735 }736 .accessibilityLabel(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris")737 if let url = it.url {738 ShareLink(item: url, subject: Text(it.title))739 }740 }741 }742 .task {743 recents.record(item)744 // fiche complète depuis l'API de détail du site (galerie entière,745 // description longue, inclusions, estimation Vrai-Prix, heures…)746 if let u = universe, enriched == nil,747 let full = await DetailService.enrich(item, universe: u) {748 withAnimation(.snappy) { enriched = full }749 }750 if it.universeID == "resto-ka" {751 let uid = String(it.id.dropFirst("resto-ka:".count))752 menu = await RestoMenuLoader.load(uid: uid)753 }754 if let u = universe, u.map != nil, similar.isEmpty {755 let batch = (try? await UniverseService.fetch(u, city: it.city, limit: 10)) ?? []756 similar = batch.filter { $0.id != it.id }.prefix(6).map { $0 }757 }758 }759 .fullScreenCover(item: $fullScreenImage) { img in760 ZoomableImageView(url: img, accent: universe?.accent ?? .gray)761 }762 .confirmationDialog("Ajouter à…", isPresented: $showCollections, titleVisibility: .visible) {763 ForEach(favorites.collections) { c in764 Button(c.name) { favorites.toggle(item, in: c.id) }765 }766 }767 .tint(universe?.accent)768 }769}770