// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // UniversesView.swift — la grille des 12 univers (cartes accent + compteur en // direct), l'explorateur générique d'un univers (liste + recherche) et la // fiche universelle (faits, favori, partage, lien web). import SwiftUI import SafariServices // MARK: - Grille des univers struct UniversesView: View { @State private var totals: [String: Int] = [:] @Environment(\.colorScheme) private var scheme private let columns = [GridItem(.adaptive(minimum: 160), spacing: 14)] @State private var showMap = false var body: some View { NavigationStack { ScrollView { LazyVGrid(columns: columns, spacing: 14) { ForEach(Ecosystem.all) { u in NavigationLink(value: u.id) { UniverseCard(universe: u, total: totals[u.id]) } .buttonStyle(.plain) } } .padding(16) } .background(KATheme.paper(scheme)) .navigationTitle("Univers") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { showMap = true } label: { Image(systemName: "map.fill") } .accessibilityLabel("Carte unifiée de l'écosystème") } } .fullScreenCover(isPresented: $showMap) { UnifiedMapView() } .navigationDestination(for: String.self) { id in if let u = Ecosystem.universe(id) { UniverseHomeView(universe: u) } } .task { await withTaskGroup(of: (String, Int?).self) { group in for u in Ecosystem.all { group.addTask { (u.id, await UniverseService.liveTotal(u)) } } for await (id, n) in group { if let n { totals[id] = n } } } } } } } struct UniverseCard: View { let universe: Universe let total: Int? @Environment(\.colorScheme) private var scheme var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { Image(systemName: universe.symbol) .font(.title3.weight(.semibold)) .foregroundStyle(universe.accent) .frame(width: 40, height: 40) .background(universe.accent.opacity(0.14), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) Spacer() Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) } KAWordmark(universe: universe, size: 17) Text(universe.tagline) .font(.caption).foregroundStyle(KATheme.ink2(scheme)) .lineLimit(2, reservesSpace: true) if let total { Text("\(total.formatted(.number.locale(Locale(identifier: "fr_CA")))) \(universe.unit)") .font(.system(.caption2, design: .monospaced).weight(.bold)) .foregroundStyle(universe.accent) .contentTransition(.numericText()) } else { Text("—").font(.system(.caption2, design: .monospaced)) .foregroundStyle(.tertiary) } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .kaCard(accent: universe.accent) .accessibilityElement(children: .combine) } } // MARK: - Explorateur d'un univers struct UniverseHomeView: View { let universe: Universe @State private var items: [KAItem] = [] @State private var query = "" @State private var loading = true @State private var errorText: String? @Environment(\.colorScheme) private var scheme var body: some View { Group { if universe.listPath == nil { webUniverse } else { list } } .background(KATheme.paper(scheme)) .navigationTitle(universe.name) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Link(destination: universe.baseURL) { Image(systemName: "safari").accessibilityLabel("Ouvrir le site \(universe.name)") } } } .tint(universe.accent) } private var webUniverse: some View { VStack(spacing: 18) { Image(systemName: universe.symbol).font(.system(size: 52)).foregroundStyle(universe.accent) KAWordmark(universe: universe, size: 30) Text(universe.tagline).font(.subheadline).foregroundStyle(.secondary) .multilineTextAlignment(.center) Link(destination: universe.baseURL) { Label("Ouvrir \(universe.name)", systemImage: "arrow.up.right.square") .font(.headline).padding(.horizontal, 22).padding(.vertical, 13) .background(universe.accent, in: Capsule()) .foregroundStyle(.white) } } .padding(30) .frame(maxWidth: .infinity, maxHeight: .infinity) } private var list: some View { ScrollView { LazyVStack(spacing: 12) { if loading { ProgressView().padding(40) } else if let e = errorText, items.isEmpty { KAEmptyState(symbol: "wifi.exclamationmark", title: "Impossible de charger", message: e + "\nTirez pour réessayer.") } else if items.isEmpty { KAEmptyState(symbol: "tray", title: "Aucun résultat", message: "Essayez d'autres mots-clés.") } else { ForEach(items) { item in NavigationLink(value: item) { KAItemRow(item: item) } .buttonStyle(.plain) } } } .padding(16) } .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } .searchable(text: $query, prompt: "Chercher dans \(universe.name)…") .onSubmit(of: .search) { Task { await load() } } .refreshable { await load() } .task { await load() } } private func load() async { loading = items.isEmpty errorText = nil do { items = try await UniverseService.fetch(universe, query: query.isEmpty ? nil : query, limit: 40) } catch { errorText = (error as? URLError)?.code == .notConnectedToInternet ? "Vous êtes hors ligne." : "Le service ne répond pas." } loading = false } } // MARK: - Fiche universelle struct ItemDetailView: View { let item: KAItem @EnvironmentObject private var favorites: FavoritesStore @Environment(\.colorScheme) private var scheme @State private var showCollections = false private var universe: Universe? { Ecosystem.universe(item.universeID) } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { if let u = universe { HStack { KAChip(text: u.wordmark, accent: u.accent); Spacer() } } Text(item.title).font(.title2.weight(.bold)) if let sub = item.subtitle { Text(sub).font(.body).foregroundStyle(KATheme.ink2(scheme)) } HStack(spacing: 10) { if let p = item.priceLabel { Text(p).font(.system(.title3, design: .rounded).weight(.bold)) .foregroundStyle(universe?.accent ?? .primary) } if let c = item.city { KAChip(text: c) } } if !item.facts.isEmpty { VStack(spacing: 0) { ForEach(item.facts, id: \.self) { f in HStack { Text(f.label).font(.subheadline).foregroundStyle(.secondary) Spacer() Text(f.value).font(.subheadline.weight(.semibold)) .multilineTextAlignment(.trailing) } .padding(.vertical, 10).padding(.horizontal, 14) if f != item.facts.last { Divider() } } } .kaCard() } if let url = item.url { Link(destination: url) { Label("Voir à la source", systemImage: "arrow.up.right.square") .font(.headline) .frame(maxWidth: .infinity).padding(.vertical, 14) .background(universe?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .foregroundStyle(.white) } Text("Groupe KA est un agrégateur : la transaction se fait chez la source originale.") .font(.caption2).foregroundStyle(.tertiary) } } .padding(16) } .background(KATheme.paper(scheme)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { Button { if favorites.isFavorite(item) { favorites.toggle(item) } else if favorites.collections.count > 1 { showCollections = true } else { favorites.toggle(item) } } label: { Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart") .foregroundStyle(favorites.isFavorite(item) ? .red : .primary) } .accessibilityLabel(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris") if let url = item.url { ShareLink(item: url, subject: Text(item.title)) } } } .confirmationDialog("Ajouter à…", isPresented: $showCollections, titleVisibility: .visible) { ForEach(favorites.collections) { c in Button(c.name) { favorites.toggle(item, in: c.id) } } } .tint(universe?.accent) } }