Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// RecentsStore.swift — historique des éléments consultés, recherches3// sauvegardées et ALERTES honnêtes : à l'ouverture, on recompte le total réel4// de chaque recherche sauvegardée via l'API et on affiche le delta (« +125// nouveautés ») — jamais de faux chiffre. Persistance JSON locale.6import Foundation7import SwiftUI89struct SavedSearch: Identifiable, Codable, Hashable {10 var id: UUID = UUID()11 var name: String12 var universeID: String // "tous" = recherche universelle13 var query: String14 var params: [String: String] = [:]15 var alertsOn: Bool = true16 var lastTotal: Int?17 var newCount: Int = 018 var lastChecked: Date?19}2021@MainActor22final class RecentsStore: ObservableObject {23 static let shared = RecentsStore()2425 @Published private(set) var viewed: [KAItem] = [] { didSet { save() } }26 @Published var savedSearches: [SavedSearch] = [] { didSet { save() } }2728 var alertCount: Int { savedSearches.filter(\.alertsOn).map(\.newCount).reduce(0, +) }2930 private var fileURL: URL {31 let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]32 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)33 return dir.appendingPathComponent("ka-recents.json")34 }3536 private struct Snapshot: Codable {37 var viewed: [KAItem]38 var searches: [SavedSearch]39 }4041 init() {42 if let data = try? Data(contentsOf: fileURL),43 let s = try? JSONDecoder().decode(Snapshot.self, from: data) {44 viewed = s.viewed45 savedSearches = s.searches46 }47 }4849 private func save() {50 if let data = try? JSONEncoder().encode(Snapshot(viewed: viewed, searches: savedSearches)) {51 try? data.write(to: fileURL, options: .atomic)52 }53 }5455 // MARK: historique consulté5657 func record(_ item: KAItem) {58 viewed.removeAll { $0.id == item.id }59 viewed.insert(item, at: 0)60 if viewed.count > 30 { viewed.removeLast(viewed.count - 30) }61 }6263 func clearHistory() { viewed = [] }6465 // MARK: recherches sauvegardées + alertes6667 func saveSearch(name: String, universeID: String, query: String, params: [String: String]) {68 var s = SavedSearch(name: name.isEmpty ? query : name,69 universeID: universeID, query: query, params: params)70 savedSearches.insert(s, at: 0)71 Haptics.success()72 // total initial en arrière-plan73 Task { [weak self] in74 guard let self else { return }75 if let u = Ecosystem.universe(universeID) {76 s.lastTotal = await UniverseService.total(u, query: query, params: params)77 s.lastChecked = .now78 if let i = self.savedSearches.firstIndex(where: { $0.id == s.id }) {79 self.savedSearches[i] = s80 }81 }82 }83 }8485 func remove(_ id: UUID) { savedSearches.removeAll { $0.id == id } }8687 func markSeen(_ id: UUID) {88 guard let i = savedSearches.firstIndex(where: { $0.id == id }) else { return }89 savedSearches[i].lastTotal = (savedSearches[i].lastTotal ?? 0) + savedSearches[i].newCount90 savedSearches[i].newCount = 091 }9293 /// Recompte chaque recherche (deltas réels). Appelé à l'ouverture de l'app.94 func refreshAlerts() async {95 for idx in savedSearches.indices {96 let s = savedSearches[idx]97 guard s.alertsOn, let u = Ecosystem.universe(s.universeID) else { continue }98 if let total = await UniverseService.total(u, query: s.query, params: s.params) {99 if let last = s.lastTotal, total > last {100 savedSearches[idx].newCount = total - last101 } else if s.lastTotal == nil {102 savedSearches[idx].lastTotal = total103 }104 savedSearches[idx].lastChecked = .now105 }106 }107 }108}109110// MARK: - Collections éditoriales (requêtes RÉELLES, aucun contenu inventé)111112struct EditorialCollection: Identifiable {113 let id: String114 let title: String115 let subtitle: String116 let symbol: String117 let universeID: String118 let query: String?119 let params: [String: String]120}121122enum Editorial {123 static let collections: [EditorialCollection] = [124 .init(id: "week-end", title: "Le week-end s'organise",125 subtitle: "Sorties gratuites à venir partout au Québec",126 symbol: "party.popper.fill", universeID: "sorti-ka",127 query: nil, params: ["free": "true"]),128 .init(id: "aubaines-epicerie", title: "Le panier futé",129 subtitle: "Les soldes d'épicerie du moment",130 symbol: "tag.fill", universeID: "food-ka",131 query: nil, params: ["on_sale": "true"]),132 .init(id: "premiere-auto", title: "Première auto",133 subtitle: "Des véhicules récents sous 15 000 $",134 symbol: "car.2.fill", universeID: "auto-ka",135 query: nil, params: ["price_max": "15000", "year_min": "2015"]),136 .init(id: "quatre-et-demi", title: "Le classique 4½",137 subtitle: "Les 4½ fraîchement affichés",138 symbol: "key.fill", universeID: "lou-ka",139 query: nil, params: ["unit_type": "4½"]),140 ]141}142