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 = false5354 var body: some View {55 NavigationStack {56 ScrollView {57 LazyVGrid(columns: columns, spacing: 14) {58 ForEach(Ecosystem.all) { u in59 NavigationLink(value: u.id) {60 UniverseCard(universe: u, total: totals[u.id])61 }62 .buttonStyle(KAPressStyle())63 }64 }65 .padding(16)66 }67 .background(KATheme.paper(scheme))68 .navigationTitle("Univers")69 .toolbar {70 ToolbarItem(placement: .topBarTrailing) {71 Button { showMap = true } label: { Image(systemName: "map.fill") }72 .accessibilityLabel("Carte unifiée de l'écosystème")73 }74 }75 .fullScreenCover(isPresented: $showMap) {76 UnifiedMapView()77 }78 .navigationDestination(for: String.self) { id in79 if let u = Ecosystem.universe(id) {80 UniverseHomeView(universe: u)81 }82 }83 .task {84 await withTaskGroup(of: (String, Int?).self) { group in85 for u in Ecosystem.all {86 group.addTask { (u.id, await UniverseService.liveTotal(u)) }87 }88 for await (id, n) in group {89 if let n { totals[id] = n }90 }91 }92 }93 }94 }95}9697struct UniverseCard: View {98 let universe: Universe99 let total: Int?100 @Environment(\.colorScheme) private var scheme101102 var body: some View {103 VStack(alignment: .leading, spacing: 10) {104 HStack {105 Image(systemName: universe.symbol)106 .font(.title3.weight(.semibold))107 .foregroundStyle(universe.accent)108 .frame(width: 40, height: 40)109 .background(universe.accent.opacity(0.14), in: RoundedRectangle(cornerRadius: 11, style: .continuous))110 Spacer()111 Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary)112 }113 KAWordmark(universe: universe, size: 17)114 Text(universe.tagline)115 .font(.caption).foregroundStyle(KATheme.ink2(scheme))116 .lineLimit(2, reservesSpace: true)117 if let total {118 Text("\(total.formatted(.number.locale(Locale(identifier: "fr_CA")))) \(universe.unit)")119 .font(.system(.caption2, design: .monospaced).weight(.bold))120 .foregroundStyle(universe.accent)121 .contentTransition(.numericText())122 } else {123 Text("—").font(.system(.caption2, design: .monospaced))124 .foregroundStyle(.tertiary)125 }126 }127 .padding(14)128 .frame(maxWidth: .infinity, alignment: .leading)129 .kaCard(accent: universe.accent)130 .accessibilityElement(children: .combine)131 }132}133134// MARK: - Explorateur d'un univers135136struct UniverseHomeView: View {137 let universe: Universe138 @State private var items: [KAItem] = []139 @State private var query = ""140 @State private var loading = true141 @State private var errorText: String?142 @State private var filterParams: [String: String] = [:]143 @State private var liveTotal: Int?144 @Environment(\.colorScheme) private var scheme145146 var body: some View {147 Group {148 if universe.id == "vrai-prix" {149 VraiPrixView(universe: universe)150 } else if universe.listPath == nil {151 webUniverse152 } else {153 list154 }155 }156 .background(KATheme.paper(scheme))157 .navigationTitle(universe.name)158 .navigationBarTitleDisplayMode(.inline)159 .toolbar {160 ToolbarItem(placement: .topBarTrailing) {161 Link(destination: universe.baseURL) {162 Image(systemName: "safari").accessibilityLabel("Ouvrir le site \(universe.name)")163 }164 }165 }166 .tint(universe.accent)167 }168169 private var webUniverse: some View {170 VStack(spacing: 18) {171 Image(systemName: universe.symbol).font(.system(size: 52)).foregroundStyle(universe.accent)172 KAWordmark(universe: universe, size: 30)173 Text(universe.tagline).font(.subheadline).foregroundStyle(.secondary)174 .multilineTextAlignment(.center)175 Link(destination: universe.baseURL) {176 Label("Ouvrir \(universe.name)", systemImage: "arrow.up.right.square")177 .font(.headline).padding(.horizontal, 22).padding(.vertical, 13)178 .background(universe.accent, in: Capsule())179 .foregroundStyle(.white)180 }181 }182 .padding(30)183 .frame(maxWidth: .infinity, maxHeight: .infinity)184 }185186 /// En-tête façon héros de site : tagline + compteur live + filtres métier187 private var hero: some View {188 VStack(alignment: .leading, spacing: 10) {189 HStack(alignment: .firstTextBaseline) {190 Text(universe.tagline)191 .font(.system(.subheadline, design: .rounded).weight(.bold))192 Spacer()193 if let n = liveTotal {194 Text("\(n.formatted(.number.locale(Locale(identifier: "fr_CA")))) \(universe.unit)")195 .font(.system(.caption2, design: .monospaced).weight(.bold))196 .padding(.horizontal, 9).padding(.vertical, 4)197 .background(universe.accent.opacity(0.15), in: Capsule())198 .foregroundStyle(universe.accent)199 }200 }201 FilterBar(universe: universe, params: $filterParams) {202 Task { await load() }203 }204 }205 }206207 private var list: some View {208 ScrollView {209 LazyVStack(spacing: 12) {210 hero211 if loading {212 ForEach(0..<6, id: \.self) { _ in KASkeletonRow() }213 } else if let e = errorText, items.isEmpty {214 KAEmptyState(symbol: "wifi.exclamationmark", title: "Impossible de charger",215 message: e + "\nTirez pour réessayer.")216 } else if items.isEmpty {217 KAEmptyState(symbol: "tray", title: "Aucun résultat",218 message: "Essayez d'autres mots-clés.")219 } else {220 ForEach(items) { item in221 NavigationLink(value: item) { KAItemRow(item: item) }222 .buttonStyle(KAPressStyle())223 }224 }225 }226 .padding(16)227 .animation(.snappy(duration: 0.3), value: items)228 }229 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }230 .searchable(text: $query, prompt: "Chercher dans \(universe.name)…")231 .onSubmit(of: .search) { Task { await load() } }232 .refreshable { await load() }233 .task {234 await load()235 liveTotal = await UniverseService.liveTotal(universe)236 }237 }238239 private func load() async {240 loading = items.isEmpty241 errorText = nil242 do {243 items = try await UniverseService.fetch(universe, query: query.isEmpty ? nil : query, limit: 40, params: filterParams)244 } catch {245 errorText = (error as? URLError)?.code == .notConnectedToInternet246 ? "Vous êtes hors ligne." : "Le service ne répond pas."247 }248 loading = false249 }250}251252// MARK: - Fiche universelle253254struct ItemDetailView: View {255 let item: KAItem256 @EnvironmentObject private var favorites: FavoritesStore257 @Environment(\.colorScheme) private var scheme258 @State private var showCollections = false259 @State private var menu: [RestoMenuSection] = []260 @State private var similar: [KAItem] = []261 @State private var fullScreenImage: URL?262 @EnvironmentObject private var recents: RecentsStore263264 private var universe: Universe? { Ecosystem.universe(item.universeID) }265266 var body: some View {267 ScrollView {268 VStack(alignment: .leading, spacing: 16) {269 if !item.imageURLs.isEmpty {270 TabView {271 ForEach(item.imageURLs, id: \.self) { img in272 KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")273 .onTapGesture { Haptics.tap(); fullScreenImage = img }274 .accessibilityLabel("Photo de \(item.title) — toucher pour agrandir")275 .accessibilityAddTraits(.isButton)276 }277 }278 .tabViewStyle(.page(indexDisplayMode: item.imageURLs.count > 1 ? .automatic : .never))279 .frame(height: 240)280 .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))281 .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous)282 .strokeBorder(.primary.opacity(0.3), lineWidth: 1.2))283 .overlay(alignment: .topTrailing) {284 if item.imageURLs.count > 1 {285 Text("\(item.imageURLs.count) photos")286 .font(.system(size: 10, design: .monospaced).weight(.bold))287 .padding(.horizontal, 8).padding(.vertical, 4)288 .background(.ultraThinMaterial, in: Capsule())289 .padding(8)290 }291 }292 }293 if let u = universe {294 HStack { KAChip(text: u.wordmark, accent: u.accent); Spacer() }295 }296 Text(item.title).font(.title2.weight(.bold))297 if let sub = item.subtitle { Text(sub).font(.body).foregroundStyle(KATheme.ink2(scheme)) }298 HStack(spacing: 10) {299 if let p = item.priceLabel {300 Text(p).font(.system(.title3, design: .rounded).weight(.bold))301 .foregroundStyle(universe?.accent ?? .primary)302 }303 if let c = item.city { KAChip(text: c) }304 }305306 if let detail = item.detail, !detail.isEmpty {307 VStack(alignment: .leading, spacing: 6) {308 Text("À propos").font(.headline)309 Text(detail)310 .font(.subheadline)311 .foregroundStyle(KATheme.ink2(scheme))312 .lineLimit(12)313 }314 .padding(14)315 .frame(maxWidth: .infinity, alignment: .leading)316 .kaCard()317 }318319 if !item.facts.isEmpty {320 VStack(spacing: 0) {321 ForEach(item.facts, id: \.self) { f in322 HStack {323 Text(f.label).font(.subheadline).foregroundStyle(.secondary)324 Spacer()325 Text(f.value).font(.subheadline.weight(.semibold))326 .multilineTextAlignment(.trailing)327 }328 .padding(.vertical, 10).padding(.horizontal, 14)329 if f != item.facts.last { Divider() }330 }331 }332 .kaCard()333 }334335 if !item.links.isEmpty {336 VStack(alignment: .leading, spacing: 8) {337 Text("Comptes & liens").font(.headline)338 ForEach(item.links, id: \.self) { l in339 if let u = URL(string: l.value) {340 Link(destination: u) {341 HStack {342 Image(systemName: "link")343 Text(l.label).font(.subheadline.weight(.semibold))344 Spacer()345 Image(systemName: "arrow.up.right").font(.caption)346 }347 .padding(11)348 .background((universe?.accent ?? .gray).opacity(0.1), in: RoundedRectangle(cornerRadius: 10, style: .continuous))349 }350 .foregroundStyle(universe?.accent ?? .primary)351 }352 }353 }354 }355356 if !menu.isEmpty {357 VStack(alignment: .leading, spacing: 10) {358 HStack {359 Text("Menu & prix réels").font(.headline)360 Spacer()361 KAChip(text: "\(menu.reduce(0) { $0 + $1.items.count }) plats", accent: universe?.accent)362 }363 ForEach(menu.prefix(6)) { section in364 DisclosureGroup {365 VStack(spacing: 0) {366 ForEach(section.items, id: \.name) { dish in367 HStack(alignment: .top) {368 Text(dish.name).font(.subheadline)369 Spacer()370 if let p = dish.price {371 Text(p).font(.system(.subheadline, design: .rounded).weight(.bold))372 .foregroundStyle(universe?.accent ?? .primary)373 }374 }375 .padding(.vertical, 7)376 Divider().opacity(dish.name == section.items.last?.name ? 0 : 0.6)377 }378 }379 .padding(.top, 4)380 } label: {381 Text(section.name).font(.subheadline.weight(.bold))382 }383 .padding(.horizontal, 14).padding(.vertical, 8)384 .kaCard()385 }386 }387 }388389 HStack(spacing: 10) {390 if let url = item.url {391 Link(destination: url) {392 Label("Voir à la source", systemImage: "arrow.up.right.square")393 .font(.headline)394 .frame(maxWidth: .infinity).padding(.vertical, 14)395 .background(universe?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 12, style: .continuous))396 .foregroundStyle(.white)397 }398 }399 if let la = item.latitude, let lo = item.longitude {400 Button {401 Haptics.tap()402 let place = MKMapItem(placemark: MKPlacemark(coordinate: .init(latitude: la, longitude: lo)))403 place.name = item.title404 place.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault])405 } label: {406 Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill")407 .font(.headline)408 .padding(.horizontal, 16).padding(.vertical, 14)409 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous))410 .foregroundStyle(KATheme.lime)411 }412 .accessibilityLabel("Itinéraire vers \(item.title)")413 }414 }415 if let src = item.url?.host() {416 Text("Source : \(src) — Groupe KA est un agrégateur, la transaction se fait chez la source originale.")417 .font(.caption2).foregroundStyle(.tertiary)418 }419420 if !similar.isEmpty {421 VStack(alignment: .leading, spacing: 10) {422 Text("Semblables\(item.city.map { " à \($0)" } ?? "")").font(.headline)423 ScrollView(.horizontal, showsIndicators: false) {424 HStack(spacing: 10) {425 ForEach(similar) { s in426 NavigationLink(value: s) {427 VStack(alignment: .leading, spacing: 0) {428 KAImage(url: s.imageURL, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")429 .frame(width: 150, height: 84).clipped()430 VStack(alignment: .leading, spacing: 2) {431 Text(s.title).font(.caption.weight(.semibold))432 .lineLimit(2, reservesSpace: true)433 if let p = s.priceLabel {434 Text(p).font(.system(.caption2, design: .rounded).weight(.bold))435 .foregroundStyle(universe?.accent ?? .primary)436 }437 }438 .padding(8)439 }440 .frame(width: 150, alignment: .topLeading)441 .kaCard(accent: universe?.accent)442 }443 .buttonStyle(KAPressStyle())444 }445 }446 .padding(.vertical, 4)447 }448 }449 }450 }451 .padding(16)452 }453 .background(KATheme.paper(scheme))454 .navigationBarTitleDisplayMode(.inline)455 .toolbar {456 ToolbarItemGroup(placement: .topBarTrailing) {457 Button {458 if favorites.isFavorite(item) { favorites.toggle(item) }459 else if favorites.collections.count > 1 { showCollections = true }460 else { favorites.toggle(item) }461 } label: {462 Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart")463 .foregroundStyle(favorites.isFavorite(item) ? .red : .primary)464 }465 .accessibilityLabel(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris")466 if let url = item.url {467 ShareLink(item: url, subject: Text(item.title))468 }469 }470 }471 .task {472 recents.record(item)473 if item.universeID == "resto-ka" {474 let uid = String(item.id.dropFirst("resto-ka:".count))475 menu = await RestoMenuLoader.load(uid: uid)476 }477 if let u = universe, u.map != nil, similar.isEmpty {478 let batch = (try? await UniverseService.fetch(u, city: item.city, limit: 10)) ?? []479 similar = batch.filter { $0.id != item.id }.prefix(6).map { $0 }480 }481 }482 .fullScreenCover(item: $fullScreenImage) { img in483 ZoomableImageView(url: img, accent: universe?.accent ?? .gray)484 }485 .confirmationDialog("Ajouter à…", isPresented: $showCollections, titleVisibility: .visible) {486 ForEach(favorites.collections) { c in487 Button(c.name) { favorites.toggle(item, in: c.id) }488 }489 }490 .tint(universe?.accent)491 }492}493