SPB Git

spb/ka-ios

Public
22.1 KB · 493 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.listPath == nil {151                webUniverse152            } else {153                list154            }155        }156        .background(KATheme.paper(scheme))157        .navigationTitle(universe.name)158        .navigationBarTitleDisplayMode(.inline)159        .toolbar {160            ToolbarItem(placement: .topBarTrailing) {161                Link(destination: universe.baseURL) {162                    Image(systemName: "safari").accessibilityLabel("Ouvrir le site \(universe.name)")163                }164            }165        }166        .tint(universe.accent)167    }168169    private var webUniverse: some View {170        VStack(spacing: 18) {171            Image(systemName: universe.symbol).font(.system(size: 52)).foregroundStyle(universe.accent)172            KAWordmark(universe: universe, size: 30)173            Text(universe.tagline).font(.subheadline).foregroundStyle(.secondary)174                .multilineTextAlignment(.center)175            Link(destination: universe.baseURL) {176                Label("Ouvrir \(universe.name)", systemImage: "arrow.up.right.square")177                    .font(.headline).padding(.horizontal, 22).padding(.vertical, 13)178                    .background(universe.accent, in: Capsule())179                    .foregroundStyle(.white)180            }181        }182        .padding(30)183        .frame(maxWidth: .infinity, maxHeight: .infinity)184    }185186    /// En-tête façon héros de site : tagline + compteur live + filtres métier187    private var hero: some View {188        VStack(alignment: .leading, spacing: 10) {189            HStack(alignment: .firstTextBaseline) {190                Text(universe.tagline)191                    .font(.system(.subheadline, design: .rounded).weight(.bold))192                Spacer()193                if let n = liveTotal {194                    Text("\(n.formatted(.number.locale(Locale(identifier: "fr_CA")))) \(universe.unit)")195                        .font(.system(.caption2, design: .monospaced).weight(.bold))196                        .padding(.horizontal, 9).padding(.vertical, 4)197                        .background(universe.accent.opacity(0.15), in: Capsule())198                        .foregroundStyle(universe.accent)199                }200            }201            FilterBar(universe: universe, params: $filterParams) {202                Task { await load() }203            }204        }205    }206207    private var list: some View {208        ScrollView {209            LazyVStack(spacing: 12) {210                hero211                if loading {212                    ForEach(0..<6, id: \.self) { _ in KASkeletonRow() }213                } else if let e = errorText, items.isEmpty {214                    KAEmptyState(symbol: "wifi.exclamationmark", title: "Impossible de charger",215                                 message: e + "\nTirez pour réessayer.")216                } else if items.isEmpty {217                    KAEmptyState(symbol: "tray", title: "Aucun résultat",218                                 message: "Essayez d'autres mots-clés.")219                } else {220                    ForEach(items) { item in221                        NavigationLink(value: item) { KAItemRow(item: item) }222                            .buttonStyle(KAPressStyle())223                    }224                }225            }226            .padding(16)227            .animation(.snappy(duration: 0.3), value: items)228        }229        .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }230        .searchable(text: $query, prompt: "Chercher dans \(universe.name)…")231        .onSubmit(of: .search) { Task { await load() } }232        .refreshable { await load() }233        .task {234            await load()235            liveTotal = await UniverseService.liveTotal(universe)236        }237    }238239    private func load() async {240        loading = items.isEmpty241        errorText = nil242        do {243            items = try await UniverseService.fetch(universe, query: query.isEmpty ? nil : query, limit: 40, params: filterParams)244        } catch {245            errorText = (error as? URLError)?.code == .notConnectedToInternet246                ? "Vous êtes hors ligne." : "Le service ne répond pas."247        }248        loading = false249    }250}251252// MARK: - Fiche universelle253254struct ItemDetailView: View {255    let item: KAItem256    @EnvironmentObject private var favorites: FavoritesStore257    @Environment(\.colorScheme) private var scheme258    @State private var showCollections = false259    @State private var menu: [RestoMenuSection] = []260    @State private var similar: [KAItem] = []261    @State private var fullScreenImage: URL?262    @EnvironmentObject private var recents: RecentsStore263264    private var universe: Universe? { Ecosystem.universe(item.universeID) }265266    var body: some View {267        ScrollView {268            VStack(alignment: .leading, spacing: 16) {269                if !item.imageURLs.isEmpty {270                    TabView {271                        ForEach(item.imageURLs, id: \.self) { img in272                            KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")273                                .onTapGesture { Haptics.tap(); fullScreenImage = img }274                                .accessibilityLabel("Photo de \(item.title) — toucher pour agrandir")275                                .accessibilityAddTraits(.isButton)276                        }277                    }278                    .tabViewStyle(.page(indexDisplayMode: item.imageURLs.count > 1 ? .automatic : .never))279                    .frame(height: 240)280                    .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))281                    .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous)282                        .strokeBorder(.primary.opacity(0.3), lineWidth: 1.2))283                    .overlay(alignment: .topTrailing) {284                        if item.imageURLs.count > 1 {285                            Text("\(item.imageURLs.count) photos")286                                .font(.system(size: 10, design: .monospaced).weight(.bold))287                                .padding(.horizontal, 8).padding(.vertical, 4)288                                .background(.ultraThinMaterial, in: Capsule())289                                .padding(8)290                        }291                    }292                }293                if let u = universe {294                    HStack { KAChip(text: u.wordmark, accent: u.accent); Spacer() }295                }296                Text(item.title).font(.title2.weight(.bold))297                if let sub = item.subtitle { Text(sub).font(.body).foregroundStyle(KATheme.ink2(scheme)) }298                HStack(spacing: 10) {299                    if let p = item.priceLabel {300                        Text(p).font(.system(.title3, design: .rounded).weight(.bold))301                            .foregroundStyle(universe?.accent ?? .primary)302                    }303                    if let c = item.city { KAChip(text: c) }304                }305306                if let detail = item.detail, !detail.isEmpty {307                    VStack(alignment: .leading, spacing: 6) {308                        Text("À propos").font(.headline)309                        Text(detail)310                            .font(.subheadline)311                            .foregroundStyle(KATheme.ink2(scheme))312                            .lineLimit(12)313                    }314                    .padding(14)315                    .frame(maxWidth: .infinity, alignment: .leading)316                    .kaCard()317                }318319                if !item.facts.isEmpty {320                    VStack(spacing: 0) {321                        ForEach(item.facts, id: \.self) { f in322                            HStack {323                                Text(f.label).font(.subheadline).foregroundStyle(.secondary)324                                Spacer()325                                Text(f.value).font(.subheadline.weight(.semibold))326                                    .multilineTextAlignment(.trailing)327                            }328                            .padding(.vertical, 10).padding(.horizontal, 14)329                            if f != item.facts.last { Divider() }330                        }331                    }332                    .kaCard()333                }334335                if !item.links.isEmpty {336                    VStack(alignment: .leading, spacing: 8) {337                        Text("Comptes & liens").font(.headline)338                        ForEach(item.links, id: \.self) { l in339                            if let u = URL(string: l.value) {340                                Link(destination: u) {341                                    HStack {342                                        Image(systemName: "link")343                                        Text(l.label).font(.subheadline.weight(.semibold))344                                        Spacer()345                                        Image(systemName: "arrow.up.right").font(.caption)346                                    }347                                    .padding(11)348                                    .background((universe?.accent ?? .gray).opacity(0.1), in: RoundedRectangle(cornerRadius: 10, style: .continuous))349                                }350                                .foregroundStyle(universe?.accent ?? .primary)351                            }352                        }353                    }354                }355356                if !menu.isEmpty {357                    VStack(alignment: .leading, spacing: 10) {358                        HStack {359                            Text("Menu & prix réels").font(.headline)360                            Spacer()361                            KAChip(text: "\(menu.reduce(0) { $0 + $1.items.count }) plats", accent: universe?.accent)362                        }363                        ForEach(menu.prefix(6)) { section in364                            DisclosureGroup {365                                VStack(spacing: 0) {366                                    ForEach(section.items, id: \.name) { dish in367                                        HStack(alignment: .top) {368                                            Text(dish.name).font(.subheadline)369                                            Spacer()370                                            if let p = dish.price {371                                                Text(p).font(.system(.subheadline, design: .rounded).weight(.bold))372                                                    .foregroundStyle(universe?.accent ?? .primary)373                                            }374                                        }375                                        .padding(.vertical, 7)376                                        Divider().opacity(dish.name == section.items.last?.name ? 0 : 0.6)377                                    }378                                }379                                .padding(.top, 4)380                            } label: {381                                Text(section.name).font(.subheadline.weight(.bold))382                            }383                            .padding(.horizontal, 14).padding(.vertical, 8)384                            .kaCard()385                        }386                    }387                }388389                HStack(spacing: 10) {390                    if let url = item.url {391                        Link(destination: url) {392                            Label("Voir à la source", systemImage: "arrow.up.right.square")393                                .font(.headline)394                                .frame(maxWidth: .infinity).padding(.vertical, 14)395                                .background(universe?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 12, style: .continuous))396                                .foregroundStyle(.white)397                        }398                    }399                    if let la = item.latitude, let lo = item.longitude {400                        Button {401                            Haptics.tap()402                            let place = MKMapItem(placemark: MKPlacemark(coordinate: .init(latitude: la, longitude: lo)))403                            place.name = item.title404                            place.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault])405                        } label: {406                            Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill")407                                .font(.headline)408                                .padding(.horizontal, 16).padding(.vertical, 14)409                                .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous))410                                .foregroundStyle(KATheme.lime)411                        }412                        .accessibilityLabel("Itinéraire vers \(item.title)")413                    }414                }415                if let src = item.url?.host() {416                    Text("Source : \(src) — Groupe KA est un agrégateur, la transaction se fait chez la source originale.")417                        .font(.caption2).foregroundStyle(.tertiary)418                }419420                if !similar.isEmpty {421                    VStack(alignment: .leading, spacing: 10) {422                        Text("Semblables\(item.city.map { " à \($0)" } ?? "")").font(.headline)423                        ScrollView(.horizontal, showsIndicators: false) {424                            HStack(spacing: 10) {425                                ForEach(similar) { s in426                                    NavigationLink(value: s) {427                                        VStack(alignment: .leading, spacing: 0) {428                                            KAImage(url: s.imageURL, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")429                                                .frame(width: 150, height: 84).clipped()430                                            VStack(alignment: .leading, spacing: 2) {431                                                Text(s.title).font(.caption.weight(.semibold))432                                                    .lineLimit(2, reservesSpace: true)433                                                if let p = s.priceLabel {434                                                    Text(p).font(.system(.caption2, design: .rounded).weight(.bold))435                                                        .foregroundStyle(universe?.accent ?? .primary)436                                                }437                                            }438                                            .padding(8)439                                        }440                                        .frame(width: 150, alignment: .topLeading)441                                        .kaCard(accent: universe?.accent)442                                    }443                                    .buttonStyle(KAPressStyle())444                                }445                            }446                            .padding(.vertical, 4)447                        }448                    }449                }450            }451            .padding(16)452        }453        .background(KATheme.paper(scheme))454        .navigationBarTitleDisplayMode(.inline)455        .toolbar {456            ToolbarItemGroup(placement: .topBarTrailing) {457                Button {458                    if favorites.isFavorite(item) { favorites.toggle(item) }459                    else if favorites.collections.count > 1 { showCollections = true }460                    else { favorites.toggle(item) }461                } label: {462                    Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart")463                        .foregroundStyle(favorites.isFavorite(item) ? .red : .primary)464                }465                .accessibilityLabel(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris")466                if let url = item.url {467                    ShareLink(item: url, subject: Text(item.title))468                }469            }470        }471        .task {472            recents.record(item)473            if item.universeID == "resto-ka" {474                let uid = String(item.id.dropFirst("resto-ka:".count))475                menu = await RestoMenuLoader.load(uid: uid)476            }477            if let u = universe, u.map != nil, similar.isEmpty {478                let batch = (try? await UniverseService.fetch(u, city: item.city, limit: 10)) ?? []479                similar = batch.filter { $0.id != item.id }.prefix(6).map { $0 }480            }481        }482        .fullScreenCover(item: $fullScreenImage) { img in483            ZoomableImageView(url: img, accent: universe?.accent ?? .gray)484        }485        .confirmationDialog("Ajouter à…", isPresented: $showCollections, titleVisibility: .visible) {486            ForEach(favorites.collections) { c in487                Button(c.name) { favorites.toggle(item, in: c.id) }488            }489        }490        .tint(universe?.accent)491    }492}493
Files · ce73f14a9dd1fe80ae09bf638fcba97f1a74e08a
Menu

Theme