SPB Git

spb/ka-ios

Public
10.5 KB · 267 lines swift
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
Files · 94a2a56dd85a861c4319a2f53a8841ad0481989d
Menu

Theme