// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // UniversesView.swift — la grille des 12 univers (cartes accent + compteur en // direct), l'explorateur générique d'un univers (liste + recherche) et la // fiche universelle (faits, favori, partage, lien web). import SwiftUI import SafariServices import MapKit extension URL: @retroactive Identifiable { public var id: String { absoluteString } } /// Visionneuse plein écran avec zoom (pincement + double-tap) struct ZoomableImageView: View { let url: URL let accent: Color @Environment(\.dismiss) private var dismiss @State private var scale: CGFloat = 1 @State private var lastScale: CGFloat = 1 var body: some View { ZStack(alignment: .topTrailing) { Color.black.ignoresSafeArea() KAImage(url: url, accent: accent) .aspectRatio(contentMode: .fit) .scaleEffect(scale) .gesture( MagnifyGesture() .onChanged { v in scale = min(max(lastScale * v.magnification, 1), 5) } .onEnded { _ in lastScale = scale } ) .onTapGesture(count: 2) { withAnimation(.snappy) { scale = scale > 1.5 ? 1 : 2.5; lastScale = scale } } .accessibilityLabel("Photo en plein écran, pincer pour zoomer") Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") .font(.title2).foregroundStyle(.white.opacity(0.85)) .padding(16) } .accessibilityLabel("Fermer la photo") } .statusBarHidden() } } // MARK: - Grille des univers struct UniversesView: View { @State private var totals: [String: Int] = [:] @Environment(\.colorScheme) private var scheme @State private var showMap = false @State private var path = NavigationPath() var body: some View { NavigationStack(path: $path) { ScrollView { VStack(alignment: .leading, spacing: 14) { // manchette éditoriale — l'index de l'écosystème VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Rectangle().fill(KATheme.lime).frame(width: 8, height: 8) Text("L'ÉCOSYSTÈME · \(Ecosystem.all.count) UNIVERS") .font(KAFont.mono(9.5)).kerning(1.1) .foregroundStyle(KATheme.ink2(scheme)) } Text("Explorer") .font(KAFont.display(36)) .foregroundStyle(KATheme.ink(scheme)) Text("Mille sites. Un seul KA.") .font(.subheadline) .foregroundStyle(KATheme.ink2(scheme)) } .padding(.bottom, 6) ForEach(Ecosystem.all) { u in NavigationLink(value: u.id) { UniverseBand(universe: u, total: totals[u.id]) } .buttonStyle(KAPressStyle()) .kaScrollPop(axis: .vertical) } } .padding(16) } .background { ZStack { KATheme.paper(scheme) KAAurora() } .ignoresSafeArea() } .navigationTitle("") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { showMap = true } label: { Image(systemName: "map.fill") } .accessibilityLabel("Carte unifiée de l'écosystème") } } .fullScreenCover(isPresented: $showMap) { UnifiedMapView() } .navigationDestination(for: String.self) { id in if let u = Ecosystem.universe(id) { UniverseHomeView(universe: u) } } .task { // pilotage debug/captures : ouvre directement un univers if let id = UserDefaults.standard.string(forKey: "ka.debug.universe"), Ecosystem.universe(id) != nil, path.isEmpty { path.append(id) } await withTaskGroup(of: (String, Int?).self) { group in for u in Ecosystem.all { group.addTask { (u.id, await UniverseService.liveTotal(u)) } } for await (id, n) in group { if let n { totals[id] = n } } } } } } } /// Bande éditoriale d'un univers — l'index magazine de l'écosystème : /// gradient à l'accent, logo en filigrane, grand wordmark, compteur roulant. struct UniverseBand: View { let universe: Universe let total: Int? @Environment(\.colorScheme) private var scheme var body: some View { ZStack(alignment: .topTrailing) { RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(LinearGradient( colors: [universe.accent.opacity(scheme == .dark ? 0.30 : 0.20), universe.accent.opacity(scheme == .dark ? 0.08 : 0.03)], startPoint: .topTrailing, endPoint: .bottomLeading)) .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) KALogo(universe: universe, size: 120) .opacity(scheme == .dark ? 0.14 : 0.10) .rotationEffect(.degrees(9)) .offset(x: 26, y: -18) .accessibilityHidden(true) HStack(spacing: 12) { VStack(alignment: .leading, spacing: 5) { KAWordmark(universe: universe, size: 23) Text(universe.tagline) .font(.caption) .foregroundStyle(KATheme.ink2(scheme)) .lineLimit(2) .multilineTextAlignment(.leading) HStack(spacing: 7) { Circle().fill(universe.accent).frame(width: 7, height: 7) if let total { KACountUp(value: total, font: KAFont.display(15), color: universe.accent) Text(universe.unit.uppercased()) .font(KAFont.mono(8)) .foregroundStyle(KATheme.ink2(scheme)) .lineLimit(1) } else { Text("EN DIRECT") .font(KAFont.mono(8)) .foregroundStyle(universe.accent) } } .padding(.top, 2) } Spacer(minLength: 0) Image(systemName: "arrow.right") .font(.subheadline.weight(.bold)) .foregroundStyle(.white) .frame(width: 32, height: 32) .background(universe.accent, in: Circle()) } .padding(16) } .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.8), lineWidth: 1.5)) .background(RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(universe.accent.opacity(scheme == .dark ? 0.35 : 0.95)) .offset(x: 6, y: 6)) .accessibilityElement(children: .combine) .accessibilityLabel("\(universe.name) — \(universe.tagline)") } } // MARK: - Héros de site (reproduction fidèle de la page d'accueil de l'univers) struct SiteHero: View { let universe: Universe let liveTotal: Int? var openSite: (() -> Void)? = nil @Environment(\.colorScheme) private var scheme private var copy: HeroCopy? { KAHeroes.hero(universe.id) } var body: some View { VStack(alignment: .leading, spacing: 12) { ticker if let c = copy { if let k = c.kicker { HStack(spacing: 10) { Rectangle().fill(KATheme.green).frame(width: 26, height: 2.5) Text(k.uppercased()) .font(KAFont.mono(10)) .foregroundStyle(scheme == .dark ? KATheme.lime : KATheme.green) .kerning(1.1) .lineLimit(2) } } headline(c) Text(c.sub) .font(.subheadline) .foregroundStyle(KATheme.ink2(scheme)) .lineLimit(4) } HStack(spacing: 10) { statPill Spacer() if let openSite { Button(action: { Haptics.tap(); openSite() }) { Text("VOIR LE SITE ↗") .font(KAFont.mono(10)) .foregroundStyle(universe.accent) .padding(.horizontal, 12).padding(.vertical, 8) .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) } .buttonStyle(KAPressStyle()) .accessibilityLabel("Voir le site \(universe.name) dans l'app") } } } } /// Bandeau ticker encre — la signature du haut des sites private var ticker: some View { HStack(spacing: 8) { KALogo(universe: universe, size: 18) Text(universe.unit.uppercased()) .font(KAFont.mono(9.5)) .foregroundStyle(.white.opacity(0.9)) Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent) Text("EN DIRECT") .font(KAFont.mono(9.5)) .foregroundStyle(universe.accent) Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent) Text(universe.domain.replacingOccurrences(of: "www.", with: "").uppercased()) .font(KAFont.mono(9.5)) .foregroundStyle(.white.opacity(0.75)) .lineLimit(1) Spacer(minLength: 0) } .padding(.horizontal, 12).padding(.vertical, 8) .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } /// Titre display avec le segment surligné SUR l'accent (comme le h1 du site) private func headline(_ c: HeroCopy) -> some View { var attr = AttributedString(c.headline) attr.font = KAFont.display(27) attr.foregroundColor = KATheme.ink(scheme) if let hl = c.highlight, let range = attr.range(of: hl, options: .caseInsensitive) { attr[range].backgroundColor = universe.accent attr[range].foregroundColor = .white } return Text(attr).lineSpacing(2) } private var statPill: some View { HStack(spacing: 7) { Circle().fill(universe.accent).frame(width: 8, height: 8) if let n = liveTotal { KACountUp(value: n, font: KAFont.display(15), color: KATheme.ink(scheme)) Text(universe.unit) .font(.caption).foregroundStyle(KATheme.ink2(scheme)) } else { Text("En direct").font(.caption).foregroundStyle(KATheme.ink2(scheme)) } } .padding(.horizontal, 13).padding(.vertical, 8) .background(KATheme.surface(scheme), in: Capsule()) .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5)) .background(Capsule().fill(scheme == .dark ? Color.clear : KATheme.inkLight).offset(x: 4, y: 4)) } } // MARK: - Explorateur d'un univers struct UniverseHomeView: View { let universe: Universe @State private var items: [KAItem] = [] @State private var query = "" @State private var loading = true @State private var errorText: String? @State private var filterParams: [String: String] = [:] @State private var liveTotal: Int? @State private var showSite = false @State private var cityFilter: String? @State private var cityPool: [String] = [] // top villes du premier chargement @Environment(\.colorScheme) private var scheme var body: some View { Group { if universe.id == "vrai-prix" { VraiPrixView(universe: universe) } else if universe.id == "api-ka" { APIPlaygroundView(universe: universe) } else if universe.listPath == nil { webUniverse } else { list } } .background(KATheme.paper(scheme)) .navigationTitle(universe.name) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { Haptics.tap(); showSite = true } label: { Image(systemName: "globe").accessibilityLabel("Voir le site \(universe.name) dans l'app") } } } .fullScreenCover(isPresented: $showSite) { SiteBrowserCover(universe: universe) } .tint(universe.accent) } /// Univers sans liste native : le site lui-même, embarqué private var webUniverse: some View { EmbeddedSite(universe: universe) } /// En-tête FIDÈLE au site : bande teintée accent + logo filigrane autour /// du héros (ticker encre, kicker, titre surligné, pilule live roulante). private var hero: some View { VStack(alignment: .leading, spacing: 0) { UniverseHeroBand(universe: universe) { SiteHero(universe: universe, liveTotal: liveTotal) { showSite = true } } FilterBar(universe: universe, params: $filterParams) { Task { await load() } } .padding(.top, 12) // les chips MÉTIER de l'univers (4½, Sushi, Télétravail, ≤ 300 k$…) UniverseQuickChips(universe: universe, params: $filterParams) { Task { await load() } } .padding(.top, 8) } } private var list: some View { ScrollView { LazyVStack(spacing: 14) { hero if loading { ForEach(0..<6, id: \.self) { _ in KASkeletonRow() } } else if let e = errorText, items.isEmpty { KAEmptyState(symbol: "wifi.exclamationmark", title: "Impossible de charger", message: e + "\nTirez pour réessayer.") } else if items.isEmpty { if universe.id == "trouve-ka" && query.isEmpty { KAEmptyState(symbol: "magnifyingglass", title: "Cherchez le web québécois", message: "1,2 M de pages d'ici indexées — tapez un mot dans la barre de recherche ci-dessus.") } else { KAEmptyState(symbol: "tray", title: "Aucun résultat", message: "Essayez d'autres mots-clés.") } } else { UniverseStatStrip(universe: universe, liveTotal: liveTotal, shown: items.count, cityCount: Set(items.compactMap(\.city)).count) if cityPool.count > 1 { UniverseCityChips(cities: cityPool, selected: $cityFilter, accent: universe.accent) { Task { await load() } } } magazine } } .padding(16) .animation(.snappy(duration: 0.3), value: items) } .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } .searchable(text: $query, prompt: "Chercher dans \(universe.name)…") .onSubmit(of: .search) { Task { await load() } } .refreshable { await load() } .task { await load() liveTotal = await UniverseService.liveTotal(universe) } } /// Mise en page MAGAZINE quand l'univers a des photos : vitrine pleine /// largeur → carrousel « En vedette » → mosaïque 2 colonnes. Sans photos /// (Job·Ka, Trouve·Ka…) : liste éditoriale classique. Sorti·Ka a son /// propre rendu : l'AGENDA (sections par horizon temporel). @ViewBuilder private var magazine: some View { if universe.id == "sorti-ka" { agenda } else { magazineBody } } /// L'agenda Sorti·Ka : les événements regroupés par quand ça se passe. @ViewBuilder private var agenda: some View { let groups = Dictionary(grouping: items) { EventBucket.bucket(iso: $0.date) } ForEach(EventBucket.allCases.sorted(), id: \.rawValue) { bucket in if let group = groups[bucket], !group.isEmpty { UniverseSectionHeader(title: bucket.label, accent: universe.accent, detail: "\(group.count)") let visuals = group.filter { $0.imageURL != nil } if visuals.count >= 2 { LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { ForEach(group) { it in NavigationLink(value: it) { UniverseMosaicCell(item: it, universe: universe) } .buttonStyle(KAPressStyle()) .kaScrollPop(axis: .vertical) } } } else { ForEach(group) { it in NavigationLink(value: it) { KAItemRow(item: it) } .buttonStyle(KAPressStyle()) .kaScrollPop(axis: .vertical) } } } } } @ViewBuilder private var magazineBody: some View { let visuals = items.filter { $0.imageURL != nil } if visuals.count >= 3, let showcase = visuals.first { let featured = Array(visuals.dropFirst().prefix(6)) let usedIDs = Set([showcase.id] + featured.map(\.id)) let rest = items.filter { !usedIDs.contains($0.id) } NavigationLink(value: showcase) { UniverseShowcaseCard(item: showcase, universe: universe) } .buttonStyle(KAPressStyle()) .kaScrollPop(axis: .vertical) if !featured.isEmpty { UniverseSectionHeader(title: "En vedette", accent: universe.accent) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(featured) { it in NavigationLink(value: it) { UniverseFeaturedCard(item: it, universe: universe) } .buttonStyle(KAPressStyle()) .kaScrollPop() } } .scrollTargetLayout() .padding(.vertical, 4) } .scrollTargetBehavior(.viewAligned) } if !rest.isEmpty { UniverseSectionHeader(title: "Tout le flux", accent: universe.accent, detail: "\(rest.count)") LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { ForEach(rest) { it in NavigationLink(value: it) { UniverseMosaicCell(item: it, universe: universe) } .buttonStyle(KAPressStyle()) .kaScrollPop(axis: .vertical) } } } } else { ForEach(items) { item in NavigationLink(value: item) { KAItemRow(item: item) } .buttonStyle(KAPressStyle()) .kaScrollPop(axis: .vertical) } } } private func load() async { loading = items.isEmpty errorText = nil do { items = try await UniverseService.fetch(universe, query: query.isEmpty ? nil : query, city: cityFilter, limit: 40, params: filterParams) // top villes mémorisées sur un chargement NON filtré (chips stables) if query.isEmpty, cityFilter == nil, filterParams.isEmpty { let counts = Dictionary(grouping: items.compactMap(\.city), by: { $0 }) .mapValues(\.count) cityPool = counts.sorted { $0.value > $1.value }.prefix(8).map(\.key) } } catch { errorText = (error as? URLError)?.code == .notConnectedToInternet ? "Vous êtes hors ligne." : "Le service ne répond pas." } loading = false } } // MARK: - Fiche universelle struct ItemDetailView: View { let item: KAItem @EnvironmentObject private var favorites: FavoritesStore @Environment(\.colorScheme) private var scheme @State private var showCollections = false @State private var menu: [RestoMenuSection] = [] @State private var similar: [KAItem] = [] @State private var fullScreenImage: URL? /// Fiche COMPLÈTE chargée depuis l'API de détail du site (galerie entière, /// description longue, inclusions, caractéristiques, estimation Vrai-Prix…) @State private var enriched: KAItem? @State private var descExpanded = false @EnvironmentObject private var recents: RecentsStore private var universe: Universe? { Ecosystem.universe(it.universeID) } /// L'item affiché : enrichi dès que l'API de détail répond, sinon la liste. private var it: KAItem { enriched ?? item } private var accent: Color { universe?.accent ?? KATheme.green } @Environment(\.openURL) private var openURL /// L'action principale parle la langue de l'univers — pas un générique. private var sourceCTA: (label: String, symbol: String) { switch it.universeID { case "job-ka": return ("Postuler chez l'employeur", "paperplane.fill") case "lou-ka": return ("Voir l'annonce du logement", "key.fill") case "immo-ka": return ("Voir chez le courtier", "house.fill") case "auto-ka": return ("Voir chez le concessionnaire", "car.fill") case "resto-ka": return ("Fiche du restaurant", "fork.knife") case "sorti-ka": return ("Billets & infos", "ticket.fill") case "food-ka": return ("Voir en épicerie", "cart.fill") case "fabri-ka": return ("Voir la boutique", "bag.fill") default: return ("Voir à la source", "arrow.up.right.square") } } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { if !it.imageURLs.isEmpty { heroGallery } contentCard } } .ignoresSafeArea(edges: it.imageURLs.isEmpty ? [] : .top) .background(KATheme.paper(scheme)) .navigationBarTitleDisplayMode(.inline) .toolbarBackground(it.imageURLs.isEmpty ? .automatic : .hidden, for: .navigationBar) .toolbar { ToolbarItem(placement: .topBarTrailing) { if let url = it.url { ShareLink(item: url, subject: Text(it.title)) } } } .safeAreaInset(edge: .bottom) { // barre d'action flottante — la safe area étendue du dock la pose // automatiquement au-dessus de lui, et le contenu défile net stickyBar .padding(.horizontal, 12) .padding(.bottom, 6) } .task { recents.record(item) PersonalizationStore.shared.record(.view, item: item) // fiche complète depuis l'API de détail du site (galerie entière, // description longue, inclusions, estimation Vrai-Prix, heures…) if let u = universe, enriched == nil, let full = await DetailService.enrich(item, universe: u) { withAnimation(.snappy) { enriched = full } } if it.universeID == "resto-ka" { let uid = String(it.id.dropFirst("resto-ka:".count)) menu = await RestoMenuLoader.load(uid: uid) } if let u = universe, u.map != nil, similar.isEmpty { let batch = (try? await UniverseService.fetch(u, city: it.city, limit: 10)) ?? [] similar = batch.filter { $0.id != it.id }.prefix(6).map { $0 } } } .fullScreenCover(item: $fullScreenImage) { img in ZoomableImageView(url: img, accent: universe?.accent ?? .gray) } .confirmationDialog("Ajouter à…", isPresented: $showCollections, titleVisibility: .visible) { ForEach(favorites.collections) { c in Button(c.name) { favorites.toggle(item, in: c.id) } } } .tint(universe?.accent) } // MARK: héros photo PLEIN CADRE (jusque sous la barre d'état) private var heroGallery: some View { TabView { ForEach(it.imageURLs, id: \.self) { img in KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo") .onTapGesture { Haptics.tap(); fullScreenImage = img } .accessibilityLabel("Photo de \(it.title) — toucher pour agrandir") .accessibilityAddTraits(.isButton) } } .tabViewStyle(.page(indexDisplayMode: .never)) .frame(height: 400) .clipped() // scrims : lisibilité du bouton retour en haut, fondu vers la carte en bas .overlay(alignment: .top) { LinearGradient(colors: [.black.opacity(0.38), .clear], startPoint: .top, endPoint: .bottom) .frame(height: 110) .allowsHitTesting(false) } .overlay(alignment: .bottomTrailing) { if it.imageURLs.count > 1 { Label("\(it.imageURLs.count) photos", systemImage: "photo.stack") .font(.system(size: 10, design: .monospaced).weight(.bold)) .padding(.horizontal, 9).padding(.vertical, 5) .background(.ultraThinMaterial, in: Capsule()) .padding(.trailing, 14) .padding(.bottom, 44) } } } // MARK: barre d'action collante — prix + favori + le geste de l'univers @ViewBuilder private var stickyBar: some View { HStack(spacing: 10) { VStack(alignment: .leading, spacing: 1) { if let p = it.priceLabel { Text(p) .font(KAFont.display(17)) .foregroundStyle(accent) .lineLimit(1) .minimumScaleFactor(0.6) } else { Text(universe?.wordmark ?? "KA") .font(KAFont.display(15)) .foregroundStyle(KATheme.ink(scheme)) } if let c = it.city { Text(c).font(.caption2).foregroundStyle(.secondary).lineLimit(1) } } Spacer(minLength: 4) Button { if favorites.isFavorite(item) { favorites.toggle(item) } else if favorites.collections.count > 1 { showCollections = true } else { favorites.toggle(item) } } label: { Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart") .font(.headline) .foregroundStyle(favorites.isFavorite(item) ? .red : KATheme.ink(scheme)) .frame(width: 44, height: 44) .background(KATheme.surface(scheme), in: Circle()) .overlay(Circle().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1.2)) } .accessibilityLabel(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris") if let url = it.url { Button { Haptics.tap() PersonalizationStore.shared.record(.openSource, item: it) openURL(url) } label: { Label(sourceCTA.label, systemImage: sourceCTA.symbol) .font(.subheadline.weight(.bold)) .lineLimit(1) .minimumScaleFactor(0.75) .padding(.horizontal, 16) .frame(height: 44) .background(accent, in: Capsule()) .foregroundStyle(.white) } .buttonStyle(KAPressStyle()) } } .padding(.horizontal, 12) .padding(.vertical, 8) .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.4 : 0.85), lineWidth: 1.5)) .background(RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(accent.opacity(scheme == .dark ? 0.4 : 0.95)) .offset(x: 5, y: 5)) .shadow(color: .black.opacity(0.15), radius: 12, y: 6) } // MARK: le contenu, en carte qui chevauche le héros private var contentCard: some View { VStack(alignment: .leading, spacing: 16) { if let u = universe { HStack { KAChip(text: u.wordmark, accent: u.accent) Spacer() if enriched != nil { Text("FICHE COMPLÈTE") .font(KAFont.mono(8)) .foregroundStyle(accent) .transition(.opacity) } } } Text(it.title).font(KAFont.display(23)) if let sub = it.subtitle, !sub.isEmpty { Text(sub).font(.body).foregroundStyle(KATheme.ink2(scheme)) } HStack(spacing: 10) { if let p = it.priceLabel { Text(p).font(KAFont.display(21)) .foregroundStyle(accent) } if let c = it.city { KAChip(text: c) } } // « En bref » — le digest de la fiche du site if let brief = it.brief, !brief.isEmpty { HStack(alignment: .top, spacing: 10) { Rectangle().fill(accent).frame(width: 3) VStack(alignment: .leading, spacing: 4) { Text("EN BREF") .font(KAFont.mono(9)) .foregroundStyle(accent) Text(brief) .font(.subheadline) .foregroundStyle(KATheme.ink(scheme)) } } .padding(13) .frame(maxWidth: .infinity, alignment: .leading) .background(accent.opacity(scheme == .dark ? 0.14 : 0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } // étiquettes de la fiche (commodités, inclusions, équipements…) if let tags = it.tags, !tags.isEmpty { VStack(alignment: .leading, spacing: 8) { UniverseSectionHeader(title: "Caractéristiques", accent: accent, detail: "\(tags.count)") KAFlow(spacing: 7) { ForEach(tags, id: \.self) { t in Text(t) .font(.system(.caption2, design: .rounded, weight: .semibold)) .padding(.horizontal, 10).padding(.vertical, 6) .background(accent.opacity(0.10), in: Capsule()) .overlay(Capsule().strokeBorder(accent.opacity(0.35), lineWidth: 1)) .foregroundStyle(KATheme.ink(scheme)) } } } } if let detail = it.detail, !detail.isEmpty { VStack(alignment: .leading, spacing: 6) { Text("À propos").font(.headline) Text(detail) .font(.subheadline) .foregroundStyle(KATheme.ink2(scheme)) .lineLimit(descExpanded ? nil : 8) if detail.count > 350 { Button { withAnimation(.snappy) { descExpanded.toggle() } } label: { Text(descExpanded ? "Réduire ↑" : "Lire la suite ↓") .font(KAFont.mono(10)) .foregroundStyle(accent) } } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .kaCard() } // fiche technique — grille 2 colonnes, comme le tableau du site if !it.facts.isEmpty { VStack(alignment: .leading, spacing: 8) { UniverseSectionHeader(title: "Détails", accent: accent, detail: "\(it.facts.count)") LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], alignment: .leading, spacing: 10) { ForEach(it.facts, id: \.self) { f in VStack(alignment: .leading, spacing: 3) { Text(f.label.uppercased()) .font(KAFont.mono(8)) .foregroundStyle(KATheme.ink2(scheme)) .lineLimit(1) Text(f.value) .font(.subheadline.weight(.semibold)) .foregroundStyle(KATheme.ink(scheme)) .lineLimit(3) .multilineTextAlignment(.leading) } .padding(.horizontal, 11).padding(.vertical, 9) .frame(maxWidth: .infinity, minHeight: 54, alignment: .topLeading) .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(KATheme.ink(scheme).opacity(0.18), lineWidth: 1)) .accessibilityElement(children: .combine) } } } } // localisation sur la carte if let la = it.latitude, let lo = it.longitude { VStack(alignment: .leading, spacing: 8) { UniverseSectionHeader(title: "Sur la carte", accent: accent) Map(position: .constant(.region(MKCoordinateRegion( center: .init(latitude: la, longitude: lo), span: .init(latitudeDelta: 0.012, longitudeDelta: 0.012))))) { Marker(it.title, coordinate: .init(latitude: la, longitude: lo)) .tint(accent) } .frame(height: 170) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1.2)) .allowsHitTesting(false) .accessibilityLabel("Emplacement de \(it.title) sur la carte") } } if !it.links.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Comptes & liens").font(.headline) ForEach(it.links, id: \.self) { l in if let u = URL(string: l.value) { Link(destination: u) { HStack { Image(systemName: "link") Text(l.label).font(.subheadline.weight(.semibold)) Spacer() Image(systemName: "arrow.up.right").font(.caption) } .padding(11) .background((universe?.accent ?? .gray).opacity(0.1), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } .foregroundStyle(universe?.accent ?? .primary) } } } } if !menu.isEmpty { VStack(alignment: .leading, spacing: 10) { HStack { Text("Menu & prix réels").font(.headline) Spacer() KAChip(text: "\(menu.reduce(0) { $0 + $1.items.count }) plats", accent: universe?.accent) } ForEach(menu.prefix(6)) { section in DisclosureGroup { VStack(spacing: 0) { ForEach(section.items, id: \.name) { dish in HStack(alignment: .top) { Text(dish.name).font(.subheadline) Spacer() if let p = dish.price { Text(p).font(.system(.subheadline, design: .rounded).weight(.bold)) .foregroundStyle(universe?.accent ?? .primary) } } .padding(.vertical, 7) Divider().opacity(dish.name == section.items.last?.name ? 0 : 0.6) } } .padding(.top, 4) } label: { Text(section.name).font(.subheadline.weight(.bold)) } .padding(.horizontal, 14).padding(.vertical, 8) .kaCard() } } } // hypothèque estimée — l'ADN Immo·Ka (calcul local transparent) if it.universeID == "immo-ka", let price = it.price, price > 25_000 { MortgageCard(price: price, accent: accent) } if let la = it.latitude, let lo = it.longitude { Button { Haptics.tap() let place = MKMapItem(placemark: MKPlacemark(coordinate: .init(latitude: la, longitude: lo))) place.name = it.title place.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault]) } label: { Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill") .font(.headline) .frame(maxWidth: .infinity).padding(.vertical, 13) .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .foregroundStyle(KATheme.lime) } .accessibilityLabel("Itinéraire vers \(it.title)") } if let src = it.url?.host() { Text("Source : \(src) — Groupe KA est un agrégateur, la transaction se fait chez la source originale.") .font(.caption2).foregroundStyle(.tertiary) } if !similar.isEmpty { VStack(alignment: .leading, spacing: 10) { Text("Semblables\(it.city.map { " à \($0)" } ?? "")").font(.headline) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(similar) { s in NavigationLink(value: s) { VStack(alignment: .leading, spacing: 0) { KAImage(url: s.imageURL, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo") .frame(width: 150, height: 84).clipped() VStack(alignment: .leading, spacing: 2) { Text(s.title).font(.caption.weight(.semibold)) .lineLimit(2, reservesSpace: true) if let p = s.priceLabel { Text(p).font(.system(.caption2, design: .rounded).weight(.bold)) .foregroundStyle(universe?.accent ?? .primary) } } .padding(8) } .frame(width: 150, alignment: .topLeading) .kaCard(accent: universe?.accent) } .buttonStyle(KAPressStyle()) } } .padding(.vertical, 4) } } } } .padding(16) .padding(.top, it.imageURLs.isEmpty ? 0 : 6) .frame(maxWidth: .infinity, alignment: .leading) .background(KATheme.paper(scheme), in: UnevenRoundedRectangle(topLeadingRadius: 26, topTrailingRadius: 26, style: .continuous)) .overlay(alignment: .top) { // poignée discrète, façon feuille if !it.imageURLs.isEmpty { Capsule().fill(.tertiary).frame(width: 38, height: 4).padding(.top, 8) } } .offset(y: it.imageURLs.isEmpty ? 0 : -26) .padding(.bottom, it.imageURLs.isEmpty ? 0 : -26) } }