SPB Git forge

spb/ka-macos

Public
3commits 1branches 0releases
6.1 MBsize
maindefault branch
1 mo agolast push
Swift 98.3% Shell 1.7%

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 1 mo ago (Aug 18, 2026)

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