SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%
44.4 KB · 977 lines swift
Raw Blame History
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