SPB Git

spb/ka-ios

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

Theme