SPB Git

spb/ka-ios

Public
22.6 KB · 501 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 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
Files · 49784a20c598d4fc9dfbc6fcc0a419e9854e3a5f
Menu

Theme