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
@@ -0,0 +1,80 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// AboutView.swift — À propos de KA : marque, mission, contacts et légal du
3+// Groupe KA (repris d'Ecosystem.swift), liens vers le hub.
4+import SwiftUI
5+
6+struct AboutView: View {
7+ @Environment(\.colorScheme) private var scheme
8+
9+ private var version: String {
10+ let v = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0"
11+ return "Version \(v)"
12+ }
13+
14+ var body: some View {
15+ VStack(alignment: .leading, spacing: 16) {
16+ HStack(spacing: 14) {
17+ GroupeKAMark(size: 26)
18+ VStack(alignment: .leading, spacing: 2) {
19+ Text("KA pour macOS").font(.headline)
20+ Text(version + " · l'app compagnon de l'écosystème")
21+ .font(.caption).foregroundStyle(.secondary)
22+ }
23+ }
24+
25+ Text(Ecosystem.disclaimer)
26+ .font(.subheadline)
27+ .foregroundStyle(KATheme.ink2(scheme))
28+ .padding(13)
29+ .frame(maxWidth: .infinity, alignment: .leading)
30+ .kaCard()
31+
32+ VStack(alignment: .leading, spacing: 8) {
33+ Text("Contacts").font(.headline)
34+ ForEach(Ecosystem.contacts, id: \.email) { c in
35+ Link(destination: URL(string: "mailto:\(c.email)")!) {
36+ HStack {
37+ Image(systemName: "envelope.fill").font(.caption)
38+ Text(c.email).font(.subheadline.weight(.semibold))
39+ Spacer()
40+ Text(c.role).font(.caption).foregroundStyle(.secondary)
41+ }
42+ .padding(.horizontal, 12).padding(.vertical, 8)
43+ .background(KATheme.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 9, style: .continuous))
44+ }
45+ .foregroundStyle(KATheme.green)
46+ }
47+ }
48+
49+ VStack(alignment: .leading, spacing: 8) {
50+ Text("Légal").font(.headline)
51+ ForEach(Ecosystem.legal, id: \.path) { l in
52+ Link(destination: Ecosystem.hubURL.appendingPathComponent(l.path)) {
53+ HStack {
54+ Image(systemName: "doc.text").font(.caption)
55+ Text(l.label).font(.subheadline)
56+ Spacer()
57+ Image(systemName: "arrow.up.right").font(.caption2)
58+ }
59+ }
60+ .foregroundStyle(.primary)
61+ }
62+ }
63+ .padding(13)
64+ .frame(maxWidth: .infinity, alignment: .leading)
65+ .kaCard()
66+
67+ HStack {
68+ Link("www.groupe-ka.com", destination: Ecosystem.hubURL)
69+ .font(.caption.weight(.bold))
70+ .foregroundStyle(KATheme.green)
71+ Spacer()
72+ Text("© Simon-Pierre Boucher — Groupe KA")
73+ .font(.caption2).foregroundStyle(.tertiary)
74+ }
75+ }
76+ .padding(22)
77+ .frame(width: 470)
78+ .background(KATheme.paper(scheme))
79+ }
80+}
added Sources/KA/Features/AgentChatView.swift +219 −0
@@ -0,0 +1,219 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// AgentChatView.swift — KA Agent natif plein volet : le même assistant IA que
3+// sur les sites (API centrale api-ka, site « groupe-ka »), flux SSE token par
4+// token + puces d'outils. Adapté de l'app iOS KA (AgentChatView.swift).
5+import SwiftUI
6+
7+struct AgentMessage: Identifiable, Equatable {
8+ let id = UUID()
9+ var role: String // user / assistant / tool
10+ var content: String
11+}
12+
13+@MainActor
14+final class AgentChat: ObservableObject {
15+ @Published var messages: [AgentMessage] = []
16+ @Published var busy = false
17+ var site: String = "groupe-ka"
18+
19+ func send(_ text: String) {
20+ let q = text.trimmingCharacters(in: .whitespacesAndNewlines)
21+ guard !q.isEmpty, !busy else { return }
22+ messages.append(AgentMessage(role: "user", content: q))
23+ messages.append(AgentMessage(role: "assistant", content: ""))
24+ busy = true
25+ let history = messages
26+ .filter { $0.role == "user" || ($0.role == "assistant" && !$0.content.isEmpty) }
27+ .suffix(16)
28+ .map { ["role": $0.role, "content": $0.content] }
29+
30+ Task {
31+ do {
32+ for try await event in AgentService.stream(site: site, messages: Array(history)) {
33+ switch event.kind {
34+ case .delta(let t):
35+ if let i = messages.lastIndex(where: { $0.role == "assistant" }) {
36+ messages[i].content += t
37+ }
38+ case .tool(let name):
39+ // insérer la puce outil AVANT la bulle assistante en cours
40+ if let i = messages.lastIndex(where: { $0.role == "assistant" }) {
41+ messages.insert(AgentMessage(role: "tool", content: name.replacingOccurrences(of: "_", with: " ")), at: i)
42+ }
43+ case .error(let m):
44+ if let i = messages.lastIndex(where: { $0.role == "assistant" }), messages[i].content.isEmpty {
45+ messages[i].content = "Désolé, une erreur est survenue (\(m)). Réessayez."
46+ }
47+ case .done: break
48+ }
49+ }
50+ } catch {
51+ if let i = messages.lastIndex(where: { $0.role == "assistant" }), messages[i].content.isEmpty {
52+ messages[i].content = "Impossible de joindre KA Agent — vérifiez votre connexion."
53+ }
54+ }
55+ busy = false
56+ }
57+ }
58+}
59+
60+struct AgentChatView: View {
61+ @StateObject private var chat = AgentChat()
62+ @State private var input = ""
63+ @Environment(\.colorScheme) private var scheme
64+ @FocusState private var focused: Bool
65+
66+ var body: some View {
67+ VStack(spacing: 0) {
68+ header
69+ Divider().opacity(0.4)
70+ ScrollViewReader { proxy in
71+ ScrollView {
72+ LazyVStack(alignment: .leading, spacing: 10) {
73+ hello
74+ ForEach(chat.messages) { m in
75+ bubble(m).id(m.id)
76+ }
77+ }
78+ .padding(16)
79+ .frame(maxWidth: 780)
80+ .frame(maxWidth: .infinity)
81+ }
82+ .onChange(of: chat.messages.last?.content) {
83+ if let last = chat.messages.last { proxy.scrollTo(last.id, anchor: .bottom) }
84+ }
85+ }
86+ inputBar
87+ }
88+ .background(KATheme.paper(scheme))
89+ .onAppear { focused = true }
90+ }
91+
92+ private var header: some View {
93+ HStack(spacing: 6) {
94+ Text("KA").font(.system(.headline, design: .rounded).weight(.bold))
95+ Text("Agent")
96+ .font(.system(.subheadline, design: .rounded).weight(.bold))
97+ .foregroundStyle(KATheme.lime)
98+ .padding(.horizontal, 7).padding(.vertical, 2)
99+ .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 6))
100+ .rotationEffect(.degrees(-2))
101+ Text("· l'IA de l'écosystème, branchée sur les vraies données")
102+ .font(.caption).foregroundStyle(.secondary)
103+ Spacer()
104+ if !chat.messages.isEmpty {
105+ Button {
106+ chat.messages = []
107+ } label: {
108+ Label("Nouvelle conversation", systemImage: "square.and.pencil")
109+ .font(.caption)
110+ }
111+ .disabled(chat.busy)
112+ }
113+ }
114+ .padding(.horizontal, 16).padding(.vertical, 10)
115+ }
116+
117+ private var hello: some View {
118+ Group {
119+ if chat.messages.isEmpty {
120+ VStack(alignment: .leading, spacing: 10) {
121+ Text("👋 Je suis KA Agent.")
122+ .font(.headline)
123+ Text("Posez-moi n'importe quelle question sur l'écosystème Groupe KA et ses données : logements, propriétés, autos, emplois, prix d'épicerie, restos, sorties, créateurs, statistiques…")
124+ .font(.subheadline).foregroundStyle(.secondary)
125+ FlowSuggestions { s in
126+ chat.send(s)
127+ input = ""
128+ }
129+ }
130+ .padding(16)
131+ .frame(maxWidth: .infinity, alignment: .leading)
132+ .kaCard()
133+ }
134+ }
135+ }
136+
137+ @ViewBuilder
138+ private func bubble(_ m: AgentMessage) -> some View {
139+ switch m.role {
140+ case "user":
141+ HStack {
142+ Spacer(minLength: 60)
143+ Text(m.content)
144+ .textSelection(.enabled)
145+ .padding(.horizontal, 13).padding(.vertical, 9)
146+ .background(KATheme.lime, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
147+ .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous)
148+ .strokeBorder(KATheme.inkLight.opacity(0.6), lineWidth: 1))
149+ .foregroundStyle(KATheme.inkLight)
150+ }
151+ case "tool":
152+ Label(m.content, systemImage: "magnifyingglass")
153+ .font(.system(.caption2, design: .monospaced).weight(.bold))
154+ .textCase(.uppercase)
155+ .padding(.horizontal, 10).padding(.vertical, 5)
156+ .overlay(Capsule().strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3])))
157+ .foregroundStyle(.secondary)
158+ default:
159+ Group {
160+ if m.content.isEmpty {
161+ ProgressView().controlSize(.small).padding(10)
162+ } else {
163+ Text(LocalizedStringKey(m.content)) // rend **gras** et liens Markdown
164+ .textSelection(.enabled)
165+ .padding(.horizontal, 13).padding(.vertical, 9)
166+ .kaCard()
167+ }
168+ }
169+ .frame(maxWidth: .infinity, alignment: .leading)
170+ }
171+ }
172+
173+ private var inputBar: some View {
174+ HStack(spacing: 8) {
175+ TextField("Posez votre question…", text: $input, axis: .vertical)
176+ .textFieldStyle(.plain)
177+ .lineLimit(1...4)
178+ .padding(.horizontal, 13).padding(.vertical, 10)
179+ .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
180+ .overlay(RoundedRectangle(cornerRadius: 11, style: .continuous)
181+ .strokeBorder(.primary.opacity(0.35), lineWidth: 1.2))
182+ .focused($focused)
183+ .onSubmit { chat.send(input); input = "" }
184+ Button {
185+ chat.send(input); input = ""
186+ } label: {
187+ Image(systemName: "arrow.up")
188+ .font(.headline)
189+ .frame(width: 38, height: 38)
190+ .background(KATheme.inkLight, in: Circle())
191+ .foregroundStyle(KATheme.lime)
192+ }
193+ .buttonStyle(.plain)
194+ .disabled(chat.busy || input.trimmingCharacters(in: .whitespaces).isEmpty)
195+ .accessibilityLabel("Envoyer")
196+ }
197+ .padding(12)
198+ .frame(maxWidth: 780)
199+ .frame(maxWidth: .infinity)
200+ .background(.bar)
201+ }
202+}
203+
204+private struct FlowSuggestions: View {
205+ let action: (String) -> Void
206+ private let ideas = ["Combien de logements à louer ?", "Un resto italien à Montréal", "Les sorties gratuites ce week-end", "C'est quoi le Groupe KA ?"]
207+ var body: some View {
208+ VStack(alignment: .leading, spacing: 6) {
209+ ForEach(ideas, id: \.self) { s in
210+ Button { action(s) } label: {
211+ Text(s).font(.caption.weight(.semibold))
212+ .padding(.horizontal, 11).padding(.vertical, 7)
213+ .background(.quaternary, in: Capsule())
214+ }
215+ .buttonStyle(.plain)
216+ }
217+ }
218+ }
219+}
added Sources/KA/Features/ApiPlaygroundView.swift +481 −0
@@ -0,0 +1,481 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// ApiPlaygroundView.swift — le playground NATIF d'API·Ka : openapi.json lu en
3+// direct, liste des endpoints GET groupés par tag, formulaire de paramètres
4+// généré depuis le schéma, envoi avec latence mesurée, réponse JSON colorisée
5+// et PLIABLE (arbre), « Copier en curl » dans le presse-papier macOS.
6+import SwiftUI
7+import AppKit
8+
9+// MARK: - Modèle d'endpoint (extrait d'openapi.json)
10+
11+struct ApiParam: Identifiable {
12+ let name: String
13+ let location: String // query | path
14+ let required: Bool
15+ let type: String
16+ let defaultValue: String?
17+ var id: String { "\(location):\(name)" }
18+}
19+
20+struct ApiEndpoint: Identifiable, Hashable {
21+ static func == (lhs: ApiEndpoint, rhs: ApiEndpoint) -> Bool { lhs.id == rhs.id }
22+ func hash(into h: inout Hasher) { h.combine(id) }
23+ let id: String
24+ let path: String
25+ let summary: String
26+ let detail: String?
27+ let tag: String
28+ let params: [ApiParam]
29+}
30+
31+// MARK: - Playground
32+
33+struct ApiPlaygroundView: View {
34+ let universe: Universe
35+ @EnvironmentObject private var pulse: EcosystemPulse
36+ @Environment(\.colorScheme) private var scheme
37+
38+ @State private var endpoints: [ApiEndpoint] = []
39+ @State private var loadFailed = false
40+ @State private var selected: ApiEndpoint?
41+ @State private var values: [String: String] = [:]
42+ @State private var sending = false
43+ @State private var response: JSONValue?
44+ @State private var responseRaw = ""
45+ @State private var statusCode: Int?
46+ @State private var latencyMs: Int?
47+ @State private var copied = false
48+
49+ private var grouped: [(tag: String, eps: [ApiEndpoint])] {
50+ Dictionary(grouping: endpoints, by: \.tag)
51+ .map { ($0.key, $0.value.sorted { $0.path < $1.path }) }
52+ .sorted { $0.0 < $1.0 }
53+ }
54+
55+ var body: some View {
56+ VStack(spacing: 0) {
57+ hero
58+ Divider().opacity(0.4)
59+ HStack(spacing: 0) {
60+ endpointList
61+ .frame(width: 330)
62+ Divider().opacity(0.4)
63+ requestPane
64+ .frame(maxWidth: .infinity)
65+ }
66+ }
67+ .task { await loadSpec() }
68+ }
69+
70+ private var hero: some View {
71+ HStack(alignment: .firstTextBaseline, spacing: 12) {
72+ KAWordmark(universe: universe, size: 24)
73+ Text("Playground live — openapi.json lu à la source")
74+ .font(.system(.subheadline, design: .rounded).weight(.bold))
75+ .foregroundStyle(KATheme.ink2(scheme))
76+ Spacer()
77+ Link(destination: URL(string: "https://www.api-ka.com/docs")!) {
78+ Label("Docs /docs", systemImage: "book")
79+ .font(.caption.weight(.semibold))
80+ }
81+ .foregroundStyle(universe.accent)
82+ Link(destination: universe.baseURL) {
83+ Label("Ouvrir le site", systemImage: "safari")
84+ .font(.caption.weight(.semibold))
85+ }
86+ .foregroundStyle(universe.accent)
87+ }
88+ .padding(.horizontal, 16).padding(.vertical, 12)
89+ }
90+
91+ // MARK: liste des GET
92+
93+ private var endpointList: some View {
94+ ScrollView {
95+ LazyVStack(alignment: .leading, spacing: 6) {
96+ if loadFailed {
97+ KAEmptyState(symbol: "wifi.exclamationmark", title: "Spécification indisponible",
98+ message: "Impossible de lire openapi.json — réessayez.")
99+ } else if endpoints.isEmpty {
100+ ForEach(0..<6, id: \.self) { _ in KASkeletonRow() }
101+ } else {
102+ ForEach(grouped, id: \.tag) { group in
103+ Text(group.tag.uppercased())
104+ .font(.system(.caption2, design: .monospaced).weight(.bold))
105+ .foregroundStyle(.secondary)
106+ .padding(.top, 8)
107+ ForEach(group.eps) { ep in
108+ Button {
109+ select(ep)
110+ } label: {
111+ VStack(alignment: .leading, spacing: 2) {
112+ HStack(spacing: 6) {
113+ Text("GET")
114+ .font(.system(size: 9, weight: .bold, design: .monospaced))
115+ .padding(.horizontal, 5).padding(.vertical, 2)
116+ .background(KATheme.green.opacity(0.15), in: RoundedRectangle(cornerRadius: 4))
117+ .foregroundStyle(KATheme.green)
118+ Text(ep.path)
119+ .font(.system(.caption, design: .monospaced).weight(.semibold))
120+ .lineLimit(1)
121+ }
122+ if !ep.summary.isEmpty {
123+ Text(ep.summary).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
124+ }
125+ }
126+ .padding(8)
127+ .frame(maxWidth: .infinity, alignment: .leading)
128+ .background(selected?.id == ep.id ? universe.accent.opacity(0.14) : KATheme.surface(scheme),
129+ in: RoundedRectangle(cornerRadius: 8, style: .continuous))
130+ .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous)
131+ .strokeBorder(selected?.id == ep.id ? universe.accent : KATheme.ink(scheme).opacity(0.25),
132+ lineWidth: selected?.id == ep.id ? 1.6 : 1))
133+ }
134+ .buttonStyle(.plain)
135+ }
136+ }
137+ }
138+ }
139+ .padding(12)
140+ }
141+ .background(KATheme.paper(scheme))
142+ }
143+
144+ // MARK: requête + réponse
145+
146+ @ViewBuilder
147+ private var requestPane: some View {
148+ if let ep = selected {
149+ ScrollView {
150+ VStack(alignment: .leading, spacing: 14) {
151+ VStack(alignment: .leading, spacing: 6) {
152+ HStack(spacing: 8) {
153+ Text("GET").font(.system(.caption, design: .monospaced).weight(.bold))
154+ .padding(.horizontal, 7).padding(.vertical, 3)
155+ .background(KATheme.green.opacity(0.15), in: RoundedRectangle(cornerRadius: 5))
156+ .foregroundStyle(KATheme.green)
157+ Text(ep.path).font(.system(.body, design: .monospaced).weight(.bold))
158+ .textSelection(.enabled)
159+ }
160+ if let d = ep.detail, !d.isEmpty {
161+ Text(d).font(.caption).foregroundStyle(.secondary)
162+ }
163+ }
164+
165+ if !ep.params.isEmpty {
166+ VStack(spacing: 0) {
167+ ForEach(ep.params) { p in
168+ HStack(spacing: 10) {
169+ VStack(alignment: .leading, spacing: 1) {
170+ HStack(spacing: 4) {
171+ Text(p.name).font(.system(.caption, design: .monospaced).weight(.bold))
172+ if p.required { Text("requis").font(.system(size: 9)).foregroundStyle(.red) }
173+ }
174+ Text("\(p.location) · \(p.type)")
175+ .font(.system(size: 9, design: .monospaced))
176+ .foregroundStyle(.tertiary)
177+ }
178+ .frame(width: 150, alignment: .leading)
179+ TextField(p.defaultValue ?? "", text: Binding(
180+ get: { values[p.id] ?? "" },
181+ set: { values[p.id] = $0 }))
182+ .textFieldStyle(.roundedBorder)
183+ .font(.system(.caption, design: .monospaced))
184+ }
185+ .padding(.vertical, 7).padding(.horizontal, 12)
186+ if p.id != ep.params.last?.id { Divider() }
187+ }
188+ }
189+ .kaCard()
190+ }
191+
192+ HStack(spacing: 10) {
193+ Button {
194+ Task { await send(ep) }
195+ } label: {
196+ Label(sending ? "Envoi…" : "Envoyer", systemImage: "paperplane.fill")
197+ .font(.caption.weight(.bold))
198+ .padding(.horizontal, 14).padding(.vertical, 8)
199+ .background(universe.accent, in: Capsule())
200+ .foregroundStyle(.white)
201+ }
202+ .buttonStyle(.plain)
203+ .keyboardShortcut(.defaultAction) // ⏎ envoie la requête
204+ .disabled(sending)
205+ Button {
206+ copyCurl(ep)
207+ } label: {
208+ Label(copied ? "Copié ✓" : "Copier en curl", systemImage: "terminal")
209+ .font(.caption.weight(.bold))
210+ }
211+ .help("Copie la commande curl équivalente dans le presse-papier")
212+ Spacer()
213+ if let code = statusCode {
214+ Text("HTTP \(code)")
215+ .font(.system(.caption, design: .monospaced).weight(.bold))
216+ .foregroundStyle((200..<300).contains(code) ? KATheme.green : .red)
217+ }
218+ if let ms = latencyMs {
219+ Text("\(ms) ms")
220+ .font(.system(.caption, design: .monospaced).weight(.bold))
221+ .padding(.horizontal, 8).padding(.vertical, 3)
222+ .background(KATheme.green.opacity(0.12), in: Capsule())
223+ .foregroundStyle(ms < 800 ? KATheme.green : .orange)
224+ }
225+ }
226+
227+ if sending {
228+ HStack(spacing: 8) { ProgressView().controlSize(.small); Text("Requête en cours…").font(.caption).foregroundStyle(.secondary) }
229+ } else if let r = response {
230+ VStack(alignment: .leading, spacing: 8) {
231+ HStack {
232+ Text("Réponse").font(.headline)
233+ Spacer()
234+ Button {
235+ NSPasteboard.general.clearContents()
236+ NSPasteboard.general.setString(responseRaw, forType: .string)
237+ } label: {
238+ Label("Copier le JSON", systemImage: "doc.on.doc").font(.caption)
239+ }
240+ }
241+ ScrollView(.horizontal) {
242+ JSONTreeView(key: nil, value: r, depth: 0)
243+ .padding(12)
244+ }
245+ .frame(maxWidth: .infinity, alignment: .leading)
246+ .kaCard(accent: universe.accent)
247+ }
248+ }
249+ }
250+ .padding(16)
251+ }
252+ .background(KATheme.paper(scheme))
253+ } else {
254+ KAEmptyState(symbol: "terminal",
255+ title: "Choisissez un endpoint",
256+ message: "La donnée de tout l'écosystème, interrogeable en direct — formulaire généré depuis le schéma OpenAPI.")
257+ .frame(maxWidth: .infinity, maxHeight: .infinity)
258+ .background(KATheme.paper(scheme))
259+ }
260+ }
261+
262+ // MARK: actions
263+
264+ private func select(_ ep: ApiEndpoint) {
265+ selected = ep
266+ values = [:]
267+ response = nil
268+ statusCode = nil
269+ latencyMs = nil
270+ for p in ep.params where p.defaultValue != nil { values[p.id] = "" }
271+ }
272+
273+ private func buildURL(_ ep: ApiEndpoint) -> URL? {
274+ var path = ep.path
275+ for p in ep.params where p.location == "path" {
276+ let v = values[p.id]?.trimmingCharacters(in: .whitespaces) ?? ""
277+ path = path.replacingOccurrences(of: "{\(p.name)}", with: v.isEmpty ? "0" : v)
278+ }
279+ var comps = URLComponents(string: "https://www.api-ka.com\(path)")
280+ let q = ep.params
281+ .filter { $0.location == "query" }
282+ .compactMap { p -> URLQueryItem? in
283+ let v = (values[p.id] ?? "").trimmingCharacters(in: .whitespaces)
284+ let final = v.isEmpty ? (p.defaultValue ?? "") : v
285+ guard !final.isEmpty else { return nil }
286+ return URLQueryItem(name: p.name, value: final)
287+ }
288+ if !q.isEmpty { comps?.queryItems = q }
289+ return comps?.url
290+ }
291+
292+ private func send(_ ep: ApiEndpoint) async {
293+ guard let url = buildURL(ep) else { return }
294+ sending = true
295+ defer { sending = false }
296+ var req = URLRequest(url: url)
297+ req.timeoutInterval = 20
298+ req.setValue("KA-macOS/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")
299+ let start = Date()
300+ do {
301+ let (data, resp) = try await URLSession.shared.data(for: req)
302+ latencyMs = Int(Date().timeIntervalSince(start) * 1000)
303+ statusCode = (resp as? HTTPURLResponse)?.statusCode
304+ responseRaw = prettyJSON(data) ?? String(data: data, encoding: .utf8) ?? ""
305+ response = try? JSONDecoder().decode(JSONValue.self, from: data)
306+ if response == nil {
307+ response = .string(responseRaw.isEmpty ? "(réponse vide)" : String(responseRaw.prefix(4000)))
308+ }
309+ } catch {
310+ latencyMs = Int(Date().timeIntervalSince(start) * 1000)
311+ statusCode = nil
312+ response = .string("Erreur réseau : \(error.localizedDescription)")
313+ responseRaw = ""
314+ }
315+ }
316+
317+ private func prettyJSON(_ data: Data) -> String? {
318+ guard let obj = try? JSONSerialization.jsonObject(with: data),
319+ let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys])
320+ else { return nil }
321+ return String(data: pretty, encoding: .utf8)
322+ }
323+
324+ private func copyCurl(_ ep: ApiEndpoint) {
325+ guard let url = buildURL(ep) else { return }
326+ let cmd = "curl -s '\(url.absoluteString)' -H 'Accept: application/json'"
327+ NSPasteboard.general.clearContents()
328+ NSPasteboard.general.setString(cmd, forType: .string)
329+ copied = true
330+ Task { try? await Task.sleep(for: .seconds(2)); copied = false }
331+ }
332+
333+ // MARK: openapi.json → endpoints GET
334+
335+ private func loadSpec() async {
336+ guard endpoints.isEmpty else { return }
337+ guard let url = URL(string: "https://www.api-ka.com/openapi.json"),
338+ let root = try? await APIClient.shared.json(url, ttl: 3600),
339+ let paths = root.object?["paths"]?.object else {
340+ loadFailed = true
341+ return
342+ }
343+ var eps: [ApiEndpoint] = []
344+ for (path, methods) in paths {
345+ guard let get = methods.object?["get"]?.object else { continue }
346+ let params: [ApiParam] = (get["parameters"]?.array ?? []).compactMap { p in
347+ guard let po = p.object, let name = po.str("name") else { return nil }
348+ let schema = po["schema"]?.object
349+ let required: Bool = { if case .bool(true) = po["required"] ?? .null { return true }; return false }()
350+ return ApiParam(name: name,
351+ location: po.str("in") ?? "query",
352+ required: required,
353+ type: schema?.str("type") ?? schema?["anyOf"]?.array?.first?.object?.str("type") ?? "string",
354+ defaultValue: schema?["default"]?.text)
355+ }
356+ eps.append(ApiEndpoint(id: "GET \(path)",
357+ path: path,
358+ summary: get.str("summary") ?? "",
359+ detail: get.str("description"),
360+ tag: get["tags"]?.array?.first?.text ?? "divers",
361+ params: params))
362+ }
363+ endpoints = eps.sorted { $0.path < $1.path }
364+ loadFailed = eps.isEmpty
365+ // démo vivante : /health sélectionné et interrogé d'emblée (inoffensif)
366+ if selected == nil, let health = endpoints.first(where: { $0.path == "/health" }) {
367+ select(health)
368+ await send(health)
369+ }
370+ }
371+}
372+
373+// MARK: - Arbre JSON colorisé & pliable
374+
375+struct JSONTreeView: View {
376+ let key: String?
377+ let value: JSONValue
378+ let depth: Int
379+ @State private var expanded: Bool
380+
381+ init(key: String?, value: JSONValue, depth: Int) {
382+ self.key = key
383+ self.value = value
384+ self.depth = depth
385+ _expanded = State(initialValue: depth < 2)
386+ }
387+
388+ var body: some View {
389+ switch value {
390+ case .object(let o):
391+ if o.isEmpty { leaf(text: "{}", color: .secondary) }
392+ else {
393+ DisclosureGroup(isExpanded: $expanded) {
394+ VStack(alignment: .leading, spacing: 2) {
395+ ForEach(o.keys.sorted(), id: \.self) { k in
396+ JSONTreeView(key: k, value: o[k]!, depth: depth + 1)
397+ }
398+ }
399+ .padding(.leading, 8)
400+ } label: {
401+ label(suffix: "{ \(o.count) }", color: .secondary)
402+ }
403+ .disclosureGroupStyle(KADisclosure())
404+ }
405+ case .array(let a):
406+ if a.isEmpty { leaf(text: "[]", color: .secondary) }
407+ else {
408+ DisclosureGroup(isExpanded: $expanded) {
409+ VStack(alignment: .leading, spacing: 2) {
410+ ForEach(Array(a.prefix(50).enumerated()), id: \.offset) { i, v in
411+ JSONTreeView(key: "[\(i)]", value: v, depth: depth + 1)
412+ }
413+ if a.count > 50 {
414+ Text("… \(a.count - 50) de plus")
415+ .font(.system(size: 11, design: .monospaced))
416+ .foregroundStyle(.tertiary)
417+ }
418+ }
419+ .padding(.leading, 8)
420+ } label: {
421+ label(suffix: "[ \(a.count) ]", color: .secondary)
422+ }
423+ .disclosureGroupStyle(KADisclosure())
424+ }
425+ case .string(let s):
426+ leaf(text: "\"\(s.count > 200 ? String(s.prefix(200)) + "…" : s)\"", color: Color(hex: "#1c7ed6"))
427+ case .number(let n):
428+ leaf(text: n == n.rounded() ? String(Int(n)) : String(n), color: Color(hex: "#1f9d55"))
429+ case .bool(let b):
430+ leaf(text: b ? "true" : "false", color: Color(hex: "#f08c00"))
431+ case .null:
432+ leaf(text: "null", color: .secondary)
433+ }
434+ }
435+
436+ private func label(suffix: String, color: Color) -> some View {
437+ HStack(spacing: 5) {
438+ if let key {
439+ Text(key).font(.system(size: 12, weight: .bold, design: .monospaced))
440+ Text(":").foregroundStyle(.tertiary)
441+ }
442+ Text(suffix).font(.system(size: 11, design: .monospaced)).foregroundStyle(color)
443+ }
444+ }
445+
446+ private func leaf(text: String, color: Color) -> some View {
447+ HStack(alignment: .top, spacing: 5) {
448+ if let key {
449+ Text(key).font(.system(size: 12, weight: .bold, design: .monospaced))
450+ Text(":").foregroundStyle(.tertiary)
451+ }
452+ Text(text)
453+ .font(.system(size: 12, design: .monospaced))
454+ .foregroundStyle(color)
455+ .textSelection(.enabled)
456+ }
457+ }
458+}
459+
460+/// Style de pliage compact (chevron discret, sans indentation système).
461+struct KADisclosure: DisclosureGroupStyle {
462+ func makeBody(configuration: Configuration) -> some View {
463+ VStack(alignment: .leading, spacing: 2) {
464+ Button {
465+ withAnimation(.snappy(duration: 0.15)) { configuration.isExpanded.toggle() }
466+ } label: {
467+ HStack(spacing: 4) {
468+ Image(systemName: "chevron.right")
469+ .font(.system(size: 8, weight: .bold))
470+ .rotationEffect(.degrees(configuration.isExpanded ? 90 : 0))
471+ .foregroundStyle(.secondary)
472+ configuration.label
473+ }
474+ }
475+ .buttonStyle(.plain)
476+ if configuration.isExpanded {
477+ configuration.content
478+ }
479+ }
480+ }
481+}
added Sources/KA/Features/HomeView.swift +209 −0
@@ -0,0 +1,209 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// HomeView.swift — l'Accueil : le pouls de l'écosystème (compteurs live),
3+// collections éditoriales (requêtes RÉELLES vers les API — aucun contenu
4+// inventé), grille des 12 univers, reprise de l'historique récent.
5+import SwiftUI
6+
7+struct HomeView: View {
8+ @EnvironmentObject private var pulse: EcosystemPulse
9+ @EnvironmentObject private var state: AppState
10+ @EnvironmentObject private var recents: RecentsStore
11+ @Environment(\.colorScheme) private var scheme
12+
13+ private var totalAll: Int {
14+ // le pouls global = somme des compteurs connus (sans les pages indexées ni le parc Vrai-Prix, hors offre "consommable")
15+ Ecosystem.all
16+ .filter { !["trouve-ka", "vrai-prix", "api-ka"].contains($0.id) }
17+ .compactMap { pulse.totals[$0.id] }
18+ .reduce(0, +)
19+ }
20+
21+ var body: some View {
22+ ScrollView {
23+ VStack(alignment: .leading, spacing: 22) {
24+ header
25+ if recents.alertCount > 0 { alertBanner }
26+ editorial
27+ universes
28+ if !recents.viewed.isEmpty { recentStrip }
29+ footer
30+ }
31+ .padding(20)
32+ .frame(maxWidth: 1300)
33+ .frame(maxWidth: .infinity)
34+ }
35+ .background(KATheme.paper(scheme))
36+ }
37+
38+ // MARK: en-tête
39+
40+ private var header: some View {
41+ VStack(alignment: .leading, spacing: 8) {
42+ GroupeKAMark(size: 30)
43+ Text("Le Québec entier, lu à la source.")
44+ .font(.system(.title2, design: .rounded).weight(.bold))
45+ HStack(spacing: 8) {
46+ if totalAll > 0 {
47+ Text("\(totalAll.fr) éléments en direct")
48+ .font(.system(.caption, design: .monospaced).weight(.bold))
49+ .padding(.horizontal, 10).padding(.vertical, 5)
50+ .background(KATheme.green.opacity(0.12), in: Capsule())
51+ .foregroundStyle(KATheme.green)
52+ .contentTransition(.numericText())
53+ }
54+ Text("\(pulse.servicesUp)/\(pulse.health.count) services en ligne")
55+ .font(.system(.caption, design: .monospaced).weight(.bold))
56+ .padding(.horizontal, 10).padding(.vertical, 5)
57+ .background((pulse.servicesUp == pulse.health.count ? KATheme.green : .orange).opacity(0.12), in: Capsule())
58+ .foregroundStyle(pulse.servicesUp == pulse.health.count ? KATheme.green : .orange)
59+ Spacer()
60+ Button {
61+ state.openUniversalSearch()
62+ } label: {
63+ Label("Recherche universelle", systemImage: "magnifyingglass")
64+ .font(.caption.weight(.bold))
65+ .padding(.horizontal, 12).padding(.vertical, 7)
66+ .background(KATheme.inkLight, in: Capsule())
67+ .foregroundStyle(KATheme.lime)
68+ }
69+ .buttonStyle(.plain)
70+ .help("⌘K — une barre pour tous les univers")
71+ }
72+ }
73+ }
74+
75+ private var alertBanner: some View {
76+ Button {
77+ state.selection = .alerts
78+ } label: {
79+ HStack(spacing: 10) {
80+ Image(systemName: "bell.badge.fill").foregroundStyle(.red)
81+ Text("**+\(recents.alertCount) nouveauté\(recents.alertCount > 1 ? "s" : "")** dans vos recherches sauvegardées")
82+ .font(.subheadline)
83+ Spacer()
84+ Image(systemName: "chevron.right").font(.caption).foregroundStyle(.secondary)
85+ }
86+ .padding(13)
87+ .frame(maxWidth: .infinity, alignment: .leading)
88+ .kaCard(accent: .red)
89+ }
90+ .buttonStyle(KAPressStyle())
91+ }
92+
93+ // MARK: collections éditoriales (requêtes réelles)
94+
95+ private var editorial: some View {
96+ VStack(alignment: .leading, spacing: 10) {
97+ Text("En ce moment").font(.title3.weight(.bold))
98+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 270), spacing: 12)], spacing: 12) {
99+ ForEach(Editorial.collections) { c in
100+ let u = Ecosystem.universe(c.universeID)
101+ Button {
102+ state.openUniverse(c.universeID, query: c.query ?? "", params: c.params)
103+ } label: {
104+ HStack(spacing: 12) {
105+ Image(systemName: c.symbol)
106+ .font(.title3.weight(.semibold))
107+ .foregroundStyle(u?.accent ?? .gray)
108+ .frame(width: 42, height: 42)
109+ .background((u?.accent ?? .gray).opacity(0.13),
110+ in: RoundedRectangle(cornerRadius: 11, style: .continuous))
111+ VStack(alignment: .leading, spacing: 2) {
112+ Text(c.title).font(.subheadline.weight(.bold))
113+ Text(c.subtitle).font(.caption).foregroundStyle(KATheme.ink2(scheme))
114+ .lineLimit(2)
115+ }
116+ Spacer()
117+ Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary)
118+ }
119+ .padding(12)
120+ .frame(maxWidth: .infinity, alignment: .leading)
121+ .kaCard(accent: u?.accent)
122+ }
123+ .buttonStyle(KAPressStyle())
124+ }
125+ }
126+ }
127+ }
128+
129+ // MARK: grille des univers
130+
131+ private var universes: some View {
132+ VStack(alignment: .leading, spacing: 10) {
133+ Text("Les univers").font(.title3.weight(.bold))
134+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 215), spacing: 13)], spacing: 13) {
135+ ForEach(Ecosystem.all) { u in
136+ Button {
137+ state.selection = .universe(u.id)
138+ } label: {
139+ VStack(alignment: .leading, spacing: 9) {
140+ HStack {
141+ Image(systemName: u.symbol)
142+ .font(.title3.weight(.semibold))
143+ .foregroundStyle(u.accent)
144+ .frame(width: 38, height: 38)
145+ .background(u.accent.opacity(0.14), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
146+ Spacer()
147+ Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary)
148+ }
149+ KAWordmark(universe: u, size: 16)
150+ Text(u.tagline)
151+ .font(.caption).foregroundStyle(KATheme.ink2(scheme))
152+ .lineLimit(2, reservesSpace: true)
153+ .multilineTextAlignment(.leading)
154+ if let total = pulse.totals[u.id] {
155+ Text("\(total.fr) \(u.unit)")
156+ .font(.system(.caption2, design: .monospaced).weight(.bold))
157+ .foregroundStyle(u.accent)
158+ .contentTransition(.numericText())
159+ } else {
160+ Text("—").font(.system(.caption2, design: .monospaced))
161+ .foregroundStyle(.tertiary)
162+ }
163+ }
164+ .padding(13)
165+ .frame(maxWidth: .infinity, alignment: .leading)
166+ .kaCard(accent: u.accent)
167+ }
168+ .buttonStyle(KAPressStyle())
169+ .accessibilityLabel("\(u.name) — \(u.tagline)")
170+ }
171+ }
172+ }
173+ }
174+
175+ // MARK: reprise (derniers consultés)
176+
177+ private var recentStrip: some View {
178+ VStack(alignment: .leading, spacing: 10) {
179+ HStack {
180+ Text("Reprendre où vous étiez").font(.title3.weight(.bold))
181+ Spacer()
182+ Button("Tout l'historique") { state.selection = .history }
183+ .buttonStyle(.plain)
184+ .font(.caption.weight(.semibold))
185+ .foregroundStyle(KATheme.green)
186+ }
187+ ScrollView(.horizontal, showsIndicators: false) {
188+ HStack(spacing: 11) {
189+ ForEach(recents.viewed.prefix(8)) { item in
190+ Button {
191+ state.openUniverse(item.universeID, query: item.title)
192+ } label: {
193+ KAItemCard(item: item)
194+ .frame(width: 230)
195+ }
196+ .buttonStyle(KAPressStyle())
197+ }
198+ }
199+ .padding(.vertical, 2)
200+ }
201+ }
202+ }
203+
204+ private var footer: some View {
205+ Text(Ecosystem.disclaimer)
206+ .font(.caption2).foregroundStyle(.tertiary)
207+ .padding(.top, 4)
208+ }
209+}
added Sources/KA/Features/LibraryViews.swift +289 −0
@@ -0,0 +1,289 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// LibraryViews.swift — la bibliothèque personnelle : Favoris par collections
3+// (multi-univers), Historique consulté, Recherches sauvegardées & ALERTES
4+// honnêtes (deltas réels recomptés via l'API). Stores repris de l'app iOS KA.
5+import SwiftUI
6+
7+// MARK: - Favoris (collections)
8+
9+struct FavoritesView: View {
10+ @EnvironmentObject private var favorites: FavoritesStore
11+ @Environment(\.colorScheme) private var scheme
12+ @State private var selectedCollection: UUID?
13+ @State private var selected: KAItem?
14+ @State private var newName = ""
15+
16+ private var current: FavoritesStore.FavCollection? {
17+ favorites.collections.first { $0.id == selectedCollection } ?? favorites.collections.first
18+ }
19+
20+ var body: some View {
21+ VStack(spacing: 0) {
22+ header
23+ Divider().opacity(0.4)
24+ HStack(spacing: 0) {
25+ grid
26+ .frame(maxWidth: .infinity)
27+ Divider().opacity(0.4)
28+ Group {
29+ if let item = selected {
30+ ItemDetailPane(item: item)
31+ } else {
32+ KAEmptyState(symbol: "heart",
33+ title: "Vos favoris, tous univers confondus",
34+ message: "Un 4½, une auto et un resto peuvent vivre dans la même collection.")
35+ .frame(maxHeight: .infinity)
36+ .background(KATheme.paper(scheme))
37+ }
38+ }
39+ .frame(width: 440)
40+ }
41+ }
42+ }
43+
44+ private var header: some View {
45+ HStack(spacing: 10) {
46+ Text("Favoris").font(.title2.weight(.bold))
47+ Picker("Collection", selection: Binding(
48+ get: { selectedCollection ?? favorites.collections.first?.id ?? UUID() },
49+ set: { selectedCollection = $0; selected = nil }
50+ )) {
51+ ForEach(favorites.collections) { c in
52+ Text("\(c.name) (\(c.items.count))").tag(c.id)
53+ }
54+ }
55+ .labelsHidden()
56+ .frame(maxWidth: 260)
57+ if let c = current, favorites.collections.count > 1 {
58+ Button {
59+ favorites.removeCollection(c.id)
60+ selectedCollection = favorites.collections.first?.id
61+ selected = nil
62+ } label: {
63+ Image(systemName: "trash")
64+ }
65+ .help("Supprimer la collection « \(c.name) » (les éléments sont retirés des favoris)")
66+ }
67+ Spacer()
68+ TextField("Nouvelle collection…", text: $newName)
69+ .textFieldStyle(.roundedBorder)
70+ .frame(maxWidth: 200)
71+ .onSubmit { addCollection() }
72+ Button("Créer") { addCollection() }
73+ .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty)
74+ }
75+ .padding(.horizontal, 16).padding(.vertical, 11)
76+ }
77+
78+ private var grid: some View {
79+ ScrollView {
80+ if let c = current, !c.items.isEmpty {
81+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 230), spacing: 12)], spacing: 12) {
82+ ForEach(c.items) { item in
83+ Button { selected = item } label: {
84+ KAItemCard(item: item, selected: selected?.id == item.id)
85+ }
86+ .buttonStyle(KAPressStyle())
87+ .contextMenu {
88+ Button("Retirer des favoris") {
89+ favorites.toggle(item)
90+ if selected?.id == item.id { selected = nil }
91+ }
92+ if favorites.collections.count > 1 {
93+ Menu("Déplacer vers") {
94+ ForEach(favorites.collections.filter { $0.id != c.id }) { other in
95+ Button(other.name) { favorites.move(item, to: other.id) }
96+ }
97+ }
98+ }
99+ }
100+ }
101+ }
102+ .padding(14)
103+ } else {
104+ KAEmptyState(symbol: "heart.slash",
105+ title: "Aucun favori ici",
106+ message: "Cliquez le cœur sur n'importe quelle fiche pour la garder — consultable hors ligne.")
107+ .padding(30)
108+ }
109+ }
110+ .background(KATheme.paper(scheme))
111+ }
112+
113+ private func addCollection() {
114+ favorites.addCollection(newName)
115+ newName = ""
116+ selectedCollection = favorites.collections.last?.id
117+ }
118+}
119+
120+// MARK: - Historique consulté
121+
122+struct HistoryView: View {
123+ @EnvironmentObject private var recents: RecentsStore
124+ @Environment(\.colorScheme) private var scheme
125+ @State private var selected: KAItem?
126+
127+ var body: some View {
128+ VStack(spacing: 0) {
129+ HStack {
130+ Text("Historique").font(.title2.weight(.bold))
131+ Text("\(recents.viewed.count) fiche\(recents.viewed.count > 1 ? "s" : "") consultée\(recents.viewed.count > 1 ? "s" : "")")
132+ .font(.caption).foregroundStyle(.secondary)
133+ Spacer()
134+ Button("Effacer l'historique") { recents.clearHistory(); selected = nil }
135+ .disabled(recents.viewed.isEmpty)
136+ }
137+ .padding(.horizontal, 16).padding(.vertical, 11)
138+ Divider().opacity(0.4)
139+ HStack(spacing: 0) {
140+ ScrollView {
141+ if recents.viewed.isEmpty {
142+ KAEmptyState(symbol: "clock.arrow.circlepath",
143+ title: "Rien encore",
144+ message: "Les fiches que vous consultez apparaîtront ici, consultables hors ligne.")
145+ .padding(30)
146+ } else {
147+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 230), spacing: 12)], spacing: 12) {
148+ ForEach(recents.viewed) { item in
149+ Button { selected = item } label: {
150+ KAItemCard(item: item, selected: selected?.id == item.id)
151+ }
152+ .buttonStyle(KAPressStyle())
153+ }
154+ }
155+ .padding(14)
156+ }
157+ }
158+ .background(KATheme.paper(scheme))
159+ .frame(maxWidth: .infinity)
160+ Divider().opacity(0.4)
161+ Group {
162+ if let item = selected {
163+ ItemDetailPane(item: item)
164+ } else {
165+ KAEmptyState(symbol: "clock",
166+ title: "Choisissez une fiche",
167+ message: "Votre historique reste local, sur ce Mac.")
168+ .frame(maxHeight: .infinity)
169+ .background(KATheme.paper(scheme))
170+ }
171+ }
172+ .frame(width: 440)
173+ }
174+ }
175+ }
176+}
177+
178+// MARK: - Recherches sauvegardées & alertes
179+
180+struct AlertsView: View {
181+ @EnvironmentObject private var recents: RecentsStore
182+ @EnvironmentObject private var state: AppState
183+ @Environment(\.colorScheme) private var scheme
184+ @State private var refreshing = false
185+
186+ var body: some View {
187+ ScrollView {
188+ VStack(alignment: .leading, spacing: 14) {
189+ HStack {
190+ Text("Recherches & alertes").font(.title2.weight(.bold))
191+ Spacer()
192+ Button {
193+ refreshing = true
194+ Task { await recents.refreshAlerts(); refreshing = false }
195+ } label: {
196+ if refreshing { ProgressView().controlSize(.small) }
197+ else { Label("Recompter maintenant", systemImage: "arrow.clockwise").font(.caption) }
198+ }
199+ .disabled(refreshing || recents.savedSearches.isEmpty)
200+ }
201+ Text("KA recompte le total réel de chaque recherche via l'API du site et affiche le delta — jamais de faux chiffre. Sauvegardez une recherche depuis n'importe quel univers (bouton « Sauvegarder + alerte »).")
202+ .font(.caption).foregroundStyle(.secondary)
203+
204+ if recents.savedSearches.isEmpty {
205+ KAEmptyState(symbol: "bell.slash",
206+ title: "Aucune recherche sauvegardée",
207+ message: "Dans un univers, lancez une recherche ou des filtres puis « Sauvegarder + alerte ».")
208+ .padding(20)
209+ } else {
210+ LazyVStack(spacing: 10) {
211+ ForEach(recents.savedSearches) { s in
212+ row(s)
213+ }
214+ }
215+ }
216+ }
217+ .padding(18)
218+ .frame(maxWidth: 860)
219+ .frame(maxWidth: .infinity)
220+ }
221+ .background(KATheme.paper(scheme))
222+ }
223+
224+ private func row(_ s: SavedSearch) -> some View {
225+ let u = Ecosystem.universe(s.universeID)
226+ return HStack(spacing: 12) {
227+ Image(systemName: u?.symbol ?? "magnifyingglass")
228+ .foregroundStyle(u?.accent ?? .gray)
229+ .frame(width: 30, height: 30)
230+ .background((u?.accent ?? .gray).opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
231+ VStack(alignment: .leading, spacing: 3) {
232+ HStack(spacing: 7) {
233+ Text(s.name.isEmpty ? "(filtres seulement)" : s.name)
234+ .font(.subheadline.weight(.bold))
235+ if s.newCount > 0 {
236+ Text("+\(s.newCount) nouveauté\(s.newCount > 1 ? "s" : "")")
237+ .font(.system(.caption2, design: .monospaced).weight(.bold))
238+ .padding(.horizontal, 7).padding(.vertical, 2)
239+ .background(Color.red.opacity(0.85), in: Capsule())
240+ .foregroundStyle(.white)
241+ }
242+ }
243+ HStack(spacing: 6) {
244+ if let u { KAChip(text: u.wordmark, accent: u.accent) }
245+ if !s.params.isEmpty {
246+ Text(s.params.map { "\($0.key)=\($0.value)" }.sorted().joined(separator: " · "))
247+ .font(.system(size: 10, design: .monospaced))
248+ .foregroundStyle(.secondary).lineLimit(1)
249+ }
250+ if let total = s.lastTotal {
251+ Text("\(total.fr) résultats")
252+ .font(.system(size: 10, design: .monospaced).weight(.bold))
253+ .foregroundStyle(u?.accent ?? .secondary)
254+ }
255+ if let d = s.lastChecked {
256+ Text("vérifié \(d.formatted(.relative(presentation: .named, unitsStyle: .narrow)))")
257+ .font(.system(size: 10)).foregroundStyle(.tertiary)
258+ }
259+ }
260+ }
261+ Spacer()
262+ Toggle("Alerte", isOn: Binding(
263+ get: { s.alertsOn },
264+ set: { v in
265+ if let i = recents.savedSearches.firstIndex(where: { $0.id == s.id }) {
266+ recents.savedSearches[i].alertsOn = v
267+ }
268+ }))
269+ .toggleStyle(.switch)
270+ .controlSize(.mini)
271+ .labelsHidden()
272+ .help(s.alertsOn ? "Alerte active" : "Alerte en pause")
273+ Button("Ouvrir") {
274+ if s.newCount > 0 { recents.markSeen(s.id) }
275+ state.openUniverse(s.universeID, query: s.query, params: s.params)
276+ }
277+ .tint(u?.accent)
278+ Button {
279+ recents.remove(s.id)
280+ } label: {
281+ Image(systemName: "trash")
282+ }
283+ .help("Supprimer cette recherche sauvegardée")
284+ }
285+ .padding(12)
286+ .frame(maxWidth: .infinity, alignment: .leading)
287+ .kaCard(accent: s.newCount > 0 ? .red : u?.accent)
288+ }
289+}
added Sources/KA/Features/MainWindow.swift +123 −0
@@ -0,0 +1,123 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// MainWindow.swift — la fenêtre principale : sidebar (Accueil, Recherche,
3+// Carte, les 12 univers avec accent + compteur live, Favoris, Historique,
4+// Recherches & alertes, KA Agent, Statut) → volet de contenu premium.
5+import SwiftUI
6+
7+struct MainWindowView: View {
8+ @EnvironmentObject private var pulse: EcosystemPulse
9+ @EnvironmentObject private var state: AppState
10+ @EnvironmentObject private var favorites: FavoritesStore
11+ @EnvironmentObject private var recents: RecentsStore
12+ @Environment(\.openWindow) private var openWindow
13+ @Environment(\.colorScheme) private var scheme
14+
15+ var body: some View {
16+ NavigationSplitView {
17+ sidebar
18+ } detail: {
19+ detail
20+ .background(KATheme.paper(scheme))
21+ }
22+ .frame(minWidth: 1000, minHeight: 640)
23+ .onAppear {
24+ state.openMain = { focusMainWindow(openWindow) }
25+ }
26+ }
27+
28+ private var sidebar: some View {
29+ List(selection: $state.selection) {
30+ Section {
31+ Label("Accueil", systemImage: "house")
32+ .tag(SidebarSelection.home)
33+ Label("Recherche", systemImage: "magnifyingglass")
34+ .tag(SidebarSelection.search)
35+ Label("Carte", systemImage: "map")
36+ .tag(SidebarSelection.map)
37+ Label("KA Agent", systemImage: "sparkle")
38+ .tag(SidebarSelection.agent)
39+ } header: {
40+ GroupeKAMark(size: 15).padding(.vertical, 4)
41+ }
42+
43+ Section("Univers") {
44+ ForEach(Ecosystem.all) { u in
45+ HStack(spacing: 8) {
46+ Image(systemName: u.symbol)
47+ .font(.caption.weight(.semibold))
48+ .foregroundStyle(u.accent)
49+ .frame(width: 18)
50+ KAWordmark(universe: u, size: 12)
51+ Spacer(minLength: 4)
52+ if let n = pulse.totals[u.id] {
53+ Text(Double(n).compact)
54+ .font(.system(.caption2, design: .monospaced).weight(.bold))
55+ .foregroundStyle(u.accent)
56+ .contentTransition(.numericText())
57+ }
58+ }
59+ .tag(SidebarSelection.universe(u.id))
60+ }
61+ }
62+
63+ Section("Bibliothèque") {
64+ HStack {
65+ Label("Favoris", systemImage: "heart")
66+ Spacer()
67+ if favorites.count > 0 {
68+ Text("\(favorites.count)")
69+ .font(.system(.caption2, design: .monospaced).weight(.bold))
70+ .foregroundStyle(.secondary)
71+ }
72+ }
73+ .tag(SidebarSelection.favorites)
74+ Label("Historique", systemImage: "clock.arrow.circlepath")
75+ .tag(SidebarSelection.history)
76+ HStack {
77+ Label("Recherches & alertes", systemImage: "bell")
78+ Spacer()
79+ if recents.alertCount > 0 {
80+ Text("+\(recents.alertCount)")
81+ .font(.system(.caption2, design: .monospaced).weight(.bold))
82+ .padding(.horizontal, 6).padding(.vertical, 2)
83+ .background(Color.red.opacity(0.85), in: Capsule())
84+ .foregroundStyle(.white)
85+ }
86+ }
87+ .tag(SidebarSelection.alerts)
88+ }
89+
90+ Section {
91+ HStack {
92+ Label("Statut", systemImage: "waveform.path.ecg")
93+ Spacer()
94+ Text("\(pulse.servicesUp)/\(pulse.health.count)")
95+ .font(.system(.caption2, design: .monospaced).weight(.bold))
96+ .foregroundStyle(pulse.servicesUp == pulse.health.count ? KATheme.green : .orange)
97+ }
98+ .tag(SidebarSelection.status)
99+ }
100+ }
101+ .listStyle(.sidebar)
102+ .navigationSplitViewColumnWidth(min: 215, ideal: 235)
103+ }
104+
105+ @ViewBuilder
106+ private var detail: some View {
107+ switch state.selection ?? .home {
108+ case .home: HomeView()
109+ case .search: UniversalSearchView()
110+ case .map: MapExplorerView()
111+ case .agent: AgentChatView()
112+ case .status: StatusView()
113+ case .favorites: FavoritesView()
114+ case .history: HistoryView()
115+ case .alerts: AlertsView()
116+ case .universe(let id):
117+ if let u = Ecosystem.universe(id) {
118+ UniverseExplorerView(universe: u)
119+ .id(u.id) // réinitialise l'état quand on change d'univers
120+ }
121+ }
122+ }
123+}
added Sources/KA/Features/MapExplorerView.swift +380 −0
@@ -0,0 +1,380 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// MapExplorerView.swift — la CARTE avancée en volet côte à côte carte|liste :
3+// marqueurs-pilules avec prix, CLUSTERING par grille (dégroupage au zoom),
4+// « Rechercher dans cette zone » (bbox RÉEL côté API pour lou/immo/job-ka,
5+// filtre client sinon), fiche synchronisée carte↔liste↔détail.
6+// Logique reprise de l'app iOS KA (MapView.swift : ClusterEngine, PricePill).
7+import SwiftUI
8+import MapKit
9+
10+// MARK: - Clustering par grille (pur)
11+
12+struct MapCluster: Identifiable, Hashable {
13+ let id: String
14+ let latitude: Double
15+ let longitude: Double
16+ let items: [KAItem]
17+ var isSingle: Bool { items.count == 1 }
18+ var item: KAItem { items[0] }
19+}
20+
21+enum ClusterEngine {
22+ /// Regroupe les items en cellules de grille (~grid × grid cellules sur la
23+ /// région) ; une cellule d'un seul élément reste un marqueur individuel.
24+ static func clusterize(_ items: [KAItem], region: MKCoordinateRegion, grid: Double = 9) -> [MapCluster] {
25+ let cellLat = max(region.span.latitudeDelta / grid, 0.0001)
26+ let cellLon = max(region.span.longitudeDelta / grid, 0.0001)
27+ var cells: [String: [KAItem]] = [:]
28+ for it in items {
29+ guard let la = it.latitude, let lo = it.longitude else { continue }
30+ let key = "\(Int((la / cellLat).rounded(.down)))|\(Int((lo / cellLon).rounded(.down)))"
31+ cells[key, default: []].append(it)
32+ }
33+ return cells.map { key, members in
34+ let la = members.compactMap(\.latitude).reduce(0, +) / Double(members.count)
35+ let lo = members.compactMap(\.longitude).reduce(0, +) / Double(members.count)
36+ return MapCluster(id: key + ":\(members.count)", latitude: la, longitude: lo, items: members)
37+ }
38+ .sorted { $0.items.count > $1.items.count }
39+ .prefix(150) // plafond de rendu — jamais de carte qui rame
40+ .map { $0 }
41+ }
42+}
43+
44+// MARK: - Carte | liste
45+
46+struct MapExplorerView: View {
47+ @Environment(\.colorScheme) private var scheme
48+
49+ @State private var camera: MapCameraPosition = .region(
50+ MKCoordinateRegion(center: .init(latitude: 46.81, longitude: -71.21),
51+ span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3)))
52+ @State private var visibleRegion = MKCoordinateRegion(
53+ center: .init(latitude: 46.81, longitude: -71.21),
54+ span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3))
55+ @State private var items: [KAItem] = []
56+ @State private var enabled: Set<String> = ["lou-ka", "immo-ka"]
57+ @State private var loading = false
58+ @State private var zoneDirty = false
59+ @State private var selected: KAItem?
60+ @State private var showDetail = false
61+ @State private var is3D = false
62+
63+ /// Univers cartographiables (coordonnées disponibles)
64+ private var mapUniverses: [Universe] {
65+ Ecosystem.all.filter { ["lou-ka", "immo-ka", "job-ka", "resto-ka", "sorti-ka"].contains($0.id) }
66+ }
67+ private var visibleItems: [KAItem] { items.filter { enabled.contains($0.universeID) } }
68+ private var clusters: [MapCluster] { ClusterEngine.clusterize(visibleItems, region: visibleRegion) }
69+
70+ var body: some View {
71+ VStack(spacing: 0) {
72+ chips
73+ Divider().opacity(0.4)
74+ HSplitView {
75+ mapPane
76+ .frame(minWidth: 480)
77+ .layoutPriority(1)
78+ sidePane
79+ .frame(minWidth: 360, idealWidth: 420, maxWidth: 560)
80+ }
81+ }
82+ .task { await loadZone() }
83+ }
84+
85+ // MARK: chips univers
86+
87+ private var chips: some View {
88+ HStack(spacing: 8) {
89+ ForEach(mapUniverses) { u in
90+ let on = enabled.contains(u.id)
91+ Button {
92+ if on { enabled.remove(u.id) } else { enabled.insert(u.id) }
93+ selected = nil
94+ Task { await loadZone() }
95+ } label: {
96+ Label(u.wordmark, systemImage: u.symbol)
97+ .font(.system(.caption, design: .monospaced).weight(.bold))
98+ .padding(.horizontal, 11).padding(.vertical, 7)
99+ .background(on ? u.accent : KATheme.surface(scheme), in: Capsule())
100+ .foregroundStyle(on ? .white : .secondary)
101+ .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))
102+ }
103+ .buttonStyle(.plain)
104+ .help("\(on ? "Masquer" : "Afficher") \(u.name) sur la carte")
105+ }
106+ Spacer()
107+ Button {
108+ is3D.toggle()
109+ let center = visibleRegion.center
110+ if is3D {
111+ // vue 3D : caméra inclinée (relief + bâtiments réalistes)
112+ let distance = max(visibleRegion.span.latitudeDelta, 0.005) * 111_000 * 1.4
113+ withAnimation(.easeInOut(duration: 0.6)) {
114+ camera = .camera(MapCamera(centerCoordinate: center, distance: distance,
115+ heading: 20, pitch: 60))
116+ }
117+ } else {
118+ withAnimation(.easeInOut(duration: 0.6)) {
119+ camera = .region(visibleRegion)
120+ }
121+ }
122+ } label: {
123+ Label(is3D ? "2D" : "3D", systemImage: is3D ? "map" : "view.3d")
124+ .font(.system(.caption, design: .monospaced).weight(.bold))
125+ .padding(.horizontal, 11).padding(.vertical, 7)
126+ .background(is3D ? KATheme.inkLight : KATheme.surface(scheme), in: Capsule())
127+ .foregroundStyle(is3D ? KATheme.lime : .primary)
128+ .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))
129+ }
130+ .buttonStyle(.plain)
131+ .help(is3D ? "Revenir à la vue 2D" : "Vue 3D inclinée (relief et bâtiments)")
132+ Text(loading ? "Chargement…" : "\(visibleItems.count) résultats dans la zone")
133+ .font(.system(.caption, design: .monospaced).weight(.bold))
134+ .foregroundStyle(.secondary)
135+ }
136+ .padding(.horizontal, 16).padding(.vertical, 10)
137+ }
138+
139+ // MARK: carte
140+
141+ private var mapPane: some View {
142+ ZStack(alignment: .bottom) {
143+ Map(position: $camera) {
144+ ForEach(clusters) { cluster in
145+ Annotation("", coordinate: .init(latitude: cluster.latitude, longitude: cluster.longitude)) {
146+ if cluster.isSingle {
147+ PricePill(item: cluster.item,
148+ selected: selected?.id == cluster.item.id)
149+ .onTapGesture {
150+ withAnimation(.snappy) { selected = cluster.item; showDetail = false }
151+ }
152+ } else {
153+ ClusterBadge(cluster: cluster)
154+ .onTapGesture {
155+ // dégroupage : zoom sur le groupe
156+ withAnimation(.easeInOut(duration: 0.4)) {
157+ camera = .region(.init(
158+ center: .init(latitude: cluster.latitude, longitude: cluster.longitude),
159+ span: .init(latitudeDelta: visibleRegion.span.latitudeDelta / 3.2,
160+ longitudeDelta: visibleRegion.span.longitudeDelta / 3.2)))
161+ }
162+ }
163+ }
164+ }
165+ .annotationTitles(.hidden)
166+ }
167+ }
168+ .mapStyle(.standard(elevation: .realistic, pointsOfInterest: .excludingAll))
169+ .mapControls {
170+ MapCompass()
171+ MapPitchToggle()
172+ MapZoomStepper()
173+ }
174+ .onMapCameraChange(frequency: .onEnd) { ctx in
175+ visibleRegion = ctx.region
176+ zoneDirty = true
177+ }
178+
179+ if zoneDirty && !loading {
180+ Button {
181+ Task { await loadZone() }
182+ } label: {
183+ Label("Rechercher dans cette zone", systemImage: "arrow.clockwise")
184+ .font(.caption.weight(.bold))
185+ .padding(.horizontal, 13).padding(.vertical, 10)
186+ .background(KATheme.inkLight, in: Capsule())
187+ .foregroundStyle(KATheme.lime)
188+ }
189+ .buttonStyle(.plain)
190+ .padding(.bottom, 14)
191+ .transition(.scale.combined(with: .opacity))
192+ }
193+ if loading {
194+ ProgressView()
195+ .controlSize(.small)
196+ .padding(10)
197+ .background(.ultraThinMaterial, in: Circle())
198+ .padding(.bottom, 14)
199+ }
200+ }
201+ .animation(.snappy, value: zoneDirty)
202+ }
203+
204+ // MARK: volet liste / fiche (synchronisé)
205+
206+ @ViewBuilder
207+ private var sidePane: some View {
208+ if showDetail, let item = selected {
209+ VStack(spacing: 0) {
210+ HStack {
211+ Button {
212+ withAnimation(.snappy) { showDetail = false }
213+ } label: {
214+ Label("Retour aux résultats", systemImage: "chevron.left")
215+ .font(.caption.weight(.bold))
216+ }
217+ .buttonStyle(.plain)
218+ Spacer()
219+ }
220+ .padding(.horizontal, 14).padding(.vertical, 9)
221+ Divider().opacity(0.4)
222+ ItemDetailPane(item: item)
223+ }
224+ .background(KATheme.paper(scheme))
225+ } else {
226+ VStack(spacing: 0) {
227+ if let item = selected {
228+ compactCard(item)
229+ .padding(.horizontal, 12).padding(.top, 10)
230+ }
231+ ScrollView {
232+ LazyVStack(spacing: 9) {
233+ if visibleItems.isEmpty && !loading {
234+ KAEmptyState(symbol: "map",
235+ title: "Rien dans cette zone",
236+ message: "Déplacez la carte puis « Rechercher dans cette zone », ou activez d'autres univers.")
237+ }
238+ ForEach(visibleItems.prefix(80)) { item in
239+ Button {
240+ withAnimation(.snappy) {
241+ selected = item
242+ if let la = item.latitude, let lo = item.longitude {
243+ camera = .region(.init(center: .init(latitude: la, longitude: lo),
244+ span: .init(latitudeDelta: 0.02, longitudeDelta: 0.02)))
245+ }
246+ }
247+ } label: {
248+ KAItemRow(item: item, compact: true)
249+ .overlay(
250+ RoundedRectangle(cornerRadius: 12, style: .continuous)
251+ .strokeBorder(Ecosystem.universe(item.universeID)?.accent ?? .gray,
252+ lineWidth: selected?.id == item.id ? 2.2 : 0)
253+ .padding(.trailing, 4).padding(.bottom, 4)
254+ )
255+ }
256+ .buttonStyle(KAPressStyle())
257+ }
258+ }
259+ .padding(12)
260+ }
261+ }
262+ .background(KATheme.paper(scheme))
263+ }
264+ }
265+
266+ private func compactCard(_ item: KAItem) -> some View {
267+ Button {
268+ withAnimation(.snappy) { showDetail = true }
269+ } label: {
270+ HStack(spacing: 10) {
271+ if let img = item.imageURL {
272+ KAImage(url: img, accent: Ecosystem.universe(item.universeID)?.accent ?? .gray)
273+ .frame(width: 54, height: 54)
274+ .clipShape(RoundedRectangle(cornerRadius: 9))
275+ }
276+ VStack(alignment: .leading, spacing: 3) {
277+ Text(item.title).font(.subheadline.weight(.bold)).lineLimit(1)
278+ HStack(spacing: 6) {
279+ if let p = item.priceLabel {
280+ Text(p).font(.system(.caption, design: .rounded).weight(.bold))
281+ .foregroundStyle(Ecosystem.universe(item.universeID)?.accent ?? .primary)
282+ }
283+ if let c = item.city { Text(c).font(.caption2).foregroundStyle(.secondary) }
284+ }
285+ Text("Cliquer pour la fiche complète").font(.system(size: 9, design: .monospaced))
286+ .foregroundStyle(.tertiary)
287+ }
288+ Spacer()
289+ Button {
290+ withAnimation(.snappy) { selected = nil }
291+ } label: {
292+ Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
293+ }
294+ .buttonStyle(.plain)
295+ .accessibilityLabel("Fermer l'aperçu")
296+ }
297+ .padding(10)
298+ .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 13, style: .continuous))
299+ .overlay(RoundedRectangle(cornerRadius: 13, style: .continuous)
300+ .strokeBorder(.primary.opacity(0.5), lineWidth: 1.3))
301+ .shadow(color: .black.opacity(0.15), radius: 6, y: 3)
302+ }
303+ .buttonStyle(KAPressStyle())
304+ .transition(.move(edge: .top).combined(with: .opacity))
305+ }
306+
307+ // MARK: données
308+
309+ private func loadZone() async {
310+ loading = true
311+ zoneDirty = false
312+ let r = visibleRegion
313+ let west = r.center.longitude - r.span.longitudeDelta / 2
314+ let east = r.center.longitude + r.span.longitudeDelta / 2
315+ let south = r.center.latitude - r.span.latitudeDelta / 2
316+ let north = r.center.latitude + r.span.latitudeDelta / 2
317+ var all: [KAItem] = []
318+ await withTaskGroup(of: [KAItem].self) { group in
319+ for u in mapUniverses where enabled.contains(u.id) {
320+ group.addTask {
321+ await UniverseService.mapItems(u, west: west, south: south, east: east, north: north)
322+ }
323+ }
324+ for await batch in group { all.append(contentsOf: batch) }
325+ }
326+ withAnimation(.easeOut(duration: 0.25)) { items = all }
327+ if let sel = selected, !all.contains(where: { $0.id == sel.id }) {
328+ selected = nil
329+ showDetail = false
330+ }
331+ loading = false
332+ }
333+}
334+
335+// MARK: - Marqueurs
336+
337+/// Pilule de prix (ou symbole) — le marqueur signature, teinté par univers.
338+struct PricePill: View {
339+ let item: KAItem
340+ var selected: Bool
341+ private var universe: Universe? { Ecosystem.universe(item.universeID) }
342+
343+ var body: some View {
344+ HStack(spacing: 3) {
345+ Image(systemName: universe?.symbol ?? "mappin")
346+ .font(.system(size: 9, weight: .bold))
347+ if let p = item.priceLabel?.split(separator: " ").prefix(2).joined(separator: " ") {
348+ Text(p).font(.system(size: 11, weight: .bold, design: .rounded))
349+ }
350+ }
351+ .padding(.horizontal, 8).padding(.vertical, 5)
352+ .background(selected ? KATheme.inkLight : (universe?.accent ?? .gray), in: Capsule())
353+ .foregroundStyle(selected ? KATheme.lime : .white)
354+ .overlay(Capsule().strokeBorder(.white.opacity(0.9), lineWidth: 1.4))
355+ .shadow(color: .black.opacity(0.3), radius: 2, y: 1)
356+ .scaleEffect(selected ? 1.18 : 1)
357+ .animation(.spring(response: 0.3, dampingFraction: 0.6), value: selected)
358+ .accessibilityLabel("\(item.title), \(item.priceLabel ?? "")")
359+ }
360+}
361+
362+/// Badge de groupe : compte teinté par l'univers dominant.
363+struct ClusterBadge: View {
364+ let cluster: MapCluster
365+ private var dominant: Color {
366+ let counts = Dictionary(grouping: cluster.items, by: \.universeID).mapValues(\.count)
367+ let top = counts.max { $0.value < $1.value }?.key
368+ return top.flatMap { Ecosystem.universe($0)?.accent } ?? .gray
369+ }
370+ var body: some View {
371+ Text("\(cluster.items.count)")
372+ .font(.system(size: 13, weight: .bold, design: .rounded))
373+ .frame(minWidth: 34, minHeight: 34)
374+ .background(dominant, in: Circle())
375+ .foregroundStyle(.white)
376+ .overlay(Circle().strokeBorder(.white, lineWidth: 2))
377+ .shadow(color: .black.opacity(0.3), radius: 2, y: 1)
378+ .accessibilityLabel("Groupe de \(cluster.items.count) résultats — cliquer pour zoomer")
379+ }
380+}
added Sources/KA/Features/MenuBarView.swift +238 −0
@@ -0,0 +1,238 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// MenuBarView.swift — le MenuBarExtra « Ka » : pouls de l'écosystème en direct
3+// (compteurs 5 min), recherche universelle rapide (résultats compacts, clic →
4+// fiche web à la source), pastilles des 13 services, bouton « Ouvrir KA ».
5+import SwiftUI
6+
7+struct MenuBarView: View {
8+ @EnvironmentObject private var pulse: EcosystemPulse
9+ @EnvironmentObject private var state: AppState
10+ @Environment(\.openWindow) private var openWindow
11+ @Environment(\.colorScheme) private var scheme
12+
13+ @State private var query = ""
14+ @State private var results: [KAItem] = []
15+ @State private var searching = false
16+ @State private var searched = ""
17+
18+ var body: some View {
19+ VStack(alignment: .leading, spacing: 10) {
20+ header
21+ KASearchField(prompt: "Recherche rapide partout…", text: $query) {
22+ Task { await quickSearch() }
23+ }
24+ .onChange(of: query) {
25+ if query.isEmpty { results = []; searched = "" }
26+ }
27+ if searching || !searched.isEmpty {
28+ searchResults
29+ } else {
30+ pulseGrid
31+ }
32+ Divider().opacity(0.5)
33+ statusStrip
34+ footer
35+ }
36+ .padding(12)
37+ .frame(width: 350)
38+ .background(KATheme.paper(scheme))
39+ .onAppear { state.openMain = { focusMainWindow(openWindow) } }
40+ }
41+
42+ // MARK: en-tête
43+
44+ private var header: some View {
45+ HStack {
46+ GroupeKAMark(size: 15)
47+ Spacer()
48+ Button {
49+ focusMainWindow(openWindow)
50+ } label: {
51+ Label("Ouvrir KA", systemImage: "macwindow")
52+ .font(.caption.weight(.bold))
53+ .padding(.horizontal, 10).padding(.vertical, 5)
54+ .background(KATheme.inkLight, in: Capsule())
55+ .foregroundStyle(KATheme.lime)
56+ }
57+ .buttonStyle(.plain)
58+ }
59+ }
60+
61+ // MARK: le pouls (compteurs des 12 univers)
62+
63+ private var pulseGrid: some View {
64+ VStack(alignment: .leading, spacing: 6) {
65+ Text("Le pouls de l'écosystème")
66+ .font(.system(.caption2, design: .monospaced).weight(.bold))
67+ .textCase(.uppercase)
68+ .foregroundStyle(.secondary)
69+ LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 6) {
70+ ForEach(Ecosystem.all) { u in
71+ Button {
72+ state.selection = .universe(u.id)
73+ focusMainWindow(openWindow)
74+ } label: {
75+ HStack(spacing: 6) {
76+ Image(systemName: u.symbol)
77+ .font(.caption2.weight(.semibold))
78+ .foregroundStyle(u.accent)
79+ .frame(width: 14)
80+ KAWordmark(universe: u, size: 10)
81+ Spacer(minLength: 2)
82+ Text(pulse.totals[u.id].map { Double($0).compact } ?? "—")
83+ .font(.system(.caption2, design: .monospaced).weight(.bold))
84+ .foregroundStyle(pulse.totals[u.id] == nil ? .secondary : u.accent)
85+ .contentTransition(.numericText())
86+ }
87+ .padding(.horizontal, 8).padding(.vertical, 6)
88+ .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
89+ .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous)
90+ .strokeBorder(KATheme.ink(scheme).opacity(0.35), lineWidth: 1))
91+ }
92+ .buttonStyle(.plain)
93+ .help("\(u.name) — \(u.tagline)")
94+ }
95+ }
96+ }
97+ }
98+
99+ // MARK: résultats compacts multi-univers (clic → fiche web à la source)
100+
101+ @ViewBuilder
102+ private var searchResults: some View {
103+ ScrollView {
104+ VStack(alignment: .leading, spacing: 6) {
105+ if searching {
106+ HStack(spacing: 8) {
107+ ProgressView().controlSize(.small)
108+ Text("KA interroge les univers…").font(.caption).foregroundStyle(.secondary)
109+ }
110+ .padding(10)
111+ } else if results.isEmpty {
112+ Text("Rien trouvé pour « \(searched) ».")
113+ .font(.caption).foregroundStyle(.secondary).padding(10)
114+ } else {
115+ ForEach(results) { item in
116+ let u = Ecosystem.universe(item.universeID)
117+ Button {
118+ NSWorkspace.shared.open(item.url ?? u?.baseURL ?? Ecosystem.hubURL)
119+ } label: {
120+ HStack(spacing: 8) {
121+ RoundedRectangle(cornerRadius: 3)
122+ .fill(u?.accent ?? .gray)
123+ .frame(width: 4, height: 30)
124+ VStack(alignment: .leading, spacing: 1) {
125+ Text(item.title).font(.caption.weight(.semibold)).lineLimit(1)
126+ HStack(spacing: 6) {
127+ if let p = item.priceLabel {
128+ Text(p).font(.system(.caption2, design: .rounded).weight(.bold))
129+ .foregroundStyle(u?.accent ?? .primary)
130+ }
131+ Text(u?.wordmark ?? "")
132+ .font(.system(size: 9, weight: .bold, design: .monospaced))
133+ .foregroundStyle(.secondary)
134+ }
135+ }
136+ Spacer()
137+ Image(systemName: "arrow.up.right").font(.system(size: 9)).foregroundStyle(.tertiary)
138+ }
139+ .padding(.horizontal, 8).padding(.vertical, 5)
140+ .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
141+ .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous)
142+ .strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))
143+ }
144+ .buttonStyle(.plain)
145+ .help("Ouvrir la fiche à la source")
146+ }
147+ Button {
148+ focusMainWindow(openWindow)
149+ state.openUniversalSearch()
150+ } label: {
151+ Text("Recherche complète dans KA →")
152+ .font(.caption.weight(.semibold))
153+ .foregroundStyle(KATheme.green)
154+ .padding(.top, 2)
155+ }
156+ .buttonStyle(.plain)
157+ }
158+ }
159+ }
160+ .frame(maxHeight: 300)
161+ }
162+
163+ // MARK: pastilles des 13 services
164+
165+ private var statusStrip: some View {
166+ VStack(alignment: .leading, spacing: 6) {
167+ HStack {
168+ Text("Services")
169+ .font(.system(.caption2, design: .monospaced).weight(.bold))
170+ .textCase(.uppercase)
171+ .foregroundStyle(.secondary)
172+ Spacer()
173+ Text("\(pulse.servicesUp)/\(pulse.health.count) en ligne")
174+ .font(.system(.caption2, design: .monospaced).weight(.bold))
175+ .foregroundStyle(pulse.servicesUp == pulse.health.count ? KATheme.green : .orange)
176+ }
177+ LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 4) {
178+ ForEach(pulse.health) { s in
179+ HStack(spacing: 5) {
180+ KAStatusDot(up: s.up)
181+ Text(s.name.replacingOccurrences(of: "Groupe-KA", with: "Hub"))
182+ .font(.system(size: 10, weight: .semibold))
183+ .lineLimit(1)
184+ Spacer(minLength: 0)
185+ }
186+ .help("\(s.id)\(s.latencyMs.map { " — \($0) ms" } ?? "")")
187+ }
188+ }
189+ }
190+ }
191+
192+ // MARK: pied
193+
194+ private var footer: some View {
195+ HStack {
196+ if let d = pulse.lastRefresh {
197+ Text("Actualisé \(d.formatted(.relative(presentation: .named, unitsStyle: .narrow)))")
198+ .font(.system(size: 10)).foregroundStyle(.tertiary)
199+ } else {
200+ Text("Première actualisation…").font(.system(size: 10)).foregroundStyle(.tertiary)
201+ }
202+ Spacer()
203+ Button("Quitter") { NSApp.terminate(nil) }
204+ .font(.system(size: 10))
205+ .buttonStyle(.plain)
206+ .foregroundStyle(.secondary)
207+ .keyboardShortcut("q")
208+ }
209+ }
210+
211+ private func quickSearch() async {
212+ let q = query.trimmingCharacters(in: .whitespaces)
213+ guard !q.isEmpty else { return }
214+ searching = true
215+ searched = q
216+ let targets = Ecosystem.all.filter { $0.map != nil }
217+ var found: [(Universe, [KAItem])] = []
218+ await withTaskGroup(of: (String, [KAItem]).self) { group in
219+ for u in targets {
220+ group.addTask {
221+ let items = (try? await UniverseService.fetch(u, query: q, limit: 3)) ?? []
222+ return (u.id, items)
223+ }
224+ }
225+ var dict: [String: [KAItem]] = [:]
226+ for await (id, items) in group { dict[id] = items }
227+ found = targets.map { ($0, dict[$0.id] ?? []) }
228+ }
229+ results = found
230+ .sorted {
231+ if $0.0.id == "trouve-ka" { return false }
232+ if $1.0.id == "trouve-ka" { return true }
233+ return $0.1.count > $1.1.count
234+ }
235+ .flatMap { $0.1.prefix(2) }
236+ searching = false
237+ }
238+}
added Sources/KA/Features/SearchView.swift +218 −0
@@ -0,0 +1,218 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// SearchView.swift — la recherche UNIVERSELLE : une seule barre qui interroge
3+// tous les univers en parallèle (fan-out sur leurs API + web québécois via
4+// Trouve·Ka), résultats groupés par univers, filtres, historique local,
5+// fiche détaillée dans le panneau de droite. Adapté de l'app iOS KA.
6+import SwiftUI
7+
8+struct UniversalSearchView: View {
9+ @EnvironmentObject private var state: AppState
10+ @Environment(\.colorScheme) private var scheme
11+ @FocusState private var focused: Bool
12+
13+ @State private var query = ""
14+ @State private var submitted = ""
15+ @State private var sections: [(universe: Universe, items: [KAItem])] = []
16+ @State private var searching = false
17+ @State private var selectedFilters: Set<String> = [] // vide = tous
18+ @State private var selected: KAItem?
19+ @AppStorage("ka.search.history") private var historyRaw = ""
20+
21+ private var history: [String] { historyRaw.split(separator: "\n").map(String.init) }
22+ private var searchables: [Universe] { Ecosystem.all.filter { $0.map != nil } }
23+
24+ var body: some View {
25+ VStack(spacing: 0) {
26+ header
27+ Divider().opacity(0.4)
28+ HStack(spacing: 0) {
29+ results
30+ .frame(maxWidth: .infinity)
31+ Divider().opacity(0.4)
32+ Group {
33+ if let item = selected {
34+ ItemDetailPane(item: item)
35+ } else {
36+ KAEmptyState(symbol: "magnifyingglass",
37+ title: "Recherche universelle",
38+ message: "Une seule barre pour les \(searchables.count) univers interrogeables du Groupe KA.")
39+ .frame(maxHeight: .infinity)
40+ .background(KATheme.paper(scheme))
41+ }
42+ }
43+ .frame(width: 440)
44+ }
45+ }
46+ .onChange(of: state.searchFocusToken) { focused = true }
47+ .onAppear { focused = true }
48+ }
49+
50+ private var header: some View {
51+ VStack(alignment: .leading, spacing: 10) {
52+ HStack(spacing: 8) {
53+ Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
54+ TextField("appartement Rouyn, resto italien, Corolla, emploi infirmière…", text: $query)
55+ .textFieldStyle(.plain)
56+ .font(.title3)
57+ .focused($focused)
58+ .onSubmit { Task { await search() } }
59+ if searching { ProgressView().controlSize(.small) }
60+ Text("⌘⇧K")
61+ .font(.system(.caption2, design: .monospaced).weight(.bold))
62+ .foregroundStyle(.tertiary)
63+ }
64+ .padding(.horizontal, 13).padding(.vertical, 10)
65+ .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
66+ .overlay(RoundedRectangle(cornerRadius: 11, style: .continuous)
67+ .strokeBorder(KATheme.ink(scheme).opacity(0.55), lineWidth: 1.3))
68+ filterChips
69+ }
70+ .padding(.horizontal, 16).padding(.vertical, 12)
71+ }
72+
73+ private var filterChips: some View {
74+ ScrollView(.horizontal, showsIndicators: false) {
75+ HStack(spacing: 7) {
76+ ForEach(searchables) { u in
77+ let on = selectedFilters.isEmpty || selectedFilters.contains(u.id)
78+ Button {
79+ if selectedFilters.contains(u.id) { selectedFilters.remove(u.id) }
80+ else { selectedFilters.insert(u.id) }
81+ if selectedFilters.count == searchables.count { selectedFilters = [] }
82+ if !submitted.isEmpty { Task { await search(submitted) } }
83+ } label: {
84+ Text(u.wordmark)
85+ .font(.system(.caption, design: .monospaced).weight(.bold))
86+ .padding(.horizontal, 10).padding(.vertical, 5)
87+ .background(on ? u.accent.opacity(0.9) : KATheme.surface(scheme), in: Capsule())
88+ .foregroundStyle(on ? .white : .secondary)
89+ .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))
90+ }
91+ .buttonStyle(.plain)
92+ .help("\(on ? "Masquer" : "Afficher") \(u.name)")
93+ }
94+ }
95+ .padding(.vertical, 2)
96+ }
97+ }
98+
99+ @ViewBuilder
100+ private var results: some View {
101+ ScrollView {
102+ LazyVStack(alignment: .leading, spacing: 16) {
103+ if searching {
104+ HStack(spacing: 10) {
105+ ProgressView().controlSize(.small)
106+ Text("KA interroge \(selectedFilters.isEmpty ? searchables.count : selectedFilters.count) univers…")
107+ .font(.subheadline).foregroundStyle(.secondary)
108+ }
109+ .padding(24).frame(maxWidth: .infinity)
110+ } else if submitted.isEmpty {
111+ suggestions
112+ } else if sections.allSatisfy({ $0.items.isEmpty }) {
113+ KAEmptyState(symbol: "magnifyingglass", title: "Rien trouvé pour « \(submitted) »",
114+ message: "Essayez un autre mot, ou élargissez les univers filtrés.")
115+ } else {
116+ ForEach(sections.filter { !$0.items.isEmpty }, id: \.universe.id) { section in
117+ VStack(alignment: .leading, spacing: 8) {
118+ HStack {
119+ KAWordmark(universe: section.universe, size: 15)
120+ Spacer()
121+ Text("\(section.items.count)")
122+ .font(.system(.caption, design: .monospaced).weight(.bold))
123+ .foregroundStyle(section.universe.accent)
124+ }
125+ ForEach(section.items.prefix(5)) { item in
126+ Button { selected = item } label: {
127+ KAItemRow(item: item, compact: true)
128+ .overlay(
129+ RoundedRectangle(cornerRadius: 12, style: .continuous)
130+ .strokeBorder(section.universe.accent,
131+ lineWidth: selected?.id == item.id ? 2.2 : 0)
132+ .padding(.trailing, 4).padding(.bottom, 4)
133+ )
134+ }
135+ .buttonStyle(KAPressStyle())
136+ }
137+ Button {
138+ state.openUniverse(section.universe.id, query: submitted)
139+ } label: {
140+ Text("Tout voir dans \(section.universe.name) →")
141+ .font(.subheadline.weight(.semibold))
142+ .foregroundStyle(section.universe.accent)
143+ }
144+ .buttonStyle(.plain)
145+ }
146+ }
147+ }
148+ }
149+ .padding(16)
150+ }
151+ .background(KATheme.paper(scheme))
152+ }
153+
154+ private var suggestions: some View {
155+ VStack(alignment: .leading, spacing: 12) {
156+ if !history.isEmpty {
157+ Text("Récemment cherché").font(.headline)
158+ ForEach(history.prefix(6), id: \.self) { h in
159+ Button {
160+ query = h
161+ Task { await search(h) }
162+ } label: {
163+ HStack {
164+ Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary)
165+ Text(h)
166+ Spacer()
167+ }
168+ .padding(11).kaCard()
169+ }
170+ .buttonStyle(.plain)
171+ }
172+ }
173+ Text("Idées").font(.headline)
174+ ForEach(["4½ à Québec", "resto italien Montréal", "Toyota Corolla", "emploi infirmière", "spectacle ce week-end"], id: \.self) { s in
175+ Button { query = s; Task { await search(s) } } label: {
176+ HStack {
177+ Image(systemName: "sparkle.magnifyingglass").foregroundStyle(KATheme.green)
178+ Text(s); Spacer()
179+ }
180+ .padding(11).kaCard()
181+ }
182+ .buttonStyle(.plain)
183+ }
184+ }
185+ }
186+
187+ private func search(_ text: String? = nil) async {
188+ let q = (text ?? query).trimmingCharacters(in: .whitespaces)
189+ guard !q.isEmpty else { return }
190+ submitted = q
191+ searching = true
192+ selected = nil
193+ var hist = history.filter { $0 != q }
194+ hist.insert(q, at: 0)
195+ historyRaw = hist.prefix(10).joined(separator: "\n")
196+
197+ let targets = searchables.filter { selectedFilters.isEmpty || selectedFilters.contains($0.id) }
198+ var found: [String: [KAItem]] = [:]
199+ await withTaskGroup(of: (String, [KAItem]).self) { group in
200+ for u in targets {
201+ group.addTask {
202+ let items = (try? await UniverseService.fetch(u, query: q, limit: 8)) ?? []
203+ return (u.id, items)
204+ }
205+ }
206+ for await (id, items) in group { found[id] = items }
207+ }
208+ // ordre : univers avec le plus de résultats d'abord, web québécois à la fin
209+ sections = targets
210+ .map { ($0, found[$0.id] ?? []) }
211+ .sorted {
212+ if $0.0.id == "trouve-ka" { return false }
213+ if $1.0.id == "trouve-ka" { return true }
214+ return $0.1.count > $1.1.count
215+ }
216+ searching = false
217+ }
218+}
added Sources/KA/Features/StatusView.swift +98 −0
@@ -0,0 +1,98 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// StatusView.swift — l'état des 13 plateformes Groupe-KA : pastille vert/rouge
3+// (HEAD sur https://www.<domaine>/), latence, ouverture du site, actualisation
4+// toutes les 5 minutes (pouls partagé) ou manuelle.
5+import SwiftUI
6+
7+struct StatusView: View {
8+ @EnvironmentObject private var pulse: EcosystemPulse
9+ @Environment(\.colorScheme) private var scheme
10+
11+ var body: some View {
12+ ScrollView {
13+ VStack(alignment: .leading, spacing: 14) {
14+ header
15+ summary
16+ LazyVStack(spacing: 9) {
17+ ForEach(pulse.health) { s in
18+ row(s)
19+ }
20+ }
21+ Text("Vérification : requête HEAD sur https://<domaine>/ — vert si le serveur répond (200–399, ou 405 quand HEAD est refusé). Actualisé automatiquement toutes les 5 minutes.")
22+ .font(.caption2).foregroundStyle(.tertiary)
23+ }
24+ .padding(18)
25+ .frame(maxWidth: 720)
26+ .frame(maxWidth: .infinity)
27+ }
28+ .background(KATheme.paper(scheme))
29+ }
30+
31+ private var header: some View {
32+ HStack(alignment: .firstTextBaseline) {
33+ Text("Statut des plateformes").font(.title2.weight(.bold))
34+ Spacer()
35+ if let d = pulse.lastRefresh {
36+ Text("Actualisé \(d.formatted(.relative(presentation: .named, unitsStyle: .narrow)))")
37+ .font(.caption).foregroundStyle(.secondary)
38+ }
39+ Button {
40+ Task { await pulse.refresh() }
41+ } label: {
42+ if pulse.refreshing {
43+ ProgressView().controlSize(.small)
44+ } else {
45+ Label("Actualiser", systemImage: "arrow.clockwise").font(.caption)
46+ }
47+ }
48+ .disabled(pulse.refreshing)
49+ }
50+ }
51+
52+ private var summary: some View {
53+ HStack(spacing: 12) {
54+ let up = pulse.servicesUp
55+ let total = pulse.health.count
56+ KAStatusDot(up: up == total ? true : (up == 0 ? false : nil))
57+ Text(up == total
58+ ? "Les \(total) services sont en ligne."
59+ : "\(up) service\(up > 1 ? "s" : "") en ligne sur \(total).")
60+ .font(.subheadline.weight(.semibold))
61+ Spacer()
62+ Link("Page de statut officielle", destination: Ecosystem.statusURL)
63+ .font(.caption.weight(.semibold))
64+ .foregroundStyle(KATheme.green)
65+ }
66+ .padding(13)
67+ .frame(maxWidth: .infinity, alignment: .leading)
68+ .kaCard(accent: pulse.servicesUp == pulse.health.count ? KATheme.green : .orange)
69+ }
70+
71+ private func row(_ s: ServiceHealth) -> some View {
72+ HStack(spacing: 11) {
73+ KAStatusDot(up: s.up)
74+ VStack(alignment: .leading, spacing: 2) {
75+ Text(s.name).font(.subheadline.weight(.bold))
76+ Text(s.id).font(.system(.caption2, design: .monospaced)).foregroundStyle(.secondary)
77+ }
78+ Spacer()
79+ if let ms = s.latencyMs, s.up != nil {
80+ Text("\(ms) ms")
81+ .font(.system(.caption, design: .monospaced).weight(.bold))
82+ .foregroundStyle(ms < 500 ? KATheme.green : .orange)
83+ .padding(.horizontal, 8).padding(.vertical, 3)
84+ .background((ms < 500 ? KATheme.green : Color.orange).opacity(0.12), in: Capsule())
85+ } else if s.up == nil {
86+ Text("vérification…").font(.caption2).foregroundStyle(.tertiary)
87+ }
88+ Link(destination: URL(string: "https://\(s.id)/")!) {
89+ Image(systemName: "arrow.up.right.square")
90+ }
91+ .foregroundStyle(.secondary)
92+ .help("Ouvrir https://\(s.id)/")
93+ }
94+ .padding(.horizontal, 14).padding(.vertical, 10)
95+ .frame(maxWidth: .infinity, alignment: .leading)
96+ .kaCard(accent: s.up == false ? .red : nil)
97+ }
98+}
added Sources/KA/Features/UniverseExplorerView.swift +458 −0
@@ -0,0 +1,458 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// UniverseExplorerView.swift — l'explorateur premium d'un univers : héros
3+// (wordmark, tagline, compteur live, lien site), recherche + FILTRES MÉTIER,
4+// grille photo multi-colonnes (grandes fenêtres) ou liste, fiche détaillée
5+// riche (galerie, faits, description, liens, menu resto), sauvegarde de la
6+// recherche avec alerte. Vrai-Prix a son expérience NATIVE (search+estimate).
7+// Adapté de l'app iOS KA (UniversesView.swift + VraiPrixView.swift).
8+import SwiftUI
9+
10+struct UniverseExplorerView: View {
11+ let universe: Universe
12+ @EnvironmentObject private var pulse: EcosystemPulse
13+ @EnvironmentObject private var state: AppState
14+ @EnvironmentObject private var recents: RecentsStore
15+ @Environment(\.colorScheme) private var scheme
16+
17+ @State private var items: [KAItem] = []
18+ @State private var selected: KAItem?
19+ @State private var query = ""
20+ @State private var filterParams: [String: String] = [:]
21+ @State private var loading = true
22+ @State private var errorText: String?
23+ @State private var asGrid = true
24+ @State private var savedFlash = false
25+
26+ var body: some View {
27+ Group {
28+ if universe.id == "vrai-prix" {
29+ VraiPrixExplorer(universe: universe)
30+ } else if universe.id == "api-ka" {
31+ ApiPlaygroundView(universe: universe)
32+ } else if universe.listPath == nil || universe.map == nil {
33+ webUniverse
34+ } else {
35+ explorer
36+ }
37+ }
38+ }
39+
40+ // MARK: univers sans liste native → renvoi au site
41+
42+ private var webUniverse: some View {
43+ VStack(spacing: 18) {
44+ Image(systemName: universe.symbol).font(.system(size: 52)).foregroundStyle(universe.accent)
45+ KAWordmark(universe: universe, size: 30)
46+ Text(universe.tagline).font(.title3).foregroundStyle(.secondary)
47+ .multilineTextAlignment(.center)
48+ if let n = pulse.totals[universe.id] {
49+ Text("\(n.fr) \(universe.unit)")
50+ .font(.system(.subheadline, design: .monospaced).weight(.bold))
51+ .foregroundStyle(universe.accent)
52+ }
53+ Link(destination: universe.baseURL) {
54+ Label("Ouvrir \(universe.name)", systemImage: "arrow.up.right.square")
55+ .font(.headline).padding(.horizontal, 22).padding(.vertical, 12)
56+ .background(universe.accent, in: Capsule())
57+ .foregroundStyle(.white)
58+ }
59+ .buttonStyle(.plain)
60+ }
61+ .padding(30)
62+ .frame(maxWidth: .infinity, maxHeight: .infinity)
63+ }
64+
65+ // MARK: explorateur grille/liste + fiche
66+
67+ private var explorer: some View {
68+ VStack(spacing: 0) {
69+ hero
70+ Divider().opacity(0.4)
71+ HStack(spacing: 0) {
72+ results
73+ .frame(maxWidth: .infinity)
74+ Divider().opacity(0.4)
75+ Group {
76+ if let item = selected {
77+ ItemDetailPane(item: item)
78+ } else {
79+ KAEmptyState(symbol: universe.symbol,
80+ title: "Choisissez un élément",
81+ message: "La fiche détaillée s'affichera ici.")
82+ .frame(maxHeight: .infinity)
83+ .background(KATheme.paper(scheme))
84+ }
85+ }
86+ .frame(width: 440)
87+ }
88+ }
89+ .task {
90+ consumePrefill()
91+ await load()
92+ }
93+ .onChange(of: state.prefill?.universeID) {
94+ if state.prefill?.universeID == universe.id {
95+ consumePrefill()
96+ Task { await load() }
97+ }
98+ }
99+ }
100+
101+ private var hero: some View {
102+ VStack(alignment: .leading, spacing: 10) {
103+ HStack(alignment: .firstTextBaseline, spacing: 12) {
104+ KAWordmark(universe: universe, size: 24)
105+ Text(universe.tagline)
106+ .font(.system(.subheadline, design: .rounded).weight(.bold))
107+ .foregroundStyle(KATheme.ink2(scheme))
108+ Spacer()
109+ if let n = pulse.totals[universe.id] {
110+ Text("\(n.fr) \(universe.unit)")
111+ .font(.system(.caption, design: .monospaced).weight(.bold))
112+ .padding(.horizontal, 9).padding(.vertical, 4)
113+ .background(universe.accent.opacity(0.15), in: Capsule())
114+ .foregroundStyle(universe.accent)
115+ .contentTransition(.numericText())
116+ }
117+ Picker("Présentation", selection: $asGrid) {
118+ Image(systemName: "square.grid.2x2").tag(true)
119+ Image(systemName: "list.bullet").tag(false)
120+ }
121+ .pickerStyle(.segmented)
122+ .labelsHidden()
123+ .frame(width: 90)
124+ Link(destination: universe.baseURL) {
125+ Label("Ouvrir le site", systemImage: "safari")
126+ .font(.caption.weight(.semibold))
127+ }
128+ .foregroundStyle(universe.accent)
129+ }
130+ HStack(spacing: 8) {
131+ KASearchField(prompt: "Chercher dans \(universe.name)…", text: $query) {
132+ Task { await load() }
133+ }
134+ FilterBar(universe: universe, params: $filterParams) {
135+ Task { await load() }
136+ }
137+ if !query.isEmpty || !filterParams.isEmpty {
138+ Button {
139+ recents.saveSearch(name: "", universeID: universe.id,
140+ query: query, params: filterParams)
141+ savedFlash = true
142+ Task { try? await Task.sleep(for: .seconds(2)); savedFlash = false }
143+ } label: {
144+ Label(savedFlash ? "Alerte créée ✓" : "Sauvegarder + alerte",
145+ systemImage: savedFlash ? "bell.badge.fill" : "bell.badge")
146+ .font(.caption.weight(.bold))
147+ .padding(.horizontal, 10).padding(.vertical, 7)
148+ .background(savedFlash ? KATheme.green : universe.accent, in: Capsule())
149+ .foregroundStyle(.white)
150+ }
151+ .buttonStyle(.plain)
152+ .help("Sauvegarder cette recherche — KA recompte le total à chaque ouverture et signale les nouveautés")
153+ }
154+ }
155+ }
156+ .padding(.horizontal, 16).padding(.vertical, 12)
157+ }
158+
159+ @ViewBuilder
160+ private var results: some View {
161+ ScrollView {
162+ Group {
163+ if universe.id == "trouve-ka" && query.isEmpty {
164+ KAEmptyState(symbol: "magnifyingglass",
165+ title: "Cherchez le web québécois",
166+ message: "Trouve·Ka fouille \(pulse.totals[universe.id].map { $0.fr } ?? "plus d'un million de") pages indexées — tapez un mot-clé ci-dessus.")
167+ } else if loading {
168+ LazyVStack(spacing: 10) {
169+ ForEach(0..<6, id: \.self) { _ in KASkeletonRow() }
170+ }
171+ } else if let e = errorText, items.isEmpty {
172+ KAEmptyState(symbol: "wifi.exclamationmark", title: "Impossible de charger", message: e)
173+ } else if items.isEmpty {
174+ KAEmptyState(symbol: "tray", title: "Aucun résultat",
175+ message: "Essayez d'autres mots-clés ou élargissez les filtres.")
176+ } else if asGrid {
177+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 230), spacing: 12)], spacing: 12) {
178+ ForEach(items) { item in
179+ Button { selected = item } label: {
180+ KAItemCard(item: item, selected: selected?.id == item.id)
181+ }
182+ .buttonStyle(KAPressStyle())
183+ }
184+ }
185+ } else {
186+ LazyVStack(spacing: 10) {
187+ ForEach(items) { item in
188+ Button { selected = item } label: {
189+ KAItemRow(item: item)
190+ .overlay(
191+ RoundedRectangle(cornerRadius: 12, style: .continuous)
192+ .strokeBorder(universe.accent,
193+ lineWidth: selected?.id == item.id ? 2.4 : 0)
194+ .padding(.trailing, 4).padding(.bottom, 4)
195+ )
196+ }
197+ .buttonStyle(KAPressStyle())
198+ }
199+ }
200+ }
201+ }
202+ .padding(14)
203+ .animation(.snappy(duration: 0.3), value: items)
204+ }
205+ .background(KATheme.paper(scheme))
206+ }
207+
208+ private func consumePrefill() {
209+ if let p = state.prefill, p.universeID == universe.id {
210+ query = p.query
211+ filterParams = p.params
212+ state.prefill = nil
213+ }
214+ }
215+
216+ private func load() async {
217+ // Trouve·Ka est un moteur : pas de liste sans requête
218+ if universe.id == "trouve-ka" && query.isEmpty {
219+ items = []; selected = nil; loading = false; errorText = nil
220+ return
221+ }
222+ loading = items.isEmpty
223+ errorText = nil
224+ do {
225+ // Trouve·Ka (FastAPI) plafonne limit à 50 — 422 au-delà
226+ let cap = universe.id == "trouve-ka" ? 50 : 60
227+ items = try await UniverseService.fetch(universe, query: query.isEmpty ? nil : query,
228+ limit: cap, params: filterParams)
229+ if let sel = selected, !items.contains(where: { $0.id == sel.id }) { selected = nil }
230+ if selected == nil { selected = items.first }
231+ } catch {
232+ errorText = (error as? URLError)?.code == .notConnectedToInternet
233+ ? "Vous êtes hors ligne." : "Le service ne répond pas."
234+ }
235+ loading = false
236+ }
237+}
238+
239+// MARK: - Fiche universelle (panneau de droite)
240+
241+struct ItemDetailPane: View {
242+ let item: KAItem
243+ @Environment(\.colorScheme) private var scheme
244+ @EnvironmentObject private var favorites: FavoritesStore
245+ @EnvironmentObject private var recents: RecentsStore
246+ @State private var galleryIndex = 0
247+ @State private var menu: [RestoMenuSection] = []
248+
249+ private var universe: Universe? { Ecosystem.universe(item.universeID) }
250+
251+ var body: some View {
252+ ScrollView {
253+ VStack(alignment: .leading, spacing: 16) {
254+ gallery
255+ HStack {
256+ if let u = universe { KAChip(text: u.wordmark, accent: u.accent) }
257+ Spacer()
258+ if favorites.collections.count > 1 && !favorites.isFavorite(item) {
259+ Menu {
260+ ForEach(favorites.collections) { c in
261+ Button(c.name) { favorites.toggle(item, in: c.id) }
262+ }
263+ } label: {
264+ Image(systemName: "heart")
265+ }
266+ .menuStyle(.borderlessButton)
267+ .fixedSize()
268+ .help("Ajouter à une collection")
269+ } else {
270+ Button {
271+ favorites.toggle(item)
272+ } label: {
273+ Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart")
274+ .foregroundStyle(favorites.isFavorite(item) ? .red : .primary)
275+ }
276+ .buttonStyle(.plain)
277+ .help(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris")
278+ }
279+ if let url = item.url {
280+ ShareLink(item: url, subject: Text(item.title)) {
281+ Image(systemName: "square.and.arrow.up")
282+ }
283+ .buttonStyle(.plain)
284+ .help("Partager le lien")
285+ }
286+ }
287+ .font(.title3)
288+ Text(item.title).font(.title2.weight(.bold)).textSelection(.enabled)
289+ if let sub = item.subtitle, !sub.isEmpty {
290+ Text(sub).font(.body).foregroundStyle(KATheme.ink2(scheme)).textSelection(.enabled)
291+ }
292+ HStack(spacing: 10) {
293+ if let p = item.priceLabel {
294+ Text(p).font(.system(.title3, design: .rounded).weight(.bold))
295+ .foregroundStyle(universe?.accent ?? .primary)
296+ }
297+ if let c = item.city, !c.isEmpty { KAChip(text: c) }
298+ }
299+
300+ if let detail = item.detail, !detail.isEmpty {
301+ VStack(alignment: .leading, spacing: 6) {
302+ Text("À propos").font(.headline)
303+ Text(detail)
304+ .font(.subheadline)
305+ .foregroundStyle(KATheme.ink2(scheme))
306+ .textSelection(.enabled)
307+ }
308+ .padding(14)
309+ .frame(maxWidth: .infinity, alignment: .leading)
310+ .kaCard()
311+ }
312+
313+ if !item.facts.isEmpty {
314+ VStack(spacing: 0) {
315+ ForEach(item.facts, id: \.self) { f in
316+ HStack(alignment: .top) {
317+ Text(f.label).font(.subheadline).foregroundStyle(.secondary)
318+ Spacer()
319+ Text(f.value).font(.subheadline.weight(.semibold))
320+ .multilineTextAlignment(.trailing)
321+ .textSelection(.enabled)
322+ }
323+ .padding(.vertical, 9).padding(.horizontal, 14)
324+ if f != item.facts.last { Divider() }
325+ }
326+ }
327+ .kaCard()
328+ }
329+
330+ if !item.links.isEmpty {
331+ VStack(alignment: .leading, spacing: 8) {
332+ Text("Comptes & liens").font(.headline)
333+ ForEach(item.links, id: \.self) { l in
334+ if let u = URL(string: l.value) {
335+ Link(destination: u) {
336+ HStack {
337+ Image(systemName: "link")
338+ Text(l.label).font(.subheadline.weight(.semibold))
339+ Spacer()
340+ Image(systemName: "arrow.up.right").font(.caption)
341+ }
342+ .padding(11)
343+ .background((universe?.accent ?? .gray).opacity(0.1),
344+ in: RoundedRectangle(cornerRadius: 10, style: .continuous))
345+ }
346+ .foregroundStyle(universe?.accent ?? .primary)
347+ }
348+ }
349+ }
350+ }
351+
352+ if !menu.isEmpty {
353+ VStack(alignment: .leading, spacing: 10) {
354+ HStack {
355+ Text("Menu & prix réels").font(.headline)
356+ Spacer()
357+ KAChip(text: "\(menu.reduce(0) { $0 + $1.items.count }) plats", accent: universe?.accent)
358+ }
359+ ForEach(menu.prefix(8)) { section in
360+ DisclosureGroup {
361+ VStack(spacing: 0) {
362+ ForEach(section.items, id: \.name) { dish in
363+ HStack(alignment: .top) {
364+ Text(dish.name).font(.subheadline)
365+ Spacer()
366+ if let p = dish.price {
367+ Text(p).font(.system(.subheadline, design: .rounded).weight(.bold))
368+ .foregroundStyle(universe?.accent ?? .primary)
369+ }
370+ }
371+ .padding(.vertical, 6)
372+ Divider().opacity(dish.name == section.items.last?.name ? 0 : 0.6)
373+ }
374+ }
375+ .padding(.top, 4)
376+ } label: {
377+ Text(section.name).font(.subheadline.weight(.bold))
378+ }
379+ .padding(.horizontal, 14).padding(.vertical, 8)
380+ .kaCard()
381+ }
382+ }
383+ }
384+
385+ if let url = item.url {
386+ Link(destination: url) {
387+ Label("Voir à la source", systemImage: "arrow.up.right.square")
388+ .font(.headline)
389+ .frame(maxWidth: .infinity).padding(.vertical, 13)
390+ .background(universe?.accent ?? KATheme.green,
391+ in: RoundedRectangle(cornerRadius: 11, style: .continuous))
392+ .foregroundStyle(.white)
393+ }
394+ .buttonStyle(.plain)
395+ Text("Groupe KA est un agrégateur : la transaction se fait chez la source originale.")
396+ .font(.caption2).foregroundStyle(.tertiary)
397+ }
398+ }
399+ .padding(18)
400+ }
401+ .background(KATheme.paper(scheme))
402+ .task(id: item.id) {
403+ galleryIndex = 0
404+ menu = []
405+ recents.record(item) // historique consulté
406+ if item.universeID == "resto-ka" {
407+ let uid = String(item.id.dropFirst("resto-ka:".count))
408+ menu = await RestoMenuLoader.load(uid: uid)
409+ }
410+ }
411+ }
412+
413+ // MARK: galerie (image principale + vignettes cliquables)
414+
415+ @ViewBuilder
416+ private var gallery: some View {
417+ if !item.imageURLs.isEmpty {
418+ VStack(spacing: 8) {
419+ ZStack(alignment: .bottomTrailing) {
420+ KAImage(url: item.imageURLs[min(galleryIndex, item.imageURLs.count - 1)],
421+ accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")
422+ .frame(maxWidth: .infinity)
423+ .frame(height: 250)
424+ .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
425+ .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous)
426+ .strokeBorder(.primary.opacity(0.3), lineWidth: 1.2))
427+ .accessibilityLabel("Photo \(galleryIndex + 1) sur \(item.imageURLs.count) : \(item.title)")
428+ if item.imageURLs.count > 1 {
429+ Text("\(galleryIndex + 1)/\(item.imageURLs.count)")
430+ .font(.system(.caption2, design: .monospaced).weight(.bold))
431+ .padding(.horizontal, 8).padding(.vertical, 4)
432+ .background(.ultraThinMaterial, in: Capsule())
433+ .padding(8)
434+ }
435+ }
436+ if item.imageURLs.count > 1 {
437+ ScrollView(.horizontal, showsIndicators: false) {
438+ HStack(spacing: 6) {
439+ ForEach(Array(item.imageURLs.enumerated()), id: \.offset) { i, url in
440+ Button { galleryIndex = i } label: {
441+ KAImage(url: url, accent: universe?.accent ?? .gray)
442+ .frame(width: 64, height: 44)
443+ .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
444+ .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous)
445+ .strokeBorder(i == galleryIndex ? (universe?.accent ?? .blue) : .primary.opacity(0.2),
446+ lineWidth: i == galleryIndex ? 2.2 : 1))
447+ }
448+ .buttonStyle(.plain)
449+ .accessibilityLabel("Photo \(i + 1)")
450+ }
451+ }
452+ .padding(2)
453+ }
454+ }
455+ }
456+ }
457+ }
458+}
added Sources/KA/Features/VraiPrixExplorer.swift +276 −0
@@ -0,0 +1,276 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// VraiPrixExplorer.swift — l'expérience Vrai-Prix NATIVE macOS, fidèle au
3+// site : recherche d'adresse (FTS 3,7 M unités, /api/search) → fiche
4+// d'estimation complète (/api/estimate : estimation, fourchette, confiance
5+// A-D, valeur au rôle, historique 2021-2026 en barres, portrait).
6+// Adapté de l'app iOS KA (VraiPrixView.swift).
7+import SwiftUI
8+
9+struct VPResult: Identifiable, Hashable {
10+ let id: String
11+ let adresse: String
12+ let municipalite: String
13+ let type: String?
14+}
15+
16+struct VraiPrixExplorer: View {
17+ let universe: Universe
18+ @EnvironmentObject private var pulse: EcosystemPulse
19+ @Environment(\.colorScheme) private var scheme
20+ @State private var query = ""
21+ @State private var results: [VPResult] = []
22+ @State private var selected: VPResult?
23+ @State private var searching = false
24+
25+ var body: some View {
26+ VStack(spacing: 0) {
27+ hero
28+ Divider().opacity(0.4)
29+ HStack(spacing: 0) {
30+ list
31+ .frame(width: 420)
32+ Divider().opacity(0.4)
33+ Group {
34+ if let r = selected {
35+ EstimatePane(result: r, universe: universe)
36+ .id(r.id)
37+ } else {
38+ KAEmptyState(symbol: "chart.line.uptrend.xyaxis",
39+ title: "Estimez n'importe quelle adresse du Québec",
40+ message: "Cherchez une adresse à gauche — le moteur Vrai-Prix calcule la valeur réelle, la fourchette et la confiance.")
41+ .frame(maxHeight: .infinity)
42+ .background(KATheme.paper(scheme))
43+ }
44+ }
45+ .frame(maxWidth: .infinity)
46+ }
47+ }
48+ }
49+
50+ private var hero: some View {
51+ VStack(alignment: .leading, spacing: 10) {
52+ HStack(alignment: .firstTextBaseline, spacing: 12) {
53+ KAWordmark(universe: universe, size: 24)
54+ Text("LA VALEUR RÉELLE · \(pulse.totals[universe.id].map { $0.fr } ?? "3 747 008") PROPRIÉTÉS")
55+ .font(.system(size: 10, design: .monospaced).weight(.bold))
56+ .foregroundStyle(universe.accent)
57+ Spacer()
58+ Link(destination: universe.baseURL) {
59+ Label("Ouvrir le site", systemImage: "safari")
60+ .font(.caption.weight(.semibold))
61+ }
62+ .foregroundStyle(universe.accent)
63+ }
64+ KASearchField(prompt: "1305 Chemin Sainte-Foy, Québec…", text: $query) {
65+ Task { await search() }
66+ }
67+ }
68+ .padding(.horizontal, 16).padding(.vertical, 12)
69+ }
70+
71+ private var list: some View {
72+ ScrollView {
73+ LazyVStack(spacing: 9) {
74+ if searching {
75+ ForEach(0..<4, id: \.self) { _ in KASkeletonRow() }
76+ } else if results.isEmpty && !query.isEmpty {
77+ KAEmptyState(symbol: "house.slash", title: "Adresse introuvable",
78+ message: "Essayez numéro + rue + ville.")
79+ } else if results.isEmpty {
80+ KAEmptyState(symbol: "magnifyingglass",
81+ title: "Cherchez une adresse",
82+ message: "Le moteur fouille 3,7 M d'unités d'évaluation du Québec.")
83+ } else {
84+ ForEach(results) { r in
85+ Button { selected = r } label: {
86+ VStack(alignment: .leading, spacing: 3) {
87+ Text(r.adresse).font(.subheadline.weight(.semibold))
88+ HStack {
89+ Text(r.municipalite).font(.caption).foregroundStyle(.secondary)
90+ if let t = r.type { KAChip(text: t, accent: universe.accent) }
91+ }
92+ }
93+ .frame(maxWidth: .infinity, alignment: .leading)
94+ .padding(11)
95+ .kaCard(accent: universe.accent)
96+ .overlay(
97+ RoundedRectangle(cornerRadius: 12, style: .continuous)
98+ .strokeBorder(universe.accent, lineWidth: selected?.id == r.id ? 2.4 : 0)
99+ .padding(.trailing, 4).padding(.bottom, 4)
100+ )
101+ }
102+ .buttonStyle(KAPressStyle())
103+ }
104+ }
105+ }
106+ .padding(14)
107+ }
108+ .background(KATheme.paper(scheme))
109+ }
110+
111+ private func search() async {
112+ let q = query.trimmingCharacters(in: .whitespaces)
113+ guard q.count > 2 else { return }
114+ searching = true
115+ defer { searching = false }
116+ guard var comps = URLComponents(string: "https://www.vrai-prix.com/api/search") else { return }
117+ comps.queryItems = [.init(name: "q", value: q)]
118+ guard let url = comps.url, let root = try? await APIClient.shared.json(url),
119+ let arr = root.object?["results"]?.array else { results = []; return }
120+ results = arr.compactMap { v in
121+ guard let o = v.object, let id = o.str("id"), let ad = o.str("adresse") else { return nil }
122+ return VPResult(id: id,
123+ adresse: [ad, o.str("apt")].compactMap { $0?.isEmpty == false ? $0 : nil }.joined(separator: " app. "),
124+ municipalite: o.str("municipalite") ?? "", type: o.str("typeProp"))
125+ }
126+ if selected == nil { selected = results.first }
127+ }
128+}
129+
130+// MARK: - Fiche d'estimation
131+
132+struct EstimatePane: View {
133+ let result: VPResult
134+ let universe: Universe
135+ @State private var unit: [String: JSONValue]?
136+ @State private var res: [String: JSONValue]?
137+ @State private var failed = false
138+ @Environment(\.colorScheme) private var scheme
139+
140+ var body: some View {
141+ ScrollView {
142+ VStack(alignment: .leading, spacing: 16) {
143+ Text(result.adresse).font(.title2.weight(.bold)).textSelection(.enabled)
144+ Text(result.municipalite).font(.subheadline).foregroundStyle(.secondary)
145+
146+ if let res {
147+ estimateCard(res)
148+ if let u = unit { historyCard(u); portraitCard(u) }
149+ Link(destination: URL(string: "https://www.vrai-prix.com/estimation/\(result.id)")!) {
150+ Label("Fiche complète + rapport PDF sur Vrai-Prix", systemImage: "doc.richtext")
151+ .font(.headline)
152+ .frame(maxWidth: .infinity).padding(.vertical, 13)
153+ .background(universe.accent, in: RoundedRectangle(cornerRadius: 11, style: .continuous))
154+ .foregroundStyle(.white)
155+ }
156+ .buttonStyle(.plain)
157+ } else if failed {
158+ KAEmptyState(symbol: "wifi.exclamationmark", title: "Estimation indisponible",
159+ message: "Le service ne répond pas — réessayez.")
160+ } else {
161+ ProgressView("Le moteur estime…").frame(maxWidth: .infinity).padding(40)
162+ }
163+ }
164+ .padding(18)
165+ }
166+ .background(KATheme.paper(scheme))
167+ .task { await load() }
168+ }
169+
170+ private func estimateCard(_ r: [String: JSONValue]) -> some View {
171+ VStack(alignment: .leading, spacing: 8) {
172+ Text("ESTIMATION VRAI-PRIX")
173+ .font(.system(size: 10, design: .monospaced).weight(.bold))
174+ .foregroundStyle(Color(hex: "#f5f3ee").opacity(0.6))
175+ Text((r.num("estimate") ?? 0).money0)
176+ .font(.system(size: 38, weight: .bold, design: .rounded))
177+ .foregroundStyle(universe.accent)
178+ .minimumScaleFactor(0.6).lineLimit(1)
179+ if let lo = r.num("low"), let hi = r.num("high") {
180+ Text("Fourchette \(lo.money0) – \(hi.money0)")
181+ .font(.subheadline).foregroundStyle(Color(hex: "#f5f3ee").opacity(0.85))
182+ }
183+ HStack(spacing: 8) {
184+ if let level = r.str("confidenceLevel") {
185+ Text("Confiance \(level)")
186+ .font(.system(.caption, design: .monospaced).weight(.bold))
187+ .padding(.horizontal, 10).padding(.vertical, 4)
188+ .background(confColor(level), in: Capsule())
189+ .foregroundStyle(.black)
190+ }
191+ if let pct = r.num("confidencePct") {
192+ Text("\(Int(pct)) %").font(.caption).foregroundStyle(Color(hex: "#f5f3ee").opacity(0.7))
193+ }
194+ Spacer()
195+ if let role = unit?.num("valeurRole") {
196+ VStack(alignment: .trailing, spacing: 0) {
197+ Text("Rôle 2026").font(.system(size: 9, design: .monospaced)).foregroundStyle(Color(hex: "#f5f3ee").opacity(0.55))
198+ Text(role.money0).font(.caption.weight(.bold)).foregroundStyle(Color(hex: "#f5f3ee"))
199+ }
200+ }
201+ }
202+ }
203+ .padding(18)
204+ .frame(maxWidth: .infinity, alignment: .leading)
205+ .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
206+ }
207+
208+ private func historyCard(_ u: [String: JSONValue]) -> some View {
209+ Group {
210+ if let hist = u["history"]?.array, hist.count > 1 {
211+ VStack(alignment: .leading, spacing: 10) {
212+ Text("Valeur au rôle, 2021 → 2026").font(.headline)
213+ let pts: [(String, Double)] = hist.compactMap { h in
214+ guard let o = h.object, let y = o.num("year"), let v = o.num("value") else { return nil }
215+ return (String(Int(y)), v)
216+ }
217+ let maxV = pts.map(\.1).max() ?? 1
218+ HStack(alignment: .bottom, spacing: 10) {
219+ ForEach(pts, id: \.0) { (year, v) in
220+ VStack(spacing: 4) {
221+ Text(v.compact).font(.system(size: 8, design: .monospaced)).foregroundStyle(.secondary)
222+ RoundedRectangle(cornerRadius: 3)
223+ .fill(universe.accent.opacity(year == pts.last?.0 ? 1 : 0.45))
224+ .frame(height: max(CGFloat(v / maxV) * 90, 6))
225+ Text(year).font(.system(size: 9, design: .monospaced).weight(.bold))
226+ }
227+ .frame(maxWidth: .infinity)
228+ }
229+ }
230+ }
231+ .padding(14).kaCard(accent: universe.accent)
232+ }
233+ }
234+ }
235+
236+ private func portraitCard(_ u: [String: JSONValue]) -> some View {
237+ let rows: [(String, String?)] = [
238+ ("Type", u.str("cubfLibelle") ?? u.str("typeProp")),
239+ ("Année de construction", u.num("anneeConstruction").map { String(Int($0)) }),
240+ ("Aire des étages", u.num("aireEtagesM2").map { "\(Int($0)) m²" }),
241+ ("Terrain", u.num("superficieTerrainM2").map { "\(Int($0)) m²" }),
242+ ("Logements", u.num("nbLogements").map { String(Int($0)) }),
243+ ]
244+ return VStack(spacing: 0) {
245+ ForEach(rows.filter { $0.1 != nil }, id: \.0) { (label, value) in
246+ HStack {
247+ Text(label).font(.subheadline).foregroundStyle(.secondary)
248+ Spacer()
249+ Text(value ?? "").font(.subheadline.weight(.semibold))
250+ }
251+ .padding(.vertical, 9).padding(.horizontal, 14)
252+ Divider().opacity(label == rows.last?.0 ? 0 : 1)
253+ }
254+ }
255+ .kaCard()
256+ }
257+
258+ private func confColor(_ level: String) -> Color {
259+ switch level {
260+ case "A": return Color(hex: "#d9f26b")
261+ case "B": return Color(hex: "#a8e063")
262+ case "C": return Color(hex: "#e8a33d")
263+ default: return Color(hex: "#ff8a80")
264+ }
265+ }
266+
267+ private func load() async {
268+ guard var comps = URLComponents(string: "https://www.vrai-prix.com/api/estimate") else { return }
269+ comps.queryItems = [.init(name: "id", value: result.id)]
270+ guard let url = comps.url, let root = try? await APIClient.shared.json(url, ttl: 3600),
271+ let obj = root.object else { failed = true; return }
272+ unit = obj["unit"]?.object
273+ res = obj["result"]?.object
274+ if res == nil { failed = true }
275+ }
276+}
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
@@ -0,0 +1,24 @@
1+#!/bin/bash
2+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3+# Génère Assets/AppIcon.icns à partir d'Assets/icon-1024.png (repris de
4+# l'app iOS KA — rendu de docs/icon.svg). Pipeline iconset → iconutil,
5+# pattern forge-studio.
6+set -euo pipefail
7+cd "$(dirname "$0")/.."
8+
9+SRC="Assets/icon-1024.png"
10+if [ ! -f "$SRC" ]; then
11+ echo "ERREUR : $SRC introuvable (copiez icon-1024.png de l'app iOS KA)." >&2
12+ exit 1
13+fi
14+
15+ICONSET="Assets/AppIcon.iconset"
16+rm -rf "$ICONSET" && mkdir -p "$ICONSET"
17+for s in 16 32 128 256 512; do
18+ sips -z $s $s "$SRC" --out "$ICONSET/icon_${s}x${s}.png" >/dev/null
19+ d=$((s * 2))
20+ sips -z $d $d "$SRC" --out "$ICONSET/icon_${s}x${s}@2x.png" >/dev/null
21+done
22+iconutil -c icns "$ICONSET" -o Assets/AppIcon.icns
23+rm -rf "$ICONSET"
24+echo "OK : Assets/AppIcon.icns"
added scripts/notarize.sh +36 −0
@@ -0,0 +1,36 @@
1+#!/bin/bash
2+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3+# Signature Developer ID, DMG et notarisation de KA pour macOS.
4+# Identité et flux repris du pipeline forge-studio / zyquo-term
5+# (Team 3YM54G49SN, profil notarytool keychain « MacLustr-Notarize »).
6+set -euo pipefail
7+cd "$(dirname "$0")/.."
8+
9+IDENTITY="Developer ID Application: Simon-Pierre Boucher (3YM54G49SN)"
10+KEYCHAIN_PROFILE="MacLustr-Notarize"
11+APP_DIR="dist/KA.app"
12+DMG_NAME="dist/KA-macos-1.0.0.dmg" # publié en téléchargement sur groupe-ka.com
13+
14+./scripts/package-app.sh release
15+
16+echo "=== Signature (Developer ID, hardened runtime) ==="
17+codesign --force --options runtime --timestamp \
18+ --sign "$IDENTITY" "$APP_DIR/Contents/MacOS/KA"
19+codesign --force --options runtime --timestamp \
20+ --sign "$IDENTITY" "$APP_DIR"
21+codesign --verify --deep --strict --verbose=2 "$APP_DIR"
22+
23+echo "=== DMG ==="
24+rm -f "$DMG_NAME"
25+hdiutil create -volname "KA" -srcfolder "$APP_DIR" -ov -format UDZO "$DMG_NAME"
26+codesign --force --sign "$IDENTITY" --timestamp "$DMG_NAME"
27+
28+echo "=== Notarisation (profil : $KEYCHAIN_PROFILE) ==="
29+xcrun notarytool submit "$DMG_NAME" --keychain-profile "$KEYCHAIN_PROFILE" --wait
30+
31+echo "=== Stapling ==="
32+xcrun stapler staple "$APP_DIR"
33+xcrun stapler staple "$DMG_NAME"
34+xcrun stapler validate "$DMG_NAME"
35+spctl -a -vv "$APP_DIR"
36+echo "OK : $DMG_NAME"
added scripts/package-app.sh +45 −0
@@ -0,0 +1,45 @@
1+#!/bin/bash
2+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3+# Compile l'exécutable SwiftPM et l'assemble en dist/KA.app (signature ad-hoc
4+# pour l'itération locale ; scripts/notarize.sh fait la vraie signature).
5+# Pipeline repris de forge-studio (scripts/package-app.sh).
6+set -euo pipefail
7+cd "$(dirname "$0")/.."
8+
9+CONFIG="${1:-release}"
10+swift build -c "$CONFIG"
11+
12+BIN=".build/$CONFIG/KA"
13+APP_DIR="dist/KA.app"
14+rm -rf "$APP_DIR"
15+mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources"
16+
17+cp "$BIN" "$APP_DIR/Contents/MacOS/KA"
18+if [ ! -f Assets/AppIcon.icns ]; then ./scripts/generate-icon.sh; fi
19+cp Assets/AppIcon.icns "$APP_DIR/Contents/Resources/AppIcon.icns"
20+
21+cat > "$APP_DIR/Contents/Info.plist" <<'PLIST'
22+<?xml version="1.0" encoding="UTF-8"?>
23+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
24+<plist version="1.0">
25+<dict>
26+ <key>CFBundleName</key><string>KA</string>
27+ <key>CFBundleDisplayName</key><string>KA</string>
28+ <key>CFBundleIdentifier</key><string>com.groupeka.ka.mac</string>
29+ <key>CFBundleExecutable</key><string>KA</string>
30+ <key>CFBundleVersion</key><string>1</string>
31+ <key>CFBundleShortVersionString</key><string>1.0.0</string>
32+ <key>CFBundlePackageType</key><string>APPL</string>
33+ <key>LSMinimumSystemVersion</key><string>14.0</string>
34+ <key>LSApplicationCategoryType</key><string>public.app-category.lifestyle</string>
35+ <key>CFBundleIconFile</key><string>AppIcon</string>
36+ <key>CFBundleDevelopmentRegion</key><string>fr</string>
37+ <key>NSHighResolutionCapable</key><true/>
38+ <key>NSHumanReadableCopyright</key><string>© Simon-Pierre Boucher — Groupe KA</string>
39+</dict>
40+</plist>
41+PLIST
42+
43+codesign --force --deep --sign - "$APP_DIR"
44+codesign --verify --deep --strict --verbose=2 "$APP_DIR"
45+echo "OK : $APP_DIR"
46
Menu

Theme