SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
29 days agolast push
Swift 100%
7.6 KB · 170 lines swift
Raw Blame History
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