Swift 100%
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 @State private var showMap = false52 @State private var path = NavigationPath()5354 var body: some View {55 NavigationStack(path: $path) {56 ScrollView {57 VStack(alignment: .leading, spacing: 14) {58 // manchette éditoriale — l'index de l'écosystème59 VStack(alignment: .leading, spacing: 4) {60 HStack(spacing: 8) {61 Rectangle().fill(KATheme.lime).frame(width: 8, height: 8)62 Text("L'ÉCOSYSTÈME · \(Ecosystem.all.count) UNIVERS")63 .font(KAFont.mono(9.5)).kerning(1.1)64 .foregroundStyle(KATheme.ink2(scheme))65 }66 Text("Explorer")67 .font(KAFont.display(36))68 .foregroundStyle(KATheme.ink(scheme))69 Text("Mille sites. Un seul KA.")70 .font(.subheadline)71 .foregroundStyle(KATheme.ink2(scheme))72 }73 .padding(.bottom, 6)74 ForEach(Ecosystem.all) { u in75 NavigationLink(value: u.id) {76 UniverseBand(universe: u, total: totals[u.id])77 }78 .buttonStyle(KAPressStyle())79 .kaScrollPop(axis: .vertical)80 }81 }82 .padding(16)83 }84 .background {85 ZStack {86 KATheme.paper(scheme)87 KAAurora()88 }89 .ignoresSafeArea()90 }91 .navigationTitle("")92 .toolbar {93 ToolbarItem(placement: .topBarTrailing) {94 Button { showMap = true } label: { Image(systemName: "map.fill") }95 .accessibilityLabel("Carte unifiée de l'écosystème")96 }97 }98 .fullScreenCover(isPresented: $showMap) {99 UnifiedMapView()100 }101 .navigationDestination(for: String.self) { id in102 if let u = Ecosystem.universe(id) {103 UniverseHomeView(universe: u)104 }105 }106 .task {107 // pilotage debug/captures : ouvre directement un univers108 if let id = UserDefaults.standard.string(forKey: "ka.debug.universe"),109 Ecosystem.universe(id) != nil, path.isEmpty {110 path.append(id)111 }112 await withTaskGroup(of: (String, Int?).self) { group in113 for u in Ecosystem.all {114 group.addTask { (u.id, await UniverseService.liveTotal(u)) }115 }116 for await (id, n) in group {117 if let n { totals[id] = n }118 }119 }120 }121 }122 }123}124125/// Bande éditoriale d'un univers — l'index magazine de l'écosystème :126/// gradient à l'accent, logo en filigrane, grand wordmark, compteur roulant.127struct UniverseBand: View {128 let universe: Universe129 let total: Int?130 @Environment(\.colorScheme) private var scheme131132 var body: some View {133 ZStack(alignment: .topTrailing) {134 RoundedRectangle(cornerRadius: 18, style: .continuous)135 .fill(LinearGradient(136 colors: [universe.accent.opacity(scheme == .dark ? 0.30 : 0.20),137 universe.accent.opacity(scheme == .dark ? 0.08 : 0.03)],138 startPoint: .topTrailing, endPoint: .bottomLeading))139 .background(KATheme.surface(scheme),140 in: RoundedRectangle(cornerRadius: 18, style: .continuous))141 KALogo(universe: universe, size: 120)142 .opacity(scheme == .dark ? 0.14 : 0.10)143 .rotationEffect(.degrees(9))144 .offset(x: 26, y: -18)145 .accessibilityHidden(true)146 HStack(spacing: 12) {147 VStack(alignment: .leading, spacing: 5) {148 KAWordmark(universe: universe, size: 23)149 Text(universe.tagline)150 .font(.caption)151 .foregroundStyle(KATheme.ink2(scheme))152 .lineLimit(2)153 .multilineTextAlignment(.leading)154 HStack(spacing: 7) {155 Circle().fill(universe.accent).frame(width: 7, height: 7)156 if let total {157 KACountUp(value: total, font: KAFont.display(15),158 color: universe.accent)159 Text(universe.unit.uppercased())160 .font(KAFont.mono(8))161 .foregroundStyle(KATheme.ink2(scheme))162 .lineLimit(1)163 } else {164 Text("EN DIRECT")165 .font(KAFont.mono(8))166 .foregroundStyle(universe.accent)167 }168 }169 .padding(.top, 2)170 }171 Spacer(minLength: 0)172 Image(systemName: "arrow.right")173 .font(.subheadline.weight(.bold))174 .foregroundStyle(.white)175 .frame(width: 32, height: 32)176 .background(universe.accent, in: Circle())177 }178 .padding(16)179 }180 .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))181 .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous)182 .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.8), lineWidth: 1.5))183 .background(RoundedRectangle(cornerRadius: 18, style: .continuous)184 .fill(universe.accent.opacity(scheme == .dark ? 0.35 : 0.95))185 .offset(x: 6, y: 6))186 .accessibilityElement(children: .combine)187 .accessibilityLabel("\(universe.name) — \(universe.tagline)")188 }189}190191// MARK: - Héros de site (reproduction fidèle de la page d'accueil de l'univers)192193struct SiteHero: View {194 let universe: Universe195 let liveTotal: Int?196 var openSite: (() -> Void)? = nil197 @Environment(\.colorScheme) private var scheme198199 private var copy: HeroCopy? { KAHeroes.hero(universe.id) }200201 var body: some View {202 VStack(alignment: .leading, spacing: 12) {203 ticker204 if let c = copy {205 if let k = c.kicker {206 HStack(spacing: 10) {207 Rectangle().fill(KATheme.green).frame(width: 26, height: 2.5)208 Text(k.uppercased())209 .font(KAFont.mono(10))210 .foregroundStyle(scheme == .dark ? KATheme.lime : KATheme.green)211 .kerning(1.1)212 .lineLimit(2)213 }214 }215 headline(c)216 Text(c.sub)217 .font(.subheadline)218 .foregroundStyle(KATheme.ink2(scheme))219 .lineLimit(4)220 }221 HStack(spacing: 10) {222 statPill223 Spacer()224 if let openSite {225 Button(action: { Haptics.tap(); openSite() }) {226 Text("VOIR LE SITE ↗")227 .font(KAFont.mono(10))228 .foregroundStyle(universe.accent)229 .padding(.horizontal, 12).padding(.vertical, 8)230 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 6, style: .continuous))231 }232 .buttonStyle(KAPressStyle())233 .accessibilityLabel("Voir le site \(universe.name) dans l'app")234 }235 }236 }237 }238239 /// Bandeau ticker encre — la signature du haut des sites240 private var ticker: some View {241 HStack(spacing: 8) {242 KALogo(universe: universe, size: 18)243 Text(universe.unit.uppercased())244 .font(KAFont.mono(9.5))245 .foregroundStyle(.white.opacity(0.9))246 Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent)247 Text("EN DIRECT")248 .font(KAFont.mono(9.5))249 .foregroundStyle(universe.accent)250 Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent)251 Text(universe.domain.replacingOccurrences(of: "www.", with: "").uppercased())252 .font(KAFont.mono(9.5))253 .foregroundStyle(.white.opacity(0.75))254 .lineLimit(1)255 Spacer(minLength: 0)256 }257 .padding(.horizontal, 12).padding(.vertical, 8)258 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 8, style: .continuous))259 }260261 /// Titre display avec le segment surligné SUR l'accent (comme le h1 du site)262 private func headline(_ c: HeroCopy) -> some View {263 var attr = AttributedString(c.headline)264 attr.font = KAFont.display(27)265 attr.foregroundColor = KATheme.ink(scheme)266 if let hl = c.highlight,267 let range = attr.range(of: hl, options: .caseInsensitive) {268 attr[range].backgroundColor = universe.accent269 attr[range].foregroundColor = .white270 }271 return Text(attr).lineSpacing(2)272 }273274 private var statPill: some View {275 HStack(spacing: 7) {276 Circle().fill(universe.accent).frame(width: 8, height: 8)277 if let n = liveTotal {278 KACountUp(value: n, font: KAFont.display(15),279 color: KATheme.ink(scheme))280 Text(universe.unit)281 .font(.caption).foregroundStyle(KATheme.ink2(scheme))282 } else {283 Text("En direct").font(.caption).foregroundStyle(KATheme.ink2(scheme))284 }285 }286 .padding(.horizontal, 13).padding(.vertical, 8)287 .background(KATheme.surface(scheme), in: Capsule())288 .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5))289 .background(Capsule().fill(scheme == .dark ? Color.clear : KATheme.inkLight).offset(x: 4, y: 4))290 }291}292293// MARK: - Explorateur d'un univers294295struct UniverseHomeView: View {296 let universe: Universe297 @State private var items: [KAItem] = []298 @State private var query = ""299 @State private var loading = true300 @State private var errorText: String?301 @State private var filterParams: [String: String] = [:]302 @State private var liveTotal: Int?303 @State private var showSite = false304 @State private var cityFilter: String?305 @State private var cityPool: [String] = [] // top villes du premier chargement306 @Environment(\.colorScheme) private var scheme307308 var body: some View {309 Group {310 if universe.id == "vrai-prix" {311 VraiPrixView(universe: universe)312 } else if universe.id == "api-ka" {313 APIPlaygroundView(universe: universe)314 } else if universe.listPath == nil {315 webUniverse316 } else {317 list318 }319 }320 .background(KATheme.paper(scheme))321 .navigationTitle(universe.name)322 .navigationBarTitleDisplayMode(.inline)323 .toolbar {324 ToolbarItem(placement: .topBarTrailing) {325 Button { Haptics.tap(); showSite = true } label: {326 Image(systemName: "globe").accessibilityLabel("Voir le site \(universe.name) dans l'app")327 }328 }329 }330 .fullScreenCover(isPresented: $showSite) {331 SiteBrowserCover(universe: universe)332 }333 .tint(universe.accent)334 }335336 /// Univers sans liste native : le site lui-même, embarqué337 private var webUniverse: some View {338 EmbeddedSite(universe: universe)339 }340341 /// En-tête FIDÈLE au site : bande teintée accent + logo filigrane autour342 /// du héros (ticker encre, kicker, titre surligné, pilule live roulante).343 private var hero: some View {344 VStack(alignment: .leading, spacing: 0) {345 UniverseHeroBand(universe: universe) {346 SiteHero(universe: universe, liveTotal: liveTotal) { showSite = true }347 }348 FilterBar(universe: universe, params: $filterParams) {349 Task { await load() }350 }351 .padding(.top, 12)352 // les chips MÉTIER de l'univers (4½, Sushi, Télétravail, ≤ 300 k$…)353 UniverseQuickChips(universe: universe, params: $filterParams) {354 Task { await load() }355 }356 .padding(.top, 8)357 }358 }359360 private var list: some View {361 ScrollView {362 LazyVStack(spacing: 14) {363 hero364 if loading {365 ForEach(0..<6, id: \.self) { _ in KASkeletonRow() }366 } else if let e = errorText, items.isEmpty {367 KAEmptyState(symbol: "wifi.exclamationmark", title: "Impossible de charger",368 message: e + "\nTirez pour réessayer.")369 } else if items.isEmpty {370 if universe.id == "trouve-ka" && query.isEmpty {371 KAEmptyState(symbol: "magnifyingglass",372 title: "Cherchez le web québécois",373 message: "1,2 M de pages d'ici indexées — tapez un mot dans la barre de recherche ci-dessus.")374 } else {375 KAEmptyState(symbol: "tray", title: "Aucun résultat",376 message: "Essayez d'autres mots-clés.")377 }378 } else {379 UniverseStatStrip(universe: universe, liveTotal: liveTotal,380 shown: items.count,381 cityCount: Set(items.compactMap(\.city)).count)382 if cityPool.count > 1 {383 UniverseCityChips(cities: cityPool, selected: $cityFilter,384 accent: universe.accent) {385 Task { await load() }386 }387 }388 magazine389 }390 }391 .padding(16)392 .animation(.snappy(duration: 0.3), value: items)393 }394 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }395 .searchable(text: $query, prompt: "Chercher dans \(universe.name)…")396 .onSubmit(of: .search) { Task { await load() } }397 .refreshable { await load() }398 .task {399 await load()400 liveTotal = await UniverseService.liveTotal(universe)401 }402 }403404 /// Mise en page MAGAZINE quand l'univers a des photos : vitrine pleine405 /// largeur → carrousel « En vedette » → mosaïque 2 colonnes. Sans photos406 /// (Job·Ka, Trouve·Ka…) : liste éditoriale classique. Sorti·Ka a son407 /// propre rendu : l'AGENDA (sections par horizon temporel).408 @ViewBuilder409 private var magazine: some View {410 if universe.id == "sorti-ka" {411 agenda412 } else {413 magazineBody414 }415 }416417 /// L'agenda Sorti·Ka : les événements regroupés par quand ça se passe.418 @ViewBuilder419 private var agenda: some View {420 let groups = Dictionary(grouping: items) { EventBucket.bucket(iso: $0.date) }421 ForEach(EventBucket.allCases.sorted(), id: \.rawValue) { bucket in422 if let group = groups[bucket], !group.isEmpty {423 UniverseSectionHeader(title: bucket.label, accent: universe.accent,424 detail: "\(group.count)")425 let visuals = group.filter { $0.imageURL != nil }426 if visuals.count >= 2 {427 LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),428 GridItem(.flexible(), spacing: 12)], spacing: 12) {429 ForEach(group) { it in430 NavigationLink(value: it) {431 UniverseMosaicCell(item: it, universe: universe)432 }433 .buttonStyle(KAPressStyle())434 .kaScrollPop(axis: .vertical)435 }436 }437 } else {438 ForEach(group) { it in439 NavigationLink(value: it) { KAItemRow(item: it) }440 .buttonStyle(KAPressStyle())441 .kaScrollPop(axis: .vertical)442 }443 }444 }445 }446 }447448 @ViewBuilder449 private var magazineBody: some View {450 let visuals = items.filter { $0.imageURL != nil }451 if visuals.count >= 3, let showcase = visuals.first {452 let featured = Array(visuals.dropFirst().prefix(6))453 let usedIDs = Set([showcase.id] + featured.map(\.id))454 let rest = items.filter { !usedIDs.contains($0.id) }455456 NavigationLink(value: showcase) {457 UniverseShowcaseCard(item: showcase, universe: universe)458 }459 .buttonStyle(KAPressStyle())460 .kaScrollPop(axis: .vertical)461462 if !featured.isEmpty {463 UniverseSectionHeader(title: "En vedette", accent: universe.accent)464 ScrollView(.horizontal, showsIndicators: false) {465 HStack(spacing: 12) {466 ForEach(featured) { it in467 NavigationLink(value: it) {468 UniverseFeaturedCard(item: it, universe: universe)469 }470 .buttonStyle(KAPressStyle())471 .kaScrollPop()472 }473 }474 .scrollTargetLayout()475 .padding(.vertical, 4)476 }477 .scrollTargetBehavior(.viewAligned)478 }479480 if !rest.isEmpty {481 UniverseSectionHeader(title: "Tout le flux", accent: universe.accent,482 detail: "\(rest.count)")483 LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),484 GridItem(.flexible(), spacing: 12)], spacing: 12) {485 ForEach(rest) { it in486 NavigationLink(value: it) {487 UniverseMosaicCell(item: it, universe: universe)488 }489 .buttonStyle(KAPressStyle())490 .kaScrollPop(axis: .vertical)491 }492 }493 }494 } else {495 ForEach(items) { item in496 NavigationLink(value: item) { KAItemRow(item: item) }497 .buttonStyle(KAPressStyle())498 .kaScrollPop(axis: .vertical)499 }500 }501 }502503 private func load() async {504 loading = items.isEmpty505 errorText = nil506 do {507 items = try await UniverseService.fetch(universe,508 query: query.isEmpty ? nil : query,509 city: cityFilter,510 limit: 40, params: filterParams)511 // top villes mémorisées sur un chargement NON filtré (chips stables)512 if query.isEmpty, cityFilter == nil, filterParams.isEmpty {513 let counts = Dictionary(grouping: items.compactMap(\.city), by: { $0 })514 .mapValues(\.count)515 cityPool = counts.sorted { $0.value > $1.value }.prefix(8).map(\.key)516 }517 } catch {518 errorText = (error as? URLError)?.code == .notConnectedToInternet519 ? "Vous êtes hors ligne." : "Le service ne répond pas."520 }521 loading = false522 }523}524525// MARK: - Fiche universelle526527struct ItemDetailView: View {528 let item: KAItem529 @EnvironmentObject private var favorites: FavoritesStore530 @Environment(\.colorScheme) private var scheme531 @State private var showCollections = false532 @State private var menu: [RestoMenuSection] = []533 @State private var similar: [KAItem] = []534 @State private var fullScreenImage: URL?535 /// Fiche COMPLÈTE chargée depuis l'API de détail du site (galerie entière,536 /// description longue, inclusions, caractéristiques, estimation Vrai-Prix…)537 @State private var enriched: KAItem?538 @State private var descExpanded = false539 @EnvironmentObject private var recents: RecentsStore540541 private var universe: Universe? { Ecosystem.universe(it.universeID) }542 /// L'item affiché : enrichi dès que l'API de détail répond, sinon la liste.543 private var it: KAItem { enriched ?? item }544 private var accent: Color { universe?.accent ?? KATheme.green }545 @Environment(\.openURL) private var openURL546547 /// L'action principale parle la langue de l'univers — pas un générique.548 private var sourceCTA: (label: String, symbol: String) {549 switch it.universeID {550 case "job-ka": return ("Postuler chez l'employeur", "paperplane.fill")551 case "lou-ka": return ("Voir l'annonce du logement", "key.fill")552 case "immo-ka": return ("Voir chez le courtier", "house.fill")553 case "auto-ka": return ("Voir chez le concessionnaire", "car.fill")554 case "resto-ka": return ("Fiche du restaurant", "fork.knife")555 case "sorti-ka": return ("Billets & infos", "ticket.fill")556 case "food-ka": return ("Voir en épicerie", "cart.fill")557 case "fabri-ka": return ("Voir la boutique", "bag.fill")558 default: return ("Voir à la source", "arrow.up.right.square")559 }560 }561562 var body: some View {563 ScrollView {564 VStack(alignment: .leading, spacing: 0) {565 if !it.imageURLs.isEmpty { heroGallery }566 contentCard567 }568 }569 .ignoresSafeArea(edges: it.imageURLs.isEmpty ? [] : .top)570 .background(KATheme.paper(scheme))571 .navigationBarTitleDisplayMode(.inline)572 .toolbarBackground(it.imageURLs.isEmpty ? .automatic : .hidden, for: .navigationBar)573 .toolbar {574 ToolbarItem(placement: .topBarTrailing) {575 if let url = it.url {576 ShareLink(item: url, subject: Text(it.title))577 }578 }579 }580 .safeAreaInset(edge: .bottom) {581 // barre d'action flottante — la safe area étendue du dock la pose582 // automatiquement au-dessus de lui, et le contenu défile net583 stickyBar584 .padding(.horizontal, 12)585 .padding(.bottom, 6)586 }587 .task {588 recents.record(item)589 PersonalizationStore.shared.record(.view, item: item)590 // fiche complète depuis l'API de détail du site (galerie entière,591 // description longue, inclusions, estimation Vrai-Prix, heures…)592 if let u = universe, enriched == nil,593 let full = await DetailService.enrich(item, universe: u) {594 withAnimation(.snappy) { enriched = full }595 }596 if it.universeID == "resto-ka" {597 let uid = String(it.id.dropFirst("resto-ka:".count))598 menu = await RestoMenuLoader.load(uid: uid)599 }600 if let u = universe, u.map != nil, similar.isEmpty {601 let batch = (try? await UniverseService.fetch(u, city: it.city, limit: 10)) ?? []602 similar = batch.filter { $0.id != it.id }.prefix(6).map { $0 }603 }604 }605 .fullScreenCover(item: $fullScreenImage) { img in606 ZoomableImageView(url: img, accent: universe?.accent ?? .gray)607 }608 .confirmationDialog("Ajouter à…", isPresented: $showCollections, titleVisibility: .visible) {609 ForEach(favorites.collections) { c in610 Button(c.name) { favorites.toggle(item, in: c.id) }611 }612 }613 .tint(universe?.accent)614 }615616 // MARK: héros photo PLEIN CADRE (jusque sous la barre d'état)617618 private var heroGallery: some View {619 TabView {620 ForEach(it.imageURLs, id: \.self) { img in621 KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")622 .onTapGesture { Haptics.tap(); fullScreenImage = img }623 .accessibilityLabel("Photo de \(it.title) — toucher pour agrandir")624 .accessibilityAddTraits(.isButton)625 }626 }627 .tabViewStyle(.page(indexDisplayMode: .never))628 .frame(height: 400)629 .clipped()630 // scrims : lisibilité du bouton retour en haut, fondu vers la carte en bas631 .overlay(alignment: .top) {632 LinearGradient(colors: [.black.opacity(0.38), .clear],633 startPoint: .top, endPoint: .bottom)634 .frame(height: 110)635 .allowsHitTesting(false)636 }637 .overlay(alignment: .bottomTrailing) {638 if it.imageURLs.count > 1 {639 Label("\(it.imageURLs.count) photos", systemImage: "photo.stack")640 .font(.system(size: 10, design: .monospaced).weight(.bold))641 .padding(.horizontal, 9).padding(.vertical, 5)642 .background(.ultraThinMaterial, in: Capsule())643 .padding(.trailing, 14)644 .padding(.bottom, 44)645 }646 }647 }648649 // MARK: barre d'action collante — prix + favori + le geste de l'univers650651 @ViewBuilder652 private var stickyBar: some View {653 HStack(spacing: 10) {654 VStack(alignment: .leading, spacing: 1) {655 if let p = it.priceLabel {656 Text(p)657 .font(KAFont.display(17))658 .foregroundStyle(accent)659 .lineLimit(1)660 .minimumScaleFactor(0.6)661 } else {662 Text(universe?.wordmark ?? "KA")663 .font(KAFont.display(15))664 .foregroundStyle(KATheme.ink(scheme))665 }666 if let c = it.city {667 Text(c).font(.caption2).foregroundStyle(.secondary).lineLimit(1)668 }669 }670 Spacer(minLength: 4)671 Button {672 if favorites.isFavorite(item) { favorites.toggle(item) }673 else if favorites.collections.count > 1 { showCollections = true }674 else { favorites.toggle(item) }675 } label: {676 Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart")677 .font(.headline)678 .foregroundStyle(favorites.isFavorite(item) ? .red : KATheme.ink(scheme))679 .frame(width: 44, height: 44)680 .background(KATheme.surface(scheme), in: Circle())681 .overlay(Circle().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1.2))682 }683 .accessibilityLabel(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris")684 if let url = it.url {685 Button {686 Haptics.tap()687 PersonalizationStore.shared.record(.openSource, item: it)688 openURL(url)689 } label: {690 Label(sourceCTA.label, systemImage: sourceCTA.symbol)691 .font(.subheadline.weight(.bold))692 .lineLimit(1)693 .minimumScaleFactor(0.75)694 .padding(.horizontal, 16)695 .frame(height: 44)696 .background(accent, in: Capsule())697 .foregroundStyle(.white)698 }699 .buttonStyle(KAPressStyle())700 }701 }702 .padding(.horizontal, 12)703 .padding(.vertical, 8)704 .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 18, style: .continuous))705 .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous)706 .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.4 : 0.85), lineWidth: 1.5))707 .background(RoundedRectangle(cornerRadius: 18, style: .continuous)708 .fill(accent.opacity(scheme == .dark ? 0.4 : 0.95))709 .offset(x: 5, y: 5))710 .shadow(color: .black.opacity(0.15), radius: 12, y: 6)711 }712713 // MARK: le contenu, en carte qui chevauche le héros714715 private var contentCard: some View {716 VStack(alignment: .leading, spacing: 16) {717 if let u = universe {718 HStack {719 KAChip(text: u.wordmark, accent: u.accent)720 Spacer()721 if enriched != nil {722 Text("FICHE COMPLÈTE")723 .font(KAFont.mono(8))724 .foregroundStyle(accent)725 .transition(.opacity)726 }727 }728 }729 Text(it.title).font(KAFont.display(23))730 if let sub = it.subtitle, !sub.isEmpty {731 Text(sub).font(.body).foregroundStyle(KATheme.ink2(scheme))732 }733 HStack(spacing: 10) {734 if let p = it.priceLabel {735 Text(p).font(KAFont.display(21))736 .foregroundStyle(accent)737 }738 if let c = it.city { KAChip(text: c) }739 }740741 // « En bref » — le digest de la fiche du site742 if let brief = it.brief, !brief.isEmpty {743 HStack(alignment: .top, spacing: 10) {744 Rectangle().fill(accent).frame(width: 3)745 VStack(alignment: .leading, spacing: 4) {746 Text("EN BREF")747 .font(KAFont.mono(9))748 .foregroundStyle(accent)749 Text(brief)750 .font(.subheadline)751 .foregroundStyle(KATheme.ink(scheme))752 }753 }754 .padding(13)755 .frame(maxWidth: .infinity, alignment: .leading)756 .background(accent.opacity(scheme == .dark ? 0.14 : 0.08),757 in: RoundedRectangle(cornerRadius: 12, style: .continuous))758 }759760 // étiquettes de la fiche (commodités, inclusions, équipements…)761 if let tags = it.tags, !tags.isEmpty {762 VStack(alignment: .leading, spacing: 8) {763 UniverseSectionHeader(title: "Caractéristiques", accent: accent,764 detail: "\(tags.count)")765 KAFlow(spacing: 7) {766 ForEach(tags, id: \.self) { t in767 Text(t)768 .font(.system(.caption2, design: .rounded, weight: .semibold))769 .padding(.horizontal, 10).padding(.vertical, 6)770 .background(accent.opacity(0.10), in: Capsule())771 .overlay(Capsule().strokeBorder(accent.opacity(0.35), lineWidth: 1))772 .foregroundStyle(KATheme.ink(scheme))773 }774 }775 }776 }777778 if let detail = it.detail, !detail.isEmpty {779 VStack(alignment: .leading, spacing: 6) {780 Text("À propos").font(.headline)781 Text(detail)782 .font(.subheadline)783 .foregroundStyle(KATheme.ink2(scheme))784 .lineLimit(descExpanded ? nil : 8)785 if detail.count > 350 {786 Button {787 withAnimation(.snappy) { descExpanded.toggle() }788 } label: {789 Text(descExpanded ? "Réduire ↑" : "Lire la suite ↓")790 .font(KAFont.mono(10))791 .foregroundStyle(accent)792 }793 }794 }795 .padding(14)796 .frame(maxWidth: .infinity, alignment: .leading)797 .kaCard()798 }799800 // fiche technique — grille 2 colonnes, comme le tableau du site801 if !it.facts.isEmpty {802 VStack(alignment: .leading, spacing: 8) {803 UniverseSectionHeader(title: "Détails", accent: accent,804 detail: "\(it.facts.count)")805 LazyVGrid(columns: [GridItem(.flexible(), spacing: 10),806 GridItem(.flexible(), spacing: 10)],807 alignment: .leading, spacing: 10) {808 ForEach(it.facts, id: \.self) { f in809 VStack(alignment: .leading, spacing: 3) {810 Text(f.label.uppercased())811 .font(KAFont.mono(8))812 .foregroundStyle(KATheme.ink2(scheme))813 .lineLimit(1)814 Text(f.value)815 .font(.subheadline.weight(.semibold))816 .foregroundStyle(KATheme.ink(scheme))817 .lineLimit(3)818 .multilineTextAlignment(.leading)819 }820 .padding(.horizontal, 11).padding(.vertical, 9)821 .frame(maxWidth: .infinity, minHeight: 54, alignment: .topLeading)822 .background(KATheme.surface(scheme),823 in: RoundedRectangle(cornerRadius: 10, style: .continuous))824 .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous)825 .strokeBorder(KATheme.ink(scheme).opacity(0.18), lineWidth: 1))826 .accessibilityElement(children: .combine)827 }828 }829 }830 }831832 // localisation sur la carte833 if let la = it.latitude, let lo = it.longitude {834 VStack(alignment: .leading, spacing: 8) {835 UniverseSectionHeader(title: "Sur la carte", accent: accent)836 Map(position: .constant(.region(MKCoordinateRegion(837 center: .init(latitude: la, longitude: lo),838 span: .init(latitudeDelta: 0.012, longitudeDelta: 0.012))))) {839 Marker(it.title, coordinate: .init(latitude: la, longitude: lo))840 .tint(accent)841 }842 .frame(height: 170)843 .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))844 .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous)845 .strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1.2))846 .allowsHitTesting(false)847 .accessibilityLabel("Emplacement de \(it.title) sur la carte")848 }849 }850851 if !it.links.isEmpty {852 VStack(alignment: .leading, spacing: 8) {853 Text("Comptes & liens").font(.headline)854 ForEach(it.links, id: \.self) { l in855 if let u = URL(string: l.value) {856 Link(destination: u) {857 HStack {858 Image(systemName: "link")859 Text(l.label).font(.subheadline.weight(.semibold))860 Spacer()861 Image(systemName: "arrow.up.right").font(.caption)862 }863 .padding(11)864 .background((universe?.accent ?? .gray).opacity(0.1), in: RoundedRectangle(cornerRadius: 10, style: .continuous))865 }866 .foregroundStyle(universe?.accent ?? .primary)867 }868 }869 }870 }871872 if !menu.isEmpty {873 VStack(alignment: .leading, spacing: 10) {874 HStack {875 Text("Menu & prix réels").font(.headline)876 Spacer()877 KAChip(text: "\(menu.reduce(0) { $0 + $1.items.count }) plats", accent: universe?.accent)878 }879 ForEach(menu.prefix(6)) { section in880 DisclosureGroup {881 VStack(spacing: 0) {882 ForEach(section.items, id: \.name) { dish in883 HStack(alignment: .top) {884 Text(dish.name).font(.subheadline)885 Spacer()886 if let p = dish.price {887 Text(p).font(.system(.subheadline, design: .rounded).weight(.bold))888 .foregroundStyle(universe?.accent ?? .primary)889 }890 }891 .padding(.vertical, 7)892 Divider().opacity(dish.name == section.items.last?.name ? 0 : 0.6)893 }894 }895 .padding(.top, 4)896 } label: {897 Text(section.name).font(.subheadline.weight(.bold))898 }899 .padding(.horizontal, 14).padding(.vertical, 8)900 .kaCard()901 }902 }903 }904905 // hypothèque estimée — l'ADN Immo·Ka (calcul local transparent)906 if it.universeID == "immo-ka", let price = it.price, price > 25_000 {907 MortgageCard(price: price, accent: accent)908 }909910 if let la = it.latitude, let lo = it.longitude {911 Button {912 Haptics.tap()913 let place = MKMapItem(placemark: MKPlacemark(coordinate: .init(latitude: la, longitude: lo)))914 place.name = it.title915 place.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault])916 } label: {917 Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill")918 .font(.headline)919 .frame(maxWidth: .infinity).padding(.vertical, 13)920 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous))921 .foregroundStyle(KATheme.lime)922 }923 .accessibilityLabel("Itinéraire vers \(it.title)")924 }925 if let src = it.url?.host() {926 Text("Source : \(src) — Groupe KA est un agrégateur, la transaction se fait chez la source originale.")927 .font(.caption2).foregroundStyle(.tertiary)928 }929930 if !similar.isEmpty {931 VStack(alignment: .leading, spacing: 10) {932 Text("Semblables\(it.city.map { " à \($0)" } ?? "")").font(.headline)933 ScrollView(.horizontal, showsIndicators: false) {934 HStack(spacing: 10) {935 ForEach(similar) { s in936 NavigationLink(value: s) {937 VStack(alignment: .leading, spacing: 0) {938 KAImage(url: s.imageURL, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")939 .frame(width: 150, height: 84).clipped()940 VStack(alignment: .leading, spacing: 2) {941 Text(s.title).font(.caption.weight(.semibold))942 .lineLimit(2, reservesSpace: true)943 if let p = s.priceLabel {944 Text(p).font(.system(.caption2, design: .rounded).weight(.bold))945 .foregroundStyle(universe?.accent ?? .primary)946 }947 }948 .padding(8)949 }950 .frame(width: 150, alignment: .topLeading)951 .kaCard(accent: universe?.accent)952 }953 .buttonStyle(KAPressStyle())954 }955 }956 .padding(.vertical, 4)957 }958 }959 }960 }961 .padding(16)962 .padding(.top, it.imageURLs.isEmpty ? 0 : 6)963 .frame(maxWidth: .infinity, alignment: .leading)964 .background(KATheme.paper(scheme),965 in: UnevenRoundedRectangle(topLeadingRadius: 26, topTrailingRadius: 26,966 style: .continuous))967 .overlay(alignment: .top) {968 // poignée discrète, façon feuille969 if !it.imageURLs.isEmpty {970 Capsule().fill(.tertiary).frame(width: 38, height: 4).padding(.top, 8)971 }972 }973 .offset(y: it.imageURLs.isEmpty ? 0 : -26)974 .padding(.bottom, it.imageURLs.isEmpty ? 0 : -26)975 }976}977