KA pour macOS 1.0.0 — app native SwiftUI du Groupe KA
Refonte premium complète : fenêtre principale NavigationSplitView (Accueil, Recherche universelle, Carte, KA Agent, 12 univers, Favoris/Historique/Alertes), MenuBarExtra « pouls » 13/13 services, playground API-KA, fix Trouve-Ka « recherche d'abord », raccourci global ⌘⇧K (Carbon), icône liquid glass v2, pipeline package + notarisation Developer ID (DMG KA-macos-1.0.0.dmg, Accepted). Captures de validation à l'écran dans docs/captures/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
31 changed files +4,951 −0
added
.gitignore
+5 −0
@@ -0,0 +1,5 @@ | |||||
| 1 | +.build/ | ||||
| 2 | +dist/ | ||||
| 3 | +.DS_Store | ||||
| 4 | +*.dmg | ||||
| 5 | +Assets/AppIcon.iconset/ | ||||
added
Assets/AppIcon.icns
+0 −0
Binary file not shown.
added
Assets/icon-1024.png
+0 −0
Binary file not shown.
added
Package.swift
+17 −0
@@ -0,0 +1,17 @@ | |||||
| 1 | +// swift-tools-version:5.10 | ||||
| 2 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 3 | +// Package.swift — KA pour macOS : exécutable SwiftPM pur (zéro dépendance), | ||||
| 4 | +// assemblé en KA.app par scripts/package-app.sh (pattern forge-studio). | ||||
| 5 | +import PackageDescription | ||||
| 6 | + | ||||
| 7 | +let package = Package( | ||||
| 8 | + name: "KA", | ||||
| 9 | + platforms: [.macOS(.v14)], | ||||
| 10 | + targets: [ | ||||
| 11 | + .executableTarget( | ||||
| 12 | + name: "KA", | ||||
| 13 | + path: "Sources/KA", | ||||
| 14 | + linkerSettings: [.linkedFramework("Carbon")] | ||||
| 15 | + ) | ||||
| 16 | + ] | ||||
| 17 | +) | ||||
added
Sources/KA/App/HotKey.swift
+34 −0
@@ -0,0 +1,34 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// HotKey.swift — raccourci GLOBAL ⌘⇧K via Carbon RegisterEventHotKey | ||||
| 3 | +// (fonctionne même quand l'app est en arrière-plan, sans permission | ||||
| 4 | +// d'accessibilité, app non sandboxée). | ||||
| 5 | +import Carbon.HIToolbox | ||||
| 6 | +import Foundation | ||||
| 7 | + | ||||
| 8 | +final class HotKeyManager { | ||||
| 9 | + var onHotKey: (() -> Void)? | ||||
| 10 | + private var hotKeyRef: EventHotKeyRef? | ||||
| 11 | + private var handlerRef: EventHandlerRef? | ||||
| 12 | + | ||||
| 13 | + /// Enregistre ⌘⇧K comme raccourci système. | ||||
| 14 | + func register() { | ||||
| 15 | + var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), | ||||
| 16 | + eventKind: UInt32(kEventHotKeyPressed)) | ||||
| 17 | + let selfPtr = Unmanaged.passUnretained(self).toOpaque() | ||||
| 18 | + InstallEventHandler(GetEventDispatcherTarget(), { _, _, userData in | ||||
| 19 | + guard let userData else { return noErr } | ||||
| 20 | + let manager = Unmanaged<HotKeyManager>.fromOpaque(userData).takeUnretainedValue() | ||||
| 21 | + DispatchQueue.main.async { manager.onHotKey?() } | ||||
| 22 | + return noErr | ||||
| 23 | + }, 1, &eventType, selfPtr, &handlerRef) | ||||
| 24 | + | ||||
| 25 | + let id = EventHotKeyID(signature: OSType(0x4B41_4D43), id: 1) // 'KAMC' | ||||
| 26 | + RegisterEventHotKey(UInt32(kVK_ANSI_K), UInt32(cmdKey | shiftKey), | ||||
| 27 | + id, GetEventDispatcherTarget(), 0, &hotKeyRef) | ||||
| 28 | + } | ||||
| 29 | + | ||||
| 30 | + deinit { | ||||
| 31 | + if let hotKeyRef { UnregisterEventHotKey(hotKeyRef) } | ||||
| 32 | + if let handlerRef { RemoveEventHandler(handlerRef) } | ||||
| 33 | + } | ||||
| 34 | +} | ||||
added
Sources/KA/App/KAApp.swift
+274 −0
@@ -0,0 +1,274 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// KAApp.swift — point d'entrée : fenêtre principale (Accueil, Recherche, | ||||
| 3 | +// Carte, 12 univers, Favoris, Historique, Alertes, KA Agent, Statut), | ||||
| 4 | +// MenuBarExtra « Ka », menus natifs (⌘1..9 univers, ⌘F/⌘K recherche, ⌘M | ||||
| 5 | +// carte), raccourci global ⌘⇧K, apparence clair/sombre/système, pouls 5 min. | ||||
| 6 | +// Spec des données : app iOS KA (~/Desktop/KA). | ||||
| 7 | +import SwiftUI | ||||
| 8 | + | ||||
| 9 | +// MARK: - Navigation | ||||
| 10 | + | ||||
| 11 | +enum SidebarSelection: Hashable { | ||||
| 12 | + case home, search, map, agent, status | ||||
| 13 | + case favorites, history, alerts | ||||
| 14 | + case universe(String) | ||||
| 15 | +} | ||||
| 16 | + | ||||
| 17 | +@MainActor | ||||
| 18 | +final class AppState: ObservableObject { | ||||
| 19 | + static let shared = AppState() | ||||
| 20 | + @Published var selection: SidebarSelection? = .home | ||||
| 21 | + /// Incrémenté pour redonner le focus au champ de recherche universel. | ||||
| 22 | + @Published var searchFocusToken = 0 | ||||
| 23 | + /// Pré-remplissage d'un explorateur d'univers (depuis Accueil/Alertes). | ||||
| 24 | + @Published var prefill: (universeID: String, query: String, params: [String: String])? | ||||
| 25 | + /// Ouvre (ou ramène) la fenêtre principale — capturé depuis la vue racine. | ||||
| 26 | + var openMain: (() -> Void)? | ||||
| 27 | + | ||||
| 28 | + func openUniversalSearch() { | ||||
| 29 | + selection = .search | ||||
| 30 | + searchFocusToken += 1 | ||||
| 31 | + NSApp.activate(ignoringOtherApps: true) | ||||
| 32 | + openMain?() | ||||
| 33 | + } | ||||
| 34 | + | ||||
| 35 | + func openUniverse(_ id: String, query: String = "", params: [String: String] = [:]) { | ||||
| 36 | + if !query.isEmpty || !params.isEmpty { | ||||
| 37 | + prefill = (id, query, params) | ||||
| 38 | + } | ||||
| 39 | + selection = .universe(id) | ||||
| 40 | + } | ||||
| 41 | +} | ||||
| 42 | + | ||||
| 43 | +// MARK: - Apparence (clair par défaut, sombre complet, ou système) | ||||
| 44 | + | ||||
| 45 | +enum KAAppearance: String, CaseIterable, Identifiable { | ||||
| 46 | + case clair, sombre, systeme | ||||
| 47 | + var id: String { rawValue } | ||||
| 48 | + var label: String { | ||||
| 49 | + switch self { | ||||
| 50 | + case .clair: return "Clair" | ||||
| 51 | + case .sombre: return "Sombre" | ||||
| 52 | + case .systeme: return "Système" | ||||
| 53 | + } | ||||
| 54 | + } | ||||
| 55 | + var colorScheme: ColorScheme? { | ||||
| 56 | + switch self { | ||||
| 57 | + case .clair: return .light | ||||
| 58 | + case .sombre: return .dark | ||||
| 59 | + case .systeme: return nil | ||||
| 60 | + } | ||||
| 61 | + } | ||||
| 62 | +} | ||||
| 63 | + | ||||
| 64 | +// MARK: - Le pouls de l'écosystème (compteurs + statut, rafraîchi 5 min) | ||||
| 65 | + | ||||
| 66 | +@MainActor | ||||
| 67 | +final class EcosystemPulse: ObservableObject { | ||||
| 68 | + static let shared = EcosystemPulse() | ||||
| 69 | + @Published var totals: [String: Int] = [:] | ||||
| 70 | + @Published var health: [ServiceHealth] = StatusService.allServices | ||||
| 71 | + @Published var lastRefresh: Date? | ||||
| 72 | + @Published var refreshing = false | ||||
| 73 | + private var loop: Task<Void, Never>? | ||||
| 74 | + | ||||
| 75 | + func start() { | ||||
| 76 | + guard loop == nil else { return } | ||||
| 77 | + loop = Task { [weak self] in | ||||
| 78 | + while !Task.isCancelled { | ||||
| 79 | + await self?.refresh() | ||||
| 80 | + try? await Task.sleep(for: .seconds(300)) // 5 minutes | ||||
| 81 | + } | ||||
| 82 | + } | ||||
| 83 | + } | ||||
| 84 | + | ||||
| 85 | + func refresh() async { | ||||
| 86 | + refreshing = true | ||||
| 87 | + // Compteurs des univers (fan-out parallèle) | ||||
| 88 | + await withTaskGroup(of: (String, Int?).self) { group in | ||||
| 89 | + for u in Ecosystem.all { | ||||
| 90 | + group.addTask { (u.id, await UniverseService.liveTotal(u)) } | ||||
| 91 | + } | ||||
| 92 | + for await (id, n) in group { | ||||
| 93 | + if let n { totals[id] = n } | ||||
| 94 | + } | ||||
| 95 | + } | ||||
| 96 | + // Statut des 13 services (HEAD + latence) | ||||
| 97 | + var checked = health | ||||
| 98 | + await withTaskGroup(of: (String, Bool, Int).self) { group in | ||||
| 99 | + for s in checked { | ||||
| 100 | + group.addTask { | ||||
| 101 | + let r = await StatusService.check(domain: s.id) | ||||
| 102 | + return (s.id, r.up, r.latencyMs) | ||||
| 103 | + } | ||||
| 104 | + } | ||||
| 105 | + for await (id, up, ms) in group { | ||||
| 106 | + if let i = checked.firstIndex(where: { $0.id == id }) { | ||||
| 107 | + checked[i].up = up | ||||
| 108 | + checked[i].latencyMs = ms | ||||
| 109 | + checked[i].checkedAt = Date() | ||||
| 110 | + } | ||||
| 111 | + } | ||||
| 112 | + } | ||||
| 113 | + health = checked | ||||
| 114 | + lastRefresh = Date() | ||||
| 115 | + refreshing = false | ||||
| 116 | + } | ||||
| 117 | + | ||||
| 118 | + var servicesUp: Int { health.filter { $0.up == true }.count } | ||||
| 119 | +} | ||||
| 120 | + | ||||
| 121 | +// MARK: - Délégué (raccourci global + démarrage du pouls + alertes) | ||||
| 122 | + | ||||
| 123 | +final class AppDelegate: NSObject, NSApplicationDelegate { | ||||
| 124 | + private let hotKey = HotKeyManager() | ||||
| 125 | + | ||||
| 126 | + func applicationDidFinishLaunching(_ notification: Notification) { | ||||
| 127 | + hotKey.onHotKey = { Task { @MainActor in AppState.shared.openUniversalSearch() } } | ||||
| 128 | + hotKey.register() | ||||
| 129 | + Task { @MainActor in | ||||
| 130 | + EcosystemPulse.shared.start() | ||||
| 131 | + await RecentsStore.shared.refreshAlerts() // deltas réels des recherches sauvegardées | ||||
| 132 | + } | ||||
| 133 | + } | ||||
| 134 | +} | ||||
| 135 | + | ||||
| 136 | +// MARK: - App | ||||
| 137 | + | ||||
| 138 | +@main | ||||
| 139 | +struct KAApp: App { | ||||
| 140 | + @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate | ||||
| 141 | + @StateObject private var pulse = EcosystemPulse.shared | ||||
| 142 | + @StateObject private var state = AppState.shared | ||||
| 143 | + @StateObject private var favorites = FavoritesStore.shared | ||||
| 144 | + @StateObject private var recents = RecentsStore.shared | ||||
| 145 | + @AppStorage("ka.appearance") private var appearance = KAAppearance.clair.rawValue | ||||
| 146 | + | ||||
| 147 | + private var scheme: ColorScheme? { KAAppearance(rawValue: appearance)?.colorScheme } | ||||
| 148 | + | ||||
| 149 | + var body: some Scene { | ||||
| 150 | + WindowGroup("KA", id: "main") { | ||||
| 151 | + MainWindowView() | ||||
| 152 | + .environmentObject(pulse) | ||||
| 153 | + .environmentObject(state) | ||||
| 154 | + .environmentObject(favorites) | ||||
| 155 | + .environmentObject(recents) | ||||
| 156 | + .preferredColorScheme(scheme) | ||||
| 157 | + } | ||||
| 158 | + .defaultSize(width: 1280, height: 820) | ||||
| 159 | + .commands { | ||||
| 160 | + CommandGroup(replacing: .appInfo) { AboutCommand() } | ||||
| 161 | + CommandGroup(after: .sidebar) { | ||||
| 162 | + Divider() | ||||
| 163 | + Picker("Apparence", selection: $appearance) { | ||||
| 164 | + ForEach(KAAppearance.allCases) { a in | ||||
| 165 | + Text(a.label).tag(a.rawValue) | ||||
| 166 | + } | ||||
| 167 | + } | ||||
| 168 | + Divider() | ||||
| 169 | + NavCommand(label: "Accueil", to: .home, key: "0") | ||||
| 170 | + NavCommand(label: "Recherche universelle", to: .search, key: "f") | ||||
| 171 | + NavCommand(label: "Carte", to: .map, key: "m") | ||||
| 172 | + NavCommand(label: "KA Agent", to: .agent, key: "g") | ||||
| 173 | + NavCommand(label: "Favoris", to: .favorites, key: "d") | ||||
| 174 | + NavCommand(label: "Statut des plateformes", to: .status, key: "t") | ||||
| 175 | + } | ||||
| 176 | + CommandGroup(after: .sidebar) { SearchCommands() } | ||||
| 177 | + CommandMenu("Univers") { | ||||
| 178 | + ForEach(Array(Ecosystem.all.enumerated()), id: \.element.id) { i, u in | ||||
| 179 | + UniverseCommand(universe: u, index: i) | ||||
| 180 | + } | ||||
| 181 | + } | ||||
| 182 | + } | ||||
| 183 | + | ||||
| 184 | + Window("À propos de KA", id: "about") { | ||||
| 185 | + AboutView() | ||||
| 186 | + .preferredColorScheme(scheme) | ||||
| 187 | + } | ||||
| 188 | + .windowResizability(.contentSize) | ||||
| 189 | + .defaultPosition(.center) | ||||
| 190 | + | ||||
| 191 | + MenuBarExtra { | ||||
| 192 | + MenuBarView() | ||||
| 193 | + .environmentObject(pulse) | ||||
| 194 | + .environmentObject(state) | ||||
| 195 | + .environmentObject(favorites) | ||||
| 196 | + .environmentObject(recents) | ||||
| 197 | + .preferredColorScheme(scheme) | ||||
| 198 | + } label: { | ||||
| 199 | + Text("Ka").font(.system(size: 13, weight: .heavy, design: .rounded)) | ||||
| 200 | + } | ||||
| 201 | + .menuBarExtraStyle(.window) | ||||
| 202 | + } | ||||
| 203 | +} | ||||
| 204 | + | ||||
| 205 | +/// Ramène la fenêtre principale existante au premier plan — n'en crée une | ||||
| 206 | +/// nouvelle QUE s'il n'y en a aucune (évite l'empilement de fenêtres). | ||||
| 207 | +@MainActor | ||||
| 208 | +func focusMainWindow(_ openWindow: OpenWindowAction) { | ||||
| 209 | + NSApp.activate(ignoringOtherApps: true) | ||||
| 210 | + if let w = NSApp.windows.first(where: { ($0.identifier?.rawValue.hasPrefix("main") ?? false) && $0.canBecomeKey }) { | ||||
| 211 | + w.makeKeyAndOrderFront(nil) | ||||
| 212 | + } else { | ||||
| 213 | + openWindow(id: "main") | ||||
| 214 | + } | ||||
| 215 | +} | ||||
| 216 | + | ||||
| 217 | +/// « À propos de KA » (remplace l'élément standard du menu application). | ||||
| 218 | +private struct AboutCommand: View { | ||||
| 219 | + @Environment(\.openWindow) private var openWindow | ||||
| 220 | + var body: some View { | ||||
| 221 | + Button("À propos de KA") { openWindow(id: "about") } | ||||
| 222 | + } | ||||
| 223 | +} | ||||
| 224 | + | ||||
| 225 | +/// Navigation menu « Présentation » (⌘0 accueil, ⌘F recherche, ⌘M carte…). | ||||
| 226 | +private struct NavCommand: View { | ||||
| 227 | + let label: String | ||||
| 228 | + let to: SidebarSelection | ||||
| 229 | + let key: KeyEquivalent | ||||
| 230 | + @Environment(\.openWindow) private var openWindow | ||||
| 231 | + var body: some View { | ||||
| 232 | + Button(label) { | ||||
| 233 | + focusMainWindow(openWindow) | ||||
| 234 | + if to == .search { AppState.shared.openUniversalSearch() } | ||||
| 235 | + else { AppState.shared.selection = to } | ||||
| 236 | + } | ||||
| 237 | + .keyboardShortcut(key, modifiers: .command) | ||||
| 238 | + } | ||||
| 239 | +} | ||||
| 240 | + | ||||
| 241 | +/// Recherche universelle — ⌘K et ⌘⇧K (aussi raccourci GLOBAL Carbon). | ||||
| 242 | +private struct SearchCommands: View { | ||||
| 243 | + @Environment(\.openWindow) private var openWindow | ||||
| 244 | + var body: some View { | ||||
| 245 | + Button("Recherche rapide") { | ||||
| 246 | + focusMainWindow(openWindow) | ||||
| 247 | + AppState.shared.openUniversalSearch() | ||||
| 248 | + } | ||||
| 249 | + .keyboardShortcut("k", modifiers: .command) | ||||
| 250 | + Button("Recherche universelle (globale)") { | ||||
| 251 | + focusMainWindow(openWindow) | ||||
| 252 | + AppState.shared.openUniversalSearch() | ||||
| 253 | + } | ||||
| 254 | + .keyboardShortcut("k", modifiers: [.command, .shift]) | ||||
| 255 | + } | ||||
| 256 | +} | ||||
| 257 | + | ||||
| 258 | +/// Menu « Univers » : ⌘1..⌘9 pour les 9 premiers. | ||||
| 259 | +private struct UniverseCommand: View { | ||||
| 260 | + let universe: Universe | ||||
| 261 | + let index: Int | ||||
| 262 | + @Environment(\.openWindow) private var openWindow | ||||
| 263 | + var body: some View { | ||||
| 264 | + let btn = Button(universe.name) { | ||||
| 265 | + focusMainWindow(openWindow) | ||||
| 266 | + AppState.shared.selection = .universe(universe.id) | ||||
| 267 | + } | ||||
| 268 | + if index < 9 { | ||||
| 269 | + btn.keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command) | ||||
| 270 | + } else { | ||||
| 271 | + btn | ||||
| 272 | + } | ||||
| 273 | + } | ||||
| 274 | +} | ||||
added
Sources/KA/Core/Ecosystem.swift
+391 −0
@@ -0,0 +1,391 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// Ecosystem.swift — la source de vérité des 13 univers Groupe-KA dans l'app : | ||||
| 3 | +// identité (nom, accent, symbole, tagline) + configuration d'API (endpoint de | ||||
| 4 | +// liste, clé des items, mapping JSON → KAItem). Ajouter un univers = ajouter | ||||
| 5 | +// une entrée ici, rien d'autre. | ||||
| 6 | +// Repris quasi tel quel de l'app iOS KA (~/Desktop/KA/KA/Core/Ecosystem.swift). | ||||
| 7 | +import SwiftUI | ||||
| 8 | + | ||||
| 9 | +// MARK: - JSON générique (les API des univers ont chacune leur forme) | ||||
| 10 | + | ||||
| 11 | +enum JSONValue: Decodable { | ||||
| 12 | + case string(String), number(Double), bool(Bool) | ||||
| 13 | + case object([String: JSONValue]), array([JSONValue]), null | ||||
| 14 | + | ||||
| 15 | + init(from decoder: Decoder) throws { | ||||
| 16 | + let c = try decoder.singleValueContainer() | ||||
| 17 | + if c.decodeNil() { self = .null } | ||||
| 18 | + else if let b = try? c.decode(Bool.self) { self = .bool(b) } | ||||
| 19 | + else if let n = try? c.decode(Double.self) { self = .number(n) } | ||||
| 20 | + else if let s = try? c.decode(String.self) { self = .string(s) } | ||||
| 21 | + else if let a = try? c.decode([JSONValue].self) { self = .array(a) } | ||||
| 22 | + else { self = .object(try c.decode([String: JSONValue].self)) } | ||||
| 23 | + } | ||||
| 24 | + | ||||
| 25 | + var string: String? { if case .string(let s) = self { return s }; return nil } | ||||
| 26 | + var number: Double? { if case .number(let n) = self { return n }; return nil } | ||||
| 27 | + var array: [JSONValue]? { if case .array(let a) = self { return a }; return nil } | ||||
| 28 | + var object: [String: JSONValue]? { if case .object(let o) = self { return o }; return nil } | ||||
| 29 | + /// Texte « au mieux » (string, nombre formaté, liste jointe) | ||||
| 30 | + var text: String? { | ||||
| 31 | + switch self { | ||||
| 32 | + case .string(let s): return s.isEmpty ? nil : s | ||||
| 33 | + case .number(let n): return n == n.rounded() ? String(Int(n)) : String(n) | ||||
| 34 | + case .array(let a): let parts = a.compactMap(\.text); return parts.isEmpty ? nil : parts.joined(separator: ", ") | ||||
| 35 | + default: return nil | ||||
| 36 | + } | ||||
| 37 | + } | ||||
| 38 | +} | ||||
| 39 | + | ||||
| 40 | +extension [String: JSONValue] { | ||||
| 41 | + func str(_ keys: String...) -> String? { | ||||
| 42 | + for k in keys { if let v = self[k]?.text { return v } } | ||||
| 43 | + return nil | ||||
| 44 | + } | ||||
| 45 | + func num(_ keys: String...) -> Double? { | ||||
| 46 | + for k in keys { if let v = self[k]?.number { return v } } | ||||
| 47 | + return nil | ||||
| 48 | + } | ||||
| 49 | +} | ||||
| 50 | + | ||||
| 51 | +// MARK: - L'élément universel | ||||
| 52 | + | ||||
| 53 | +struct KAItem: Identifiable, Hashable, Codable { | ||||
| 54 | + var id: String | ||||
| 55 | + var universeID: String | ||||
| 56 | + var title: String | ||||
| 57 | + var subtitle: String? | ||||
| 58 | + var priceLabel: String? | ||||
| 59 | + var city: String? | ||||
| 60 | + var url: URL? | ||||
| 61 | + var imageURL: URL? | ||||
| 62 | + /// Galerie complète (les sites en ont souvent plusieurs) | ||||
| 63 | + var imageURLs: [URL] = [] | ||||
| 64 | + var latitude: Double? | ||||
| 65 | + var longitude: Double? | ||||
| 66 | + /// Description longue (fiche) | ||||
| 67 | + var detail: String? | ||||
| 68 | + /// Paires libres affichées sur la fiche (« Année : 2021 », « Salaire : 65 000 $ »…) | ||||
| 69 | + var facts: [Fact] | ||||
| 70 | + /// Liens riches (boutons) — ex. les comptes d'un créateur | ||||
| 71 | + var links: [Fact] = [] | ||||
| 72 | + | ||||
| 73 | + struct Fact: Hashable, Codable { var label: String; var value: String } | ||||
| 74 | +} | ||||
| 75 | + | ||||
| 76 | +// MARK: - Univers | ||||
| 77 | + | ||||
| 78 | +struct Universe: Identifiable { | ||||
| 79 | + let id: String | ||||
| 80 | + let wordmark: String // « Lou·Ka » | ||||
| 81 | + let name: String // « Lou-KA » | ||||
| 82 | + let tagline: String | ||||
| 83 | + let accentHex: String | ||||
| 84 | + let symbol: String // SF Symbol | ||||
| 85 | + let domain: String | ||||
| 86 | + let unit: String // « logements », « offres »… | ||||
| 87 | + /// Chemin de la liste (nil = univers sans liste native, ex. vrai-prix) | ||||
| 88 | + let listPath: String? | ||||
| 89 | + let itemsKey: String | ||||
| 90 | + let searchParam: String | ||||
| 91 | + let statTotalKeys: [String] // clés du total dans /api/stats | ||||
| 92 | + let map: (@Sendable ([String: JSONValue]) -> KAItem?)? | ||||
| 93 | + | ||||
| 94 | + var accent: Color { Color(hex: accentHex) } | ||||
| 95 | + var baseURL: URL { URL(string: "https://\(domain)")! } | ||||
| 96 | +} | ||||
| 97 | + | ||||
| 98 | +enum Ecosystem { | ||||
| 99 | + static let hubURL = URL(string: "https://www.groupe-ka.com")! | ||||
| 100 | + static let hubDomain = "www.groupe-ka.com" | ||||
| 101 | + static let signupURL = URL(string: "https://www.groupe-ka.com/connexion")! | ||||
| 102 | + static let statusURL = URL(string: "https://www.groupe-ka.com/status")! | ||||
| 103 | + static let contacts: [(email: String, role: String)] = [ | ||||
| 104 | + ("contact@groupe-ka.com", "Projets, partenariats & données"), | ||||
| 105 | + ("info@groupe-ka.com", "Médias & questions générales"), | ||||
| 106 | + ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"), | ||||
| 107 | + ] | ||||
| 108 | + static let legal: [(label: String, path: String)] = [ | ||||
| 109 | + ("Conditions d'utilisation", "/conditions"), | ||||
| 110 | + ("Politique de confidentialité", "/confidentialite"), | ||||
| 111 | + ("Renseignements personnels (Loi 25)", "/loi-25"), | ||||
| 112 | + ("Transparence des robots", "/bots"), | ||||
| 113 | + ] | ||||
| 114 | + static let disclaimer = "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction. Données lues à la source — rien d'inventé, tout est traçable." | ||||
| 115 | + | ||||
| 116 | + static func universe(_ id: String) -> Universe? { all.first { $0.id == id } } | ||||
| 117 | + | ||||
| 118 | + // Aides de mapping communes | ||||
| 119 | + private static func base(_ o: [String: JSONValue], universe: String, idKeys: [String] = ["uid"]) -> KAItem { | ||||
| 120 | + var id: String? | ||||
| 121 | + for k in idKeys { if let v = o[k]?.text { id = v; break } } | ||||
| 122 | + // galerie : champ `images` (liste) ou `image` — jusqu'à 10, http seulement | ||||
| 123 | + var gallery: [URL] = (o["images"]?.array ?? []) | ||||
| 124 | + .compactMap { $0.text } | ||||
| 125 | + .filter { $0.hasPrefix("http") } | ||||
| 126 | + .prefix(10) | ||||
| 127 | + .compactMap(URL.init(string:)) | ||||
| 128 | + if gallery.isEmpty, | ||||
| 129 | + let single = o.str("image", "image_url", "photo", "thumbnail").flatMap(URL.init(string:)) { | ||||
| 130 | + gallery = [single] | ||||
| 131 | + } | ||||
| 132 | + let image = gallery.first | ||||
| 133 | + return KAItem( | ||||
| 134 | + id: "\(universe):\(id ?? UUID().uuidString)", | ||||
| 135 | + universeID: universe, | ||||
| 136 | + title: o.str("title", "name", "display_name") ?? "Sans titre", | ||||
| 137 | + subtitle: nil, priceLabel: nil, | ||||
| 138 | + city: o.str("city"), | ||||
| 139 | + url: o.str("url").flatMap(URL.init(string:)), | ||||
| 140 | + imageURL: image, | ||||
| 141 | + imageURLs: gallery, | ||||
| 142 | + latitude: o.num("lat", "latitude"), | ||||
| 143 | + longitude: o.num("lng", "lon", "longitude"), | ||||
| 144 | + detail: o.str("description", "menu_summary", "bio").map { $0.strippingHTML.trimmingCharacters(in: .whitespacesAndNewlines) }, | ||||
| 145 | + facts: [] | ||||
| 146 | + ) | ||||
| 147 | + } | ||||
| 148 | + | ||||
| 149 | + // MARK: les 12 univers (+ le hub, sans liste) | ||||
| 150 | + static let all: [Universe] = [ | ||||
| 151 | + Universe(id: "lou-ka", wordmark: "Lou·Ka", name: "Lou-KA", | ||||
| 152 | + tagline: "Tous les logements à louer", accentHex: "#ff6a00", | ||||
| 153 | + symbol: "key.fill", domain: "www.lou-ka.com", unit: "logements", | ||||
| 154 | + listPath: "/api/listings", itemsKey: "listings", searchParam: "q", | ||||
| 155 | + statTotalKeys: ["total"], | ||||
| 156 | + map: { o in | ||||
| 157 | + var it = base(o, universe: "lou-ka") | ||||
| 158 | + it.subtitle = [o.str("unit_type"), o.str("address")].compactMap { $0 }.joined(separator: " · ") | ||||
| 159 | + if let p = o.num("price"), p > 50 { it.priceLabel = p.money0 + "/mois" } | ||||
| 160 | + it.facts = [ | ||||
| 161 | + o.str("unit_type").map { .init(label: "Taille", value: $0) }, | ||||
| 162 | + o.str("address").map { .init(label: "Adresse", value: $0) }, | ||||
| 163 | + o.str("available_by").map { .init(label: "Disponible", value: $0) }, | ||||
| 164 | + o.num("area_sqft").flatMap { $0 > 50 ? KAItem.Fact(label: "Superficie", value: "\(Int($0)) pi²") : nil }, | ||||
| 165 | + o.str("source").map { .init(label: "Gestionnaire", value: $0) }, | ||||
| 166 | + ].compactMap { $0 } | ||||
| 167 | + return it | ||||
| 168 | + }), | ||||
| 169 | + Universe(id: "immo-ka", wordmark: "Immo·Ka", name: "Immo-KA", | ||||
| 170 | + tagline: "Toutes les propriétés à vendre", accentHex: "#e23744", | ||||
| 171 | + symbol: "house.fill", domain: "www.immo-ka.com", unit: "propriétés", | ||||
| 172 | + listPath: "/api/listings", itemsKey: "listings", searchParam: "q", | ||||
| 173 | + statTotalKeys: ["total"], | ||||
| 174 | + map: { o in | ||||
| 175 | + var it = base(o, universe: "immo-ka") | ||||
| 176 | + it.subtitle = [o.str("property_type"), o.str("region")].compactMap { $0 }.joined(separator: " · ") | ||||
| 177 | + if let p = o.num("price"), p > 1000 { it.priceLabel = p.money0 } | ||||
| 178 | + it.facts = [ | ||||
| 179 | + o.num("bedrooms").map { .init(label: "Chambres", value: String(Int($0))) }, | ||||
| 180 | + o.num("bathrooms").map { .init(label: "Salles de bain", value: String(Int($0))) }, | ||||
| 181 | + o.num("area_sqft").flatMap { $0 > 50 ? KAItem.Fact(label: "Superficie", value: "\(Int($0)) pi²") : nil }, | ||||
| 182 | + o.str("address").map { .init(label: "Adresse", value: $0) }, | ||||
| 183 | + o.str("source").map { .init(label: "Source", value: $0) }, | ||||
| 184 | + ].compactMap { $0 } | ||||
| 185 | + return it | ||||
| 186 | + }), | ||||
| 187 | + Universe(id: "vrai-prix", wordmark: "Vrai-Prix", name: "Vrai-Prix", | ||||
| 188 | + tagline: "La valeur réelle de chaque propriété", accentHex: "#ff5148", | ||||
| 189 | + symbol: "chart.line.uptrend.xyaxis", domain: "www.vrai-prix.com", unit: "propriétés estimées", | ||||
| 190 | + listPath: nil, itemsKey: "", searchParam: "q", | ||||
| 191 | + statTotalKeys: ["units_total"], map: nil), | ||||
| 192 | + Universe(id: "auto-ka", wordmark: "Auto·Ka", name: "Auto-KA", | ||||
| 193 | + tagline: "Les voitures usagées du Québec", accentHex: "#ff5a2a", | ||||
| 194 | + symbol: "car.fill", domain: "www.auto-ka.com", unit: "véhicules", | ||||
| 195 | + listPath: "/api/vehicles", itemsKey: "vehicles", searchParam: "q", | ||||
| 196 | + statTotalKeys: ["total"], | ||||
| 197 | + map: { o in | ||||
| 198 | + var it = base(o, universe: "auto-ka") | ||||
| 199 | + it.subtitle = [o.num("year").map { String(Int($0)) }, o.str("mileage_label")].compactMap { $0 }.joined(separator: " · ") | ||||
| 200 | + it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money0) | ||||
| 201 | + it.facts = [ | ||||
| 202 | + o.str("make").map { .init(label: "Marque", value: $0) }, | ||||
| 203 | + o.str("model").map { .init(label: "Modèle", value: $0) }, | ||||
| 204 | + o.str("transmission").map { .init(label: "Boîte", value: $0) }, | ||||
| 205 | + o.str("fuel").map { .init(label: "Carburant", value: $0) }, | ||||
| 206 | + o.str("drivetrain").map { .init(label: "Rouage", value: $0) }, | ||||
| 207 | + o.str("body_type").map { .init(label: "Carrosserie", value: $0) }, | ||||
| 208 | + o.str("dealer_name").map { .init(label: "Concessionnaire", value: $0) }, | ||||
| 209 | + o.str("mileage_label").map { .init(label: "Kilométrage", value: $0) }, | ||||
| 210 | + ].compactMap { $0 } | ||||
| 211 | + return it | ||||
| 212 | + }), | ||||
| 213 | + Universe(id: "fabri-ka", wordmark: "Fabri·Ka", name: "Fabri-KA", | ||||
| 214 | + tagline: "Les produits fabriqués au Québec", accentHex: "#c4532e", | ||||
| 215 | + symbol: "shippingbox.fill", domain: "www.fabri-ka.com", unit: "produits", | ||||
| 216 | + listPath: "/api/products", itemsKey: "items", searchParam: "q", | ||||
| 217 | + statTotalKeys: ["totals.products", "total"], | ||||
| 218 | + map: { o in | ||||
| 219 | + var it = base(o, universe: "fabri-ka") | ||||
| 220 | + it.subtitle = o.str("store_id", "store") | ||||
| 221 | + it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money2) | ||||
| 222 | + it.facts = [o.str("category").map { .init(label: "Catégorie", value: $0) }].compactMap { $0 } | ||||
| 223 | + return it | ||||
| 224 | + }), | ||||
| 225 | + Universe(id: "food-ka", wordmark: "Food·Ka", name: "Food-KA", | ||||
| 226 | + tagline: "Les prix d'épicerie, suivis à la source", accentHex: "#1f9d55", | ||||
| 227 | + symbol: "cart.fill", domain: "www.food-ka.com", unit: "produits", | ||||
| 228 | + listPath: "/api/products", itemsKey: "products", searchParam: "q", | ||||
| 229 | + statTotalKeys: ["total"], | ||||
| 230 | + map: { o in | ||||
| 231 | + var it = base(o, universe: "food-ka") | ||||
| 232 | + it.subtitle = [o.str("brand"), o.str("size_label")].compactMap { $0 }.joined(separator: " · ") | ||||
| 233 | + let onSale: Bool = { if case .bool(true) = o["on_sale"] ?? .null { return true }; return false }() | ||||
| 234 | + if let p = o.num("price"), p > 0.2 { | ||||
| 235 | + if onSale, let reg = o.num("regular_price"), reg > p { | ||||
| 236 | + it.priceLabel = "\(p.money2) 🏷️ (rég. \(reg.money2))" | ||||
| 237 | + } else { it.priceLabel = p.money2 } | ||||
| 238 | + } | ||||
| 239 | + it.facts = [ | ||||
| 240 | + o.str("category").map { .init(label: "Catégorie", value: $0) }, | ||||
| 241 | + o.num("unit_price").flatMap { $0 > 0.001 ? KAItem.Fact(label: "Prix unitaire", value: $0.money2) : nil }, | ||||
| 242 | + onSale ? KAItem.Fact(label: "Solde", value: "Oui 🏷️") : nil, | ||||
| 243 | + ].compactMap { $0 } | ||||
| 244 | + return it | ||||
| 245 | + }), | ||||
| 246 | + Universe(id: "resto-ka", wordmark: "Resto·Ka", name: "Resto-KA", | ||||
| 247 | + tagline: "Chaque resto, chaque plat, chaque prix", accentHex: "#f08c00", | ||||
| 248 | + symbol: "fork.knife", domain: "www.resto-ka.com", unit: "restaurants", | ||||
| 249 | + listPath: "/api/restaurants", itemsKey: "restaurants", searchParam: "q", | ||||
| 250 | + statTotalKeys: ["restaurants", "total"], | ||||
| 251 | + map: { o in | ||||
| 252 | + var it = base(o, universe: "resto-ka") | ||||
| 253 | + let cuisines = o["cuisines"]?.text | ||||
| 254 | + it.subtitle = [cuisines, o.str("price_range")].compactMap { $0 }.joined(separator: " · ") | ||||
| 255 | + it.facts = [ | ||||
| 256 | + o.str("address").map { .init(label: "Adresse", value: $0) }, | ||||
| 257 | + o.str("chain").map { .init(label: "Chaîne", value: $0) }, | ||||
| 258 | + ].compactMap { $0 } | ||||
| 259 | + return it | ||||
| 260 | + }), | ||||
| 261 | + Universe(id: "sorti-ka", wordmark: "Sorti·Ka", name: "Sorti-KA", | ||||
| 262 | + tagline: "Toutes les sorties, dans les 17 régions", accentHex: "#d6336c", | ||||
| 263 | + symbol: "ticket.fill", domain: "www.sorti-ka.com", unit: "événements", | ||||
| 264 | + listPath: "/api/events?upcoming=true", itemsKey: "events", searchParam: "q", | ||||
| 265 | + statTotalKeys: ["total_active", "total"], | ||||
| 266 | + map: { o in | ||||
| 267 | + var it = base(o, universe: "sorti-ka") | ||||
| 268 | + it.subtitle = [o.str("start_date"), o.str("venue")].compactMap { $0 }.joined(separator: " · ") | ||||
| 269 | + it.facts = [ | ||||
| 270 | + o.str("start_date").map { .init(label: "Début", value: $0) }, | ||||
| 271 | + o.str("end_date").map { .init(label: "Fin", value: $0) }, | ||||
| 272 | + o.str("venue").map { .init(label: "Lieu", value: $0) }, | ||||
| 273 | + o.str("region").map { .init(label: "Région", value: $0) }, | ||||
| 274 | + (o["is_free"].flatMap { if case .bool(true) = $0 { return KAItem.Fact(label: "Entrée", value: "Gratuite 🎉") } ; return nil }), | ||||
| 275 | + o.num("price_min").flatMap { $0 > 1 ? KAItem.Fact(label: "Billets dès", value: $0.money0) : nil }, | ||||
| 276 | + ].compactMap { $0 } | ||||
| 277 | + return it | ||||
| 278 | + }), | ||||
| 279 | + Universe(id: "crea-ka", wordmark: "Créa·Ka", name: "Créa-KA", | ||||
| 280 | + tagline: "Les créateurs d'ici, tous leurs liens", accentHex: "#7048e8", | ||||
| 281 | + symbol: "sparkles", domain: "www.crea-ka.com", unit: "créateurs", | ||||
| 282 | + listPath: "/api/creators", itemsKey: "items", searchParam: "q", | ||||
| 283 | + statTotalKeys: ["creators", "total"], | ||||
| 284 | + map: { o in | ||||
| 285 | + var it = base(o, universe: "crea-ka", idKeys: ["cid", "uid", "display_name"]) | ||||
| 286 | + let plats = o["platforms"]?.array?.compactMap(\.object) ?? [] | ||||
| 287 | + let totalFollowers = plats.compactMap { $0.num("followers") }.reduce(0, +) | ||||
| 288 | + it.subtitle = [o["niches"]?.text, | ||||
| 289 | + totalFollowers > 0 ? totalFollowers.compact + " abonnés" : nil] | ||||
| 290 | + .compactMap { $0 }.joined(separator: " · ") | ||||
| 291 | + if it.url == nil { it.url = plats.first?.str("url").flatMap(URL.init(string:)) } | ||||
| 292 | + it.facts = plats.prefix(6).compactMap { pl in | ||||
| 293 | + guard let name = pl.str("platform") else { return nil } | ||||
| 294 | + let f = pl.num("followers").map { $0.compact } ?? "" | ||||
| 295 | + return KAItem.Fact(label: name.capitalized, value: f.isEmpty ? "@" + (pl.str("handle") ?? "") : f + " abonnés") | ||||
| 296 | + } | ||||
| 297 | + it.links = plats.prefix(6).compactMap { pl in | ||||
| 298 | + guard let name = pl.str("platform"), let url = pl.str("url") else { return nil } | ||||
| 299 | + return KAItem.Fact(label: name.capitalized, value: url) | ||||
| 300 | + } | ||||
| 301 | + return it | ||||
| 302 | + }), | ||||
| 303 | + Universe(id: "job-ka", wordmark: "Job·Ka", name: "Job-KA", | ||||
| 304 | + tagline: "Tous les emplois des employeurs québécois", accentHex: "#0c8599", | ||||
| 305 | + symbol: "briefcase.fill", domain: "www.job-ka.com", unit: "offres d'emploi", | ||||
| 306 | + listPath: "/api/jobs", itemsKey: "jobs", searchParam: "q", | ||||
| 307 | + statTotalKeys: ["total"], | ||||
| 308 | + map: { o in | ||||
| 309 | + var it = base(o, universe: "job-ka") | ||||
| 310 | + it.subtitle = [o.str("employer"), o.str("city")].compactMap { $0 }.joined(separator: " · ") | ||||
| 311 | + let sMin = o.num("salary_year_min"), sMax = o.num("salary_year_max") | ||||
| 312 | + if let a = sMin, a > 20000 { | ||||
| 313 | + it.priceLabel = (sMax != nil && sMax! > a) ? "\(a.money0)–\(sMax!.money0)/an" : a.money0 + "/an" | ||||
| 314 | + } else if let h = o.num("salary_hour_min"), h > 10 { | ||||
| 315 | + it.priceLabel = h.money2 + "/h" | ||||
| 316 | + } | ||||
| 317 | + it.facts = [ | ||||
| 318 | + o.str("employer").map { .init(label: "Employeur", value: $0) }, | ||||
| 319 | + o.str("work_mode").map { .init(label: "Mode de travail", value: $0) }, | ||||
| 320 | + o.str("employment_type").map { .init(label: "Type", value: $0) }, | ||||
| 321 | + o.str("ats").map { .init(label: "Plateforme carrière", value: $0) }, | ||||
| 322 | + o.str("date_posted").map { .init(label: "Publiée le", value: String($0.prefix(10))) }, | ||||
| 323 | + ].compactMap { $0 } | ||||
| 324 | + return it | ||||
| 325 | + }), | ||||
| 326 | + Universe(id: "trouve-ka", wordmark: "Trouve·Ka", name: "Trouve-KA", | ||||
| 327 | + tagline: "Le moteur de recherche du web québécois", accentHex: "#1c7ed6", | ||||
| 328 | + symbol: "magnifyingglass", domain: "www.trouve-ka.com", unit: "pages indexées", | ||||
| 329 | + listPath: "/api/search", itemsKey: "results", searchParam: "q", | ||||
| 330 | + statTotalKeys: ["pages_indexed"], | ||||
| 331 | + map: { o in | ||||
| 332 | + var it = base(o, universe: "trouve-ka", idKeys: ["url"]) | ||||
| 333 | + it.title = (o.str("title") ?? "Page web").strippingHTML | ||||
| 334 | + it.subtitle = o.str("snippet")?.strippingHTML | ||||
| 335 | + it.facts = [o.str("domain").map { .init(label: "Domaine", value: $0) }].compactMap { $0 } | ||||
| 336 | + return it | ||||
| 337 | + }), | ||||
| 338 | + Universe(id: "api-ka", wordmark: "API·Ka", name: "API-KA", | ||||
| 339 | + tagline: "La donnée de l'écosystème, par API", accentHex: "#3b5bdb", | ||||
| 340 | + symbol: "terminal.fill", domain: "www.api-ka.com", unit: "appels API", | ||||
| 341 | + listPath: nil, itemsKey: "", searchParam: "q", | ||||
| 342 | + statTotalKeys: ["total"], map: nil), | ||||
| 343 | + ] | ||||
| 344 | +} | ||||
| 345 | + | ||||
| 346 | +// MARK: - petites extensions | ||||
| 347 | + | ||||
| 348 | +extension Double { | ||||
| 349 | + /// 17 100 000 → « 17,1 M », 5 200 → « 5,2 k » | ||||
| 350 | + var compact: String { | ||||
| 351 | + if self >= 1_000_000 { return String(format: "%.1f M", self / 1_000_000).replacingOccurrences(of: ".", with: ",") } | ||||
| 352 | + if self >= 10_000 { return String(format: "%.0f k", self / 1_000) } | ||||
| 353 | + if self >= 1_000 { return String(format: "%.1f k", self / 1_000).replacingOccurrences(of: ".", with: ",") } | ||||
| 354 | + return String(Int(self)) | ||||
| 355 | + } | ||||
| 356 | + var money0: String { | ||||
| 357 | + let f = NumberFormatter(); f.numberStyle = .currency | ||||
| 358 | + f.locale = Locale(identifier: "fr_CA"); f.maximumFractionDigits = 0 | ||||
| 359 | + return f.string(from: NSNumber(value: self)) ?? "\(Int(self)) $" | ||||
| 360 | + } | ||||
| 361 | + var money2: String { | ||||
| 362 | + let f = NumberFormatter(); f.numberStyle = .currency | ||||
| 363 | + f.locale = Locale(identifier: "fr_CA"); f.maximumFractionDigits = 2 | ||||
| 364 | + return f.string(from: NSNumber(value: self)) ?? "\(self) $" | ||||
| 365 | + } | ||||
| 366 | +} | ||||
| 367 | + | ||||
| 368 | +extension Int { | ||||
| 369 | + /// « 12 345 » à la québécoise | ||||
| 370 | + var fr: String { formatted(.number.locale(Locale(identifier: "fr_CA"))) } | ||||
| 371 | +} | ||||
| 372 | + | ||||
| 373 | +extension String { | ||||
| 374 | + var strippingHTML: String { | ||||
| 375 | + replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) | ||||
| 376 | + .replacingOccurrences(of: "&", with: "&") | ||||
| 377 | + .replacingOccurrences(of: " ", with: " ") | ||||
| 378 | + } | ||||
| 379 | +} | ||||
| 380 | + | ||||
| 381 | +extension Color { | ||||
| 382 | + init(hex: String) { | ||||
| 383 | + var h = hex.trimmingCharacters(in: .alphanumerics.inverted) | ||||
| 384 | + if h.count == 3 { h = h.map { "\($0)\($0)" }.joined() } | ||||
| 385 | + let v = UInt64(h, radix: 16) ?? 0 | ||||
| 386 | + self.init(.sRGB, | ||||
| 387 | + red: Double((v >> 16) & 0xFF) / 255, | ||||
| 388 | + green: Double((v >> 8) & 0xFF) / 255, | ||||
| 389 | + blue: Double(v & 0xFF) / 255) | ||||
| 390 | + } | ||||
| 391 | +} | ||||
added
Sources/KA/Core/FavoritesStore.swift
+83 −0
@@ -0,0 +1,83 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// FavoritesStore.swift — favoris et collections UNIFIÉS (tous univers dans une | ||||
| 3 | +// même collection : « Déménagement à Val-d'Or » peut contenir un 4½, une auto | ||||
| 4 | +// et un resto). Persistance JSON locale (consultation hors ligne). | ||||
| 5 | +// Repris de l'app iOS KA, sans haptique. | ||||
| 6 | +import Foundation | ||||
| 7 | +import SwiftUI | ||||
| 8 | + | ||||
| 9 | +@MainActor | ||||
| 10 | +final class FavoritesStore: ObservableObject { | ||||
| 11 | + static let shared = FavoritesStore() | ||||
| 12 | + | ||||
| 13 | + struct FavCollection: Identifiable, Codable, Hashable { | ||||
| 14 | + var id: UUID = UUID() | ||||
| 15 | + var name: String | ||||
| 16 | + var items: [KAItem] = [] | ||||
| 17 | + } | ||||
| 18 | + | ||||
| 19 | + @Published private(set) var collections: [FavCollection] = [] { | ||||
| 20 | + didSet { save() } | ||||
| 21 | + } | ||||
| 22 | + | ||||
| 23 | + private var fileURL: URL { | ||||
| 24 | + let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | ||||
| 25 | + .appendingPathComponent("KA", isDirectory: true) | ||||
| 26 | + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | ||||
| 27 | + return dir.appendingPathComponent("ka-favoris.json") | ||||
| 28 | + } | ||||
| 29 | + | ||||
| 30 | + init() { | ||||
| 31 | + if let data = try? Data(contentsOf: fileURL), | ||||
| 32 | + let saved = try? JSONDecoder().decode([FavCollection].self, from: data) { | ||||
| 33 | + collections = saved | ||||
| 34 | + } | ||||
| 35 | + if collections.isEmpty { | ||||
| 36 | + collections = [FavCollection(name: "Mes favoris")] | ||||
| 37 | + } | ||||
| 38 | + } | ||||
| 39 | + | ||||
| 40 | + private func save() { | ||||
| 41 | + if let data = try? JSONEncoder().encode(collections) { | ||||
| 42 | + try? data.write(to: fileURL, options: .atomic) | ||||
| 43 | + } | ||||
| 44 | + } | ||||
| 45 | + | ||||
| 46 | + // MARK: API | ||||
| 47 | + | ||||
| 48 | + var allItems: [KAItem] { collections.flatMap(\.items) } | ||||
| 49 | + var count: Int { collections.reduce(0) { $0 + $1.items.count } } | ||||
| 50 | + | ||||
| 51 | + func isFavorite(_ item: KAItem) -> Bool { | ||||
| 52 | + collections.contains { $0.items.contains { $0.id == item.id } } | ||||
| 53 | + } | ||||
| 54 | + | ||||
| 55 | + func toggle(_ item: KAItem, in collectionID: UUID? = nil) { | ||||
| 56 | + if isFavorite(item) { | ||||
| 57 | + for i in collections.indices { | ||||
| 58 | + collections[i].items.removeAll { $0.id == item.id } | ||||
| 59 | + } | ||||
| 60 | + } else { | ||||
| 61 | + let idx = collections.firstIndex { $0.id == collectionID } ?? 0 | ||||
| 62 | + collections[idx].items.insert(item, at: 0) | ||||
| 63 | + } | ||||
| 64 | + } | ||||
| 65 | + | ||||
| 66 | + func addCollection(_ name: String) { | ||||
| 67 | + let trimmed = name.trimmingCharacters(in: .whitespaces) | ||||
| 68 | + guard !trimmed.isEmpty else { return } | ||||
| 69 | + collections.append(FavCollection(name: trimmed)) | ||||
| 70 | + } | ||||
| 71 | + | ||||
| 72 | + func removeCollection(_ id: UUID) { | ||||
| 73 | + guard collections.count > 1 else { return } | ||||
| 74 | + collections.removeAll { $0.id == id } | ||||
| 75 | + } | ||||
| 76 | + | ||||
| 77 | + func move(_ item: KAItem, to collectionID: UUID) { | ||||
| 78 | + for i in collections.indices { collections[i].items.removeAll { $0.id == item.id } } | ||||
| 79 | + if let idx = collections.firstIndex(where: { $0.id == collectionID }) { | ||||
| 80 | + collections[idx].items.insert(item, at: 0) | ||||
| 81 | + } | ||||
| 82 | + } | ||||
| 83 | +} | ||||
added
Sources/KA/Core/KAFilters.swift
+205 −0
@@ -0,0 +1,205 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// KAFilters.swift — les FILTRES MÉTIER de chaque univers, comme sur les sites | ||||
| 3 | +// web : ville/taille/prix (Lou·Ka), marque/année/km (Auto·Ka), gratuit | ||||
| 4 | +// (Sorti·Ka), en solde (Food·Ka), cuisine (Resto·Ka)… Chaque filtre mappe | ||||
| 5 | +// directement un paramètre de l'API du site. | ||||
| 6 | +// Catalogue repris de l'app iOS KA (KAFilters.swift), UI adaptée macOS | ||||
| 7 | +// (popover au lieu de feuille, sans haptique). | ||||
| 8 | +import SwiftUI | ||||
| 9 | + | ||||
| 10 | +struct KAFilter: Identifiable { | ||||
| 11 | + enum Kind { | ||||
| 12 | + case text(placeholder: String) // champ libre → param | ||||
| 13 | + case options([String]) // choix unique → param | ||||
| 14 | + case minMax(minParam: String, maxParam: String, unit: String, step: Double, range: ClosedRange<Double>) | ||||
| 15 | + case toggle(value: String) // interrupteur → param=value | ||||
| 16 | + } | ||||
| 17 | + let id: String // nom du paramètre API (ou préfixe pour minMax) | ||||
| 18 | + let label: String | ||||
| 19 | + let kind: Kind | ||||
| 20 | +} | ||||
| 21 | + | ||||
| 22 | +enum FilterCatalog { | ||||
| 23 | + static func filters(for universeID: String) -> [KAFilter] { | ||||
| 24 | + switch universeID { | ||||
| 25 | + case "lou-ka": return [ | ||||
| 26 | + .init(id: "city", label: "Ville", kind: .text(placeholder: "Québec, Montréal…")), | ||||
| 27 | + .init(id: "unit_type", label: "Taille", kind: .options(["Studio", "1½", "2½", "3½", "4½", "5½", "6½"])), | ||||
| 28 | + .init(id: "loyer", label: "Loyer", kind: .minMax(minParam: "price_min", maxParam: "price_max", unit: "$", step: 50, range: 300...4000)), | ||||
| 29 | + ] | ||||
| 30 | + case "immo-ka": return [ | ||||
| 31 | + .init(id: "city", label: "Ville", kind: .text(placeholder: "Lévis, Gatineau…")), | ||||
| 32 | + .init(id: "prix", label: "Prix", kind: .minMax(minParam: "price_min", maxParam: "price_max", unit: "$", step: 25000, range: 50000...2000000)), | ||||
| 33 | + ] | ||||
| 34 | + case "auto-ka": return [ | ||||
| 35 | + .init(id: "make", label: "Marque", kind: .text(placeholder: "Toyota, Kia…")), | ||||
| 36 | + .init(id: "model", label: "Modèle", kind: .text(placeholder: "Corolla…")), | ||||
| 37 | + .init(id: "annee", label: "Année", kind: .minMax(minParam: "year_min", maxParam: "year_max", unit: "", step: 1, range: 2000...2026)), | ||||
| 38 | + .init(id: "prix", label: "Prix", kind: .minMax(minParam: "price_min", maxParam: "price_max", unit: "$", step: 1000, range: 1000...120000)), | ||||
| 39 | + .init(id: "km_max", label: "Km max", kind: .options(["50000", "100000", "150000", "200000"])), | ||||
| 40 | + ] | ||||
| 41 | + case "job-ka": return [ | ||||
| 42 | + .init(id: "city", label: "Ville", kind: .text(placeholder: "Montréal, Québec…")), | ||||
| 43 | + ] | ||||
| 44 | + case "food-ka": return [ | ||||
| 45 | + .init(id: "on_sale", label: "En solde 🏷️", kind: .toggle(value: "true")), | ||||
| 46 | + ] | ||||
| 47 | + case "resto-ka": return [ | ||||
| 48 | + .init(id: "city", label: "Ville", kind: .text(placeholder: "Montréal…")), | ||||
| 49 | + .init(id: "cuisine", label: "Cuisine", kind: .options(["italien", "quebecois", "sushi", "bbq-grillades", "mexicain", "indien", "vegetarien", "dejeuner"])), | ||||
| 50 | + ] | ||||
| 51 | + case "sorti-ka": return [ | ||||
| 52 | + .init(id: "city", label: "Ville", kind: .text(placeholder: "Sherbrooke…")), | ||||
| 53 | + .init(id: "region", label: "Région", kind: .options(["Montréal", "Capitale-Nationale", "Montérégie", "Estrie", "Laurentides", "Outaouais", "Mauricie"])), | ||||
| 54 | + .init(id: "free", label: "Gratuit 🎉", kind: .toggle(value: "true")), | ||||
| 55 | + ] | ||||
| 56 | + default: return [] | ||||
| 57 | + } | ||||
| 58 | + } | ||||
| 59 | +} | ||||
| 60 | + | ||||
| 61 | +// MARK: - Barre de filtres (popover macOS) | ||||
| 62 | + | ||||
| 63 | +struct FilterBar: View { | ||||
| 64 | + let universe: Universe | ||||
| 65 | + @Binding var params: [String: String] | ||||
| 66 | + let onApply: () -> Void | ||||
| 67 | + @State private var showPopover = false | ||||
| 68 | + @Environment(\.colorScheme) private var scheme | ||||
| 69 | + | ||||
| 70 | + private var filters: [KAFilter] { FilterCatalog.filters(for: universe.id) } | ||||
| 71 | + private var activeCount: Int { params.count } | ||||
| 72 | + | ||||
| 73 | + var body: some View { | ||||
| 74 | + if !filters.isEmpty { | ||||
| 75 | + HStack(spacing: 8) { | ||||
| 76 | + Button { | ||||
| 77 | + showPopover = true | ||||
| 78 | + } label: { | ||||
| 79 | + Label(activeCount > 0 ? "Filtres · \(activeCount)" : "Filtres", | ||||
| 80 | + systemImage: "line.3.horizontal.decrease.circle\(activeCount > 0 ? ".fill" : "")") | ||||
| 81 | + .font(.system(.caption, design: .monospaced).weight(.bold)) | ||||
| 82 | + .padding(.horizontal, 11).padding(.vertical, 7) | ||||
| 83 | + .background(activeCount > 0 ? universe.accent : KATheme.surface(scheme), in: Capsule()) | ||||
| 84 | + .foregroundStyle(activeCount > 0 ? .white : .primary) | ||||
| 85 | + .overlay(Capsule().strokeBorder(.primary.opacity(0.3), lineWidth: 1)) | ||||
| 86 | + } | ||||
| 87 | + .buttonStyle(.plain) | ||||
| 88 | + .popover(isPresented: $showPopover, arrowEdge: .bottom) { | ||||
| 89 | + FilterPanel(universe: universe, params: $params) { | ||||
| 90 | + showPopover = false | ||||
| 91 | + onApply() | ||||
| 92 | + } | ||||
| 93 | + } | ||||
| 94 | + // interrupteurs directement dans la barre | ||||
| 95 | + ForEach(filters) { f in | ||||
| 96 | + if case .toggle(let value) = f.kind { | ||||
| 97 | + let on = params[f.id] == value | ||||
| 98 | + Button { | ||||
| 99 | + if on { params.removeValue(forKey: f.id) } else { params[f.id] = value } | ||||
| 100 | + onApply() | ||||
| 101 | + } label: { | ||||
| 102 | + Text(f.label) | ||||
| 103 | + .font(.system(.caption, design: .monospaced).weight(.bold)) | ||||
| 104 | + .padding(.horizontal, 11).padding(.vertical, 7) | ||||
| 105 | + .background(on ? universe.accent : KATheme.surface(scheme), in: Capsule()) | ||||
| 106 | + .foregroundStyle(on ? .white : .primary) | ||||
| 107 | + .overlay(Capsule().strokeBorder(.primary.opacity(0.3), lineWidth: 1)) | ||||
| 108 | + } | ||||
| 109 | + .buttonStyle(.plain) | ||||
| 110 | + } | ||||
| 111 | + } | ||||
| 112 | + if activeCount > 0 { | ||||
| 113 | + Button { | ||||
| 114 | + params = [:]; onApply() | ||||
| 115 | + } label: { | ||||
| 116 | + Label("Effacer", systemImage: "xmark") | ||||
| 117 | + .font(.system(.caption2, design: .monospaced).weight(.bold)) | ||||
| 118 | + .foregroundStyle(.secondary) | ||||
| 119 | + } | ||||
| 120 | + .buttonStyle(.plain) | ||||
| 121 | + } | ||||
| 122 | + } | ||||
| 123 | + } | ||||
| 124 | + } | ||||
| 125 | +} | ||||
| 126 | + | ||||
| 127 | +struct FilterPanel: View { | ||||
| 128 | + let universe: Universe | ||||
| 129 | + @Binding var params: [String: String] | ||||
| 130 | + let onApply: () -> Void | ||||
| 131 | + | ||||
| 132 | + var body: some View { | ||||
| 133 | + VStack(alignment: .leading, spacing: 0) { | ||||
| 134 | + Text("Filtres \(universe.name)") | ||||
| 135 | + .font(.headline) | ||||
| 136 | + .padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 6) | ||||
| 137 | + Form { | ||||
| 138 | + ForEach(FilterCatalog.filters(for: universe.id)) { f in | ||||
| 139 | + section(f) | ||||
| 140 | + } | ||||
| 141 | + } | ||||
| 142 | + .formStyle(.grouped) | ||||
| 143 | + .scrollContentBackground(.hidden) | ||||
| 144 | + HStack { | ||||
| 145 | + Button("Effacer") { params = [:] } | ||||
| 146 | + .disabled(params.isEmpty) | ||||
| 147 | + Spacer() | ||||
| 148 | + Button("Appliquer") { onApply() } | ||||
| 149 | + .keyboardShortcut(.defaultAction) | ||||
| 150 | + .tint(universe.accent) | ||||
| 151 | + } | ||||
| 152 | + .padding(.horizontal, 16).padding(.bottom, 14) | ||||
| 153 | + } | ||||
| 154 | + .frame(width: 360, height: 420) | ||||
| 155 | + } | ||||
| 156 | + | ||||
| 157 | + @ViewBuilder | ||||
| 158 | + private func section(_ f: KAFilter) -> some View { | ||||
| 159 | + Section(f.label) { | ||||
| 160 | + switch f.kind { | ||||
| 161 | + case .text(let placeholder): | ||||
| 162 | + TextField(placeholder, text: binding(f.id)) | ||||
| 163 | + .autocorrectionDisabled() | ||||
| 164 | + case .options(let opts): | ||||
| 165 | + Picker(f.label, selection: binding(f.id)) { | ||||
| 166 | + Text("Tous").tag("") | ||||
| 167 | + ForEach(opts, id: \.self) { Text($0).tag($0) } | ||||
| 168 | + } | ||||
| 169 | + .pickerStyle(.menu) | ||||
| 170 | + .labelsHidden() | ||||
| 171 | + case .minMax(let minP, let maxP, let unit, let step, let range): | ||||
| 172 | + Stepper(value: numBinding(minP, default: range.lowerBound), in: range, step: step) { | ||||
| 173 | + LabeledContent("Min", value: label(params[minP], unit: unit, fallback: "—")) | ||||
| 174 | + } | ||||
| 175 | + Stepper(value: numBinding(maxP, default: range.upperBound), in: range, step: step) { | ||||
| 176 | + LabeledContent("Max", value: label(params[maxP], unit: unit, fallback: "—")) | ||||
| 177 | + } | ||||
| 178 | + if params[minP] != nil || params[maxP] != nil { | ||||
| 179 | + Button("Réinitialiser \(f.label.lowercased())") { | ||||
| 180 | + params.removeValue(forKey: minP); params.removeValue(forKey: maxP) | ||||
| 181 | + } | ||||
| 182 | + .font(.caption) | ||||
| 183 | + } | ||||
| 184 | + case .toggle(let value): | ||||
| 185 | + Toggle(f.label, isOn: Binding( | ||||
| 186 | + get: { params[f.id] == value }, | ||||
| 187 | + set: { params[f.id] = $0 ? value : nil; if !$0 { params.removeValue(forKey: f.id) } } | ||||
| 188 | + )) | ||||
| 189 | + } | ||||
| 190 | + } | ||||
| 191 | + } | ||||
| 192 | + | ||||
| 193 | + private func binding(_ key: String) -> Binding<String> { | ||||
| 194 | + Binding(get: { params[key] ?? "" }, | ||||
| 195 | + set: { v in if v.isEmpty { params.removeValue(forKey: key) } else { params[key] = v } }) | ||||
| 196 | + } | ||||
| 197 | + private func numBinding(_ key: String, default def: Double) -> Binding<Double> { | ||||
| 198 | + Binding(get: { Double(params[key] ?? "") ?? def }, | ||||
| 199 | + set: { params[key] = String(Int($0)) }) | ||||
| 200 | + } | ||||
| 201 | + private func label(_ raw: String?, unit: String, fallback: String) -> String { | ||||
| 202 | + guard let raw, let n = Double(raw) else { return fallback } | ||||
| 203 | + return unit == "$" ? n.money0 : "\(Int(n))\(unit.isEmpty ? "" : " \(unit)")" | ||||
| 204 | + } | ||||
| 205 | +} | ||||
added
Sources/KA/Core/RecentsStore.swift
+142 −0
@@ -0,0 +1,142 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// RecentsStore.swift — historique des éléments consultés, recherches | ||||
| 3 | +// sauvegardées et ALERTES honnêtes : à l'ouverture, on recompte le total réel | ||||
| 4 | +// de chaque recherche sauvegardée via l'API et on affiche le delta (« +12 | ||||
| 5 | +// nouveautés ») — jamais de faux chiffre. Persistance JSON locale. | ||||
| 6 | +// Repris de l'app iOS KA, sans haptique. | ||||
| 7 | +import Foundation | ||||
| 8 | +import SwiftUI | ||||
| 9 | + | ||||
| 10 | +struct SavedSearch: Identifiable, Codable, Hashable { | ||||
| 11 | + var id: UUID = UUID() | ||||
| 12 | + var name: String | ||||
| 13 | + var universeID: String // "tous" = recherche universelle | ||||
| 14 | + var query: String | ||||
| 15 | + var params: [String: String] = [:] | ||||
| 16 | + var alertsOn: Bool = true | ||||
| 17 | + var lastTotal: Int? | ||||
| 18 | + var newCount: Int = 0 | ||||
| 19 | + var lastChecked: Date? | ||||
| 20 | +} | ||||
| 21 | + | ||||
| 22 | +@MainActor | ||||
| 23 | +final class RecentsStore: ObservableObject { | ||||
| 24 | + static let shared = RecentsStore() | ||||
| 25 | + | ||||
| 26 | + @Published private(set) var viewed: [KAItem] = [] { didSet { save() } } | ||||
| 27 | + @Published var savedSearches: [SavedSearch] = [] { didSet { save() } } | ||||
| 28 | + | ||||
| 29 | + var alertCount: Int { savedSearches.filter(\.alertsOn).map(\.newCount).reduce(0, +) } | ||||
| 30 | + | ||||
| 31 | + 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 | + } | ||||
| 37 | + | ||||
| 38 | + private struct Snapshot: Codable { | ||||
| 39 | + var viewed: [KAItem] | ||||
| 40 | + var searches: [SavedSearch] | ||||
| 41 | + } | ||||
| 42 | + | ||||
| 43 | + init() { | ||||
| 44 | + if let data = try? Data(contentsOf: fileURL), | ||||
| 45 | + let s = try? JSONDecoder().decode(Snapshot.self, from: data) { | ||||
| 46 | + viewed = s.viewed | ||||
| 47 | + savedSearches = s.searches | ||||
| 48 | + } | ||||
| 49 | + } | ||||
| 50 | + | ||||
| 51 | + 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 | + } | ||||
| 56 | + | ||||
| 57 | + // MARK: historique consulté | ||||
| 58 | + | ||||
| 59 | + 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 | + } | ||||
| 64 | + | ||||
| 65 | + func clearHistory() { viewed = [] } | ||||
| 66 | + | ||||
| 67 | + // MARK: recherches sauvegardées + alertes | ||||
| 68 | + | ||||
| 69 | + 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-plan | ||||
| 74 | + Task { [weak self] in | ||||
| 75 | + 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 = .now | ||||
| 79 | + if let i = self.savedSearches.firstIndex(where: { $0.id == s.id }) { | ||||
| 80 | + self.savedSearches[i] = s | ||||
| 81 | + } | ||||
| 82 | + } | ||||
| 83 | + } | ||||
| 84 | + } | ||||
| 85 | + | ||||
| 86 | + func remove(_ id: UUID) { savedSearches.removeAll { $0.id == id } } | ||||
| 87 | + | ||||
| 88 | + 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].newCount | ||||
| 91 | + savedSearches[i].newCount = 0 | ||||
| 92 | + } | ||||
| 93 | + | ||||
| 94 | + /// 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 - last | ||||
| 102 | + } else if s.lastTotal == nil { | ||||
| 103 | + savedSearches[idx].lastTotal = total | ||||
| 104 | + } | ||||
| 105 | + savedSearches[idx].lastChecked = .now | ||||
| 106 | + } | ||||
| 107 | + } | ||||
| 108 | + } | ||||
| 109 | +} | ||||
| 110 | + | ||||
| 111 | +// MARK: - Collections éditoriales (requêtes RÉELLES, aucun contenu inventé) | ||||
| 112 | + | ||||
| 113 | +struct EditorialCollection: Identifiable { | ||||
| 114 | + let id: String | ||||
| 115 | + let title: String | ||||
| 116 | + let subtitle: String | ||||
| 117 | + let symbol: String | ||||
| 118 | + let universeID: String | ||||
| 119 | + let query: String? | ||||
| 120 | + let params: [String: String] | ||||
| 121 | +} | ||||
| 122 | + | ||||
| 123 | +enum 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 | +} | ||||
added
Sources/KA/Core/Services.swift
+255 −0
@@ -0,0 +1,255 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// Services.swift — réseau : client JSON avec cache disque (mode hors ligne de | ||||
| 3 | +// consultation), service d'univers (listes/recherche), stats live, statut des | ||||
| 4 | +// 13 plateformes (HEAD + latence) et KA Agent (chat SSE vers api-ka). | ||||
| 5 | +// Repris de l'app iOS KA (~/Desktop/KA/KA/Core/Services.swift) + StatusService. | ||||
| 6 | +import Foundation | ||||
| 7 | + | ||||
| 8 | +// MARK: - Client HTTP + cache disque | ||||
| 9 | + | ||||
| 10 | +actor APIClient { | ||||
| 11 | + static let shared = APIClient() | ||||
| 12 | + private let session: URLSession | ||||
| 13 | + private let cacheDir: URL | ||||
| 14 | + | ||||
| 15 | + init() { | ||||
| 16 | + let cfg = URLSessionConfiguration.default | ||||
| 17 | + cfg.timeoutIntervalForRequest = 15 | ||||
| 18 | + cfg.waitsForConnectivity = false | ||||
| 19 | + session = URLSession(configuration: cfg) | ||||
| 20 | + cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] | ||||
| 21 | + .appendingPathComponent("ka-json", isDirectory: true) | ||||
| 22 | + try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) | ||||
| 23 | + } | ||||
| 24 | + | ||||
| 25 | + private func cacheFile(for url: URL) -> URL { | ||||
| 26 | + let name = url.absoluteString.data(using: .utf8)!.base64EncodedString() | ||||
| 27 | + .replacingOccurrences(of: "/", with: "_") | ||||
| 28 | + return cacheDir.appendingPathComponent(String(name.suffix(120)) + ".json") | ||||
| 29 | + } | ||||
| 30 | + | ||||
| 31 | + /// JSON brut ; en cas d'échec réseau, sert la dernière copie disque (hors ligne). | ||||
| 32 | + func json(_ url: URL, ttl: TimeInterval = 120) async throws -> JSONValue { | ||||
| 33 | + let file = cacheFile(for: url) | ||||
| 34 | + if let attrs = try? FileManager.default.attributesOfItem(atPath: file.path), | ||||
| 35 | + let date = attrs[.modificationDate] as? Date, Date().timeIntervalSince(date) < ttl, | ||||
| 36 | + let data = try? Data(contentsOf: file), | ||||
| 37 | + let cached = try? JSONDecoder().decode(JSONValue.self, from: data) { | ||||
| 38 | + return cached | ||||
| 39 | + } | ||||
| 40 | + do { | ||||
| 41 | + var req = URLRequest(url: url) | ||||
| 42 | + req.setValue("KA-macOS/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent") | ||||
| 43 | + let (data, resp) = try await session.data(for: req) | ||||
| 44 | + guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else { | ||||
| 45 | + throw URLError(.badServerResponse) | ||||
| 46 | + } | ||||
| 47 | + let value = try JSONDecoder().decode(JSONValue.self, from: data) | ||||
| 48 | + try? data.write(to: file) | ||||
| 49 | + return value | ||||
| 50 | + } catch { | ||||
| 51 | + if let data = try? Data(contentsOf: file), | ||||
| 52 | + let cached = try? JSONDecoder().decode(JSONValue.self, from: data) { | ||||
| 53 | + return cached // hors ligne : dernier contenu connu | ||||
| 54 | + } | ||||
| 55 | + throw error | ||||
| 56 | + } | ||||
| 57 | + } | ||||
| 58 | +} | ||||
| 59 | + | ||||
| 60 | +// MARK: - Univers : listes, recherche, stats | ||||
| 61 | + | ||||
| 62 | +enum UniverseService { | ||||
| 63 | + static func listURL(_ u: Universe, query: String?, city: String? = nil, limit: Int = 30, params: [String: String] = [:]) -> URL? { | ||||
| 64 | + guard let path = u.listPath else { return nil } | ||||
| 65 | + var comps = URLComponents(url: u.baseURL.appendingPathComponent(""), resolvingAgainstBaseURL: false)! | ||||
| 66 | + // listPath peut contenir déjà une query (ex. events?upcoming=true) | ||||
| 67 | + let split = path.split(separator: "?", maxSplits: 1) | ||||
| 68 | + comps.path = String(split[0]) | ||||
| 69 | + var items: [URLQueryItem] = split.count > 1 | ||||
| 70 | + ? split[1].split(separator: "&").map { | ||||
| 71 | + let kv = $0.split(separator: "=", maxSplits: 1) | ||||
| 72 | + return URLQueryItem(name: String(kv[0]), value: kv.count > 1 ? String(kv[1]) : nil) | ||||
| 73 | + } : [] | ||||
| 74 | + if let q = query, !q.isEmpty { items.append(.init(name: u.searchParam, value: q)) } | ||||
| 75 | + if let c = city, !c.isEmpty { items.append(.init(name: "city", value: c)) } | ||||
| 76 | + for (k, v) in params.sorted(by: { $0.key < $1.key }) where !v.isEmpty { | ||||
| 77 | + items.append(.init(name: k, value: v)) | ||||
| 78 | + } | ||||
| 79 | + items.append(.init(name: "limit", value: String(limit))) | ||||
| 80 | + comps.queryItems = items | ||||
| 81 | + return comps.url | ||||
| 82 | + } | ||||
| 83 | + | ||||
| 84 | + static func fetch(_ u: Universe, query: String? = nil, city: String? = nil, limit: Int = 30, params: [String: String] = [:]) async throws -> [KAItem] { | ||||
| 85 | + guard let url = listURL(u, query: query, city: city, limit: limit, params: params), let map = u.map else { return [] } | ||||
| 86 | + let root = try await APIClient.shared.json(url) | ||||
| 87 | + let obj = root.object ?? [:] | ||||
| 88 | + let raw = obj[u.itemsKey]?.array | ||||
| 89 | + ?? obj["items"]?.array ?? obj["results"]?.array ?? obj["hits"]?.array | ||||
| 90 | + ?? root.array ?? [] | ||||
| 91 | + return raw.compactMap { $0.object.flatMap(map) } | ||||
| 92 | + } | ||||
| 93 | + | ||||
| 94 | + /// Total d'une requête (champ `total` de la réponse liste) — pour les alertes. | ||||
| 95 | + static func total(_ u: Universe, query: String? = nil, params: [String: String] = [:]) async -> Int? { | ||||
| 96 | + guard let url = listURL(u, query: query, limit: 1, params: params), | ||||
| 97 | + let root = try? await APIClient.shared.json(url, ttl: 60) else { return nil } | ||||
| 98 | + return root.object?.num("total", "count").map(Int.init) | ||||
| 99 | + } | ||||
| 100 | + | ||||
| 101 | + /// Items GÉOLOCALISÉS pour une région de carte. | ||||
| 102 | + /// lou-ka / immo-ka / job-ka : endpoint geojson avec bbox RÉEL (vérifié). | ||||
| 103 | + /// Autres univers : liste standard filtrée client sur la région. | ||||
| 104 | + static func mapItems(_ u: Universe, west: Double, south: Double, east: Double, north: Double, | ||||
| 105 | + limit: Int = 150) async -> [KAItem] { | ||||
| 106 | + let bboxCapable = ["lou-ka": "/api/listings.geojson", | ||||
| 107 | + "immo-ka": "/api/listings.geojson", | ||||
| 108 | + "job-ka": "/api/jobs.geojson"] | ||||
| 109 | + if let path = bboxCapable[u.id], let map = u.map { | ||||
| 110 | + var comps = URLComponents(string: "https://\(u.domain)\(path)")! | ||||
| 111 | + comps.queryItems = [ | ||||
| 112 | + .init(name: "bbox", value: "\(west),\(south),\(east),\(north)"), | ||||
| 113 | + .init(name: "limit", value: String(limit)), | ||||
| 114 | + ] | ||||
| 115 | + guard let url = comps.url, | ||||
| 116 | + let root = try? await APIClient.shared.json(url, ttl: 90), | ||||
| 117 | + let features = root.object?["features"]?.array else { return [] } | ||||
| 118 | + return features.compactMap { f -> KAItem? in | ||||
| 119 | + guard let fo = f.object, | ||||
| 120 | + var props = fo["properties"]?.object, | ||||
| 121 | + let coords = fo["geometry"]?.object?["coordinates"]?.array, | ||||
| 122 | + coords.count >= 2, let lon = coords[0].number, let lat = coords[1].number | ||||
| 123 | + else { return nil } | ||||
| 124 | + props["lat"] = .number(lat) | ||||
| 125 | + props["lng"] = .number(lon) | ||||
| 126 | + return map(props) | ||||
| 127 | + } | ||||
| 128 | + } | ||||
| 129 | + // repli : liste + filtre client | ||||
| 130 | + let items = (try? await fetch(u, limit: limit)) ?? [] | ||||
| 131 | + return items.filter { it in | ||||
| 132 | + guard let la = it.latitude, let lo = it.longitude else { return false } | ||||
| 133 | + return la >= south && la <= north && lo >= west && lo <= east | ||||
| 134 | + } | ||||
| 135 | + } | ||||
| 136 | + | ||||
| 137 | + /// Total « en direct » depuis /api/stats (clé selon l'univers, chemins a.b acceptés) | ||||
| 138 | + static func liveTotal(_ u: Universe) async -> Int? { | ||||
| 139 | + let path = u.id == "trouve-ka" ? "/api/status" : "/api/stats" | ||||
| 140 | + guard let url = URL(string: "https://\(u.domain)\(path)"), | ||||
| 141 | + let root = try? await APIClient.shared.json(url, ttl: 300) else { return nil } | ||||
| 142 | + for key in u.statTotalKeys { | ||||
| 143 | + var cur: JSONValue? = root | ||||
| 144 | + for part in key.split(separator: ".") { | ||||
| 145 | + cur = cur?.object?[String(part)] | ||||
| 146 | + } | ||||
| 147 | + if let n = cur?.number { return Int(n) } | ||||
| 148 | + } | ||||
| 149 | + return nil | ||||
| 150 | + } | ||||
| 151 | +} | ||||
| 152 | + | ||||
| 153 | +// MARK: - Statut des 13 plateformes (HEAD sur https://www.<domaine>/) | ||||
| 154 | + | ||||
| 155 | +struct ServiceHealth: Identifiable, Equatable { | ||||
| 156 | + let id: String // domaine | ||||
| 157 | + let name: String // « Lou-KA », « Groupe-KA »… | ||||
| 158 | + var up: Bool? // nil = vérification en cours / inconnue | ||||
| 159 | + var latencyMs: Int? | ||||
| 160 | + var checkedAt: Date? | ||||
| 161 | +} | ||||
| 162 | + | ||||
| 163 | +enum StatusService { | ||||
| 164 | + /// Les 13 services : le hub Groupe-KA + les 12 univers. | ||||
| 165 | + static var allServices: [ServiceHealth] { | ||||
| 166 | + [ServiceHealth(id: Ecosystem.hubDomain, name: "Groupe-KA", up: nil, latencyMs: nil, checkedAt: nil)] | ||||
| 167 | + + Ecosystem.all.map { ServiceHealth(id: $0.domain, name: $0.name, up: nil, latencyMs: nil, checkedAt: nil) } | ||||
| 168 | + } | ||||
| 169 | + | ||||
| 170 | + /// HEAD sur la racine ; vert si 200–399 (ou 405 : le serveur répond mais | ||||
| 171 | + /// refuse HEAD — les apps Next de l'écosystème), latence mesurée. | ||||
| 172 | + static func check(domain: String) async -> (up: Bool, latencyMs: Int) { | ||||
| 173 | + guard let url = URL(string: "https://\(domain)/") else { return (false, 0) } | ||||
| 174 | + var req = URLRequest(url: url) | ||||
| 175 | + req.httpMethod = "HEAD" | ||||
| 176 | + req.timeoutInterval = 10 | ||||
| 177 | + req.setValue("KA-macOS/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent") | ||||
| 178 | + let start = Date() | ||||
| 179 | + do { | ||||
| 180 | + let (_, resp) = try await URLSession.shared.data(for: req) | ||||
| 181 | + let ms = Int(Date().timeIntervalSince(start) * 1000) | ||||
| 182 | + guard let http = resp as? HTTPURLResponse else { return (false, ms) } | ||||
| 183 | + return ((200..<400).contains(http.statusCode) || http.statusCode == 405, ms) | ||||
| 184 | + } catch { | ||||
| 185 | + return (false, Int(Date().timeIntervalSince(start) * 1000)) | ||||
| 186 | + } | ||||
| 187 | + } | ||||
| 188 | +} | ||||
| 189 | + | ||||
| 190 | +// MARK: - Menu de resto (Resto·Ka, /api/restaurants/{uid}) | ||||
| 191 | + | ||||
| 192 | +struct RestoMenuSection: Identifiable { | ||||
| 193 | + let id = UUID() | ||||
| 194 | + let name: String | ||||
| 195 | + let items: [(name: String, price: String?)] | ||||
| 196 | +} | ||||
| 197 | + | ||||
| 198 | +enum RestoMenuLoader { | ||||
| 199 | + static func load(uid: String) async -> [RestoMenuSection] { | ||||
| 200 | + guard let url = URL(string: "https://www.resto-ka.com/api/restaurants/\(uid)"), | ||||
| 201 | + let root = try? await APIClient.shared.json(url, ttl: 600), | ||||
| 202 | + let menus = root.object?["menus"]?.array else { return [] } | ||||
| 203 | + var sections: [RestoMenuSection] = [] | ||||
| 204 | + for menu in menus.prefix(1) { | ||||
| 205 | + for s in menu.object?["sections"]?.array ?? [] { | ||||
| 206 | + guard let so = s.object, let name = so.str("name") else { continue } | ||||
| 207 | + let items: [(String, String?)] = (so["items"]?.array ?? []).prefix(30).compactMap { i in | ||||
| 208 | + guard let io = i.object, let n = io.str("name") else { return nil } | ||||
| 209 | + return (n, io.num("price").flatMap { $0 > 0 ? $0.money2 : nil }) | ||||
| 210 | + } | ||||
| 211 | + if !items.isEmpty { sections.append(RestoMenuSection(name: name, items: items)) } | ||||
| 212 | + } | ||||
| 213 | + } | ||||
| 214 | + return sections | ||||
| 215 | + } | ||||
| 216 | +} | ||||
| 217 | + | ||||
| 218 | +// MARK: - KA Agent (SSE) | ||||
| 219 | + | ||||
| 220 | +struct AgentEvent { let kind: Kind; enum Kind { case delta(String), tool(String), done, error(String) } } | ||||
| 221 | + | ||||
| 222 | +enum AgentService { | ||||
| 223 | + static let endpoint = URL(string: "https://www.api-ka.com/api/agent/chat")! | ||||
| 224 | + | ||||
| 225 | + static func stream(site: String, messages: [[String: String]]) -> AsyncThrowingStream<AgentEvent, Error> { | ||||
| 226 | + AsyncThrowingStream { continuation in | ||||
| 227 | + let task = Task { | ||||
| 228 | + var req = URLRequest(url: endpoint) | ||||
| 229 | + req.httpMethod = "POST" | ||||
| 230 | + req.setValue("application/json", forHTTPHeaderField: "Content-Type") | ||||
| 231 | + req.timeoutInterval = 90 | ||||
| 232 | + req.httpBody = try JSONSerialization.data(withJSONObject: ["site": site, "messages": messages]) | ||||
| 233 | + let (bytes, resp) = try await URLSession.shared.bytes(for: req) | ||||
| 234 | + guard (resp as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) } | ||||
| 235 | + var event = "" | ||||
| 236 | + for try await line in bytes.lines { | ||||
| 237 | + if line.hasPrefix("event: ") { event = String(line.dropFirst(7)) } | ||||
| 238 | + else if line.hasPrefix("data: ") { | ||||
| 239 | + let data = Data(line.dropFirst(6).utf8) | ||||
| 240 | + let obj = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] | ||||
| 241 | + switch event { | ||||
| 242 | + case "delta": if let t = obj["text"] as? String { continuation.yield(.init(kind: .delta(t))) } | ||||
| 243 | + case "tool": continuation.yield(.init(kind: .tool(obj["name"] as? String ?? "recherche"))) | ||||
| 244 | + case "done": continuation.yield(.init(kind: .done)); continuation.finish(); return | ||||
| 245 | + case "error": continuation.yield(.init(kind: .error(obj["message"] as? String ?? "erreur"))) | ||||
| 246 | + default: break | ||||
| 247 | + } | ||||
| 248 | + } | ||||
| 249 | + } | ||||
| 250 | + continuation.finish() | ||||
| 251 | + } | ||||
| 252 | + continuation.onTermination = { _ in task.cancel() } | ||||
| 253 | + } | ||||
| 254 | + } | ||||
| 255 | +} | ||||
added
Sources/KA/DesignMac/Theme.swift
+371 −0
@@ -0,0 +1,371 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// Theme.swift — design system Groupe-KA « éditorial sharp » adapté macOS : | ||||
| 3 | +// papier #f5f3ee / encre #141814, cartes bordure encre + ombre décalée, | ||||
| 4 | +// wordmark boîte accent, thème CLAIR par défaut. | ||||
| 5 | +// Adapté de l'app iOS KA (~/Desktop/KA/KA/Design/Theme.swift), sans UIKit. | ||||
| 6 | +import SwiftUI | ||||
| 7 | + | ||||
| 8 | +// MARK: - Palette | ||||
| 9 | + | ||||
| 10 | +enum KATheme { | ||||
| 11 | + static let paperLight = Color(hex: "#f5f3ee") | ||||
| 12 | + static let paperDark = Color(hex: "#101410") | ||||
| 13 | + static let inkLight = Color(hex: "#141814") | ||||
| 14 | + static let inkDark = Color(hex: "#f0efe8") | ||||
| 15 | + static let lime = Color(hex: "#d9f26b") | ||||
| 16 | + static let green = Color(hex: "#1c5c41") | ||||
| 17 | + | ||||
| 18 | + static func paper(_ scheme: ColorScheme) -> Color { scheme == .dark ? paperDark : paperLight } | ||||
| 19 | + static func ink(_ scheme: ColorScheme) -> Color { scheme == .dark ? inkDark : inkLight } | ||||
| 20 | + static func surface(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: "#1a1f1a") : .white } | ||||
| 21 | + static func ink2(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: "#a9b0a9") : Color(hex: "#4d5551") } | ||||
| 22 | +} | ||||
| 23 | + | ||||
| 24 | +// MARK: - Carte « éditorial sharp » (bordure encre + ombre décalée) | ||||
| 25 | + | ||||
| 26 | +struct KACard: ViewModifier { | ||||
| 27 | + @Environment(\.colorScheme) private var scheme | ||||
| 28 | + var accent: Color? = nil | ||||
| 29 | + | ||||
| 30 | + func body(content: Content) -> some View { | ||||
| 31 | + content | ||||
| 32 | + .background(KATheme.surface(scheme)) | ||||
| 33 | + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) | ||||
| 34 | + .overlay( | ||||
| 35 | + RoundedRectangle(cornerRadius: 12, style: .continuous) | ||||
| 36 | + .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.3) | ||||
| 37 | + ) | ||||
| 38 | + .background( | ||||
| 39 | + RoundedRectangle(cornerRadius: 12, style: .continuous) | ||||
| 40 | + .fill((accent ?? KATheme.ink(scheme)).opacity(scheme == .dark ? 0.25 : 0.16)) | ||||
| 41 | + .offset(x: 4, y: 4) | ||||
| 42 | + ) | ||||
| 43 | + .padding(.trailing, 4).padding(.bottom, 4) | ||||
| 44 | + } | ||||
| 45 | +} | ||||
| 46 | + | ||||
| 47 | +extension View { | ||||
| 48 | + func kaCard(accent: Color? = nil) -> some View { modifier(KACard(accent: accent)) } | ||||
| 49 | +} | ||||
| 50 | + | ||||
| 51 | +// MARK: - Wordmark (« Lou » + boîte [Ka] accent) | ||||
| 52 | + | ||||
| 53 | +struct KAWordmark: View { | ||||
| 54 | + let universe: Universe | ||||
| 55 | + var size: CGFloat = 20 | ||||
| 56 | + @Environment(\.colorScheme) private var scheme | ||||
| 57 | + | ||||
| 58 | + var body: some View { | ||||
| 59 | + let parts = universe.wordmark.split(separator: "·", maxSplits: 1) | ||||
| 60 | + HStack(alignment: .firstTextBaseline, spacing: 3) { | ||||
| 61 | + Text(parts.first.map(String.init) ?? universe.wordmark) | ||||
| 62 | + .font(.system(size: size, weight: .bold, design: .rounded)) | ||||
| 63 | + if parts.count > 1 { | ||||
| 64 | + Text(String(parts[1])) | ||||
| 65 | + .font(.system(size: size * 0.86, weight: .bold, design: .rounded)) | ||||
| 66 | + .foregroundStyle(universe.accent) | ||||
| 67 | + .padding(.horizontal, size * 0.28) | ||||
| 68 | + .padding(.vertical, size * 0.08) | ||||
| 69 | + .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.26, style: .continuous)) | ||||
| 70 | + .rotationEffect(.degrees(-2)) | ||||
| 71 | + } | ||||
| 72 | + } | ||||
| 73 | + .foregroundStyle(KATheme.ink(scheme)) | ||||
| 74 | + .fixedSize() | ||||
| 75 | + .accessibilityLabel(universe.name) | ||||
| 76 | + } | ||||
| 77 | +} | ||||
| 78 | + | ||||
| 79 | +struct GroupeKAMark: View { | ||||
| 80 | + var size: CGFloat = 24 | ||||
| 81 | + @Environment(\.colorScheme) private var scheme | ||||
| 82 | + var body: some View { | ||||
| 83 | + HStack(alignment: .firstTextBaseline, spacing: 4) { | ||||
| 84 | + Text("Groupe").font(.system(size: size, weight: .bold, design: .rounded)) | ||||
| 85 | + Text("KA") | ||||
| 86 | + .font(.system(size: size * 0.9, weight: .bold, design: .rounded)) | ||||
| 87 | + .foregroundStyle(KATheme.lime) | ||||
| 88 | + .padding(.horizontal, size * 0.3).padding(.vertical, size * 0.1) | ||||
| 89 | + .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.28, style: .continuous)) | ||||
| 90 | + .rotationEffect(.degrees(-2)) | ||||
| 91 | + } | ||||
| 92 | + .fixedSize() | ||||
| 93 | + .foregroundStyle(KATheme.ink(scheme)) | ||||
| 94 | + .accessibilityLabel("Groupe KA") | ||||
| 95 | + } | ||||
| 96 | +} | ||||
| 97 | + | ||||
| 98 | +// MARK: - Puce mono (klabel) | ||||
| 99 | + | ||||
| 100 | +struct KAChip: View { | ||||
| 101 | + let text: String | ||||
| 102 | + var accent: Color? = nil | ||||
| 103 | + @Environment(\.colorScheme) private var scheme | ||||
| 104 | + var body: some View { | ||||
| 105 | + Text(text) | ||||
| 106 | + .font(.system(.caption2, design: .monospaced).weight(.bold)) | ||||
| 107 | + .textCase(.uppercase) | ||||
| 108 | + .padding(.horizontal, 9).padding(.vertical, 4) | ||||
| 109 | + .background((accent ?? KATheme.ink(scheme)).opacity(0.12), in: Capsule()) | ||||
| 110 | + .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.4), lineWidth: 1)) | ||||
| 111 | + } | ||||
| 112 | +} | ||||
| 113 | + | ||||
| 114 | +// MARK: - Pastille de statut (vert/rouge/en cours) | ||||
| 115 | + | ||||
| 116 | +struct KAStatusDot: View { | ||||
| 117 | + let up: Bool? | ||||
| 118 | + var body: some View { | ||||
| 119 | + Circle() | ||||
| 120 | + .fill(up == nil ? Color.gray.opacity(0.4) : (up! ? Color(hex: "#2f9e44") : Color(hex: "#e03131"))) | ||||
| 121 | + .frame(width: 9, height: 9) | ||||
| 122 | + .accessibilityLabel(up == nil ? "Vérification" : (up! ? "En ligne" : "Hors ligne")) | ||||
| 123 | + } | ||||
| 124 | +} | ||||
| 125 | + | ||||
| 126 | +// MARK: - Rangée d'item universelle | ||||
| 127 | + | ||||
| 128 | +struct KAItemRow: View { | ||||
| 129 | + let item: KAItem | ||||
| 130 | + var compact = false | ||||
| 131 | + @Environment(\.colorScheme) private var scheme | ||||
| 132 | + @EnvironmentObject private var favorites: FavoritesStore | ||||
| 133 | + | ||||
| 134 | + private var universe: Universe? { Ecosystem.universe(item.universeID) } | ||||
| 135 | + | ||||
| 136 | + var body: some View { | ||||
| 137 | + HStack(alignment: .top, spacing: 12) { | ||||
| 138 | + if let img = item.imageURL { | ||||
| 139 | + KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo") | ||||
| 140 | + .frame(width: compact ? 42 : 66, height: compact ? 42 : 66) | ||||
| 141 | + .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) | ||||
| 142 | + .overlay(RoundedRectangle(cornerRadius: 9, style: .continuous) | ||||
| 143 | + .strokeBorder(.primary.opacity(0.25), lineWidth: 1)) | ||||
| 144 | + .accessibilityHidden(true) | ||||
| 145 | + } else { | ||||
| 146 | + RoundedRectangle(cornerRadius: 8, style: .continuous) | ||||
| 147 | + .fill(universe?.accent.opacity(0.9) ?? .gray) | ||||
| 148 | + .frame(width: 5) | ||||
| 149 | + .padding(.vertical, 2) | ||||
| 150 | + } | ||||
| 151 | + VStack(alignment: .leading, spacing: 3) { | ||||
| 152 | + Text(item.title) | ||||
| 153 | + .font(compact ? .subheadline.weight(.semibold) : .headline) | ||||
| 154 | + .lineLimit(compact ? 1 : 2) | ||||
| 155 | + if let sub = item.subtitle, !sub.isEmpty { | ||||
| 156 | + Text(sub).font(compact ? .caption : .subheadline) | ||||
| 157 | + .foregroundStyle(KATheme.ink2(scheme)).lineLimit(compact ? 1 : 2) | ||||
| 158 | + } | ||||
| 159 | + HStack(spacing: 8) { | ||||
| 160 | + if let price = item.priceLabel { | ||||
| 161 | + Text(price).font(.system(compact ? .caption : .subheadline, design: .rounded).weight(.bold)) | ||||
| 162 | + .foregroundStyle(universe?.accent ?? .primary) | ||||
| 163 | + .lineLimit(1) | ||||
| 164 | + } | ||||
| 165 | + if let city = item.city, !city.isEmpty { | ||||
| 166 | + Text(city).font(.caption).foregroundStyle(KATheme.ink2(scheme)).lineLimit(1) | ||||
| 167 | + } | ||||
| 168 | + Spacer(minLength: 0) | ||||
| 169 | + if let u = universe, !compact { | ||||
| 170 | + KAChip(text: u.wordmark, accent: u.accent) | ||||
| 171 | + } | ||||
| 172 | + } | ||||
| 173 | + } | ||||
| 174 | + if favorites.isFavorite(item) { | ||||
| 175 | + Image(systemName: "heart.fill") | ||||
| 176 | + .foregroundStyle(.red).font(.caption) | ||||
| 177 | + .accessibilityLabel("Dans vos favoris") | ||||
| 178 | + } | ||||
| 179 | + } | ||||
| 180 | + .padding(compact ? 8 : 12) | ||||
| 181 | + .frame(maxWidth: .infinity, alignment: .leading) | ||||
| 182 | + .kaCard(accent: universe?.accent) | ||||
| 183 | + } | ||||
| 184 | +} | ||||
| 185 | + | ||||
| 186 | +// MARK: - Carte d'item (grille photo, grandes fenêtres) | ||||
| 187 | + | ||||
| 188 | +struct KAItemCard: View { | ||||
| 189 | + let item: KAItem | ||||
| 190 | + var selected = false | ||||
| 191 | + @Environment(\.colorScheme) private var scheme | ||||
| 192 | + @EnvironmentObject private var favorites: FavoritesStore | ||||
| 193 | + | ||||
| 194 | + private var universe: Universe? { Ecosystem.universe(item.universeID) } | ||||
| 195 | + | ||||
| 196 | + var body: some View { | ||||
| 197 | + VStack(alignment: .leading, spacing: 0) { | ||||
| 198 | + ZStack(alignment: .topTrailing) { | ||||
| 199 | + KAImage(url: item.imageURL, accent: universe?.accent ?? .gray, | ||||
| 200 | + symbol: universe?.symbol ?? "photo") | ||||
| 201 | + .frame(height: 130) | ||||
| 202 | + .frame(maxWidth: .infinity) | ||||
| 203 | + .clipped() | ||||
| 204 | + if favorites.isFavorite(item) { | ||||
| 205 | + Image(systemName: "heart.fill") | ||||
| 206 | + .font(.caption).foregroundStyle(.red) | ||||
| 207 | + .padding(6) | ||||
| 208 | + .background(.ultraThinMaterial, in: Circle()) | ||||
| 209 | + .padding(6) | ||||
| 210 | + .accessibilityLabel("Dans vos favoris") | ||||
| 211 | + } | ||||
| 212 | + if item.imageURLs.count > 1 { | ||||
| 213 | + Text("\(item.imageURLs.count) 📷") | ||||
| 214 | + .font(.system(size: 9, weight: .bold, design: .monospaced)) | ||||
| 215 | + .padding(.horizontal, 6).padding(.vertical, 3) | ||||
| 216 | + .background(.ultraThinMaterial, in: Capsule()) | ||||
| 217 | + .padding(6) | ||||
| 218 | + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) | ||||
| 219 | + } | ||||
| 220 | + } | ||||
| 221 | + VStack(alignment: .leading, spacing: 4) { | ||||
| 222 | + Text(item.title).font(.subheadline.weight(.bold)).lineLimit(1) | ||||
| 223 | + if let sub = item.subtitle, !sub.isEmpty { | ||||
| 224 | + Text(sub).font(.caption).foregroundStyle(KATheme.ink2(scheme)).lineLimit(1) | ||||
| 225 | + } | ||||
| 226 | + HStack(spacing: 6) { | ||||
| 227 | + if let p = item.priceLabel { | ||||
| 228 | + Text(p).font(.system(.caption, design: .rounded).weight(.bold)) | ||||
| 229 | + .foregroundStyle(universe?.accent ?? .primary) | ||||
| 230 | + .lineLimit(1) | ||||
| 231 | + } | ||||
| 232 | + Spacer(minLength: 0) | ||||
| 233 | + if let c = item.city, !c.isEmpty { | ||||
| 234 | + Text(c).font(.system(size: 9, weight: .semibold, design: .monospaced)) | ||||
| 235 | + .foregroundStyle(.secondary).lineLimit(1) | ||||
| 236 | + } | ||||
| 237 | + } | ||||
| 238 | + } | ||||
| 239 | + .padding(10) | ||||
| 240 | + } | ||||
| 241 | + .background(KATheme.surface(scheme)) | ||||
| 242 | + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) | ||||
| 243 | + .overlay( | ||||
| 244 | + RoundedRectangle(cornerRadius: 12, style: .continuous) | ||||
| 245 | + .strokeBorder(selected ? (universe?.accent ?? KATheme.ink(scheme)) | ||||
| 246 | + : KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), | ||||
| 247 | + lineWidth: selected ? 2.6 : 1.3) | ||||
| 248 | + ) | ||||
| 249 | + .background( | ||||
| 250 | + RoundedRectangle(cornerRadius: 12, style: .continuous) | ||||
| 251 | + .fill((universe?.accent ?? KATheme.ink(scheme)).opacity(scheme == .dark ? 0.25 : 0.16)) | ||||
| 252 | + .offset(x: 4, y: 4) | ||||
| 253 | + ) | ||||
| 254 | + .padding(.trailing, 4).padding(.bottom, 4) | ||||
| 255 | + } | ||||
| 256 | +} | ||||
| 257 | + | ||||
| 258 | +// MARK: - Image réseau fluide (fondu à l'arrivée, cache URLCache partagé) | ||||
| 259 | + | ||||
| 260 | +struct KAImage: View { | ||||
| 261 | + let url: URL? | ||||
| 262 | + var accent: Color = .gray | ||||
| 263 | + var symbol: String = "photo" | ||||
| 264 | + | ||||
| 265 | + var body: some View { | ||||
| 266 | + AsyncImage(url: url, transaction: Transaction(animation: .easeOut(duration: 0.25))) { phase in | ||||
| 267 | + switch phase { | ||||
| 268 | + case .success(let image): | ||||
| 269 | + image.resizable().aspectRatio(contentMode: .fill) | ||||
| 270 | + .transition(.opacity) | ||||
| 271 | + case .failure: | ||||
| 272 | + accent.opacity(0.1) | ||||
| 273 | + .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.6))) | ||||
| 274 | + default: | ||||
| 275 | + accent.opacity(0.08) | ||||
| 276 | + .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.35))) | ||||
| 277 | + } | ||||
| 278 | + } | ||||
| 279 | + } | ||||
| 280 | +} | ||||
| 281 | + | ||||
| 282 | +// MARK: - Pression (échelle + réactivité, sans haptique sur Mac) | ||||
| 283 | + | ||||
| 284 | +struct KAPressStyle: ButtonStyle { | ||||
| 285 | + func makeBody(configuration: Configuration) -> some View { | ||||
| 286 | + configuration.label | ||||
| 287 | + .contentShape(Rectangle()) | ||||
| 288 | + .scaleEffect(configuration.isPressed ? 0.98 : 1) | ||||
| 289 | + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed) | ||||
| 290 | + } | ||||
| 291 | +} | ||||
| 292 | + | ||||
| 293 | +// MARK: - Squelette de chargement (liste) | ||||
| 294 | + | ||||
| 295 | +struct KASkeletonRow: View { | ||||
| 296 | + var body: some View { | ||||
| 297 | + HStack(alignment: .top, spacing: 12) { | ||||
| 298 | + RoundedRectangle(cornerRadius: 9).fill(.quaternary).frame(width: 66, height: 66) | ||||
| 299 | + VStack(alignment: .leading, spacing: 8) { | ||||
| 300 | + RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(height: 14) | ||||
| 301 | + RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 180, height: 11) | ||||
| 302 | + RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 90, height: 11) | ||||
| 303 | + } | ||||
| 304 | + } | ||||
| 305 | + .padding(12).frame(maxWidth: .infinity, alignment: .leading).kaCard() | ||||
| 306 | + .redacted(reason: .placeholder) | ||||
| 307 | + .shimmering() | ||||
| 308 | + } | ||||
| 309 | +} | ||||
| 310 | + | ||||
| 311 | +struct Shimmer: ViewModifier { | ||||
| 312 | + @State private var on = false | ||||
| 313 | + func body(content: Content) -> some View { | ||||
| 314 | + content | ||||
| 315 | + .opacity(on ? 0.5 : 0.9) | ||||
| 316 | + .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: on) | ||||
| 317 | + .onAppear { on = true } | ||||
| 318 | + } | ||||
| 319 | +} | ||||
| 320 | +extension View { func shimmering() -> some View { modifier(Shimmer()) } } | ||||
| 321 | + | ||||
| 322 | +// MARK: - États | ||||
| 323 | + | ||||
| 324 | +struct KAEmptyState: View { | ||||
| 325 | + let symbol: String | ||||
| 326 | + let title: String | ||||
| 327 | + var message: String? = nil | ||||
| 328 | + var body: some View { | ||||
| 329 | + VStack(spacing: 10) { | ||||
| 330 | + Image(systemName: symbol).font(.system(size: 40)).foregroundStyle(.secondary) | ||||
| 331 | + Text(title).font(.headline) | ||||
| 332 | + if let m = message { | ||||
| 333 | + Text(m).font(.subheadline).foregroundStyle(.secondary) | ||||
| 334 | + .multilineTextAlignment(.center) | ||||
| 335 | + } | ||||
| 336 | + } | ||||
| 337 | + .padding(30) | ||||
| 338 | + .frame(maxWidth: .infinity) | ||||
| 339 | + } | ||||
| 340 | +} | ||||
| 341 | + | ||||
| 342 | +// MARK: - Champ de recherche signature (bordure encre) | ||||
| 343 | + | ||||
| 344 | +struct KASearchField: View { | ||||
| 345 | + let prompt: String | ||||
| 346 | + @Binding var text: String | ||||
| 347 | + var onSubmit: () -> Void | ||||
| 348 | + @Environment(\.colorScheme) private var scheme | ||||
| 349 | + | ||||
| 350 | + var body: some View { | ||||
| 351 | + HStack(spacing: 8) { | ||||
| 352 | + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) | ||||
| 353 | + TextField(prompt, text: $text) | ||||
| 354 | + .textFieldStyle(.plain) | ||||
| 355 | + .onSubmit(onSubmit) | ||||
| 356 | + if !text.isEmpty { | ||||
| 357 | + Button { | ||||
| 358 | + text = "" | ||||
| 359 | + } label: { | ||||
| 360 | + Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) | ||||
| 361 | + } | ||||
| 362 | + .buttonStyle(.plain) | ||||
| 363 | + .accessibilityLabel("Effacer") | ||||
| 364 | + } | ||||
| 365 | + } | ||||
| 366 | + .padding(.horizontal, 11).padding(.vertical, 8) | ||||
| 367 | + .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) | ||||
| 368 | + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) | ||||
| 369 | + .strokeBorder(KATheme.ink(scheme).opacity(0.5), lineWidth: 1.2)) | ||||
| 370 | + } | ||||
| 371 | +} | ||||
added
Sources/KA/Features/AboutView.swift
+80 −0
Large diff (80 lines) is not loaded by default to keep the page fast.Load diff
added
Sources/KA/Features/AgentChatView.swift
+219 −0
Large diff (219 lines) is not loaded by default to keep the page fast.Load diff
added
Sources/KA/Features/ApiPlaygroundView.swift
+481 −0
Large diff (481 lines) is not loaded by default to keep the page fast.Load diff
added
Sources/KA/Features/HomeView.swift
+209 −0
Large diff (209 lines) is not loaded by default to keep the page fast.Load diff
added
Sources/KA/Features/LibraryViews.swift
+289 −0
Large diff (289 lines) is not loaded by default to keep the page fast.Load diff
added
Sources/KA/Features/MainWindow.swift
+123 −0
Large diff (123 lines) is not loaded by default to keep the page fast.Load diff
added
Sources/KA/Features/MapExplorerView.swift
+380 −0
Large diff (380 lines) is not loaded by default to keep the page fast.Load diff
added
Sources/KA/Features/MenuBarView.swift
+238 −0
Large diff (238 lines) is not loaded by default to keep the page fast.Load diff
added
Sources/KA/Features/SearchView.swift
+218 −0
Large diff (218 lines) is not loaded by default to keep the page fast.Load diff
added
Sources/KA/Features/StatusView.swift
+98 −0
Large diff (98 lines) is not loaded by default to keep the page fast.Load diff
added
Sources/KA/Features/UniverseExplorerView.swift
+458 −0
Large diff (458 lines) is not loaded by default to keep the page fast.Load diff
added
Sources/KA/Features/VraiPrixExplorer.swift
+276 −0
Large diff (276 lines) is not loaded by default to keep the page fast.Load diff
added
docs/captures/fenetre-principale-accueil.png
+0 −0
Binary file not shown.
added
docs/captures/menubar-pouls.png
+0 −0
Binary file not shown.
added
docs/captures/trouve-ka-poutine.png
+0 −0
Binary file not shown.
added
docs/captures/trouve-ka-univers-poutine.png
+0 −0
Binary file not shown.
added
scripts/generate-icon.sh
+24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added
scripts/notarize.sh
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added
scripts/package-app.sh
+45 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff