SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%

v3.0.0 — fiches individuelles COMPLÈTES comme les sites : DetailService lit l'endpoint de détail de chaque univers (<liste>/<uid>) et fusionne avec l'item de liste (id conservé pour favoris/historique) — galerie entière (20 photos autos), description longue dépliable « Lire la suite », EN BREF (digest Lou-Ka), étiquettes en flux KAFlow (commodités/inclusions/électros/équipements/cuisines/services/bénéfices), fiche technique en GRILLE 2 colonnes (année, terrain, MLS, courtier, estimation Vrai-Prix avec fourchette, VIN/moteur/couleurs, heures d'ouverture resto, salaire/date limite job, prix unitaire/vendeur épicerie…), liens Carfax/site événement/site resto, carte Apple Maps avec marqueur accent, badge FICHE COMPLÈTE ; dédup par libellé ET valeur (fini ZIPCODE vs Code postal), repli générique details.* traduit ; KAItem.tags/brief optionnels (favoris sérialisés compatibles) ; validé Lou-Ka + Immo-Ka simulateur, 12 tests verts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent 4dc4293

4 changed files +404 −41

added KA/Core/DetailService.swift +223 −0
@@ -0,0 +1,223 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// DetailService.swift — la fiche COMPLÈTE, comme sur le site : chaque univers
3 +// expose un endpoint de détail (`<liste>/<uid>`) beaucoup plus riche que la
4 +// liste (galerie complète, description longue, inclusions, caractéristiques,
5 +// estimation Vrai-Prix, VIN, heures d'ouverture…). Ce service le lit, refait
6 +// passer l'objet dans le mapping de l'univers, puis fusionne avec l'item de
7 +// liste (l'id est conservé — clé des favoris et de l'historique).
8 +import Foundation
9 +
10 +enum DetailService {
11 + struct Extra {
12 + var facts: [KAItem.Fact] = []
13 + var tags: [String] = []
14 + var links: [KAItem.Fact] = []
15 + var brief: String?
16 + }
17 +
18 + /// Fiche enrichie depuis l'API de détail du site (nil si indisponible).
19 + static func enrich(_ item: KAItem, universe u: Universe) async -> KAItem? {
20 + guard let map = u.map,
21 + let basePath = u.listPath?.split(separator: "?").first.map(String.init)
22 + else { return nil }
23 + let uidPart = String(item.id.dropFirst(u.id.count + 1))
24 + guard !uidPart.isEmpty else { return nil }
25 + let enc = uidPart.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)?
26 + .replacingOccurrences(of: "/", with: "%2F") ?? uidPart
27 + guard let url = URL(string: "https://\(u.domain)\(basePath)/\(enc)"),
28 + let root = try? await APIClient.shared.json(url, ttl: 300)
29 + else { return nil }
30 + // certains endpoints enveloppent l'objet (listing/item/…)
31 + var obj = root.object ?? [:]
32 + for key in ["listing", "item", "vehicle", "job", "event", "product", "restaurant", "creator"] {
33 + if let inner = obj[key]?.object { obj = inner; break }
34 + }
35 + guard !obj.isEmpty else { return nil }
36 +
37 + var full = map(obj) ?? item
38 + full.id = item.id
39 + // fusion douce : ne jamais perdre ce que la liste savait déjà
40 + if full.imageURLs.isEmpty { full.imageURLs = item.imageURLs; full.imageURL = item.imageURL }
41 + if (full.detail ?? "").isEmpty { full.detail = item.detail }
42 + if full.priceLabel == nil { full.priceLabel = item.priceLabel }
43 + if (full.subtitle ?? "").isEmpty { full.subtitle = item.subtitle }
44 + if full.city == nil { full.city = item.city }
45 + if full.url == nil { full.url = item.url }
46 + if full.latitude == nil { full.latitude = item.latitude; full.longitude = item.longitude }
47 + if full.links.isEmpty { full.links = item.links }
48 +
49 + let extra = extraContent(u.id, obj)
50 + var seenLabels = Set(full.facts.map { $0.label.lowercased() })
51 + var seenValues = Set(full.facts.map { $0.value.lowercased() })
52 + for f in extra.facts where !f.value.isEmpty
53 + && !seenLabels.contains(f.label.lowercased())
54 + && !seenValues.contains(f.value.lowercased()) {
55 + full.facts.append(f)
56 + seenLabels.insert(f.label.lowercased())
57 + seenValues.insert(f.value.lowercased())
58 + }
59 + if !extra.tags.isEmpty { full.tags = Array(extra.tags.prefix(24)) }
60 + full.brief = extra.brief
61 + for l in extra.links where !full.links.contains(l) { full.links.append(l) }
62 + return full
63 + }
64 +
65 + // MARK: - extraction par univers (faits, étiquettes, liens, en-bref)
66 +
67 + private static func extraContent(_ id: String, _ o: [String: JSONValue]) -> Extra {
68 + var e = Extra()
69 +
70 + func fact(_ label: String, _ value: String?) {
71 + if let v = value, !v.isEmpty { e.facts.append(.init(label: label, value: v)) }
72 + }
73 + func yes(_ v: JSONValue?) -> Bool { if case .bool(true) = v ?? .null { return true }; return false }
74 + func strings(_ v: JSONValue?) -> [String] {
75 + (v?.array ?? []).compactMap(\.text).filter { !$0.isEmpty }
76 + }
77 + let details = o["details"]?.object ?? [:]
78 +
79 + switch id {
80 + case "lou-ka":
81 + e.brief = o["digest"]?.object?["en_bref"]?.text
82 + fact("Secteur", o.str("sector"))
83 + fact("Disponible", o.str("availability_date", "availability")
84 + .map { $0 == "now" ? "Dès maintenant" : $0 })
85 + fact("Animaux", yes(o["pets"]) ? "Acceptés" : o["pets"].flatMap { if case .bool(false) = $0 { return "Non acceptés" }; return nil })
86 + fact("Meublé", yes(o["furnished"]) ? "Oui" : nil)
87 + fact("Immeuble", details.str("building_name"))
88 + fact("Gestionnaire", details.str("brokerage_name"))
89 + fact("Code postal", details.str("zipcode"))
90 + e.tags = strings(o["amenities"])
91 + if let inc = details["inclusions"]?.object {
92 + if yes(inc["heating"]) { e.tags.append("Chauffage inclus") }
93 + if yes(inc["electricity"]) { e.tags.append("Électricité incluse") }
94 + if yes(inc["hot_water"]) { e.tags.append("Eau chaude incluse") }
95 + if yes(inc["internet"]) { e.tags.append("Internet inclus") }
96 + }
97 + if let app = details["appliances"]?.object {
98 + if yes(app["fridge"]) { e.tags.append("Frigo") }
99 + if yes(app["stove"]) { e.tags.append("Cuisinière") }
100 + if yes(app["dishwasher"]) { e.tags.append("Lave-vaisselle") }
101 + if yes(app["washer"]) { e.tags.append("Laveuse") }
102 + if yes(app["dryer"]) { e.tags.append("Sécheuse") }
103 + }
104 + if yes(details["parking"]?.object?["available"]) { e.tags.append("Stationnement") }
105 +
106 + case "immo-ka":
107 + fact("Année de construction", o.num("year_built").flatMap { $0 > 1500 ? String(Int($0)) : nil })
108 + fact("Terrain", o.num("lot_sqft").flatMap { $0 > 50 ? "\(Int($0)) pi²" : nil })
109 + fact("Salles d'eau", o.num("powder_rooms").map { String(Int($0)) })
110 + fact("Statut", o.str("status"))
111 + fact("MLS", o.str("mls"))
112 + fact("Courtier", o.str("broker_name"))
113 + fact("Agence", o.str("agency"))
114 + fact("Secteur", o.str("sector"))
115 + fact("Région", o.str("region"))
116 + if let vp = o["vraiprix"]?.object, let v = vp.num("value"), v > 10_000 {
117 + var s = v.money0
118 + if let lo = vp.num("low"), let hi = vp.num("high"), hi > lo {
119 + s += " (\(lo.money0)–\(hi.money0))"
120 + }
121 + fact("Estimation Vrai-Prix", s)
122 + }
123 + e.tags = strings(o["features"])
124 +
125 + case "auto-ka":
126 + fact("Version", o.str("trim"))
127 + fact("Moteur", o.str("engine"))
128 + fact("Couleur extérieure", o.str("exterior_color"))
129 + fact("Couleur intérieure", o.str("interior_color"))
130 + fact("Portes", o.num("doors").flatMap { $0 > 0 ? String(Int($0)) : nil })
131 + fact("Places", o.num("seats").flatMap { $0 > 0 ? String(Int($0)) : nil })
132 + fact("NIV", o.str("vin"))
133 + fact("No de stock", o.str("stock_number"))
134 + fact("Région", o.str("region"))
135 + fact("État", details.str("condition") == "used" ? "Occasion" : details.str("condition"))
136 + if let cf = o.str("carfax_url"), cf.hasPrefix("http") {
137 + e.links.append(.init(label: "Rapport Carfax", value: cf))
138 + }
139 + e.tags = strings(o["features"])
140 +
141 + case "food-ka":
142 + fact("Marque", o.str("brand"))
143 + fact("Format", o.str("size_label"))
144 + fact("Prix unitaire", o.str("unit_price_label"))
145 + fact("Prix régulier", o.num("regular_price").flatMap { $0 > 0.2 ? $0.money2 : nil })
146 + fact("En stock", yes(o["in_stock"]) ? "Oui" : nil)
147 + fact("Vendeur", details.str("seller"))
148 + fact("Rayon", o.str("category_raw", "category"))
149 + e.tags = strings(o["keywords"])
150 +
151 + case "sorti-ka":
152 + fact("Organisateur", o.str("organizer"))
153 + fact("Public", o.str("audience"))
154 + fact("Lieu", o.str("venue"))
155 + fact("Adresse", o.str("address"))
156 + fact("Région touristique", o.str("tourist_region"))
157 + fact("Code postal", o.str("postal_code"))
158 + if let site = o.str("website"), site.hasPrefix("http") {
159 + e.links.append(.init(label: "Site de l'événement", value: site))
160 + }
161 + e.tags = strings(o["categories"])
162 +
163 + case "job-ka":
164 + fact("Salaire affiché", o.str("salary_label"))
165 + fact("Lieu", o.str("location_label"))
166 + fact("Région", o.str("region"))
167 + fact("Postuler avant", o.str("date_deadline"))
168 + fact("Postes ouverts", details.num("positions").flatMap { $0 > 0 ? String(Int($0)) : nil })
169 + fact("Code postal", o.str("postal_code"))
170 + e.tags = strings(o["benefits"])
171 +
172 + case "fabri-ka":
173 + fact("Boutique", o.str("store_name", "store_id"))
174 + fact("Type de produit", o.str("product_type"))
175 + fact("Vendeur", o.str("vendor"))
176 + fact("Prix comparé", o.num("compare_at_price").flatMap { $0 > 0.2 ? $0.money2 : nil })
177 + fact("Disponible", yes(o["available"]) ? "Oui" : nil)
178 + e.tags = strings(o["tags"])
179 +
180 + case "resto-ka":
181 + fact("Type", o.str("establishment_type"))
182 + fact("Fourchette de prix", o.str("price_range"))
183 + fact("Téléphone", o.str("phone"))
184 + fact("Adresse", o.str("address"))
185 + fact("Chaîne", o.str("chain"))
186 + fact("Région", o.str("region"))
187 + if let site = o.str("website"), site.hasPrefix("http") {
188 + e.links.append(.init(label: "Site du restaurant", value: site))
189 + }
190 + if let hours = o["hours"]?.object {
191 + let days = ["monday": "Lun", "tuesday": "Mar", "wednesday": "Mer",
192 + "thursday": "Jeu", "friday": "Ven", "saturday": "Sam", "sunday": "Dim"]
193 + let order = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
194 + for d in order {
195 + if let h = hours[d]?.text, !h.isEmpty { fact(days[d] ?? d, h) }
196 + }
197 + }
198 + e.tags = strings(o["cuisines"]) + strings(o["services"])
199 + + strings(o["dietary_options"]) + strings(o["languages"])
200 +
201 + default:
202 + break
203 + }
204 +
205 + // repli générique : toute chaîne courte du bloc `details` devient un
206 + // fait — sauf si la MÊME VALEUR est déjà affichée sous un libellé
207 + // français (évite les doublons ZIPCODE/Code postal, etc.)
208 + let frLabels = ["listed_at": "Inscrite le", "neighborhood_name": "Quartier",
209 + "transaction": "Transaction", "employment_label": "Type d'emploi"]
210 + for (k, v) in details.sorted(by: { $0.key < $1.key }) {
211 + guard e.facts.count < 30, let t = v.text, !t.isEmpty, t.count < 90,
212 + !["condition"].contains(k) else { continue }
213 + let label = frLabels[k] ?? k.replacingOccurrences(of: "_", with: " ").capitalized
214 + let dupLabel = e.facts.contains { $0.label.caseInsensitiveCompare(label) == .orderedSame }
215 + let dupValue = e.facts.contains { $0.value.caseInsensitiveCompare(t) == .orderedSame }
216 + if !dupLabel && !dupValue {
217 + e.facts.append(.init(label: label, value: t))
218 + }
219 + }
220 + e.tags = e.tags.filter { $0.count < 40 }
221 + return e
222 + }
223 +}
modified KA/Core/Ecosystem.swift +6 −0
@@ -68,6 +68,12 @@ struct KAItem: Identifiable, Hashable, Codable {
68 68 var facts: [Fact]
69 69 /// Liens riches (boutons) — ex. les comptes d'un créateur
70 70 var links: [Fact] = []
71 + /// Étiquettes de la fiche complète (commodités, caractéristiques, cuisines…)
72 + /// — optionnelles : les favoris/historique sérialisés avant leur ajout
73 + /// doivent continuer à se décoder
74 + var tags: [String]? = nil
75 + /// Résumé « en bref » de la fiche (digest du site)
76 + var brief: String? = nil
71 77
72 78 struct Fact: Hashable, Codable { var label: String; var value: String }
73 79 }
modified KA/Design/KAMotion.swift +29 −0
@@ -211,6 +211,35 @@ extension View {
211 211 }
212 212 }
213 213
214 +// MARK: - Flux d'étiquettes (retour à la ligne naturel, façon chips des sites)
215 +
216 +struct KAFlow: Layout {
217 + var spacing: CGFloat = 8
218 +
219 + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
220 + let maxW = proposal.width ?? .infinity
221 + var x: CGFloat = 0, y: CGFloat = 0, rowH: CGFloat = 0
222 + for v in subviews {
223 + let s = v.sizeThatFits(.unspecified)
224 + if x + s.width > maxW, x > 0 { x = 0; y += rowH + spacing; rowH = 0 }
225 + x += s.width + spacing
226 + rowH = max(rowH, s.height)
227 + }
228 + return CGSize(width: maxW == .infinity ? max(0, x - spacing) : maxW, height: y + rowH)
229 + }
230 +
231 + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
232 + var x = bounds.minX, y = bounds.minY, rowH: CGFloat = 0
233 + for v in subviews {
234 + let s = v.sizeThatFits(.unspecified)
235 + if x + s.width > bounds.maxX, x > bounds.minX { x = bounds.minX; y += rowH + spacing; rowH = 0 }
236 + v.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
237 + x += s.width + spacing
238 + rowH = max(rowH, s.height)
239 + }
240 + }
241 +}
242 +
214 243 // MARK: - Aurora — deux lueurs lime/vert qui dérivent, très discrètes
215 244
216 245 struct KAAurora: View {
modified KA/Features/UniversesView.swift +146 −41
@@ -431,30 +431,37 @@ struct ItemDetailView: View {
431 431 @State private var menu: [RestoMenuSection] = []
432 432 @State private var similar: [KAItem] = []
433 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 = false
434 438 @EnvironmentObject private var recents: RecentsStore
435 439
436 − private var universe: Universe? { Ecosystem.universe(item.universeID) }
440 + 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 }
437 444
438 445 var body: some View {
439 446 ScrollView {
440 447 VStack(alignment: .leading, spacing: 16) {
441 − if !item.imageURLs.isEmpty {
448 + if !it.imageURLs.isEmpty {
442 449 TabView {
443 − ForEach(item.imageURLs, id: \.self) { img in
450 + ForEach(it.imageURLs, id: \.self) { img in
444 451 KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")
445 452 .onTapGesture { Haptics.tap(); fullScreenImage = img }
446 − .accessibilityLabel("Photo de \(item.title) — toucher pour agrandir")
453 + .accessibilityLabel("Photo de \(it.title) — toucher pour agrandir")
447 454 .accessibilityAddTraits(.isButton)
448 455 }
449 456 }
450 − .tabViewStyle(.page(indexDisplayMode: item.imageURLs.count > 1 ? .automatic : .never))
457 + .tabViewStyle(.page(indexDisplayMode: it.imageURLs.count > 1 ? .automatic : .never))
451 458 .frame(height: 240)
452 459 .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
453 460 .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous)
454 461 .strokeBorder(.primary.opacity(0.3), lineWidth: 1.2))
455 462 .overlay(alignment: .topTrailing) {
456 − if item.imageURLs.count > 1 {
457 − Text("\(item.imageURLs.count) photos")
463 + if it.imageURLs.count > 1 {
464 + Text("\(it.imageURLs.count) photos")
458 465 .font(.system(size: 10, design: .monospaced).weight(.bold))
459 466 .padding(.horizontal, 8).padding(.vertical, 4)
460 467 .background(.ultraThinMaterial, in: Capsule())
@@ -463,51 +470,143 @@ struct ItemDetailView: View {
463 470 }
464 471 }
465 472 if let u = universe {
466 − HStack { KAChip(text: u.wordmark, accent: u.accent); Spacer() }
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))
467 487 }
468 − Text(item.title).font(.title2.weight(.bold))
469 − if let sub = item.subtitle { Text(sub).font(.body).foregroundStyle(KATheme.ink2(scheme)) }
470 488 HStack(spacing: 10) {
471 − if let p = item.priceLabel {
472 − Text(p).font(.system(.title3, design: .rounded).weight(.bold))
473 − .foregroundStyle(universe?.accent ?? .primary)
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 + }
495 +
496 + // « En bref » — le digest de la fiche du site
497 + 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 + }
514 +
515 + // é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 in
522 + 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 + }
474 530 }
475 − if let c = item.city { KAChip(text: c) }
476 531 }
477 532
478 − if let detail = item.detail, !detail.isEmpty {
533 + if let detail = it.detail, !detail.isEmpty {
479 534 VStack(alignment: .leading, spacing: 6) {
480 535 Text("À propos").font(.headline)
481 536 Text(detail)
482 537 .font(.subheadline)
483 538 .foregroundStyle(KATheme.ink2(scheme))
484 − .lineLimit(12)
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 + }
485 549 }
486 550 .padding(14)
487 551 .frame(maxWidth: .infinity, alignment: .leading)
488 552 .kaCard()
489 553 }
490 554
491 − if !item.facts.isEmpty {
492 − VStack(spacing: 0) {
493 − ForEach(item.facts, id: \.self) { f in
494 − HStack {
495 − Text(f.label).font(.subheadline).foregroundStyle(.secondary)
496 − Spacer()
497 − Text(f.value).font(.subheadline.weight(.semibold))
498 − .multilineTextAlignment(.trailing)
555 + // fiche technique — grille 2 colonnes, comme le tableau du site
556 + 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 in
564 + 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)
499 582 }
500 − .padding(.vertical, 10).padding(.horizontal, 14)
501 − if f != item.facts.last { Divider() }
502 583 }
503 584 }
504 − .kaCard()
505 585 }
506 586
507 − if !item.links.isEmpty {
587 + // localisation sur la carte
588 + 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 + }
605 +
606 + if !it.links.isEmpty {
508 607 VStack(alignment: .leading, spacing: 8) {
509 608 Text("Comptes & liens").font(.headline)
510 − ForEach(item.links, id: \.self) { l in
609 + ForEach(it.links, id: \.self) { l in
511 610 if let u = URL(string: l.value) {
512 611 Link(destination: u) {
513 612 HStack {
@@ -559,7 +658,7 @@ struct ItemDetailView: View {
559 658 }
560 659
561 660 HStack(spacing: 10) {
562 − if let url = item.url {
661 + if let url = it.url {
563 662 Link(destination: url) {
564 663 Label("Voir à la source", systemImage: "arrow.up.right.square")
565 664 .font(.headline)
@@ -568,11 +667,11 @@ struct ItemDetailView: View {
568 667 .foregroundStyle(.white)
569 668 }
570 669 }
571 − if let la = item.latitude, let lo = item.longitude {
670 + if let la = it.latitude, let lo = it.longitude {
572 671 Button {
573 672 Haptics.tap()
574 673 let place = MKMapItem(placemark: MKPlacemark(coordinate: .init(latitude: la, longitude: lo)))
575 − place.name = item.title
674 + place.name = it.title
576 675 place.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault])
577 676 } label: {
578 677 Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
@@ -581,17 +680,17 @@ struct ItemDetailView: View {
581 680 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
582 681 .foregroundStyle(KATheme.lime)
583 682 }
584 − .accessibilityLabel("Itinéraire vers \(item.title)")
683 + .accessibilityLabel("Itinéraire vers \(it.title)")
585 684 }
586 685 }
587 − if let src = item.url?.host() {
686 + if let src = it.url?.host() {
588 687 Text("Source : \(src) — Groupe KA est un agrégateur, la transaction se fait chez la source originale.")
589 688 .font(.caption2).foregroundStyle(.tertiary)
590 689 }
591 690
592 691 if !similar.isEmpty {
593 692 VStack(alignment: .leading, spacing: 10) {
594 − Text("Semblables\(item.city.map { " à \($0)" } ?? "")").font(.headline)
693 + Text("Semblables\(it.city.map { " à \($0)" } ?? "")").font(.headline)
595 694 ScrollView(.horizontal, showsIndicators: false) {
596 695 HStack(spacing: 10) {
597 696 ForEach(similar) { s in
@@ -635,20 +734,26 @@ struct ItemDetailView: View {
635 734 .foregroundStyle(favorites.isFavorite(item) ? .red : .primary)
636 735 }
637 736 .accessibilityLabel(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris")
638 − if let url = item.url {
639 − ShareLink(item: url, subject: Text(item.title))
737 + if let url = it.url {
738 + ShareLink(item: url, subject: Text(it.title))
640 739 }
641 740 }
642 741 }
643 742 .task {
644 743 recents.record(item)
645 − if item.universeID == "resto-ka" {
646 − let uid = String(item.id.dropFirst("resto-ka:".count))
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))
647 752 menu = await RestoMenuLoader.load(uid: uid)
648 753 }
649 754 if let u = universe, u.map != nil, similar.isEmpty {
650 − let batch = (try? await UniverseService.fetch(u, city: item.city, limit: 10)) ?? []
651 − similar = batch.filter { $0.id != item.id }.prefix(6).map { $0 }
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 }
652 757 }
653 758 }
654 759 .fullScreenCover(item: $fullScreenImage) { img in
655 760