Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// FavoritesView.swift — favoris & collections unifiés (multi-univers dans une3// même collection), consultables hors ligne, avec FILTRE par univers, TRI4// (récent / prix) et RECHERCHE dans ses trouvailles.5import SwiftUI67struct FavoritesView: View {8 @EnvironmentObject private var favorites: FavoritesStore9 @State private var newName = ""10 @State private var showNew = false11 @State private var filterUniverse: String?12 @State private var sort: FavSort = .recent13 @State private var searchText = ""14 @Environment(\.colorScheme) private var scheme1516 enum FavSort: String, CaseIterable {17 case recent = "Récents"18 case prixAsc = "Prix ↑"19 case prixDesc = "Prix ↓"20 case titre = "A → Z"21 }2223 /// Univers réellement présents dans les favoris (chips de filtre)24 private var presentUniverses: [Universe] {25 let ids = Set(favorites.allItems.map(\.universeID))26 return Ecosystem.all.filter { ids.contains($0.id) }27 }2829 private func filtered(_ items: [KAItem]) -> [KAItem] {30 var out = items31 if let f = filterUniverse { out = out.filter { $0.universeID == f } }32 if !searchText.isEmpty {33 out = out.filter {34 $0.title.localizedCaseInsensitiveContains(searchText)35 || ($0.city ?? "").localizedCaseInsensitiveContains(searchText)36 || ($0.subtitle ?? "").localizedCaseInsensitiveContains(searchText)37 }38 }39 switch sort {40 case .recent: return out41 case .prixAsc: return out.sorted { ($0.price ?? .greatestFiniteMagnitude) < ($1.price ?? .greatestFiniteMagnitude) }42 case .prixDesc: return out.sorted { ($0.price ?? 0) > ($1.price ?? 0) }43 case .titre: return out.sorted { $0.title.localizedCompare($1.title) == .orderedAscending }44 }45 }4647 var body: some View {48 ScrollView {49 VStack(alignment: .leading, spacing: 18) {50 if favorites.allItems.isEmpty {51 KAEmptyState(symbol: "heart", title: "Aucun favori pour l'instant",52 message: "Touchez ♥ sur un logement, une auto, un resto ou une sortie — tout se retrouve ici, même hors ligne.")53 } else {54 universeChips55 }56 ForEach(favorites.collections) { collection in57 collectionSection(collection)58 }59 }60 .padding(16)61 }62 .background(KATheme.paper(scheme))63 .navigationTitle("Favoris")64 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }65 .searchable(text: $searchText, prompt: "Chercher dans vos favoris…")66 .toolbar {67 ToolbarItemGroup(placement: .topBarTrailing) {68 if favorites.allItems.count > 1 {69 Menu {70 Picker("Tri", selection: $sort) {71 ForEach(FavSort.allCases, id: \.self) { Text($0.rawValue).tag($0) }72 }73 } label: { Image(systemName: "arrow.up.arrow.down") }74 .accessibilityLabel("Trier les favoris")75 }76 Button { showNew = true } label: { Image(systemName: "folder.badge.plus") }77 .accessibilityLabel("Nouvelle collection")78 }79 }80 .alert("Nouvelle collection", isPresented: $showNew) {81 TextField("Ex. Déménagement à Val-d'Or", text: $newName)82 Button("Créer") {83 favorites.addCollection(newName)84 newName = ""85 Haptics.success()86 }87 Button("Annuler", role: .cancel) { newName = "" }88 } message: {89 Text("Une collection peut mélanger tous les univers : un 4½, une auto et un resto ensemble.")90 }91 }9293 private var universeChips: some View {94 ScrollView(.horizontal, showsIndicators: false) {95 HStack(spacing: 8) {96 chip(label: "Tous · \(favorites.allItems.count)", accent: KATheme.green,97 on: filterUniverse == nil) { filterUniverse = nil }98 ForEach(presentUniverses) { u in99 let count = favorites.allItems.filter { $0.universeID == u.id }.count100 chip(label: "\(u.wordmark) · \(count)", accent: u.accent,101 on: filterUniverse == u.id) {102 filterUniverse = filterUniverse == u.id ? nil : u.id103 }104 }105 }106 }107 }108109 private func chip(label: String, accent: Color, on: Bool, action: @escaping () -> Void) -> some View {110 Button {111 Haptics.tap()112 withAnimation(.snappy) { action() }113 } label: {114 Text(label)115 .font(.system(.caption, design: .monospaced).weight(.bold))116 .padding(.horizontal, 11).padding(.vertical, 7)117 .background(on ? accent : KATheme.surface(scheme), in: Capsule())118 .foregroundStyle(on ? .white : .secondary)119 .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))120 }121 .accessibilityAddTraits(on ? [.isSelected] : [])122 }123124 @ViewBuilder125 private func collectionSection(_ c: FavoritesStore.FavCollection) -> some View {126 let items = filtered(c.items)127 // une collection vidée par le filtre s'efface (sauf si aucune n'a rien)128 if !items.isEmpty || (filterUniverse == nil && searchText.isEmpty) {129 VStack(alignment: .leading, spacing: 10) {130 HStack {131 Label(c.name, systemImage: "folder.fill")132 .font(.headline)133 Spacer()134 Text("\(items.count)")135 .font(.system(.caption, design: .monospaced).weight(.bold))136 .foregroundStyle(.secondary)137 if favorites.collections.count > 1 {138 Menu {139 Button(role: .destructive) {140 favorites.removeCollection(c.id)141 } label: { Label("Supprimer la collection", systemImage: "trash") }142 } label: {143 Image(systemName: "ellipsis.circle").foregroundStyle(.secondary)144 }145 }146 }147 if items.isEmpty {148 Text("Vide — ajoutez-y des trouvailles depuis n'importe quel univers.")149 .font(.caption).foregroundStyle(.tertiary)150 }151 ForEach(items) { item in152 NavigationLink(value: item) { KAItemRow(item: item) }153 .buttonStyle(.plain)154 .contextMenu {155 ForEach(favorites.collections.filter { $0.id != c.id }) { other in156 Button { favorites.move(item, to: other.id) } label: {157 Label("Déplacer vers \(other.name)", systemImage: "folder")158 }159 }160 if let url = item.url { ShareLink(item: url) { Label("Partager", systemImage: "square.and.arrow.up") } }161 Button(role: .destructive) { favorites.toggle(item) } label: {162 Label("Retirer", systemImage: "heart.slash")163 }164 }165 }166 }167 }168 }169}170