SPB Git

spb/ka-macos

Public

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>
Simon-Pierre Boucher committed (Aug 18, 2026) Browse files

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: "&amp;", with: "&")
377+ .replacingOccurrences(of: "&nbsp;", 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
Menu

Theme