Swift 98.3%
Shell 1.7%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// LibraryViews.swift — la bibliothèque personnelle : Favoris par collections3// (multi-univers), Historique consulté, Recherches sauvegardées & ALERTES4// honnêtes (deltas réels recomptés via l'API). Stores repris de l'app iOS KA.5import SwiftUI67// MARK: - Favoris (collections)89struct FavoritesView: View {10 @EnvironmentObject private var favorites: FavoritesStore11 @Environment(\.colorScheme) private var scheme12 @State private var selectedCollection: UUID?13 @State private var selected: KAItem?14 @State private var newName = ""1516 private var current: FavoritesStore.FavCollection? {17 favorites.collections.first { $0.id == selectedCollection } ?? favorites.collections.first18 }1920 var body: some View {21 VStack(spacing: 0) {22 header23 Divider().opacity(0.4)24 HStack(spacing: 0) {25 grid26 .frame(maxWidth: .infinity)27 Divider().opacity(0.4)28 Group {29 if let item = selected {30 ItemDetailPane(item: item)31 } else {32 KAEmptyState(symbol: "heart",33 title: "Vos favoris, tous univers confondus",34 message: "Un 4½, une auto et un resto peuvent vivre dans la même collection.")35 .frame(maxHeight: .infinity)36 .background(KATheme.paper(scheme))37 }38 }39 .frame(width: 440)40 }41 }42 }4344 private var header: some View {45 HStack(spacing: 10) {46 Text("Favoris").font(.title2.weight(.bold))47 Picker("Collection", selection: Binding(48 get: { selectedCollection ?? favorites.collections.first?.id ?? UUID() },49 set: { selectedCollection = $0; selected = nil }50 )) {51 ForEach(favorites.collections) { c in52 Text("\(c.name) (\(c.items.count))").tag(c.id)53 }54 }55 .labelsHidden()56 .frame(maxWidth: 260)57 if let c = current, favorites.collections.count > 1 {58 Button {59 favorites.removeCollection(c.id)60 selectedCollection = favorites.collections.first?.id61 selected = nil62 } label: {63 Image(systemName: "trash")64 }65 .help("Supprimer la collection « \(c.name) » (les éléments sont retirés des favoris)")66 }67 Spacer()68 TextField("Nouvelle collection…", text: $newName)69 .textFieldStyle(.roundedBorder)70 .frame(maxWidth: 200)71 .onSubmit { addCollection() }72 Button("Créer") { addCollection() }73 .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty)74 }75 .padding(.horizontal, 16).padding(.vertical, 11)76 }7778 private var grid: some View {79 ScrollView {80 if let c = current, !c.items.isEmpty {81 LazyVGrid(columns: [GridItem(.adaptive(minimum: 230), spacing: 12)], spacing: 12) {82 ForEach(c.items) { item in83 Button { selected = item } label: {84 KAItemCard(item: item, selected: selected?.id == item.id)85 }86 .buttonStyle(KAPressStyle())87 .contextMenu {88 Button("Retirer des favoris") {89 favorites.toggle(item)90 if selected?.id == item.id { selected = nil }91 }92 if favorites.collections.count > 1 {93 Menu("Déplacer vers") {94 ForEach(favorites.collections.filter { $0.id != c.id }) { other in95 Button(other.name) { favorites.move(item, to: other.id) }96 }97 }98 }99 }100 }101 }102 .padding(14)103 } else {104 KAEmptyState(symbol: "heart.slash",105 title: "Aucun favori ici",106 message: "Cliquez le cœur sur n'importe quelle fiche pour la garder — consultable hors ligne.")107 .padding(30)108 }109 }110 .background(KATheme.paper(scheme))111 }112113 private func addCollection() {114 favorites.addCollection(newName)115 newName = ""116 selectedCollection = favorites.collections.last?.id117 }118}119120// MARK: - Historique consulté121122struct HistoryView: View {123 @EnvironmentObject private var recents: RecentsStore124 @Environment(\.colorScheme) private var scheme125 @State private var selected: KAItem?126127 var body: some View {128 VStack(spacing: 0) {129 HStack {130 Text("Historique").font(.title2.weight(.bold))131 Text("\(recents.viewed.count) fiche\(recents.viewed.count > 1 ? "s" : "") consultée\(recents.viewed.count > 1 ? "s" : "")")132 .font(.caption).foregroundStyle(.secondary)133 Spacer()134 Button("Effacer l'historique") { recents.clearHistory(); selected = nil }135 .disabled(recents.viewed.isEmpty)136 }137 .padding(.horizontal, 16).padding(.vertical, 11)138 Divider().opacity(0.4)139 HStack(spacing: 0) {140 ScrollView {141 if recents.viewed.isEmpty {142 KAEmptyState(symbol: "clock.arrow.circlepath",143 title: "Rien encore",144 message: "Les fiches que vous consultez apparaîtront ici, consultables hors ligne.")145 .padding(30)146 } else {147 LazyVGrid(columns: [GridItem(.adaptive(minimum: 230), spacing: 12)], spacing: 12) {148 ForEach(recents.viewed) { item in149 Button { selected = item } label: {150 KAItemCard(item: item, selected: selected?.id == item.id)151 }152 .buttonStyle(KAPressStyle())153 }154 }155 .padding(14)156 }157 }158 .background(KATheme.paper(scheme))159 .frame(maxWidth: .infinity)160 Divider().opacity(0.4)161 Group {162 if let item = selected {163 ItemDetailPane(item: item)164 } else {165 KAEmptyState(symbol: "clock",166 title: "Choisissez une fiche",167 message: "Votre historique reste local, sur ce Mac.")168 .frame(maxHeight: .infinity)169 .background(KATheme.paper(scheme))170 }171 }172 .frame(width: 440)173 }174 }175 }176}177178// MARK: - Recherches sauvegardées & alertes179180struct AlertsView: View {181 @EnvironmentObject private var recents: RecentsStore182 @EnvironmentObject private var state: AppState183 @Environment(\.colorScheme) private var scheme184 @State private var refreshing = false185186 var body: some View {187 ScrollView {188 VStack(alignment: .leading, spacing: 14) {189 HStack {190 Text("Recherches & alertes").font(.title2.weight(.bold))191 Spacer()192 Button {193 refreshing = true194 Task { await recents.refreshAlerts(); refreshing = false }195 } label: {196 if refreshing { ProgressView().controlSize(.small) }197 else { Label("Recompter maintenant", systemImage: "arrow.clockwise").font(.caption) }198 }199 .disabled(refreshing || recents.savedSearches.isEmpty)200 }201 Text("KA recompte le total réel de chaque recherche via l'API du site et affiche le delta — jamais de faux chiffre. Sauvegardez une recherche depuis n'importe quel univers (bouton « Sauvegarder + alerte »).")202 .font(.caption).foregroundStyle(.secondary)203204 if recents.savedSearches.isEmpty {205 KAEmptyState(symbol: "bell.slash",206 title: "Aucune recherche sauvegardée",207 message: "Dans un univers, lancez une recherche ou des filtres puis « Sauvegarder + alerte ».")208 .padding(20)209 } else {210 LazyVStack(spacing: 10) {211 ForEach(recents.savedSearches) { s in212 row(s)213 }214 }215 }216 }217 .padding(18)218 .frame(maxWidth: 860)219 .frame(maxWidth: .infinity)220 }221 .background(KATheme.paper(scheme))222 }223224 private func row(_ s: SavedSearch) -> some View {225 let u = Ecosystem.universe(s.universeID)226 return HStack(spacing: 12) {227 Image(systemName: u?.symbol ?? "magnifyingglass")228 .foregroundStyle(u?.accent ?? .gray)229 .frame(width: 30, height: 30)230 .background((u?.accent ?? .gray).opacity(0.12), in: RoundedRectangle(cornerRadius: 8))231 VStack(alignment: .leading, spacing: 3) {232 HStack(spacing: 7) {233 Text(s.name.isEmpty ? "(filtres seulement)" : s.name)234 .font(.subheadline.weight(.bold))235 if s.newCount > 0 {236 Text("+\(s.newCount) nouveauté\(s.newCount > 1 ? "s" : "")")237 .font(.system(.caption2, design: .monospaced).weight(.bold))238 .padding(.horizontal, 7).padding(.vertical, 2)239 .background(Color.red.opacity(0.85), in: Capsule())240 .foregroundStyle(.white)241 }242 }243 HStack(spacing: 6) {244 if let u { KAChip(text: u.wordmark, accent: u.accent) }245 if !s.params.isEmpty {246 Text(s.params.map { "\($0.key)=\($0.value)" }.sorted().joined(separator: " · "))247 .font(.system(size: 10, design: .monospaced))248 .foregroundStyle(.secondary).lineLimit(1)249 }250 if let total = s.lastTotal {251 Text("\(total.fr) résultats")252 .font(.system(size: 10, design: .monospaced).weight(.bold))253 .foregroundStyle(u?.accent ?? .secondary)254 }255 if let d = s.lastChecked {256 Text("vérifié \(d.formatted(.relative(presentation: .named, unitsStyle: .narrow)))")257 .font(.system(size: 10)).foregroundStyle(.tertiary)258 }259 }260 }261 Spacer()262 Toggle("Alerte", isOn: Binding(263 get: { s.alertsOn },264 set: { v in265 if let i = recents.savedSearches.firstIndex(where: { $0.id == s.id }) {266 recents.savedSearches[i].alertsOn = v267 }268 }))269 .toggleStyle(.switch)270 .controlSize(.mini)271 .labelsHidden()272 .help(s.alertsOn ? "Alerte active" : "Alerte en pause")273 Button("Ouvrir") {274 if s.newCount > 0 { recents.markSeen(s.id) }275 state.openUniverse(s.universeID, query: s.query, params: s.params)276 }277 .tint(u?.accent)278 Button {279 recents.remove(s.id)280 } label: {281 Image(systemName: "trash")282 }283 .help("Supprimer cette recherche sauvegardée")284 }285 .padding(12)286 .frame(maxWidth: .infinity, alignment: .leading)287 .kaCard(accent: s.newCount > 0 ? .red : u?.accent)288 }289}290