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 SafariServices78// MARK: - Grille des univers910struct UniversesView: View {11 @State private var totals: [String: Int] = [:]12 @Environment(\.colorScheme) private var scheme1314 private let columns = [GridItem(.adaptive(minimum: 160), spacing: 14)]15 @State private var showMap = false1617 var body: some View {18 NavigationStack {19 ScrollView {20 LazyVGrid(columns: columns, spacing: 14) {21 ForEach(Ecosystem.all) { u in22 NavigationLink(value: u.id) {23 UniverseCard(universe: u, total: totals[u.id])24 }25 .buttonStyle(.plain)26 }27 }28 .padding(16)29 }30 .background(KATheme.paper(scheme))31 .navigationTitle("Univers")32 .toolbar {33 ToolbarItem(placement: .topBarTrailing) {34 Button { showMap = true } label: { Image(systemName: "map.fill") }35 .accessibilityLabel("Carte unifiée de l'écosystème")36 }37 }38 .fullScreenCover(isPresented: $showMap) {39 UnifiedMapView()40 }41 .navigationDestination(for: String.self) { id in42 if let u = Ecosystem.universe(id) {43 UniverseHomeView(universe: u)44 }45 }46 .task {47 await withTaskGroup(of: (String, Int?).self) { group in48 for u in Ecosystem.all {49 group.addTask { (u.id, await UniverseService.liveTotal(u)) }50 }51 for await (id, n) in group {52 if let n { totals[id] = n }53 }54 }55 }56 }57 }58}5960struct UniverseCard: View {61 let universe: Universe62 let total: Int?63 @Environment(\.colorScheme) private var scheme6465 var body: some View {66 VStack(alignment: .leading, spacing: 10) {67 HStack {68 Image(systemName: universe.symbol)69 .font(.title3.weight(.semibold))70 .foregroundStyle(universe.accent)71 .frame(width: 40, height: 40)72 .background(universe.accent.opacity(0.14), in: RoundedRectangle(cornerRadius: 11, style: .continuous))73 Spacer()74 Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary)75 }76 KAWordmark(universe: universe, size: 17)77 Text(universe.tagline)78 .font(.caption).foregroundStyle(KATheme.ink2(scheme))79 .lineLimit(2, reservesSpace: true)80 if let total {81 Text("\(total.formatted(.number.locale(Locale(identifier: "fr_CA")))) \(universe.unit)")82 .font(.system(.caption2, design: .monospaced).weight(.bold))83 .foregroundStyle(universe.accent)84 .contentTransition(.numericText())85 } else {86 Text("—").font(.system(.caption2, design: .monospaced))87 .foregroundStyle(.tertiary)88 }89 }90 .padding(14)91 .frame(maxWidth: .infinity, alignment: .leading)92 .kaCard(accent: universe.accent)93 .accessibilityElement(children: .combine)94 }95}9697// MARK: - Explorateur d'un univers9899struct UniverseHomeView: View {100 let universe: Universe101 @State private var items: [KAItem] = []102 @State private var query = ""103 @State private var loading = true104 @State private var errorText: String?105 @Environment(\.colorScheme) private var scheme106107 var body: some View {108 Group {109 if universe.listPath == nil {110 webUniverse111 } else {112 list113 }114 }115 .background(KATheme.paper(scheme))116 .navigationTitle(universe.name)117 .navigationBarTitleDisplayMode(.inline)118 .toolbar {119 ToolbarItem(placement: .topBarTrailing) {120 Link(destination: universe.baseURL) {121 Image(systemName: "safari").accessibilityLabel("Ouvrir le site \(universe.name)")122 }123 }124 }125 .tint(universe.accent)126 }127128 private var webUniverse: some View {129 VStack(spacing: 18) {130 Image(systemName: universe.symbol).font(.system(size: 52)).foregroundStyle(universe.accent)131 KAWordmark(universe: universe, size: 30)132 Text(universe.tagline).font(.subheadline).foregroundStyle(.secondary)133 .multilineTextAlignment(.center)134 Link(destination: universe.baseURL) {135 Label("Ouvrir \(universe.name)", systemImage: "arrow.up.right.square")136 .font(.headline).padding(.horizontal, 22).padding(.vertical, 13)137 .background(universe.accent, in: Capsule())138 .foregroundStyle(.white)139 }140 }141 .padding(30)142 .frame(maxWidth: .infinity, maxHeight: .infinity)143 }144145 private var list: some View {146 ScrollView {147 LazyVStack(spacing: 12) {148 if loading {149 ProgressView().padding(40)150 } else if let e = errorText, items.isEmpty {151 KAEmptyState(symbol: "wifi.exclamationmark", title: "Impossible de charger",152 message: e + "\nTirez pour réessayer.")153 } else if items.isEmpty {154 KAEmptyState(symbol: "tray", title: "Aucun résultat",155 message: "Essayez d'autres mots-clés.")156 } else {157 ForEach(items) { item in158 NavigationLink(value: item) { KAItemRow(item: item) }159 .buttonStyle(.plain)160 }161 }162 }163 .padding(16)164 }165 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }166 .searchable(text: $query, prompt: "Chercher dans \(universe.name)…")167 .onSubmit(of: .search) { Task { await load() } }168 .refreshable { await load() }169 .task { await load() }170 }171172 private func load() async {173 loading = items.isEmpty174 errorText = nil175 do {176 items = try await UniverseService.fetch(universe, query: query.isEmpty ? nil : query, limit: 40)177 } catch {178 errorText = (error as? URLError)?.code == .notConnectedToInternet179 ? "Vous êtes hors ligne." : "Le service ne répond pas."180 }181 loading = false182 }183}184185// MARK: - Fiche universelle186187struct ItemDetailView: View {188 let item: KAItem189 @EnvironmentObject private var favorites: FavoritesStore190 @Environment(\.colorScheme) private var scheme191 @State private var showCollections = false192193 private var universe: Universe? { Ecosystem.universe(item.universeID) }194195 var body: some View {196 ScrollView {197 VStack(alignment: .leading, spacing: 16) {198 if let u = universe {199 HStack { KAChip(text: u.wordmark, accent: u.accent); Spacer() }200 }201 Text(item.title).font(.title2.weight(.bold))202 if let sub = item.subtitle { Text(sub).font(.body).foregroundStyle(KATheme.ink2(scheme)) }203 HStack(spacing: 10) {204 if let p = item.priceLabel {205 Text(p).font(.system(.title3, design: .rounded).weight(.bold))206 .foregroundStyle(universe?.accent ?? .primary)207 }208 if let c = item.city { KAChip(text: c) }209 }210211 if !item.facts.isEmpty {212 VStack(spacing: 0) {213 ForEach(item.facts, id: \.self) { f in214 HStack {215 Text(f.label).font(.subheadline).foregroundStyle(.secondary)216 Spacer()217 Text(f.value).font(.subheadline.weight(.semibold))218 .multilineTextAlignment(.trailing)219 }220 .padding(.vertical, 10).padding(.horizontal, 14)221 if f != item.facts.last { Divider() }222 }223 }224 .kaCard()225 }226227 if let url = item.url {228 Link(destination: url) {229 Label("Voir à la source", systemImage: "arrow.up.right.square")230 .font(.headline)231 .frame(maxWidth: .infinity).padding(.vertical, 14)232 .background(universe?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 12, style: .continuous))233 .foregroundStyle(.white)234 }235 Text("Groupe KA est un agrégateur : la transaction se fait chez la source originale.")236 .font(.caption2).foregroundStyle(.tertiary)237 }238 }239 .padding(16)240 }241 .background(KATheme.paper(scheme))242 .navigationBarTitleDisplayMode(.inline)243 .toolbar {244 ToolbarItemGroup(placement: .topBarTrailing) {245 Button {246 if favorites.isFavorite(item) { favorites.toggle(item) }247 else if favorites.collections.count > 1 { showCollections = true }248 else { favorites.toggle(item) }249 } label: {250 Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart")251 .foregroundStyle(favorites.isFavorite(item) ? .red : .primary)252 }253 .accessibilityLabel(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris")254 if let url = item.url {255 ShareLink(item: url, subject: Text(item.title))256 }257 }258 }259 .confirmationDialog("Ajouter à…", isPresented: $showCollections, titleVisibility: .visible) {260 ForEach(favorites.collections) { c in261 Button(c.name) { favorites.toggle(item, in: c.id) }262 }263 }264 .tint(universe?.accent)265 }266}267