// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // FavoritesView.swift — favoris & collections unifiés (multi-univers dans une // même collection), consultables hors ligne, avec FILTRE par univers, TRI // (récent / prix) et RECHERCHE dans ses trouvailles. import SwiftUI struct FavoritesView: View { @EnvironmentObject private var favorites: FavoritesStore @State private var newName = "" @State private var showNew = false @State private var filterUniverse: String? @State private var sort: FavSort = .recent @State private var searchText = "" @Environment(\.colorScheme) private var scheme enum FavSort: String, CaseIterable { case recent = "Récents" case prixAsc = "Prix ↑" case prixDesc = "Prix ↓" case titre = "A → Z" } /// Univers réellement présents dans les favoris (chips de filtre) private var presentUniverses: [Universe] { let ids = Set(favorites.allItems.map(\.universeID)) return Ecosystem.all.filter { ids.contains($0.id) } } private func filtered(_ items: [KAItem]) -> [KAItem] { var out = items if let f = filterUniverse { out = out.filter { $0.universeID == f } } if !searchText.isEmpty { out = out.filter { $0.title.localizedCaseInsensitiveContains(searchText) || ($0.city ?? "").localizedCaseInsensitiveContains(searchText) || ($0.subtitle ?? "").localizedCaseInsensitiveContains(searchText) } } switch sort { case .recent: return out case .prixAsc: return out.sorted { ($0.price ?? .greatestFiniteMagnitude) < ($1.price ?? .greatestFiniteMagnitude) } case .prixDesc: return out.sorted { ($0.price ?? 0) > ($1.price ?? 0) } case .titre: return out.sorted { $0.title.localizedCompare($1.title) == .orderedAscending } } } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 18) { if favorites.allItems.isEmpty { KAEmptyState(symbol: "heart", title: "Aucun favori pour l'instant", message: "Touchez ♥ sur un logement, une auto, un resto ou une sortie — tout se retrouve ici, même hors ligne.") } else { universeChips } ForEach(favorites.collections) { collection in collectionSection(collection) } } .padding(16) } .background(KATheme.paper(scheme)) .navigationTitle("Favoris") .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } .searchable(text: $searchText, prompt: "Chercher dans vos favoris…") .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { if favorites.allItems.count > 1 { Menu { Picker("Tri", selection: $sort) { ForEach(FavSort.allCases, id: \.self) { Text($0.rawValue).tag($0) } } } label: { Image(systemName: "arrow.up.arrow.down") } .accessibilityLabel("Trier les favoris") } Button { showNew = true } label: { Image(systemName: "folder.badge.plus") } .accessibilityLabel("Nouvelle collection") } } .alert("Nouvelle collection", isPresented: $showNew) { TextField("Ex. Déménagement à Val-d'Or", text: $newName) Button("Créer") { favorites.addCollection(newName) newName = "" Haptics.success() } Button("Annuler", role: .cancel) { newName = "" } } message: { Text("Une collection peut mélanger tous les univers : un 4½, une auto et un resto ensemble.") } } private var universeChips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { chip(label: "Tous · \(favorites.allItems.count)", accent: KATheme.green, on: filterUniverse == nil) { filterUniverse = nil } ForEach(presentUniverses) { u in let count = favorites.allItems.filter { $0.universeID == u.id }.count chip(label: "\(u.wordmark) · \(count)", accent: u.accent, on: filterUniverse == u.id) { filterUniverse = filterUniverse == u.id ? nil : u.id } } } } } private func chip(label: String, accent: Color, on: Bool, action: @escaping () -> Void) -> some View { Button { Haptics.tap() withAnimation(.snappy) { action() } } label: { Text(label) .font(.system(.caption, design: .monospaced).weight(.bold)) .padding(.horizontal, 11).padding(.vertical, 7) .background(on ? accent : KATheme.surface(scheme), in: Capsule()) .foregroundStyle(on ? .white : .secondary) .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1)) } .accessibilityAddTraits(on ? [.isSelected] : []) } @ViewBuilder private func collectionSection(_ c: FavoritesStore.FavCollection) -> some View { let items = filtered(c.items) // une collection vidée par le filtre s'efface (sauf si aucune n'a rien) if !items.isEmpty || (filterUniverse == nil && searchText.isEmpty) { VStack(alignment: .leading, spacing: 10) { HStack { Label(c.name, systemImage: "folder.fill") .font(.headline) Spacer() Text("\(items.count)") .font(.system(.caption, design: .monospaced).weight(.bold)) .foregroundStyle(.secondary) if favorites.collections.count > 1 { Menu { Button(role: .destructive) { favorites.removeCollection(c.id) } label: { Label("Supprimer la collection", systemImage: "trash") } } label: { Image(systemName: "ellipsis.circle").foregroundStyle(.secondary) } } } if items.isEmpty { Text("Vide — ajoutez-y des trouvailles depuis n'importe quel univers.") .font(.caption).foregroundStyle(.tertiary) } ForEach(items) { item in NavigationLink(value: item) { KAItemRow(item: item) } .buttonStyle(.plain) .contextMenu { ForEach(favorites.collections.filter { $0.id != c.id }) { other in Button { favorites.move(item, to: other.id) } label: { Label("Déplacer vers \(other.name)", systemImage: "folder") } } if let url = item.url { ShareLink(item: url) { Label("Partager", systemImage: "square.and.arrow.up") } } Button(role: .destructive) { favorites.toggle(item) } label: { Label("Retirer", systemImage: "heart.slash") } } } } } } }