// 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 import MapKit extension URL: @retroactive Identifiable { public var id: String { absoluteString } } /// Visionneuse plein écran avec zoom (pincement + double-tap) struct ZoomableImageView: View { let url: URL let accent: Color @Environment(\.dismiss) private var dismiss @State private var scale: CGFloat = 1 @State private var lastScale: CGFloat = 1 var body: some View { ZStack(alignment: .topTrailing) { Color.black.ignoresSafeArea() KAImage(url: url, accent: accent) .aspectRatio(contentMode: .fit) .scaleEffect(scale) .gesture( MagnifyGesture() .onChanged { v in scale = min(max(lastScale * v.magnification, 1), 5) } .onEnded { _ in lastScale = scale } ) .onTapGesture(count: 2) { withAnimation(.snappy) { scale = scale > 1.5 ? 1 : 2.5; lastScale = scale } } .accessibilityLabel("Photo en plein écran, pincer pour zoomer") Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") .font(.title2).foregroundStyle(.white.opacity(0.85)) .padding(16) } .accessibilityLabel("Fermer la photo") } .statusBarHidden() } } // 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 @State private var path = NavigationPath() var body: some View { NavigationStack(path: $path) { ScrollView { LazyVGrid(columns: columns, spacing: 14) { ForEach(Ecosystem.all) { u in NavigationLink(value: u.id) { UniverseCard(universe: u, total: totals[u.id]) } .buttonStyle(KAPressStyle()) } } .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 { // pilotage debug/captures : ouvre directement un univers if let id = UserDefaults.standard.string(forKey: "ka.debug.universe"), Ecosystem.universe(id) != nil, path.isEmpty { path.append(id) } 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 { KALogo(universe: universe, size: 40) 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(KAFont.mono(10)) .foregroundStyle(universe.accent) .contentTransition(.numericText()) } else { Text("—").font(KAFont.mono(10, bold: false)) .foregroundStyle(.tertiary) } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .kaCard(accent: universe.accent) .accessibilityElement(children: .combine) } } // MARK: - Héros de site (reproduction fidèle de la page d'accueil de l'univers) struct SiteHero: View { let universe: Universe let liveTotal: Int? var openSite: (() -> Void)? = nil @Environment(\.colorScheme) private var scheme private var copy: HeroCopy? { KAHeroes.hero(universe.id) } var body: some View { VStack(alignment: .leading, spacing: 12) { ticker if let c = copy { if let k = c.kicker { HStack(spacing: 10) { Rectangle().fill(KATheme.green).frame(width: 26, height: 2.5) Text(k.uppercased()) .font(KAFont.mono(10)) .foregroundStyle(scheme == .dark ? KATheme.lime : KATheme.green) .kerning(1.1) .lineLimit(2) } } headline(c) Text(c.sub) .font(.subheadline) .foregroundStyle(KATheme.ink2(scheme)) .lineLimit(4) } HStack(spacing: 10) { statPill Spacer() if let openSite { Button(action: { Haptics.tap(); openSite() }) { Text("VOIR LE SITE ↗") .font(KAFont.mono(10)) .foregroundStyle(universe.accent) .padding(.horizontal, 12).padding(.vertical, 8) .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) } .buttonStyle(KAPressStyle()) .accessibilityLabel("Voir le site \(universe.name) dans l'app") } } } } /// Bandeau ticker encre — la signature du haut des sites private var ticker: some View { HStack(spacing: 8) { KALogo(universe: universe, size: 18) Text(universe.unit.uppercased()) .font(KAFont.mono(9.5)) .foregroundStyle(.white.opacity(0.9)) Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent) Text("EN DIRECT") .font(KAFont.mono(9.5)) .foregroundStyle(universe.accent) Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent) Text(universe.domain.replacingOccurrences(of: "www.", with: "").uppercased()) .font(KAFont.mono(9.5)) .foregroundStyle(.white.opacity(0.75)) .lineLimit(1) Spacer(minLength: 0) } .padding(.horizontal, 12).padding(.vertical, 8) .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } /// Titre display avec le segment surligné SUR l'accent (comme le h1 du site) private func headline(_ c: HeroCopy) -> some View { var attr = AttributedString(c.headline) attr.font = KAFont.display(27) attr.foregroundColor = KATheme.ink(scheme) if let hl = c.highlight, let range = attr.range(of: hl, options: .caseInsensitive) { attr[range].backgroundColor = universe.accent attr[range].foregroundColor = .white } return Text(attr).lineSpacing(2) } private var statPill: some View { HStack(spacing: 7) { Circle().fill(universe.accent).frame(width: 8, height: 8) if let n = liveTotal { KACountUp(value: n, font: KAFont.display(15), color: KATheme.ink(scheme)) Text(universe.unit) .font(.caption).foregroundStyle(KATheme.ink2(scheme)) } else { Text("En direct").font(.caption).foregroundStyle(KATheme.ink2(scheme)) } } .padding(.horizontal, 13).padding(.vertical, 8) .background(KATheme.surface(scheme), in: Capsule()) .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5)) .background(Capsule().fill(scheme == .dark ? Color.clear : KATheme.inkLight).offset(x: 4, y: 4)) } } // 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? @State private var filterParams: [String: String] = [:] @State private var liveTotal: Int? @State private var showSite = false @State private var cityFilter: String? @State private var cityPool: [String] = [] // top villes du premier chargement @Environment(\.colorScheme) private var scheme var body: some View { Group { if universe.id == "vrai-prix" { VraiPrixView(universe: universe) } else if universe.id == "api-ka" { APIPlaygroundView(universe: universe) } else if universe.listPath == nil { webUniverse } else { list } } .background(KATheme.paper(scheme)) .navigationTitle(universe.name) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { Haptics.tap(); showSite = true } label: { Image(systemName: "globe").accessibilityLabel("Voir le site \(universe.name) dans l'app") } } } .fullScreenCover(isPresented: $showSite) { SiteBrowserCover(universe: universe) } .tint(universe.accent) } /// Univers sans liste native : le site lui-même, embarqué private var webUniverse: some View { EmbeddedSite(universe: universe) } /// En-tête FIDÈLE au site : bande teintée accent + logo filigrane autour /// du héros (ticker encre, kicker, titre surligné, pilule live roulante). private var hero: some View { VStack(alignment: .leading, spacing: 0) { UniverseHeroBand(universe: universe) { SiteHero(universe: universe, liveTotal: liveTotal) { showSite = true } } FilterBar(universe: universe, params: $filterParams) { Task { await load() } } .padding(.top, 12) } } private var list: some View { ScrollView { LazyVStack(spacing: 14) { hero if loading { ForEach(0..<6, id: \.self) { _ in KASkeletonRow() } } 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 { if universe.id == "trouve-ka" && query.isEmpty { KAEmptyState(symbol: "magnifyingglass", title: "Cherchez le web québécois", message: "1,2 M de pages d'ici indexées — tapez un mot dans la barre de recherche ci-dessus.") } else { KAEmptyState(symbol: "tray", title: "Aucun résultat", message: "Essayez d'autres mots-clés.") } } else { UniverseStatStrip(universe: universe, liveTotal: liveTotal, shown: items.count, cityCount: Set(items.compactMap(\.city)).count) if cityPool.count > 1 { UniverseCityChips(cities: cityPool, selected: $cityFilter, accent: universe.accent) { Task { await load() } } } magazine } } .padding(16) .animation(.snappy(duration: 0.3), value: items) } .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() liveTotal = await UniverseService.liveTotal(universe) } } /// Mise en page MAGAZINE quand l'univers a des photos : vitrine pleine /// largeur → carrousel « En vedette » → mosaïque 2 colonnes. Sans photos /// (Job·Ka, Trouve·Ka…) : liste éditoriale classique. @ViewBuilder private var magazine: some View { let visuals = items.filter { $0.imageURL != nil } if visuals.count >= 3, let showcase = visuals.first { let featured = Array(visuals.dropFirst().prefix(6)) let usedIDs = Set([showcase.id] + featured.map(\.id)) let rest = items.filter { !usedIDs.contains($0.id) } NavigationLink(value: showcase) { UniverseShowcaseCard(item: showcase, universe: universe) } .buttonStyle(KAPressStyle()) .kaScrollPop(axis: .vertical) if !featured.isEmpty { UniverseSectionHeader(title: "En vedette", accent: universe.accent) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(featured) { it in NavigationLink(value: it) { UniverseFeaturedCard(item: it, universe: universe) } .buttonStyle(KAPressStyle()) .kaScrollPop() } } .scrollTargetLayout() .padding(.vertical, 4) } .scrollTargetBehavior(.viewAligned) } if !rest.isEmpty { UniverseSectionHeader(title: "Tout le flux", accent: universe.accent, detail: "\(rest.count)") LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { ForEach(rest) { it in NavigationLink(value: it) { UniverseMosaicCell(item: it, universe: universe) } .buttonStyle(KAPressStyle()) .kaScrollPop(axis: .vertical) } } } } else { ForEach(items) { item in NavigationLink(value: item) { KAItemRow(item: item) } .buttonStyle(KAPressStyle()) .kaScrollPop(axis: .vertical) } } } private func load() async { loading = items.isEmpty errorText = nil do { items = try await UniverseService.fetch(universe, query: query.isEmpty ? nil : query, city: cityFilter, limit: 40, params: filterParams) // top villes mémorisées sur un chargement NON filtré (chips stables) if query.isEmpty, cityFilter == nil, filterParams.isEmpty { let counts = Dictionary(grouping: items.compactMap(\.city), by: { $0 }) .mapValues(\.count) cityPool = counts.sorted { $0.value > $1.value }.prefix(8).map(\.key) } } 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 @State private var menu: [RestoMenuSection] = [] @State private var similar: [KAItem] = [] @State private var fullScreenImage: URL? /// Fiche COMPLÈTE chargée depuis l'API de détail du site (galerie entière, /// description longue, inclusions, caractéristiques, estimation Vrai-Prix…) @State private var enriched: KAItem? @State private var descExpanded = false @EnvironmentObject private var recents: RecentsStore private var universe: Universe? { Ecosystem.universe(it.universeID) } /// L'item affiché : enrichi dès que l'API de détail répond, sinon la liste. private var it: KAItem { enriched ?? item } private var accent: Color { universe?.accent ?? KATheme.green } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { if !it.imageURLs.isEmpty { TabView { ForEach(it.imageURLs, id: \.self) { img in KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo") .onTapGesture { Haptics.tap(); fullScreenImage = img } .accessibilityLabel("Photo de \(it.title) — toucher pour agrandir") .accessibilityAddTraits(.isButton) } } .tabViewStyle(.page(indexDisplayMode: it.imageURLs.count > 1 ? .automatic : .never)) .frame(height: 240) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(.primary.opacity(0.3), lineWidth: 1.2)) .overlay(alignment: .topTrailing) { if it.imageURLs.count > 1 { Text("\(it.imageURLs.count) photos") .font(.system(size: 10, design: .monospaced).weight(.bold)) .padding(.horizontal, 8).padding(.vertical, 4) .background(.ultraThinMaterial, in: Capsule()) .padding(8) } } } if let u = universe { HStack { KAChip(text: u.wordmark, accent: u.accent) Spacer() if enriched != nil { Text("FICHE COMPLÈTE") .font(KAFont.mono(8)) .foregroundStyle(accent) .transition(.opacity) } } } Text(it.title).font(KAFont.display(23)) if let sub = it.subtitle, !sub.isEmpty { Text(sub).font(.body).foregroundStyle(KATheme.ink2(scheme)) } HStack(spacing: 10) { if let p = it.priceLabel { Text(p).font(KAFont.display(21)) .foregroundStyle(accent) } if let c = it.city { KAChip(text: c) } } // « En bref » — le digest de la fiche du site if let brief = it.brief, !brief.isEmpty { HStack(alignment: .top, spacing: 10) { Rectangle().fill(accent).frame(width: 3) VStack(alignment: .leading, spacing: 4) { Text("EN BREF") .font(KAFont.mono(9)) .foregroundStyle(accent) Text(brief) .font(.subheadline) .foregroundStyle(KATheme.ink(scheme)) } } .padding(13) .frame(maxWidth: .infinity, alignment: .leading) .background(accent.opacity(scheme == .dark ? 0.14 : 0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } // étiquettes de la fiche (commodités, inclusions, équipements…) if let tags = it.tags, !tags.isEmpty { VStack(alignment: .leading, spacing: 8) { UniverseSectionHeader(title: "Caractéristiques", accent: accent, detail: "\(tags.count)") KAFlow(spacing: 7) { ForEach(tags, id: \.self) { t in Text(t) .font(.system(.caption2, design: .rounded, weight: .semibold)) .padding(.horizontal, 10).padding(.vertical, 6) .background(accent.opacity(0.10), in: Capsule()) .overlay(Capsule().strokeBorder(accent.opacity(0.35), lineWidth: 1)) .foregroundStyle(KATheme.ink(scheme)) } } } } if let detail = it.detail, !detail.isEmpty { VStack(alignment: .leading, spacing: 6) { Text("À propos").font(.headline) Text(detail) .font(.subheadline) .foregroundStyle(KATheme.ink2(scheme)) .lineLimit(descExpanded ? nil : 8) if detail.count > 350 { Button { withAnimation(.snappy) { descExpanded.toggle() } } label: { Text(descExpanded ? "Réduire ↑" : "Lire la suite ↓") .font(KAFont.mono(10)) .foregroundStyle(accent) } } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .kaCard() } // fiche technique — grille 2 colonnes, comme le tableau du site if !it.facts.isEmpty { VStack(alignment: .leading, spacing: 8) { UniverseSectionHeader(title: "Détails", accent: accent, detail: "\(it.facts.count)") LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], alignment: .leading, spacing: 10) { ForEach(it.facts, id: \.self) { f in VStack(alignment: .leading, spacing: 3) { Text(f.label.uppercased()) .font(KAFont.mono(8)) .foregroundStyle(KATheme.ink2(scheme)) .lineLimit(1) Text(f.value) .font(.subheadline.weight(.semibold)) .foregroundStyle(KATheme.ink(scheme)) .lineLimit(3) .multilineTextAlignment(.leading) } .padding(.horizontal, 11).padding(.vertical, 9) .frame(maxWidth: .infinity, minHeight: 54, alignment: .topLeading) .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(KATheme.ink(scheme).opacity(0.18), lineWidth: 1)) .accessibilityElement(children: .combine) } } } } // localisation sur la carte if let la = it.latitude, let lo = it.longitude { VStack(alignment: .leading, spacing: 8) { UniverseSectionHeader(title: "Sur la carte", accent: accent) Map(position: .constant(.region(MKCoordinateRegion( center: .init(latitude: la, longitude: lo), span: .init(latitudeDelta: 0.012, longitudeDelta: 0.012))))) { Marker(it.title, coordinate: .init(latitude: la, longitude: lo)) .tint(accent) } .frame(height: 170) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1.2)) .allowsHitTesting(false) .accessibilityLabel("Emplacement de \(it.title) sur la carte") } } if !it.links.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Comptes & liens").font(.headline) ForEach(it.links, id: \.self) { l in if let u = URL(string: l.value) { Link(destination: u) { HStack { Image(systemName: "link") Text(l.label).font(.subheadline.weight(.semibold)) Spacer() Image(systemName: "arrow.up.right").font(.caption) } .padding(11) .background((universe?.accent ?? .gray).opacity(0.1), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } .foregroundStyle(universe?.accent ?? .primary) } } } } if !menu.isEmpty { VStack(alignment: .leading, spacing: 10) { HStack { Text("Menu & prix réels").font(.headline) Spacer() KAChip(text: "\(menu.reduce(0) { $0 + $1.items.count }) plats", accent: universe?.accent) } ForEach(menu.prefix(6)) { section in DisclosureGroup { VStack(spacing: 0) { ForEach(section.items, id: \.name) { dish in HStack(alignment: .top) { Text(dish.name).font(.subheadline) Spacer() if let p = dish.price { Text(p).font(.system(.subheadline, design: .rounded).weight(.bold)) .foregroundStyle(universe?.accent ?? .primary) } } .padding(.vertical, 7) Divider().opacity(dish.name == section.items.last?.name ? 0 : 0.6) } } .padding(.top, 4) } label: { Text(section.name).font(.subheadline.weight(.bold)) } .padding(.horizontal, 14).padding(.vertical, 8) .kaCard() } } } HStack(spacing: 10) { if let url = it.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) } } if let la = it.latitude, let lo = it.longitude { Button { Haptics.tap() let place = MKMapItem(placemark: MKPlacemark(coordinate: .init(latitude: la, longitude: lo))) place.name = it.title place.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDefault]) } label: { Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill") .font(.headline) .padding(.horizontal, 16).padding(.vertical, 14) .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .foregroundStyle(KATheme.lime) } .accessibilityLabel("Itinéraire vers \(it.title)") } } if let src = it.url?.host() { Text("Source : \(src) — Groupe KA est un agrégateur, la transaction se fait chez la source originale.") .font(.caption2).foregroundStyle(.tertiary) } if !similar.isEmpty { VStack(alignment: .leading, spacing: 10) { Text("Semblables\(it.city.map { " à \($0)" } ?? "")").font(.headline) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(similar) { s in NavigationLink(value: s) { VStack(alignment: .leading, spacing: 0) { KAImage(url: s.imageURL, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo") .frame(width: 150, height: 84).clipped() VStack(alignment: .leading, spacing: 2) { Text(s.title).font(.caption.weight(.semibold)) .lineLimit(2, reservesSpace: true) if let p = s.priceLabel { Text(p).font(.system(.caption2, design: .rounded).weight(.bold)) .foregroundStyle(universe?.accent ?? .primary) } } .padding(8) } .frame(width: 150, alignment: .topLeading) .kaCard(accent: universe?.accent) } .buttonStyle(KAPressStyle()) } } .padding(.vertical, 4) } } } } .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 = it.url { ShareLink(item: url, subject: Text(it.title)) } } } .task { recents.record(item) // fiche complète depuis l'API de détail du site (galerie entière, // description longue, inclusions, estimation Vrai-Prix, heures…) if let u = universe, enriched == nil, let full = await DetailService.enrich(item, universe: u) { withAnimation(.snappy) { enriched = full } } if it.universeID == "resto-ka" { let uid = String(it.id.dropFirst("resto-ka:".count)) menu = await RestoMenuLoader.load(uid: uid) } if let u = universe, u.map != nil, similar.isEmpty { let batch = (try? await UniverseService.fetch(u, city: it.city, limit: 10)) ?? [] similar = batch.filter { $0.id != it.id }.prefix(6).map { $0 } } } .fullScreenCover(item: $fullScreenImage) { img in ZoomableImageView(url: img, accent: universe?.accent ?? .gray) } .confirmationDialog("Ajouter à…", isPresented: $showCollections, titleVisibility: .visible) { ForEach(favorites.collections) { c in Button(c.name) { favorites.toggle(item, in: c.id) } } } .tint(universe?.accent) } }