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>
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 | |