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.id == "api-ka" {151 APIPlaygroundView(universe: universe)152 } else if universe.listPath == nil {153 webUniverse154 } else {155 list156 }157 }158 .background(KATheme.paper(scheme))159 .navigationTitle(universe.name)160 .navigationBarTitleDisplayMode(.inline)161 .toolbar {162 ToolbarItem(placement: .topBarTrailing) {163 Link(destination: universe.baseURL) {164 Image(systemName: "safari").accessibilityLabel("Ouvrir le site \(universe.name)")165 }166 }167 }168 .tint(universe.accent)169 }170171 private var webUniverse: some View {172 VStack(spacing: 18) {173 Image(systemName: universe.symbol).font(.system(size: 52)).foregroundStyle(universe.accent)174 KAWordmark(universe: universe, size: 30)175 Text(universe.tagline).font(.subheadline).foregroundStyle(.secondary)176 .multilineTextAlignment(.center)177 Link(destination: universe.baseURL) {178 Label("Ouvrir \(universe.name)", systemImage: "arrow.up.right.square")179 .font(.headline).padding(.horizontal, 22).padding(.vertical, 13)180 .background(universe.accent, in: Capsule())181 .foregroundStyle(.white)182 }183 }184 .padding(30)185 .frame(maxWidth: .infinity, maxHeight: .infinity)186 }187188 /// En-tête façon héros de site : tagline + compteur live + filtres métier189 private var hero: some View {190 VStack(alignment: .leading, spacing: 10) {191 HStack(alignment: .firstTextBaseline) {192 Text(universe.tagline)193 .font(.system(.subheadline, design: .rounded).weight(.bold))194 Spacer()195 if let n = liveTotal {196 Text("\(n.formatted(.number.locale(Locale(identifier: "fr_CA")))) \(universe.unit)")197 .font(.system(.caption2, design: .monospaced).weight(.bold))198 .padding(.horizontal, 9).padding(.vertical, 4)199 .background(universe.accent.opacity(0.15), in: Capsule())200 .foregroundStyle(universe.accent)201 }202 }203 FilterBar(universe: universe, params: $filterParams) {204 Task { await load() }205 }206 }207 }208209 private var list: some View {210 ScrollView {211 LazyVStack(spacing: 12) {212 hero213 if loading {214 ForEach(0..<6, id: \.self) { _ in KASkeletonRow() }215 } else if let e = errorText, items.isEmpty {216 KAEmptyState(symbol: "wifi.exclamationmark", title: "Impossible de charger",217 message: e + "\nTirez pour réessayer.")218 } else if items.isEmpty {219 if universe.id == "trouve-ka" && query.isEmpty {220 KAEmptyState(symbol: "magnifyingglass",221 title: "Cherchez le web québécois",222 message: "1,2 M de pages d'ici indexées — tapez un mot dans la barre de recherche ci-dessus.")223 } else {224 KAEmptyState(symbol: "tray", title: "Aucun résultat",225 message: "Essayez d'autres mots-clés.")226 }227 } else {228 ForEach(items) { item in229 NavigationLink(value: item) { KAItemRow(item: item) }230 .buttonStyle(KAPressStyle())231 }232 }233 }234 .padding(16)235 .animation(.snappy(duration: 0.3), value: items)236 }237 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }238 .searchable(text: $query, prompt: "Chercher dans \(universe.name)…")239 .onSubmit(of: .search) { Task { await load() } }240 .refreshable { await load() }241 .task {242 await load()243 liveTotal = await UniverseService.liveTotal(universe)244 }245 }246247 private func load() async {248 loading = items.isEmpty249 errorText = nil250 do {251 items = try await UniverseService.fetch(universe, query: query.isEmpty ? nil : query, limit: 40, params: filterParams)252 } catch {253 errorText = (error as? URLError)?.code == .notConnectedToInternet254 ? "Vous êtes hors ligne." : "Le service ne répond pas."255 }256 loading = false257 }258}259260// MARK: - Fiche universelle261262struct ItemDetailView: View {263 let item: KAItem264 @EnvironmentObject private var favorites: FavoritesStore265 @Environment(\.colorScheme) private var scheme266 @State private var showCollections = false267 @State private var menu: [RestoMenuSection] = []268 @State private var similar: [KAItem] = []269 @State private var fullScreenImage: URL?270 @EnvironmentObject private var recents: RecentsStore271272 private var universe: Universe? { Ecosystem.universe(item.universeID) }273274 var body: some View {275 ScrollView {276 VStack(alignment: .leading, spacing: 16) {277 if !item.imageURLs.isEmpty {278 TabView {279 ForEach(item.imageURLs, id: \.self) { img in280 KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")281 .onTapGesture { Haptics.tap(); fullScreenImage = img }282 .accessibilityLabel("Photo de \(item.title) — toucher pour agrandir")283 .accessibilityAddTraits(.isButton)284 }285 }286 .tabViewStyle(.page(indexDisplayMode: item.imageURLs.count > 1 ? .automatic : .never))287 .frame(height: 240)288 .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))289 .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous)290 .strokeBorder(.primary.opacity(0.3), lineWidth: 1.2))291 .overlay(alignment: .topTrailing) {292 if item.imageURLs.count > 1 {293 Text("\(item.imageURLs.count) photos")294 .font(.system(size: 10, design: .monospaced).weight(.bold))295 .padding(.horizontal, 8).padding(.vertical, 4)296 .background(.ultraThinMaterial, in: Capsule())297 .padding(8)298 }299 }300 }301 if let u = universe {302 HStack { KAChip(text: u.wordmark, accent: u.accent); Spacer() }303 }304 Text(item.title).font(.title2.weight(.bold))305 if let sub = item.subtitle { Text(sub).font(.body).foregroundStyle(KATheme.ink2(scheme)) }306 HStack(spacing: 10) {307 if let p = item.priceLabel {308 Text(p).font(.system(.title3, design: .rounded).weight(.bold))309 .foregroundStyle(universe?.accent ?? .primary)310 }311 if let c = item.city { KAChip(text: c) }312 }313314 if let detail = item.detail, !detail.isEmpty {315 VStack(alignment: .leading, spacing: 6) {316 Text("À propos").font(.headline)317 Text(detail)318 .font(.subheadline)319 .foregroundStyle(KATheme.ink2(scheme))320 .lineLimit(12)321 }322 .padding(14)323 .frame(maxWidth: .infinity, alignment: .leading)324 .kaCard()325 }326327 if !item.facts.isEmpty {328 VStack(spacing: 0) {329 ForEach(item.facts, id: \.self) { f in330 HStack {331 Text(f.label).font(.subheadline).foregroundStyle(.secondary)332 Spacer()333 Text(f.value).font(.subheadline.weight(.semibold))334 .multilineTextAlignment(.trailing)335 }336 .padding(.vertical, 10).padding(.horizontal, 14)337 if f != item.facts.last { Divider() }338 }339 }340 .kaCard()341 }342343 if !item.links.isEmpty {344 VStack(alignment: .leading, spacing: 8) {345 Text("Comptes & liens").font(.headline)346 ForEach(item.links, id: \.self) { l in347 if let u = URL(string: l.value) {348 Link(destination: u) {349 HStack {350 Image(systemName: "link")351 Text(l.label).font(.subheadline.weight(.semibold))352 Spacer()353 Image(systemName: "arrow.up.right").font(.caption)354 }355 .padding(11)356 .background((universe?.accent ?? .gray).opacity(0.1), in: RoundedRectangle(cornerRadius: 10, style: .continuous))357 }358 .foregroundStyle(universe?.accent ?? .primary)359 }360 }361 }362 }363364 if !menu.isEmpty {365 VStack(alignment: .leading, spacing: 10) {366 HStack {367 Text("Menu & prix réels").font(.headline)368 Spacer()369 KAChip(text: "\(menu.reduce(0) { $0 + $1.items.count }) plats", accent: universe?.accent)370 }371 ForEach(menu.prefix(6)) { section in372 DisclosureGroup {373 VStack(spacing: 0) {374 ForEach(section.items, id: \.name) { dish in375 HStack(alignment: .top) {376 Text(dish.name).font(.subheadline)377 Spacer()378 if let p = dish.price {379 Text(p).font(.system(.subheadline, design: .rounded).weight(.bold))380 .foregroundStyle(universe?.accent ?? .primary)381 }382 }383 .padding(.vertical, 7)384 Divider().opacity(dish.name == section.items.last?.name ? 0 : 0.6)385 }386 }387 .padding(.top, 4)388 } label: {389 Text(section.name).font(.subheadline.weight(.bold))390 }391 .padding(.horizontal, 14).padding(.vertical, 8)392 .kaCard()393 }394 }395 }396397 HStack(spacing: 10) {398 if let url = item.url {399 Link(destination: url) {400 Label("Voir à la source", systemImage: "arrow.up.right.square")401 .font(.headline)402 .frame(maxWidth: .infinity).padding(.vertical, 14)403 .background(universe?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 12, style: .continuous))404 .foregroundStyle(.white)405 }406 }407 if let la = item.latitude, let lo = item.longitude {408 Button {409 Haptics.tap()410 let place = MKMapItem(placemark: MKPlacemark(coordinate: .init(latitude: la, longitude: lo)))411 place.name = item.title412 place.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault])413 } label: {414 Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill")415 .font(.headline)416 .padding(.horizontal, 16).padding(.vertical, 14)417 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous))418 .foregroundStyle(KATheme.lime)419 }420 .accessibilityLabel("Itinéraire vers \(item.title)")421 }422 }423 if let src = item.url?.host() {424 Text("Source : \(src) — Groupe KA est un agrégateur, la transaction se fait chez la source originale.")425 .font(.caption2).foregroundStyle(.tertiary)426 }427428 if !similar.isEmpty {429 VStack(alignment: .leading, spacing: 10) {430 Text("Semblables\(item.city.map { " à \($0)" } ?? "")").font(.headline)431 ScrollView(.horizontal, showsIndicators: false) {432 HStack(spacing: 10) {433 ForEach(similar) { s in434 NavigationLink(value: s) {435 VStack(alignment: .leading, spacing: 0) {436 KAImage(url: s.imageURL, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")437 .frame(width: 150, height: 84).clipped()438 VStack(alignment: .leading, spacing: 2) {439 Text(s.title).font(.caption.weight(.semibold))440 .lineLimit(2, reservesSpace: true)441 if let p = s.priceLabel {442 Text(p).font(.system(.caption2, design: .rounded).weight(.bold))443 .foregroundStyle(universe?.accent ?? .primary)444 }445 }446 .padding(8)447 }448 .frame(width: 150, alignment: .topLeading)449 .kaCard(accent: universe?.accent)450 }451 .buttonStyle(KAPressStyle())452 }453 }454 .padding(.vertical, 4)455 }456 }457 }458 }459 .padding(16)460 }461 .background(KATheme.paper(scheme))462 .navigationBarTitleDisplayMode(.inline)463 .toolbar {464 ToolbarItemGroup(placement: .topBarTrailing) {465 Button {466 if favorites.isFavorite(item) { favorites.toggle(item) }467 else if favorites.collections.count > 1 { showCollections = true }468 else { favorites.toggle(item) }469 } label: {470 Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart")471 .foregroundStyle(favorites.isFavorite(item) ? .red : .primary)472 }473 .accessibilityLabel(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris")474 if let url = item.url {475 ShareLink(item: url, subject: Text(item.title))476 }477 }478 }479 .task {480 recents.record(item)481 if item.universeID == "resto-ka" {482 let uid = String(item.id.dropFirst("resto-ka:".count))483 menu = await RestoMenuLoader.load(uid: uid)484 }485 if let u = universe, u.map != nil, similar.isEmpty {486 let batch = (try? await UniverseService.fetch(u, city: item.city, limit: 10)) ?? []487 similar = batch.filter { $0.id != item.id }.prefix(6).map { $0 }488 }489 }490 .fullScreenCover(item: $fullScreenImage) { img in491 ZoomableImageView(url: img, accent: universe?.accent ?? .gray)492 }493 .confirmationDialog("Ajouter à…", isPresented: $showCollections, titleVisibility: .visible) {494 ForEach(favorites.collections) { c in495 Button(c.name) { favorites.toggle(item, in: c.id) }496 }497 }498 .tint(universe?.accent)499 }500}501