Swift 98.3%
Shell 1.7%
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.6// Repris de l'app iOS KA, sans haptique.7import Foundation8import SwiftUI910struct SavedSearch: Identifiable, Codable, Hashable {11 var id: UUID = UUID()12 var name: String13 var universeID: String // "tous" = recherche universelle14 var query: String15 var params: [String: String] = [:]16 var alertsOn: Bool = true17 var lastTotal: Int?18 var newCount: Int = 019 var lastChecked: Date?20}2122@MainActor23final class RecentsStore: ObservableObject {24 static let shared = RecentsStore()2526 @Published private(set) var viewed: [KAItem] = [] { didSet { save() } }27 @Published var savedSearches: [SavedSearch] = [] { didSet { save() } }2829 var alertCount: Int { savedSearches.filter(\.alertsOn).map(\.newCount).reduce(0, +) }3031 private var fileURL: URL {32 let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]33 .appendingPathComponent("KA", isDirectory: true)34 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)35 return dir.appendingPathComponent("ka-recents.json")36 }3738 private struct Snapshot: Codable {39 var viewed: [KAItem]40 var searches: [SavedSearch]41 }4243 init() {44 if let data = try? Data(contentsOf: fileURL),45 let s = try? JSONDecoder().decode(Snapshot.self, from: data) {46 viewed = s.viewed47 savedSearches = s.searches48 }49 }5051 private func save() {52 if let data = try? JSONEncoder().encode(Snapshot(viewed: viewed, searches: savedSearches)) {53 try? data.write(to: fileURL, options: .atomic)54 }55 }5657 // MARK: historique consulté5859 func record(_ item: KAItem) {60 viewed.removeAll { $0.id == item.id }61 viewed.insert(item, at: 0)62 if viewed.count > 30 { viewed.removeLast(viewed.count - 30) }63 }6465 func clearHistory() { viewed = [] }6667 // MARK: recherches sauvegardées + alertes6869 func saveSearch(name: String, universeID: String, query: String, params: [String: String]) {70 var s = SavedSearch(name: name.isEmpty ? query : name,71 universeID: universeID, query: query, params: params)72 savedSearches.insert(s, at: 0)73 // total initial en arrière-plan74 Task { [weak self] in75 guard let self else { return }76 if let u = Ecosystem.universe(universeID) {77 s.lastTotal = await UniverseService.total(u, query: query, params: params)78 s.lastChecked = .now79 if let i = self.savedSearches.firstIndex(where: { $0.id == s.id }) {80 self.savedSearches[i] = s81 }82 }83 }84 }8586 func remove(_ id: UUID) { savedSearches.removeAll { $0.id == id } }8788 func markSeen(_ id: UUID) {89 guard let i = savedSearches.firstIndex(where: { $0.id == id }) else { return }90 savedSearches[i].lastTotal = (savedSearches[i].lastTotal ?? 0) + savedSearches[i].newCount91 savedSearches[i].newCount = 092 }9394 /// Recompte chaque recherche (deltas réels). Appelé à l'ouverture de l'app.95 func refreshAlerts() async {96 for idx in savedSearches.indices {97 let s = savedSearches[idx]98 guard s.alertsOn, let u = Ecosystem.universe(s.universeID) else { continue }99 if let total = await UniverseService.total(u, query: s.query, params: s.params) {100 if let last = s.lastTotal, total > last {101 savedSearches[idx].newCount = total - last102 } else if s.lastTotal == nil {103 savedSearches[idx].lastTotal = total104 }105 savedSearches[idx].lastChecked = .now106 }107 }108 }109}110111// MARK: - Collections éditoriales (requêtes RÉELLES, aucun contenu inventé)112113struct EditorialCollection: Identifiable {114 let id: String115 let title: String116 let subtitle: String117 let symbol: String118 let universeID: String119 let query: String?120 let params: [String: String]121}122123enum Editorial {124 static let collections: [EditorialCollection] = [125 .init(id: "week-end", title: "Le week-end s'organise",126 subtitle: "Sorties gratuites à venir partout au Québec",127 symbol: "party.popper.fill", universeID: "sorti-ka",128 query: nil, params: ["free": "true"]),129 .init(id: "aubaines-epicerie", title: "Le panier futé",130 subtitle: "Les soldes d'épicerie du moment",131 symbol: "tag.fill", universeID: "food-ka",132 query: nil, params: ["on_sale": "true"]),133 .init(id: "premiere-auto", title: "Première auto",134 subtitle: "Des véhicules récents sous 15 000 $",135 symbol: "car.2.fill", universeID: "auto-ka",136 query: nil, params: ["price_max": "15000", "year_min": "2015"]),137 .init(id: "quatre-et-demi", title: "Le classique 4½",138 subtitle: "Les 4½ fraîchement affichés",139 symbol: "key.fill", universeID: "lou-ka",140 query: nil, params: ["unit_type": "4½"]),141 ]142}143