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