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