KA 1.0.0 (1) — super-app iOS de l'écosystème Groupe-KA : 12 univers config-driven (données live), recherche universelle, favoris/collections unifiés, carte MapKit, KA Agent natif (SSE), widget Le pouls, onboarding animé, clair/sombre, hors ligne, tests ; build téléversée sur TestFlight
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
29 changed files +2,663 −0
added
.gitignore
+4 −0
@@ -0,0 +1,4 @@ | ||
| 1 | +build/ | |
| 2 | +*.xcodeproj | |
| 3 | +DerivedData/ | |
| 4 | +.DS_Store | |
added
KA/App/KAApp.swift
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KAApp.swift — point d'entrée : onboarding animé au premier lancement, puis | |
| 3 | +// la super-app (5 onglets + bulle KA Agent flottante partout). | |
| 4 | +import SwiftUI | |
| 5 | + | |
| 6 | +@main | |
| 7 | +struct KAApp: App { | |
| 8 | + @AppStorage("ka.onboarded") private var onboarded = false | |
| 9 | + @AppStorage("ka.appearance") private var appearance = "auto" | |
| 10 | + @StateObject private var favorites = FavoritesStore.shared | |
| 11 | + | |
| 12 | + var body: some Scene { | |
| 13 | + WindowGroup { | |
| 14 | + Group { | |
| 15 | + if onboarded { | |
| 16 | + RootView() | |
| 17 | + } else { | |
| 18 | + OnboardingView() | |
| 19 | + } | |
| 20 | + } | |
| 21 | + .environmentObject(favorites) | |
| 22 | + .preferredColorScheme(appearance == "clair" ? .light : appearance == "sombre" ? .dark : nil) | |
| 23 | + } | |
| 24 | + } | |
| 25 | +} | |
| 26 | + | |
| 27 | +// MARK: - Racine : 5 onglets + bulle Agent | |
| 28 | + | |
| 29 | +struct RootView: View { | |
| 30 | + @State private var showAgent = false | |
| 31 | + @State private var tab = 0 | |
| 32 | + | |
| 33 | + var body: some View { | |
| 34 | + ZStack(alignment: .bottomTrailing) { | |
| 35 | + TabView(selection: $tab) { | |
| 36 | + HomeView() | |
| 37 | + .tabItem { Label("Accueil", systemImage: "house.fill") }.tag(0) | |
| 38 | + SearchView() | |
| 39 | + .tabItem { Label("Recherche", systemImage: "magnifyingglass") }.tag(1) | |
| 40 | + UniversesView() | |
| 41 | + .tabItem { Label("Univers", systemImage: "circle.grid.3x3.fill") }.tag(2) | |
| 42 | + FavoritesView() | |
| 43 | + .tabItem { Label("Favoris", systemImage: "heart.fill") }.tag(3) | |
| 44 | + MoreTab() | |
| 45 | + .tabItem { Label("Profil", systemImage: "person.crop.circle") }.tag(4) | |
| 46 | + } | |
| 47 | + .tint(KATheme.green) | |
| 48 | + | |
| 49 | + // Bulle KA Agent — flottante au-dessus de la tab bar, partout | |
| 50 | + Button { | |
| 51 | + Haptics.rigid() | |
| 52 | + showAgent = true | |
| 53 | + } label: { | |
| 54 | + Text("Ka") | |
| 55 | + .font(.system(size: 19, weight: .bold, design: .rounded)) | |
| 56 | + .frame(width: 54, height: 54) | |
| 57 | + .background(KATheme.lime, in: Circle()) | |
| 58 | + .foregroundStyle(KATheme.inkLight) | |
| 59 | + .overlay(Circle().strokeBorder(KATheme.inkLight, lineWidth: 2)) | |
| 60 | + .shadow(color: .black.opacity(0.25), radius: 1, x: 3, y: 3) | |
| 61 | + } | |
| 62 | + .padding(.trailing, 16) | |
| 63 | + .padding(.bottom, 64) | |
| 64 | + .accessibilityLabel("Ouvrir KA Agent, l'assistant de l'écosystème") | |
| 65 | + } | |
| 66 | + .sheet(isPresented: $showAgent) { | |
| 67 | + AgentChatView() | |
| 68 | + .presentationDetents([.large]) | |
| 69 | + } | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +/// Onglet Profil (contient aussi Favoris pour garder 5 onglets nets — les | |
| 74 | +/// favoris ont leur raccourci au sommet). | |
| 75 | +struct MoreTab: View { | |
| 76 | + var body: some View { | |
| 77 | + ProfileView() | |
| 78 | + } | |
| 79 | +} | |
added
KA/App/OnboardingView.swift
+128 −0
@@ -0,0 +1,128 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// OnboardingView.swift — le « waouh » de la première ouverture : animation du | |
| 3 | +// logo KA (boîte encre qui se redresse, lime qui s'allume), pitch en 3 lignes, | |
| 4 | +// choix des univers favoris, puis c'est parti. Court, spectaculaire, utile. | |
| 5 | +// Reduce Motion respecté (l'animation se fige proprement). | |
| 6 | +import SwiftUI | |
| 7 | + | |
| 8 | +struct OnboardingView: View { | |
| 9 | + @AppStorage("ka.onboarded") private var onboarded = false | |
| 10 | + @AppStorage("ka.favUniverses") private var favUniversesRaw = "" | |
| 11 | + @Environment(\.accessibilityReduceMotion) private var reduceMotion | |
| 12 | + @Environment(\.colorScheme) private var scheme | |
| 13 | + | |
| 14 | + @State private var step = 0 | |
| 15 | + @State private var logoRotation: Double = -14 | |
| 16 | + @State private var logoScale: CGFloat = 0.6 | |
| 17 | + @State private var glow = false | |
| 18 | + @State private var picked: Set<String> = [] | |
| 19 | + | |
| 20 | + var body: some View { | |
| 21 | + VStack(spacing: 0) { | |
| 22 | + Spacer() | |
| 23 | + logo | |
| 24 | + Spacer() | |
| 25 | + if step == 0 { pitch } else { pickUniverses } | |
| 26 | + actions | |
| 27 | + } | |
| 28 | + .padding(24) | |
| 29 | + .background(KATheme.paper(scheme)) | |
| 30 | + .onAppear { | |
| 31 | + guard !reduceMotion else { logoRotation = -4; logoScale = 1; glow = true; return } | |
| 32 | + withAnimation(.spring(response: 0.9, dampingFraction: 0.55).delay(0.15)) { | |
| 33 | + logoRotation = -4 | |
| 34 | + logoScale = 1 | |
| 35 | + } | |
| 36 | + withAnimation(.easeInOut(duration: 1.1).delay(0.5)) { glow = true } | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + private var logo: some View { | |
| 41 | + VStack(spacing: 18) { | |
| 42 | + ZStack { | |
| 43 | + RoundedRectangle(cornerRadius: 30, style: .continuous) | |
| 44 | + .fill(KATheme.inkLight) | |
| 45 | + .frame(width: 128, height: 128) | |
| 46 | + .shadow(color: KATheme.lime.opacity(glow ? 0.55 : 0), radius: glow ? 36 : 0) | |
| 47 | + Text("KA") | |
| 48 | + .font(.system(size: 56, weight: .bold, design: .rounded)) | |
| 49 | + .foregroundStyle(KATheme.lime) | |
| 50 | + } | |
| 51 | + .rotationEffect(.degrees(logoRotation)) | |
| 52 | + .scaleEffect(logoScale) | |
| 53 | + .accessibilityHidden(true) | |
| 54 | + Text("Mille sites. Un seul KA.") | |
| 55 | + .font(.system(.title2, design: .rounded).weight(.bold)) | |
| 56 | + .multilineTextAlignment(.center) | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + private var pitch: some View { | |
| 61 | + VStack(alignment: .leading, spacing: 14) { | |
| 62 | + pitchRow("magnifyingglass", "Une recherche, tout le Québec", "Logements, propriétés, autos, emplois, restos, sorties — interrogés d'un coup.") | |
| 63 | + pitchRow("heart.fill", "Des favoris qui traversent les univers", "Un 4½, une auto et un resto dans une même collection.") | |
| 64 | + pitchRow("sparkles", "KA Agent, votre IA d'ici", "Posez une question, il fouille les vraies données de l'écosystème.") | |
| 65 | + } | |
| 66 | + .padding(.bottom, 12) | |
| 67 | + } | |
| 68 | + | |
| 69 | + private func pitchRow(_ symbol: String, _ title: String, _ sub: String) -> some View { | |
| 70 | + HStack(alignment: .top, spacing: 12) { | |
| 71 | + Image(systemName: symbol) | |
| 72 | + .font(.headline).foregroundStyle(KATheme.green) | |
| 73 | + .frame(width: 34, height: 34) | |
| 74 | + .background(KATheme.green.opacity(0.12), in: RoundedRectangle(cornerRadius: 9)) | |
| 75 | + VStack(alignment: .leading, spacing: 2) { | |
| 76 | + Text(title).font(.subheadline.weight(.bold)) | |
| 77 | + Text(sub).font(.caption).foregroundStyle(.secondary) | |
| 78 | + } | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + private var pickUniverses: some View { | |
| 83 | + VStack(alignment: .leading, spacing: 10) { | |
| 84 | + Text("Vos univers du quotidien ?") | |
| 85 | + .font(.headline) | |
| 86 | + Text("Ils remonteront en tête de votre accueil (modifiable dans Profil).") | |
| 87 | + .font(.caption).foregroundStyle(.secondary) | |
| 88 | + LazyVGrid(columns: [GridItem(.adaptive(minimum: 105), spacing: 8)], spacing: 8) { | |
| 89 | + ForEach(Ecosystem.all) { u in | |
| 90 | + let on = picked.contains(u.id) | |
| 91 | + Button { | |
| 92 | + Haptics.tap() | |
| 93 | + if on { picked.remove(u.id) } else { picked.insert(u.id) } | |
| 94 | + } label: { | |
| 95 | + VStack(spacing: 4) { | |
| 96 | + Image(systemName: u.symbol).font(.subheadline) | |
| 97 | + Text(u.name).font(.caption2.weight(.bold)).lineLimit(1) | |
| 98 | + } | |
| 99 | + .frame(maxWidth: .infinity).padding(.vertical, 10) | |
| 100 | + .background(on ? u.accent : KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) | |
| 101 | + .foregroundStyle(on ? .white : .primary) | |
| 102 | + .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(.primary.opacity(0.2), lineWidth: 1)) | |
| 103 | + } | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } | |
| 107 | + .padding(.bottom, 12) | |
| 108 | + } | |
| 109 | + | |
| 110 | + private var actions: some View { | |
| 111 | + Button { | |
| 112 | + Haptics.success() | |
| 113 | + if step == 0 { | |
| 114 | + withAnimation(.snappy) { step = 1 } | |
| 115 | + } else { | |
| 116 | + favUniversesRaw = picked.sorted().joined(separator: ",") | |
| 117 | + withAnimation(.easeOut) { onboarded = true } | |
| 118 | + } | |
| 119 | + } label: { | |
| 120 | + Text(step == 0 ? "Continuer" : picked.isEmpty ? "Explorer sans choisir" : "C'est parti 🚀") | |
| 121 | + .font(.headline) | |
| 122 | + .frame(maxWidth: .infinity).padding(.vertical, 15) | |
| 123 | + .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) | |
| 124 | + .foregroundStyle(KATheme.lime) | |
| 125 | + } | |
| 126 | + .padding(.top, 4) | |
| 127 | + } | |
| 128 | +} | |
added
KA/Core/Ecosystem.swift
+323 −0
@@ -0,0 +1,323 @@ | ||
| 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 (voir docs/ARCHITECTURE.md). | |
| 6 | +import SwiftUI | |
| 7 | + | |
| 8 | +// MARK: - JSON générique (les API des univers ont chacune leur forme) | |
| 9 | + | |
| 10 | +enum JSONValue: Decodable { | |
| 11 | + case string(String), number(Double), bool(Bool) | |
| 12 | + case object([String: JSONValue]), array([JSONValue]), null | |
| 13 | + | |
| 14 | + init(from decoder: Decoder) throws { | |
| 15 | + let c = try decoder.singleValueContainer() | |
| 16 | + if c.decodeNil() { self = .null } | |
| 17 | + else if let b = try? c.decode(Bool.self) { self = .bool(b) } | |
| 18 | + else if let n = try? c.decode(Double.self) { self = .number(n) } | |
| 19 | + else if let s = try? c.decode(String.self) { self = .string(s) } | |
| 20 | + else if let a = try? c.decode([JSONValue].self) { self = .array(a) } | |
| 21 | + else { self = .object(try c.decode([String: JSONValue].self)) } | |
| 22 | + } | |
| 23 | + | |
| 24 | + var string: String? { if case .string(let s) = self { return s }; return nil } | |
| 25 | + var number: Double? { if case .number(let n) = self { return n }; return nil } | |
| 26 | + var array: [JSONValue]? { if case .array(let a) = self { return a }; return nil } | |
| 27 | + var object: [String: JSONValue]? { if case .object(let o) = self { return o }; return nil } | |
| 28 | + /// Texte « au mieux » (string, nombre formaté, liste jointe) | |
| 29 | + var text: String? { | |
| 30 | + switch self { | |
| 31 | + case .string(let s): return s.isEmpty ? nil : s | |
| 32 | + case .number(let n): return n == n.rounded() ? String(Int(n)) : String(n) | |
| 33 | + case .array(let a): let parts = a.compactMap(\.text); return parts.isEmpty ? nil : parts.joined(separator: ", ") | |
| 34 | + default: return nil | |
| 35 | + } | |
| 36 | + } | |
| 37 | +} | |
| 38 | + | |
| 39 | +extension [String: JSONValue] { | |
| 40 | + func str(_ keys: String...) -> String? { | |
| 41 | + for k in keys { if let v = self[k]?.text { return v } } | |
| 42 | + return nil | |
| 43 | + } | |
| 44 | + func num(_ keys: String...) -> Double? { | |
| 45 | + for k in keys { if let v = self[k]?.number { return v } } | |
| 46 | + return nil | |
| 47 | + } | |
| 48 | +} | |
| 49 | + | |
| 50 | +// MARK: - L'élément universel | |
| 51 | + | |
| 52 | +struct KAItem: Identifiable, Hashable, Codable { | |
| 53 | + var id: String | |
| 54 | + var universeID: String | |
| 55 | + var title: String | |
| 56 | + var subtitle: String? | |
| 57 | + var priceLabel: String? | |
| 58 | + var city: String? | |
| 59 | + var url: URL? | |
| 60 | + var imageURL: URL? | |
| 61 | + var latitude: Double? | |
| 62 | + var longitude: Double? | |
| 63 | + /// Paires libres affichées sur la fiche (« Année : 2021 », « Salaire : 65 000 $ »…) | |
| 64 | + var facts: [Fact] | |
| 65 | + | |
| 66 | + struct Fact: Hashable, Codable { var label: String; var value: String } | |
| 67 | +} | |
| 68 | + | |
| 69 | +// MARK: - Univers | |
| 70 | + | |
| 71 | +struct Universe: Identifiable { | |
| 72 | + let id: String | |
| 73 | + let wordmark: String // « Lou·Ka » | |
| 74 | + let name: String // « Lou-KA » | |
| 75 | + let tagline: String | |
| 76 | + let accentHex: String | |
| 77 | + let symbol: String // SF Symbol | |
| 78 | + let domain: String | |
| 79 | + let unit: String // « logements », « offres »… | |
| 80 | + /// Chemin de la liste (nil = univers sans liste native, ex. groupe-ka) | |
| 81 | + let listPath: String? | |
| 82 | + let itemsKey: String | |
| 83 | + let searchParam: String | |
| 84 | + let statTotalKeys: [String] // clés du total dans /api/stats | |
| 85 | + let map: (@Sendable ([String: JSONValue]) -> KAItem?)? | |
| 86 | + | |
| 87 | + var accent: Color { Color(hex: accentHex) } | |
| 88 | + var baseURL: URL { URL(string: "https://\(domain)")! } | |
| 89 | +} | |
| 90 | + | |
| 91 | +enum Ecosystem { | |
| 92 | + static let hubURL = URL(string: "https://www.groupe-ka.com")! | |
| 93 | + static let signupURL = URL(string: "https://www.groupe-ka.com/connexion")! | |
| 94 | + static let statusURL = URL(string: "https://www.groupe-ka.com/status")! | |
| 95 | + static let contacts: [(email: String, role: String)] = [ | |
| 96 | + ("contact@groupe-ka.com", "Projets, partenariats & données"), | |
| 97 | + ("info@groupe-ka.com", "Médias & questions générales"), | |
| 98 | + ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"), | |
| 99 | + ] | |
| 100 | + static let legal: [(label: String, path: String)] = [ | |
| 101 | + ("Conditions d'utilisation", "/conditions"), | |
| 102 | + ("Politique de confidentialité", "/confidentialite"), | |
| 103 | + ("Renseignements personnels (Loi 25)", "/loi-25"), | |
| 104 | + ("Transparence des robots", "/bots"), | |
| 105 | + ] | |
| 106 | + 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." | |
| 107 | + | |
| 108 | + static func universe(_ id: String) -> Universe? { all.first { $0.id == id } } | |
| 109 | + | |
| 110 | + // Aides de mapping communes | |
| 111 | + private static func base(_ o: [String: JSONValue], universe: String, idKeys: [String] = ["uid"]) -> KAItem { | |
| 112 | + var id: String? | |
| 113 | + for k in idKeys { if let v = o[k]?.text { id = v; break } } | |
| 114 | + return KAItem( | |
| 115 | + id: "\(universe):\(id ?? UUID().uuidString)", | |
| 116 | + universeID: universe, | |
| 117 | + title: o.str("title", "name", "display_name") ?? "Sans titre", | |
| 118 | + subtitle: nil, priceLabel: nil, | |
| 119 | + city: o.str("city"), | |
| 120 | + url: o.str("url").flatMap(URL.init(string:)), | |
| 121 | + imageURL: o.str("image", "image_url", "photo", "thumbnail").flatMap(URL.init(string:)), | |
| 122 | + latitude: o.num("lat", "latitude"), | |
| 123 | + longitude: o.num("lng", "lon", "longitude"), | |
| 124 | + facts: [] | |
| 125 | + ) | |
| 126 | + } | |
| 127 | + | |
| 128 | + // MARK: les 12 univers (+ le hub, sans liste) | |
| 129 | + static let all: [Universe] = [ | |
| 130 | + Universe(id: "lou-ka", wordmark: "Lou·Ka", name: "Lou-KA", | |
| 131 | + tagline: "Tous les logements à louer", accentHex: "#ff6a00", | |
| 132 | + symbol: "key.fill", domain: "www.lou-ka.com", unit: "logements", | |
| 133 | + listPath: "/api/listings", itemsKey: "listings", searchParam: "q", | |
| 134 | + statTotalKeys: ["total"], | |
| 135 | + map: { o in | |
| 136 | + var it = base(o, universe: "lou-ka") | |
| 137 | + it.subtitle = [o.str("unit_type"), o.str("address")].compactMap { $0 }.joined(separator: " · ") | |
| 138 | + if let p = o.num("price"), p > 50 { it.priceLabel = p.money0 + "/mois" } | |
| 139 | + it.facts = [ | |
| 140 | + o.str("unit_type").map { .init(label: "Taille", value: $0) }, | |
| 141 | + o.str("address").map { .init(label: "Adresse", value: $0) }, | |
| 142 | + o.str("available_by").map { .init(label: "Disponible", value: $0) }, | |
| 143 | + ].compactMap { $0 } | |
| 144 | + return it | |
| 145 | + }), | |
| 146 | + Universe(id: "immo-ka", wordmark: "Immo·Ka", name: "Immo-KA", | |
| 147 | + tagline: "Toutes les propriétés à vendre", accentHex: "#e23744", | |
| 148 | + symbol: "house.fill", domain: "www.immo-ka.com", unit: "propriétés", | |
| 149 | + listPath: "/api/listings", itemsKey: "listings", searchParam: "q", | |
| 150 | + statTotalKeys: ["total"], | |
| 151 | + map: { o in | |
| 152 | + var it = base(o, universe: "immo-ka") | |
| 153 | + it.subtitle = [o.str("property_type"), o.str("region")].compactMap { $0 }.joined(separator: " · ") | |
| 154 | + if let p = o.num("price"), p > 1000 { it.priceLabel = p.money0 } | |
| 155 | + it.facts = [ | |
| 156 | + o.num("bedrooms").map { .init(label: "Chambres", value: String(Int($0))) }, | |
| 157 | + o.num("bathrooms").map { .init(label: "Salles de bain", value: String(Int($0))) }, | |
| 158 | + o.str("address").map { .init(label: "Adresse", value: $0) }, | |
| 159 | + ].compactMap { $0 } | |
| 160 | + return it | |
| 161 | + }), | |
| 162 | + Universe(id: "vrai-prix", wordmark: "Vrai-Prix", name: "Vrai-Prix", | |
| 163 | + tagline: "La valeur réelle de chaque propriété", accentHex: "#ff5148", | |
| 164 | + symbol: "chart.line.uptrend.xyaxis", domain: "www.vrai-prix.com", unit: "propriétés estimées", | |
| 165 | + listPath: nil, itemsKey: "", searchParam: "q", | |
| 166 | + statTotalKeys: ["units_total"], map: nil), | |
| 167 | + Universe(id: "auto-ka", wordmark: "Auto·Ka", name: "Auto-KA", | |
| 168 | + tagline: "Les voitures usagées du Québec", accentHex: "#ff5a2a", | |
| 169 | + symbol: "car.fill", domain: "www.auto-ka.com", unit: "véhicules", | |
| 170 | + listPath: "/api/vehicles", itemsKey: "vehicles", searchParam: "q", | |
| 171 | + statTotalKeys: ["total"], | |
| 172 | + map: { o in | |
| 173 | + var it = base(o, universe: "auto-ka") | |
| 174 | + it.subtitle = [o.num("year").map { String(Int($0)) }, o.str("mileage_label")].compactMap { $0 }.joined(separator: " · ") | |
| 175 | + it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money0) | |
| 176 | + it.facts = [ | |
| 177 | + o.str("make").map { .init(label: "Marque", value: $0) }, | |
| 178 | + o.str("model").map { .init(label: "Modèle", value: $0) }, | |
| 179 | + o.str("transmission").map { .init(label: "Boîte", value: $0) }, | |
| 180 | + o.str("fuel").map { .init(label: "Carburant", value: $0) }, | |
| 181 | + ].compactMap { $0 } | |
| 182 | + return it | |
| 183 | + }), | |
| 184 | + Universe(id: "fabri-ka", wordmark: "Fabri·Ka", name: "Fabri-KA", | |
| 185 | + tagline: "Les produits fabriqués au Québec", accentHex: "#c4532e", | |
| 186 | + symbol: "shippingbox.fill", domain: "www.fabri-ka.com", unit: "produits", | |
| 187 | + listPath: "/api/products", itemsKey: "items", searchParam: "q", | |
| 188 | + statTotalKeys: ["totals.products", "total"], | |
| 189 | + map: { o in | |
| 190 | + var it = base(o, universe: "fabri-ka") | |
| 191 | + it.subtitle = o.str("store_id", "store") | |
| 192 | + it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money2) | |
| 193 | + it.facts = [o.str("category").map { .init(label: "Catégorie", value: $0) }].compactMap { $0 } | |
| 194 | + return it | |
| 195 | + }), | |
| 196 | + Universe(id: "food-ka", wordmark: "Food·Ka", name: "Food-KA", | |
| 197 | + tagline: "Les prix d'épicerie, suivis à la source", accentHex: "#1f9d55", | |
| 198 | + symbol: "cart.fill", domain: "www.food-ka.com", unit: "produits", | |
| 199 | + listPath: "/api/products", itemsKey: "products", searchParam: "q", | |
| 200 | + statTotalKeys: ["total"], | |
| 201 | + map: { o in | |
| 202 | + var it = base(o, universe: "food-ka") | |
| 203 | + it.subtitle = [o.str("brand"), o.str("size_label")].compactMap { $0 }.joined(separator: " · ") | |
| 204 | + if let p = o.num("price"), p > 0.2 { it.priceLabel = p.money2 } | |
| 205 | + it.facts = [ | |
| 206 | + o.str("category").map { .init(label: "Catégorie", value: $0) }, | |
| 207 | + (o["on_sale"].flatMap { if case .bool(true) = $0 { return KAItem.Fact(label: "Solde", value: "Oui 🏷️") } ; return nil }), | |
| 208 | + ].compactMap { $0 } | |
| 209 | + return it | |
| 210 | + }), | |
| 211 | + Universe(id: "resto-ka", wordmark: "Resto·Ka", name: "Resto-KA", | |
| 212 | + tagline: "Chaque resto, chaque plat, chaque prix", accentHex: "#f08c00", | |
| 213 | + symbol: "fork.knife", domain: "www.resto-ka.com", unit: "restaurants", | |
| 214 | + listPath: "/api/restaurants", itemsKey: "restaurants", searchParam: "q", | |
| 215 | + statTotalKeys: ["restaurants", "total"], | |
| 216 | + map: { o in | |
| 217 | + var it = base(o, universe: "resto-ka") | |
| 218 | + let cuisines = o["cuisines"]?.text | |
| 219 | + it.subtitle = [cuisines, o.str("price_range")].compactMap { $0 }.joined(separator: " · ") | |
| 220 | + it.facts = [ | |
| 221 | + o.str("address").map { .init(label: "Adresse", value: $0) }, | |
| 222 | + o.str("chain").map { .init(label: "Chaîne", value: $0) }, | |
| 223 | + ].compactMap { $0 } | |
| 224 | + return it | |
| 225 | + }), | |
| 226 | + Universe(id: "sorti-ka", wordmark: "Sorti·Ka", name: "Sorti-KA", | |
| 227 | + tagline: "Toutes les sorties, dans les 17 régions", accentHex: "#d6336c", | |
| 228 | + symbol: "ticket.fill", domain: "www.sorti-ka.com", unit: "événements", | |
| 229 | + listPath: "/api/events?upcoming=true", itemsKey: "events", searchParam: "q", | |
| 230 | + statTotalKeys: ["total_active", "total"], | |
| 231 | + map: { o in | |
| 232 | + var it = base(o, universe: "sorti-ka") | |
| 233 | + it.subtitle = [o.str("start_date"), o.str("venue")].compactMap { $0 }.joined(separator: " · ") | |
| 234 | + it.facts = [ | |
| 235 | + o.str("start_date").map { .init(label: "Début", value: $0) }, | |
| 236 | + o.str("venue").map { .init(label: "Lieu", value: $0) }, | |
| 237 | + (o["is_free"].flatMap { if case .bool(true) = $0 { return KAItem.Fact(label: "Entrée", value: "Gratuite 🎉") } ; return nil }), | |
| 238 | + ].compactMap { $0 } | |
| 239 | + return it | |
| 240 | + }), | |
| 241 | + Universe(id: "crea-ka", wordmark: "Créa·Ka", name: "Créa-KA", | |
| 242 | + tagline: "Les créateurs d'ici, tous leurs liens", accentHex: "#7048e8", | |
| 243 | + symbol: "sparkles", domain: "www.crea-ka.com", unit: "créateurs", | |
| 244 | + listPath: "/api/creators", itemsKey: "items", searchParam: "q", | |
| 245 | + statTotalKeys: ["creators", "total"], | |
| 246 | + map: { o in | |
| 247 | + var it = base(o, universe: "crea-ka", idKeys: ["cid", "uid", "display_name"]) | |
| 248 | + it.subtitle = [o["niches"]?.text, o.str("primary_platform")].compactMap { $0 }.joined(separator: " · ") | |
| 249 | + if it.url == nil, let plats = o["platforms"]?.array?.first?.object { | |
| 250 | + it.url = plats.str("url").flatMap(URL.init(string:)) | |
| 251 | + } | |
| 252 | + return it | |
| 253 | + }), | |
| 254 | + Universe(id: "job-ka", wordmark: "Job·Ka", name: "Job-KA", | |
| 255 | + tagline: "Tous les emplois des employeurs québécois", accentHex: "#0c8599", | |
| 256 | + symbol: "briefcase.fill", domain: "www.job-ka.com", unit: "offres d'emploi", | |
| 257 | + listPath: "/api/jobs", itemsKey: "jobs", searchParam: "q", | |
| 258 | + statTotalKeys: ["total"], | |
| 259 | + map: { o in | |
| 260 | + var it = base(o, universe: "job-ka") | |
| 261 | + it.subtitle = [o.str("employer"), o.str("city")].compactMap { $0 }.joined(separator: " · ") | |
| 262 | + if let a = o.num("salary_year_min"), a > 20000 { it.priceLabel = a.money0 + "/an" } | |
| 263 | + it.facts = [ | |
| 264 | + o.str("employer").map { .init(label: "Employeur", value: $0) }, | |
| 265 | + o.str("work_mode").map { .init(label: "Mode", value: $0) }, | |
| 266 | + o.str("employment_type").map { .init(label: "Type", value: $0) }, | |
| 267 | + ].compactMap { $0 } | |
| 268 | + return it | |
| 269 | + }), | |
| 270 | + Universe(id: "trouve-ka", wordmark: "Trouve·Ka", name: "Trouve-KA", | |
| 271 | + tagline: "Le moteur de recherche du web québécois", accentHex: "#1c7ed6", | |
| 272 | + symbol: "magnifyingglass", domain: "www.trouve-ka.com", unit: "pages indexées", | |
| 273 | + listPath: "/api/search", itemsKey: "results", searchParam: "q", | |
| 274 | + statTotalKeys: ["pages_indexed"], | |
| 275 | + map: { o in | |
| 276 | + var it = base(o, universe: "trouve-ka", idKeys: ["url"]) | |
| 277 | + it.title = (o.str("title") ?? "Page web").strippingHTML | |
| 278 | + it.subtitle = o.str("snippet")?.strippingHTML | |
| 279 | + it.facts = [o.str("domain").map { .init(label: "Domaine", value: $0) }].compactMap { $0 } | |
| 280 | + return it | |
| 281 | + }), | |
| 282 | + Universe(id: "api-ka", wordmark: "API·Ka", name: "API-KA", | |
| 283 | + tagline: "La donnée de l'écosystème, par API", accentHex: "#3b5bdb", | |
| 284 | + symbol: "terminal.fill", domain: "www.api-ka.com", unit: "appels API", | |
| 285 | + listPath: nil, itemsKey: "", searchParam: "q", | |
| 286 | + statTotalKeys: ["total"], map: nil), | |
| 287 | + ] | |
| 288 | +} | |
| 289 | + | |
| 290 | +// MARK: - petites extensions | |
| 291 | + | |
| 292 | +extension Double { | |
| 293 | + var money0: String { | |
| 294 | + let f = NumberFormatter(); f.numberStyle = .currency | |
| 295 | + f.locale = Locale(identifier: "fr_CA"); f.maximumFractionDigits = 0 | |
| 296 | + return f.string(from: NSNumber(value: self)) ?? "\(Int(self)) $" | |
| 297 | + } | |
| 298 | + var money2: String { | |
| 299 | + let f = NumberFormatter(); f.numberStyle = .currency | |
| 300 | + f.locale = Locale(identifier: "fr_CA"); f.maximumFractionDigits = 2 | |
| 301 | + return f.string(from: NSNumber(value: self)) ?? "\(self) $" | |
| 302 | + } | |
| 303 | +} | |
| 304 | + | |
| 305 | +extension String { | |
| 306 | + var strippingHTML: String { | |
| 307 | + replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) | |
| 308 | + .replacingOccurrences(of: "&", with: "&") | |
| 309 | + .replacingOccurrences(of: " ", with: " ") | |
| 310 | + } | |
| 311 | +} | |
| 312 | + | |
| 313 | +extension Color { | |
| 314 | + init(hex: String) { | |
| 315 | + var h = hex.trimmingCharacters(in: .alphanumerics.inverted) | |
| 316 | + if h.count == 3 { h = h.map { "\($0)\($0)" }.joined() } | |
| 317 | + let v = UInt64(h, radix: 16) ?? 0 | |
| 318 | + self.init(.sRGB, | |
| 319 | + red: Double((v >> 16) & 0xFF) / 255, | |
| 320 | + green: Double((v >> 8) & 0xFF) / 255, | |
| 321 | + blue: Double(v & 0xFF) / 255) | |
| 322 | + } | |
| 323 | +} | |
added
KA/Core/FavoritesStore.swift
+81 −0
@@ -0,0 +1,81 @@ | ||
| 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 | +import Foundation | |
| 6 | +import SwiftUI | |
| 7 | + | |
| 8 | +@MainActor | |
| 9 | +final class FavoritesStore: ObservableObject { | |
| 10 | + static let shared = FavoritesStore() | |
| 11 | + | |
| 12 | + struct FavCollection: Identifiable, Codable, Hashable { | |
| 13 | + var id: UUID = UUID() | |
| 14 | + var name: String | |
| 15 | + var items: [KAItem] = [] | |
| 16 | + } | |
| 17 | + | |
| 18 | + @Published private(set) var collections: [FavCollection] = [] { | |
| 19 | + didSet { save() } | |
| 20 | + } | |
| 21 | + | |
| 22 | + private var fileURL: URL { | |
| 23 | + let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | |
| 24 | + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | |
| 25 | + return dir.appendingPathComponent("ka-favoris.json") | |
| 26 | + } | |
| 27 | + | |
| 28 | + init() { | |
| 29 | + if let data = try? Data(contentsOf: fileURL), | |
| 30 | + let saved = try? JSONDecoder().decode([FavCollection].self, from: data) { | |
| 31 | + collections = saved | |
| 32 | + } | |
| 33 | + if collections.isEmpty { | |
| 34 | + collections = [FavCollection(name: "Mes favoris")] | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + private func save() { | |
| 39 | + if let data = try? JSONEncoder().encode(collections) { | |
| 40 | + try? data.write(to: fileURL, options: .atomic) | |
| 41 | + } | |
| 42 | + } | |
| 43 | + | |
| 44 | + // MARK: API | |
| 45 | + | |
| 46 | + var allItems: [KAItem] { collections.flatMap(\.items) } | |
| 47 | + | |
| 48 | + func isFavorite(_ item: KAItem) -> Bool { | |
| 49 | + collections.contains { $0.items.contains { $0.id == item.id } } | |
| 50 | + } | |
| 51 | + | |
| 52 | + func toggle(_ item: KAItem, in collectionID: UUID? = nil) { | |
| 53 | + if isFavorite(item) { | |
| 54 | + for i in collections.indices { | |
| 55 | + collections[i].items.removeAll { $0.id == item.id } | |
| 56 | + } | |
| 57 | + } else { | |
| 58 | + let idx = collections.firstIndex { $0.id == collectionID } ?? 0 | |
| 59 | + collections[idx].items.insert(item, at: 0) | |
| 60 | + } | |
| 61 | + Haptics.tap() | |
| 62 | + } | |
| 63 | + | |
| 64 | + func addCollection(_ name: String) { | |
| 65 | + let trimmed = name.trimmingCharacters(in: .whitespaces) | |
| 66 | + guard !trimmed.isEmpty else { return } | |
| 67 | + collections.append(FavCollection(name: trimmed)) | |
| 68 | + } | |
| 69 | + | |
| 70 | + func removeCollection(_ id: UUID) { | |
| 71 | + guard collections.count > 1 else { return } | |
| 72 | + collections.removeAll { $0.id == id } | |
| 73 | + } | |
| 74 | + | |
| 75 | + func move(_ item: KAItem, to collectionID: UUID) { | |
| 76 | + for i in collections.indices { collections[i].items.removeAll { $0.id == item.id } } | |
| 77 | + if let idx = collections.firstIndex(where: { $0.id == collectionID }) { | |
| 78 | + collections[idx].items.insert(item, at: 0) | |
| 79 | + } | |
| 80 | + } | |
| 81 | +} | |
added
KA/Core/Services.swift
+143 −0
@@ -0,0 +1,143 @@ | ||
| 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, et | |
| 4 | +// KA Agent (chat SSE vers l'API centrale api-ka). | |
| 5 | +import Foundation | |
| 6 | + | |
| 7 | +// MARK: - Client HTTP + cache disque | |
| 8 | + | |
| 9 | +actor APIClient { | |
| 10 | + static let shared = APIClient() | |
| 11 | + private let session: URLSession | |
| 12 | + private let cacheDir: URL | |
| 13 | + | |
| 14 | + init() { | |
| 15 | + let cfg = URLSessionConfiguration.default | |
| 16 | + cfg.timeoutIntervalForRequest = 15 | |
| 17 | + cfg.waitsForConnectivity = false | |
| 18 | + session = URLSession(configuration: cfg) | |
| 19 | + cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] | |
| 20 | + .appendingPathComponent("ka-json", isDirectory: true) | |
| 21 | + try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) | |
| 22 | + } | |
| 23 | + | |
| 24 | + private func cacheFile(for url: URL) -> URL { | |
| 25 | + let name = url.absoluteString.data(using: .utf8)!.base64EncodedString() | |
| 26 | + .replacingOccurrences(of: "/", with: "_") | |
| 27 | + return cacheDir.appendingPathComponent(String(name.suffix(120)) + ".json") | |
| 28 | + } | |
| 29 | + | |
| 30 | + /// JSON brut ; en cas d'échec réseau, sert la dernière copie disque (hors ligne). | |
| 31 | + func json(_ url: URL, ttl: TimeInterval = 120) async throws -> JSONValue { | |
| 32 | + let file = cacheFile(for: url) | |
| 33 | + if let attrs = try? FileManager.default.attributesOfItem(atPath: file.path), | |
| 34 | + let date = attrs[.modificationDate] as? Date, Date().timeIntervalSince(date) < ttl, | |
| 35 | + let data = try? Data(contentsOf: file), | |
| 36 | + let cached = try? JSONDecoder().decode(JSONValue.self, from: data) { | |
| 37 | + return cached | |
| 38 | + } | |
| 39 | + do { | |
| 40 | + var req = URLRequest(url: url) | |
| 41 | + req.setValue("KA-iOS/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent") | |
| 42 | + let (data, resp) = try await session.data(for: req) | |
| 43 | + guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else { | |
| 44 | + throw URLError(.badServerResponse) | |
| 45 | + } | |
| 46 | + let value = try JSONDecoder().decode(JSONValue.self, from: data) | |
| 47 | + try? data.write(to: file) | |
| 48 | + return value | |
| 49 | + } catch { | |
| 50 | + if let data = try? Data(contentsOf: file), | |
| 51 | + let cached = try? JSONDecoder().decode(JSONValue.self, from: data) { | |
| 52 | + return cached // hors ligne : dernier contenu connu | |
| 53 | + } | |
| 54 | + throw error | |
| 55 | + } | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +// MARK: - Univers : listes, recherche, stats | |
| 60 | + | |
| 61 | +enum UniverseService { | |
| 62 | + static func listURL(_ u: Universe, query: String?, city: String? = nil, limit: Int = 30) -> URL? { | |
| 63 | + guard let path = u.listPath else { return nil } | |
| 64 | + var comps = URLComponents(url: u.baseURL.appendingPathComponent(""), resolvingAgainstBaseURL: false)! | |
| 65 | + // listPath peut contenir déjà une query (ex. events?upcoming=true) | |
| 66 | + let split = path.split(separator: "?", maxSplits: 1) | |
| 67 | + comps.path = String(split[0]) | |
| 68 | + var items: [URLQueryItem] = split.count > 1 | |
| 69 | + ? split[1].split(separator: "&").map { | |
| 70 | + let kv = $0.split(separator: "=", maxSplits: 1) | |
| 71 | + return URLQueryItem(name: String(kv[0]), value: kv.count > 1 ? String(kv[1]) : nil) | |
| 72 | + } : [] | |
| 73 | + if let q = query, !q.isEmpty { items.append(.init(name: u.searchParam, value: q)) } | |
| 74 | + if let c = city, !c.isEmpty { items.append(.init(name: "city", value: c)) } | |
| 75 | + items.append(.init(name: "limit", value: String(limit))) | |
| 76 | + comps.queryItems = items | |
| 77 | + return comps.url | |
| 78 | + } | |
| 79 | + | |
| 80 | + static func fetch(_ u: Universe, query: String? = nil, city: String? = nil, limit: Int = 30) async throws -> [KAItem] { | |
| 81 | + guard let url = listURL(u, query: query, city: city, limit: limit), let map = u.map else { return [] } | |
| 82 | + let root = try await APIClient.shared.json(url) | |
| 83 | + let obj = root.object ?? [:] | |
| 84 | + let raw = obj[u.itemsKey]?.array | |
| 85 | + ?? obj["items"]?.array ?? obj["results"]?.array ?? obj["hits"]?.array | |
| 86 | + ?? root.array ?? [] | |
| 87 | + return raw.compactMap { $0.object.flatMap(map) } | |
| 88 | + } | |
| 89 | + | |
| 90 | + /// Total « en direct » depuis /api/stats (clé selon l'univers, chemins a.b acceptés) | |
| 91 | + static func liveTotal(_ u: Universe) async -> Int? { | |
| 92 | + let path = u.id == "trouve-ka" ? "/api/status" : "/api/stats" | |
| 93 | + guard let url = URL(string: "https://\(u.domain)\(path)"), | |
| 94 | + let root = try? await APIClient.shared.json(url, ttl: 300) else { return nil } | |
| 95 | + for key in u.statTotalKeys { | |
| 96 | + var cur: JSONValue? = root | |
| 97 | + for part in key.split(separator: ".") { | |
| 98 | + cur = cur?.object?[String(part)] | |
| 99 | + } | |
| 100 | + if let n = cur?.number { return Int(n) } | |
| 101 | + } | |
| 102 | + return nil | |
| 103 | + } | |
| 104 | +} | |
| 105 | + | |
| 106 | +// MARK: - KA Agent (SSE) | |
| 107 | + | |
| 108 | +struct AgentEvent { let kind: Kind; enum Kind { case delta(String), tool(String), done, error(String) } } | |
| 109 | + | |
| 110 | +enum AgentService { | |
| 111 | + static let endpoint = URL(string: "https://www.api-ka.com/api/agent/chat")! | |
| 112 | + | |
| 113 | + static func stream(site: String, messages: [[String: String]]) -> AsyncThrowingStream<AgentEvent, Error> { | |
| 114 | + AsyncThrowingStream { continuation in | |
| 115 | + let task = Task { | |
| 116 | + var req = URLRequest(url: endpoint) | |
| 117 | + req.httpMethod = "POST" | |
| 118 | + req.setValue("application/json", forHTTPHeaderField: "Content-Type") | |
| 119 | + req.timeoutInterval = 90 | |
| 120 | + req.httpBody = try JSONSerialization.data(withJSONObject: ["site": site, "messages": messages]) | |
| 121 | + let (bytes, resp) = try await URLSession.shared.bytes(for: req) | |
| 122 | + guard (resp as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) } | |
| 123 | + var event = "" | |
| 124 | + for try await line in bytes.lines { | |
| 125 | + if line.hasPrefix("event: ") { event = String(line.dropFirst(7)) } | |
| 126 | + else if line.hasPrefix("data: ") { | |
| 127 | + let data = Data(line.dropFirst(6).utf8) | |
| 128 | + let obj = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] | |
| 129 | + switch event { | |
| 130 | + case "delta": if let t = obj["text"] as? String { continuation.yield(.init(kind: .delta(t))) } | |
| 131 | + case "tool": continuation.yield(.init(kind: .tool(obj["name"] as? String ?? "recherche"))) | |
| 132 | + case "done": continuation.yield(.init(kind: .done)); continuation.finish(); return | |
| 133 | + case "error": continuation.yield(.init(kind: .error(obj["message"] as? String ?? "erreur"))) | |
| 134 | + default: break | |
| 135 | + } | |
| 136 | + } | |
| 137 | + } | |
| 138 | + continuation.finish() | |
| 139 | + } | |
| 140 | + continuation.onTermination = { _ in task.cancel() } | |
| 141 | + } | |
| 142 | + } | |
| 143 | +} | |
added
KA/Design/Theme.swift
+186 −0
@@ -0,0 +1,186 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Theme.swift — design system Groupe-KA adapté iOS (HIG) : papier/encre en | |
| 3 | +// clair, encre/papier en sombre, accents par univers, composants signature | |
| 4 | +// (carte à ombre décalée, puce, wordmark) + haptique. | |
| 5 | +import SwiftUI | |
| 6 | +import UIKit | |
| 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: - Haptique | |
| 25 | + | |
| 26 | +enum Haptics { | |
| 27 | + static func tap() { UIImpactFeedbackGenerator(style: .light).impactOccurred() } | |
| 28 | + static func success() { UINotificationFeedbackGenerator().notificationOccurred(.success) } | |
| 29 | + static func rigid() { UIImpactFeedbackGenerator(style: .rigid).impactOccurred() } | |
| 30 | +} | |
| 31 | + | |
| 32 | +// MARK: - Carte « éditorial sharp » (bordure encre + ombre décalée) | |
| 33 | + | |
| 34 | +struct KACard: ViewModifier { | |
| 35 | + @Environment(\.colorScheme) private var scheme | |
| 36 | + var accent: Color? = nil | |
| 37 | + | |
| 38 | + func body(content: Content) -> some View { | |
| 39 | + content | |
| 40 | + .background(KATheme.surface(scheme)) | |
| 41 | + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) | |
| 42 | + .overlay( | |
| 43 | + RoundedRectangle(cornerRadius: 14, style: .continuous) | |
| 44 | + .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.4) | |
| 45 | + ) | |
| 46 | + .background( | |
| 47 | + RoundedRectangle(cornerRadius: 14, style: .continuous) | |
| 48 | + .fill((accent ?? KATheme.ink(scheme)).opacity(scheme == .dark ? 0.25 : 0.16)) | |
| 49 | + .offset(x: 5, y: 5) | |
| 50 | + ) | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +extension View { | |
| 55 | + func kaCard(accent: Color? = nil) -> some View { modifier(KACard(accent: accent)) } | |
| 56 | +} | |
| 57 | + | |
| 58 | +// MARK: - Wordmark (« Lou » + boîte [Ka] accent) | |
| 59 | + | |
| 60 | +struct KAWordmark: View { | |
| 61 | + let universe: Universe | |
| 62 | + var size: CGFloat = 22 | |
| 63 | + @Environment(\.colorScheme) private var scheme | |
| 64 | + | |
| 65 | + var body: some View { | |
| 66 | + let parts = universe.wordmark.split(separator: "·", maxSplits: 1) | |
| 67 | + HStack(alignment: .firstTextBaseline, spacing: 3) { | |
| 68 | + Text(parts.first.map(String.init) ?? universe.wordmark) | |
| 69 | + .font(.system(size: size, weight: .bold, design: .rounded)) | |
| 70 | + if parts.count > 1 { | |
| 71 | + Text(String(parts[1])) | |
| 72 | + .font(.system(size: size * 0.86, weight: .bold, design: .rounded)) | |
| 73 | + .foregroundStyle(universe.accent) | |
| 74 | + .padding(.horizontal, size * 0.28) | |
| 75 | + .padding(.vertical, size * 0.08) | |
| 76 | + .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.26, style: .continuous)) | |
| 77 | + .rotationEffect(.degrees(-2)) | |
| 78 | + } | |
| 79 | + } | |
| 80 | + .foregroundStyle(KATheme.ink(scheme)) | |
| 81 | + .accessibilityLabel(universe.name) | |
| 82 | + } | |
| 83 | +} | |
| 84 | + | |
| 85 | +struct GroupeKAMark: View { | |
| 86 | + var size: CGFloat = 24 | |
| 87 | + @Environment(\.colorScheme) private var scheme | |
| 88 | + var body: some View { | |
| 89 | + HStack(alignment: .firstTextBaseline, spacing: 4) { | |
| 90 | + Text("Groupe").font(.system(size: size, weight: .bold, design: .rounded)) | |
| 91 | + Text("KA") | |
| 92 | + .font(.system(size: size * 0.9, weight: .bold, design: .rounded)) | |
| 93 | + .foregroundStyle(KATheme.lime) | |
| 94 | + .padding(.horizontal, size * 0.3).padding(.vertical, size * 0.1) | |
| 95 | + .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.28, style: .continuous)) | |
| 96 | + .rotationEffect(.degrees(-2)) | |
| 97 | + } | |
| 98 | + .fixedSize() | |
| 99 | + .foregroundStyle(KATheme.ink(scheme)) | |
| 100 | + .accessibilityLabel("Groupe KA") | |
| 101 | + } | |
| 102 | +} | |
| 103 | + | |
| 104 | +// MARK: - Puce mono (klabel) | |
| 105 | + | |
| 106 | +struct KAChip: View { | |
| 107 | + let text: String | |
| 108 | + var accent: Color? = nil | |
| 109 | + @Environment(\.colorScheme) private var scheme | |
| 110 | + var body: some View { | |
| 111 | + Text(text) | |
| 112 | + .font(.system(.caption2, design: .monospaced).weight(.bold)) | |
| 113 | + .textCase(.uppercase) | |
| 114 | + .padding(.horizontal, 9).padding(.vertical, 4) | |
| 115 | + .background((accent ?? KATheme.ink(scheme)).opacity(0.12), in: Capsule()) | |
| 116 | + .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.4), lineWidth: 1)) | |
| 117 | + } | |
| 118 | +} | |
| 119 | + | |
| 120 | +// MARK: - Rangée d'item universelle | |
| 121 | + | |
| 122 | +struct KAItemRow: View { | |
| 123 | + let item: KAItem | |
| 124 | + @Environment(\.colorScheme) private var scheme | |
| 125 | + @EnvironmentObject private var favorites: FavoritesStore | |
| 126 | + | |
| 127 | + private var universe: Universe? { Ecosystem.universe(item.universeID) } | |
| 128 | + | |
| 129 | + var body: some View { | |
| 130 | + HStack(alignment: .top, spacing: 12) { | |
| 131 | + RoundedRectangle(cornerRadius: 8, style: .continuous) | |
| 132 | + .fill(universe?.accent.opacity(0.9) ?? .gray) | |
| 133 | + .frame(width: 5) | |
| 134 | + .padding(.vertical, 2) | |
| 135 | + VStack(alignment: .leading, spacing: 4) { | |
| 136 | + Text(item.title) | |
| 137 | + .font(.headline) | |
| 138 | + .lineLimit(2) | |
| 139 | + if let sub = item.subtitle, !sub.isEmpty { | |
| 140 | + Text(sub).font(.subheadline) | |
| 141 | + .foregroundStyle(KATheme.ink2(scheme)).lineLimit(2) | |
| 142 | + } | |
| 143 | + HStack(spacing: 8) { | |
| 144 | + if let price = item.priceLabel { | |
| 145 | + Text(price).font(.system(.subheadline, design: .rounded).weight(.bold)) | |
| 146 | + .foregroundStyle(universe?.accent ?? .primary) | |
| 147 | + } | |
| 148 | + if let city = item.city, !city.isEmpty { | |
| 149 | + Text(city).font(.caption).foregroundStyle(KATheme.ink2(scheme)) | |
| 150 | + } | |
| 151 | + Spacer(minLength: 0) | |
| 152 | + if let u = universe { | |
| 153 | + KAChip(text: u.wordmark, accent: u.accent) | |
| 154 | + } | |
| 155 | + } | |
| 156 | + } | |
| 157 | + if favorites.isFavorite(item) { | |
| 158 | + Image(systemName: "heart.fill") | |
| 159 | + .foregroundStyle(.red).font(.caption) | |
| 160 | + .accessibilityLabel("Dans vos favoris") | |
| 161 | + } | |
| 162 | + } | |
| 163 | + .padding(12) | |
| 164 | + .kaCard(accent: universe?.accent) | |
| 165 | + } | |
| 166 | +} | |
| 167 | + | |
| 168 | +// MARK: - États | |
| 169 | + | |
| 170 | +struct KAEmptyState: View { | |
| 171 | + let symbol: String | |
| 172 | + let title: String | |
| 173 | + var message: String? = nil | |
| 174 | + var body: some View { | |
| 175 | + VStack(spacing: 10) { | |
| 176 | + Image(systemName: symbol).font(.system(size: 40)).foregroundStyle(.secondary) | |
| 177 | + Text(title).font(.headline) | |
| 178 | + if let m = message { | |
| 179 | + Text(m).font(.subheadline).foregroundStyle(.secondary) | |
| 180 | + .multilineTextAlignment(.center) | |
| 181 | + } | |
| 182 | + } | |
| 183 | + .padding(30) | |
| 184 | + .frame(maxWidth: .infinity) | |
| 185 | + } | |
| 186 | +} | |
added
KA/Features/AgentChatView.swift
+201 −0
@@ -0,0 +1,201 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// AgentChatView.swift — KA Agent natif : le même assistant IA que sur les | |
| 3 | +// sites (Claude Haiku via l'API centrale), en chat plein écran, flux token | |
| 4 | +// par token + indicateurs d'outils. | |
| 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 | + Haptics.rigid() | |
| 26 | + let history = messages | |
| 27 | + .filter { $0.role == "user" || ($0.role == "assistant" && !$0.content.isEmpty) } | |
| 28 | + .suffix(16) | |
| 29 | + .map { ["role": $0.role, "content": $0.content] } | |
| 30 | + | |
| 31 | + Task { | |
| 32 | + do { | |
| 33 | + for try await event in AgentService.stream(site: site, messages: Array(history)) { | |
| 34 | + switch event.kind { | |
| 35 | + case .delta(let t): | |
| 36 | + if let i = messages.lastIndex(where: { $0.role == "assistant" }) { | |
| 37 | + messages[i].content += t | |
| 38 | + } | |
| 39 | + case .tool(let name): | |
| 40 | + // insérer la puce outil AVANT la bulle assistante en cours | |
| 41 | + if let i = messages.lastIndex(where: { $0.role == "assistant" }) { | |
| 42 | + messages.insert(AgentMessage(role: "tool", content: name.replacingOccurrences(of: "_", with: " ")), at: i) | |
| 43 | + } | |
| 44 | + case .error(let m): | |
| 45 | + if let i = messages.lastIndex(where: { $0.role == "assistant" }), messages[i].content.isEmpty { | |
| 46 | + messages[i].content = "Désolé, une erreur est survenue (\(m)). Réessayez." | |
| 47 | + } | |
| 48 | + case .done: break | |
| 49 | + } | |
| 50 | + } | |
| 51 | + } catch { | |
| 52 | + if let i = messages.lastIndex(where: { $0.role == "assistant" }), messages[i].content.isEmpty { | |
| 53 | + messages[i].content = "Impossible de joindre KA Agent — vérifiez votre connexion." | |
| 54 | + } | |
| 55 | + } | |
| 56 | + busy = false | |
| 57 | + Haptics.success() | |
| 58 | + } | |
| 59 | + } | |
| 60 | +} | |
| 61 | + | |
| 62 | +struct AgentChatView: View { | |
| 63 | + @StateObject private var chat = AgentChat() | |
| 64 | + @State private var input = "" | |
| 65 | + @Environment(\.dismiss) private var dismiss | |
| 66 | + @Environment(\.colorScheme) private var scheme | |
| 67 | + @FocusState private var focused: Bool | |
| 68 | + | |
| 69 | + var body: some View { | |
| 70 | + NavigationStack { | |
| 71 | + VStack(spacing: 0) { | |
| 72 | + ScrollViewReader { proxy in | |
| 73 | + ScrollView { | |
| 74 | + LazyVStack(alignment: .leading, spacing: 10) { | |
| 75 | + hello | |
| 76 | + ForEach(chat.messages) { m in | |
| 77 | + bubble(m).id(m.id) | |
| 78 | + } | |
| 79 | + } | |
| 80 | + .padding(14) | |
| 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 | + .navigationTitle("") | |
| 90 | + .toolbar { | |
| 91 | + ToolbarItem(placement: .topBarLeading) { | |
| 92 | + HStack(spacing: 6) { | |
| 93 | + Text("KA").font(.system(.headline, design: .rounded).weight(.bold)) | |
| 94 | + Text("Agent") | |
| 95 | + .font(.system(.subheadline, design: .rounded).weight(.bold)) | |
| 96 | + .foregroundStyle(KATheme.lime) | |
| 97 | + .padding(.horizontal, 7).padding(.vertical, 2) | |
| 98 | + .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 6)) | |
| 99 | + .rotationEffect(.degrees(-2)) | |
| 100 | + Text("· IA de l'écosystème").font(.caption2).foregroundStyle(.secondary) | |
| 101 | + } | |
| 102 | + } | |
| 103 | + ToolbarItem(placement: .topBarTrailing) { | |
| 104 | + Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } | |
| 105 | + .accessibilityLabel("Fermer KA Agent") | |
| 106 | + } | |
| 107 | + } | |
| 108 | + } | |
| 109 | + } | |
| 110 | + | |
| 111 | + private var hello: some View { | |
| 112 | + Group { | |
| 113 | + if chat.messages.isEmpty { | |
| 114 | + VStack(alignment: .leading, spacing: 8) { | |
| 115 | + Text("👋 Je suis KA Agent.") | |
| 116 | + .font(.headline) | |
| 117 | + 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…") | |
| 118 | + .font(.subheadline).foregroundStyle(.secondary) | |
| 119 | + FlowSuggestions { s in | |
| 120 | + input = s | |
| 121 | + chat.send(s); input = "" | |
| 122 | + } | |
| 123 | + } | |
| 124 | + .padding(4) | |
| 125 | + } | |
| 126 | + } | |
| 127 | + } | |
| 128 | + | |
| 129 | + @ViewBuilder | |
| 130 | + private func bubble(_ m: AgentMessage) -> some View { | |
| 131 | + switch m.role { | |
| 132 | + case "user": | |
| 133 | + Text(m.content) | |
| 134 | + .padding(.horizontal, 13).padding(.vertical, 9) | |
| 135 | + .background(KATheme.lime, in: RoundedRectangle(cornerRadius: 13, style: .continuous)) | |
| 136 | + .foregroundStyle(KATheme.inkLight) | |
| 137 | + .frame(maxWidth: .infinity, alignment: .trailing) | |
| 138 | + case "tool": | |
| 139 | + Label(m.content, systemImage: "magnifyingglass") | |
| 140 | + .font(.system(.caption2, design: .monospaced).weight(.bold)) | |
| 141 | + .textCase(.uppercase) | |
| 142 | + .padding(.horizontal, 10).padding(.vertical, 5) | |
| 143 | + .overlay(Capsule().strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3]))) | |
| 144 | + .foregroundStyle(.secondary) | |
| 145 | + default: | |
| 146 | + Group { | |
| 147 | + if m.content.isEmpty { | |
| 148 | + ProgressView().padding(10) | |
| 149 | + } else { | |
| 150 | + Text(LocalizedStringKey(m.content)) // rend **gras** et liens Markdown | |
| 151 | + .textSelection(.enabled) | |
| 152 | + .padding(.horizontal, 13).padding(.vertical, 9) | |
| 153 | + .kaCard() | |
| 154 | + } | |
| 155 | + } | |
| 156 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 157 | + } | |
| 158 | + } | |
| 159 | + | |
| 160 | + private var inputBar: some View { | |
| 161 | + HStack(spacing: 8) { | |
| 162 | + TextField("Posez votre question…", text: $input, axis: .vertical) | |
| 163 | + .lineLimit(1...4) | |
| 164 | + .padding(.horizontal, 13).padding(.vertical, 10) | |
| 165 | + .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) | |
| 166 | + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(.primary.opacity(0.35), lineWidth: 1.2)) | |
| 167 | + .focused($focused) | |
| 168 | + .onSubmit { chat.send(input); input = "" } | |
| 169 | + Button { | |
| 170 | + chat.send(input); input = "" | |
| 171 | + } label: { | |
| 172 | + Image(systemName: "arrow.up") | |
| 173 | + .font(.headline) | |
| 174 | + .frame(width: 44, height: 44) | |
| 175 | + .background(KATheme.inkLight, in: Circle()) | |
| 176 | + .foregroundStyle(KATheme.lime) | |
| 177 | + } | |
| 178 | + .disabled(chat.busy || input.trimmingCharacters(in: .whitespaces).isEmpty) | |
| 179 | + .accessibilityLabel("Envoyer") | |
| 180 | + } | |
| 181 | + .padding(12) | |
| 182 | + .background(.bar) | |
| 183 | + } | |
| 184 | +} | |
| 185 | + | |
| 186 | +private struct FlowSuggestions: View { | |
| 187 | + let action: (String) -> Void | |
| 188 | + private let ideas = ["Combien de logements à louer ?", "Un resto italien à Montréal", "Les sorties gratuites ce week-end", "C'est quoi le Groupe KA ?"] | |
| 189 | + var body: some View { | |
| 190 | + VStack(alignment: .leading, spacing: 6) { | |
| 191 | + ForEach(ideas, id: \.self) { s in | |
| 192 | + Button { action(s) } label: { | |
| 193 | + Text(s).font(.caption.weight(.semibold)) | |
| 194 | + .padding(.horizontal, 11).padding(.vertical, 7) | |
| 195 | + .background(.quaternary, in: Capsule()) | |
| 196 | + } | |
| 197 | + .buttonStyle(.plain) | |
| 198 | + } | |
| 199 | + } | |
| 200 | + } | |
| 201 | +} | |
added
KA/Features/FavoritesView.swift
+90 −0
@@ -0,0 +1,90 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// FavoritesView.swift — favoris & collections unifiés (multi-univers dans une | |
| 3 | +// même collection), consultables hors ligne, partage natif. | |
| 4 | +import SwiftUI | |
| 5 | + | |
| 6 | +struct FavoritesView: View { | |
| 7 | + @EnvironmentObject private var favorites: FavoritesStore | |
| 8 | + @State private var newName = "" | |
| 9 | + @State private var showNew = false | |
| 10 | + @Environment(\.colorScheme) private var scheme | |
| 11 | + | |
| 12 | + var body: some View { | |
| 13 | + NavigationStack { | |
| 14 | + ScrollView { | |
| 15 | + VStack(alignment: .leading, spacing: 18) { | |
| 16 | + if favorites.allItems.isEmpty { | |
| 17 | + KAEmptyState(symbol: "heart", title: "Aucun favori pour l'instant", | |
| 18 | + message: "Touchez ♥ sur un logement, une auto, un resto ou une sortie — tout se retrouve ici, même hors ligne.") | |
| 19 | + } | |
| 20 | + ForEach(favorites.collections) { collection in | |
| 21 | + collectionSection(collection) | |
| 22 | + } | |
| 23 | + } | |
| 24 | + .padding(16) | |
| 25 | + } | |
| 26 | + .background(KATheme.paper(scheme)) | |
| 27 | + .navigationTitle("Favoris") | |
| 28 | + .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } | |
| 29 | + .toolbar { | |
| 30 | + ToolbarItem(placement: .topBarTrailing) { | |
| 31 | + Button { showNew = true } label: { Image(systemName: "folder.badge.plus") } | |
| 32 | + .accessibilityLabel("Nouvelle collection") | |
| 33 | + } | |
| 34 | + } | |
| 35 | + .alert("Nouvelle collection", isPresented: $showNew) { | |
| 36 | + TextField("Ex. Déménagement à Val-d'Or", text: $newName) | |
| 37 | + Button("Créer") { | |
| 38 | + favorites.addCollection(newName) | |
| 39 | + newName = "" | |
| 40 | + Haptics.success() | |
| 41 | + } | |
| 42 | + Button("Annuler", role: .cancel) { newName = "" } | |
| 43 | + } message: { | |
| 44 | + Text("Une collection peut mélanger tous les univers : un 4½, une auto et un resto ensemble.") | |
| 45 | + } | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + @ViewBuilder | |
| 50 | + private func collectionSection(_ c: FavoritesStore.FavCollection) -> some View { | |
| 51 | + VStack(alignment: .leading, spacing: 10) { | |
| 52 | + HStack { | |
| 53 | + Label(c.name, systemImage: "folder.fill") | |
| 54 | + .font(.headline) | |
| 55 | + Spacer() | |
| 56 | + Text("\(c.items.count)") | |
| 57 | + .font(.system(.caption, design: .monospaced).weight(.bold)) | |
| 58 | + .foregroundStyle(.secondary) | |
| 59 | + if favorites.collections.count > 1 { | |
| 60 | + Menu { | |
| 61 | + Button(role: .destructive) { | |
| 62 | + favorites.removeCollection(c.id) | |
| 63 | + } label: { Label("Supprimer la collection", systemImage: "trash") } | |
| 64 | + } label: { | |
| 65 | + Image(systemName: "ellipsis.circle").foregroundStyle(.secondary) | |
| 66 | + } | |
| 67 | + } | |
| 68 | + } | |
| 69 | + if c.items.isEmpty { | |
| 70 | + Text("Vide — ajoutez-y des trouvailles depuis n'importe quel univers.") | |
| 71 | + .font(.caption).foregroundStyle(.tertiary) | |
| 72 | + } | |
| 73 | + ForEach(c.items) { item in | |
| 74 | + NavigationLink(value: item) { KAItemRow(item: item) } | |
| 75 | + .buttonStyle(.plain) | |
| 76 | + .contextMenu { | |
| 77 | + ForEach(favorites.collections.filter { $0.id != c.id }) { other in | |
| 78 | + Button { favorites.move(item, to: other.id) } label: { | |
| 79 | + Label("Déplacer vers \(other.name)", systemImage: "folder") | |
| 80 | + } | |
| 81 | + } | |
| 82 | + if let url = item.url { ShareLink(item: url) { Label("Partager", systemImage: "square.and.arrow.up") } } | |
| 83 | + Button(role: .destructive) { favorites.toggle(item) } label: { | |
| 84 | + Label("Retirer", systemImage: "heart.slash") | |
| 85 | + } | |
| 86 | + } | |
| 87 | + } | |
| 88 | + } | |
| 89 | + } | |
| 90 | +} | |
added
KA/Features/HomeView.swift
+189 −0
@@ -0,0 +1,189 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// HomeView.swift — l'accueil VIVANT : salutation selon l'heure, suggestions | |
| 3 | +// contextuelles (restos le soir, sorties le week-end, emplois le matin…) avec | |
| 4 | +// du VRAI contenu en direct, et le pouls de l'écosystème (compteurs live). | |
| 5 | +import SwiftUI | |
| 6 | + | |
| 7 | +struct HomeView: View { | |
| 8 | + @State private var featured: [(universe: Universe, items: [KAItem])] = [] | |
| 9 | + @State private var pulse: [(Universe, Int)] = [] | |
| 10 | + @State private var loading = true | |
| 11 | + @AppStorage("ka.favUniverses") private var favUniversesRaw = "" | |
| 12 | + @Environment(\.colorScheme) private var scheme | |
| 13 | + | |
| 14 | + var body: some View { | |
| 15 | + NavigationStack { | |
| 16 | + ScrollView { | |
| 17 | + VStack(alignment: .leading, spacing: 22) { | |
| 18 | + header | |
| 19 | + pulseStrip | |
| 20 | + ForEach(featured, id: \.universe.id) { section in | |
| 21 | + featuredSection(section.universe, section.items) | |
| 22 | + } | |
| 23 | + if loading && featured.isEmpty { | |
| 24 | + ProgressView("KA prépare votre accueil…") | |
| 25 | + .frame(maxWidth: .infinity).padding(40) | |
| 26 | + } | |
| 27 | + disclaimer | |
| 28 | + } | |
| 29 | + .padding(16) | |
| 30 | + } | |
| 31 | + .background(KATheme.paper(scheme)) | |
| 32 | + .navigationTitle("") | |
| 33 | + .toolbar { | |
| 34 | + ToolbarItem(placement: .topBarLeading) { | |
| 35 | + GroupeKAMark(size: 16) | |
| 36 | + } | |
| 37 | + } | |
| 38 | + .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } | |
| 39 | + .navigationDestination(for: String.self) { id in | |
| 40 | + if let u = Ecosystem.universe(id) { UniverseHomeView(universe: u) } | |
| 41 | + } | |
| 42 | + .refreshable { await load() } | |
| 43 | + .task { await load() } | |
| 44 | + } | |
| 45 | + } | |
| 46 | + | |
| 47 | + // MARK: sous-vues | |
| 48 | + | |
| 49 | + private var greeting: (title: String, sub: String) { | |
| 50 | + let h = Calendar.current.component(.hour, from: Date()) | |
| 51 | + let weekend = Calendar.current.isDateInWeekend(Date()) | |
| 52 | + switch h { | |
| 53 | + case 5..<12: return ("Bon matin ☀️", weekend ? "On planifie la fin de semaine ?" : "L'écosystème a travaillé toute la nuit.") | |
| 54 | + case 12..<17: return ("Bon après-midi 👋", "Tout le Québec, dans votre poche.") | |
| 55 | + case 17..<22: return ("Bonsoir 🌆", weekend ? "Resto ? Sortie ? KA a des idées." : "On soupe où ce soir ?") | |
| 56 | + default: return ("Bonne nuit 🌙", "Les connecteurs veillent — revenez demain matin.") | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + private var header: some View { | |
| 61 | + VStack(alignment: .leading, spacing: 5) { | |
| 62 | + Text(greeting.title).font(.system(.largeTitle, design: .rounded).weight(.bold)) | |
| 63 | + Text(greeting.sub).font(.subheadline).foregroundStyle(KATheme.ink2(scheme)) | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + private var pulseStrip: some View { | |
| 68 | + ScrollView(.horizontal, showsIndicators: false) { | |
| 69 | + HStack(spacing: 10) { | |
| 70 | + ForEach(pulse, id: \.0.id) { (u, n) in | |
| 71 | + NavigationLink(value: u.id) { | |
| 72 | + VStack(alignment: .leading, spacing: 3) { | |
| 73 | + Text(n.formatted(.number.locale(Locale(identifier: "fr_CA")))) | |
| 74 | + .font(.system(.headline, design: .rounded).weight(.bold)) | |
| 75 | + .foregroundStyle(u.accent) | |
| 76 | + .contentTransition(.numericText()) | |
| 77 | + Text(u.unit).font(.caption2).foregroundStyle(.secondary) | |
| 78 | + } | |
| 79 | + .padding(.horizontal, 13).padding(.vertical, 9) | |
| 80 | + .kaCard(accent: u.accent) | |
| 81 | + } | |
| 82 | + .buttonStyle(.plain) | |
| 83 | + } | |
| 84 | + if pulse.isEmpty { | |
| 85 | + Text("Le pouls de l'écosystème…").font(.caption).foregroundStyle(.tertiary).padding(10) | |
| 86 | + } | |
| 87 | + } | |
| 88 | + } | |
| 89 | + .accessibilityLabel("Le pouls de l'écosystème en direct") | |
| 90 | + } | |
| 91 | + | |
| 92 | + private func featuredSection(_ u: Universe, _ items: [KAItem]) -> some View { | |
| 93 | + VStack(alignment: .leading, spacing: 10) { | |
| 94 | + HStack { | |
| 95 | + KAWordmark(universe: u, size: 17) | |
| 96 | + Spacer() | |
| 97 | + NavigationLink(value: u.id) { | |
| 98 | + Text("Tout voir").font(.caption.weight(.semibold)).foregroundStyle(u.accent) | |
| 99 | + } | |
| 100 | + } | |
| 101 | + ScrollView(.horizontal, showsIndicators: false) { | |
| 102 | + HStack(spacing: 12) { | |
| 103 | + ForEach(items.prefix(8)) { item in | |
| 104 | + NavigationLink(value: item) { | |
| 105 | + VStack(alignment: .leading, spacing: 6) { | |
| 106 | + Text(item.title).font(.subheadline.weight(.semibold)) | |
| 107 | + .lineLimit(2, reservesSpace: true) | |
| 108 | + .multilineTextAlignment(.leading) | |
| 109 | + if let s = item.subtitle { | |
| 110 | + Text(s).font(.caption).foregroundStyle(.secondary).lineLimit(1) | |
| 111 | + } | |
| 112 | + Spacer(minLength: 0) | |
| 113 | + HStack { | |
| 114 | + if let p = item.priceLabel { | |
| 115 | + Text(p).font(.system(.caption, design: .rounded).weight(.bold)) | |
| 116 | + .foregroundStyle(u.accent) | |
| 117 | + } | |
| 118 | + Spacer() | |
| 119 | + if let c = item.city { Text(c).font(.caption2).foregroundStyle(.tertiary) } | |
| 120 | + } | |
| 121 | + } | |
| 122 | + .padding(12) | |
| 123 | + .frame(width: 190, height: 110, alignment: .topLeading) | |
| 124 | + .kaCard(accent: u.accent) | |
| 125 | + } | |
| 126 | + .buttonStyle(.plain) | |
| 127 | + } | |
| 128 | + } | |
| 129 | + .padding(.vertical, 4) | |
| 130 | + } | |
| 131 | + } | |
| 132 | + } | |
| 133 | + | |
| 134 | + private var disclaimer: some View { | |
| 135 | + Text(Ecosystem.disclaimer) | |
| 136 | + .font(.caption2).foregroundStyle(.tertiary) | |
| 137 | + .padding(.top, 8) | |
| 138 | + } | |
| 139 | + | |
| 140 | + // MARK: données | |
| 141 | + | |
| 142 | + /// Univers mis en avant selon l'heure + les préférences d'onboarding. | |
| 143 | + private var contextualUniverses: [Universe] { | |
| 144 | + let h = Calendar.current.component(.hour, from: Date()) | |
| 145 | + let weekend = Calendar.current.isDateInWeekend(Date()) | |
| 146 | + var ids: [String] | |
| 147 | + switch h { | |
| 148 | + case 5..<11: ids = ["job-ka", "food-ka", "immo-ka"] | |
| 149 | + case 11..<14: ids = ["resto-ka", "food-ka", "sorti-ka"] | |
| 150 | + case 14..<17: ids = weekend ? ["sorti-ka", "resto-ka", "auto-ka"] : ["immo-ka", "auto-ka", "lou-ka"] | |
| 151 | + case 17..<23: ids = ["resto-ka", "sorti-ka", "crea-ka"] | |
| 152 | + default: ids = ["sorti-ka", "crea-ka", "lou-ka"] | |
| 153 | + } | |
| 154 | + let favs = favUniversesRaw.split(separator: ",").map(String.init) | |
| 155 | + for f in favs.reversed() where !ids.contains(f) { ids.insert(f, at: 0) } | |
| 156 | + return ids.prefix(4).compactMap(Ecosystem.universe).filter { $0.map != nil } | |
| 157 | + } | |
| 158 | + | |
| 159 | + private func load() async { | |
| 160 | + loading = true | |
| 161 | + let universes = contextualUniverses | |
| 162 | + var found: [String: [KAItem]] = [:] | |
| 163 | + await withTaskGroup(of: (String, [KAItem]).self) { group in | |
| 164 | + for u in universes { | |
| 165 | + group.addTask { (u.id, (try? await UniverseService.fetch(u, limit: 8)) ?? []) } | |
| 166 | + } | |
| 167 | + for u in Ecosystem.all where u.statTotalKeys.first != nil { | |
| 168 | + _ = u // pouls géré plus bas | |
| 169 | + } | |
| 170 | + for await (id, items) in group { found[id] = items } | |
| 171 | + } | |
| 172 | + featured = universes.compactMap { u in | |
| 173 | + let items = found[u.id] ?? [] | |
| 174 | + return items.isEmpty ? nil : (u, items) | |
| 175 | + } | |
| 176 | + loading = false | |
| 177 | + // pouls (compteurs live) — après le contenu pour ne pas retarder l'accueil | |
| 178 | + var counts: [(Universe, Int)] = [] | |
| 179 | + await withTaskGroup(of: (String, Int?).self) { group in | |
| 180 | + for u in Ecosystem.all { | |
| 181 | + group.addTask { (u.id, await UniverseService.liveTotal(u)) } | |
| 182 | + } | |
| 183 | + for await (id, n) in group { | |
| 184 | + if let n, let u = Ecosystem.universe(id) { counts.append((u, n)) } | |
| 185 | + } | |
| 186 | + } | |
| 187 | + pulse = counts.sorted { $0.1 > $1.1 } | |
| 188 | + } | |
| 189 | +} | |
added
KA/Features/MapView.swift
+153 −0
@@ -0,0 +1,153 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// MapView.swift — la CARTE UNIFIÉE : les résultats de plusieurs univers autour | |
| 3 | +// de l'utilisateur (logements, propriétés, emplois, restos, sorties) sur une | |
| 4 | +// même carte, épingles teintées par univers, filtre par univers. | |
| 5 | +import SwiftUI | |
| 6 | +import MapKit | |
| 7 | +import CoreLocation | |
| 8 | + | |
| 9 | +struct UnifiedMapView: View { | |
| 10 | + @State private var camera: MapCameraPosition = .region( | |
| 11 | + MKCoordinateRegion(center: .init(latitude: 46.81, longitude: -71.21), // Québec | |
| 12 | + span: .init(latitudeDelta: 0.35, longitudeDelta: 0.35)) | |
| 13 | + ) | |
| 14 | + @State private var items: [KAItem] = [] | |
| 15 | + @State private var enabled: Set<String> = ["lou-ka", "immo-ka", "resto-ka", "sorti-ka", "job-ka"] | |
| 16 | + @State private var loading = false | |
| 17 | + @State private var selection: KAItem? | |
| 18 | + @State private var visibleRegion: MKCoordinateRegion? | |
| 19 | + @StateObject private var location = LocationOnce() | |
| 20 | + @Environment(\.colorScheme) private var scheme | |
| 21 | + @Environment(\.dismiss) private var dismiss | |
| 22 | + | |
| 23 | + private var mapUniverses: [Universe] { | |
| 24 | + Ecosystem.all.filter { ["lou-ka", "immo-ka", "resto-ka", "sorti-ka", "job-ka"].contains($0.id) } | |
| 25 | + } | |
| 26 | + | |
| 27 | + var body: some View { | |
| 28 | + NavigationStack { | |
| 29 | + Map(position: $camera, selection: $selection) { | |
| 30 | + UserAnnotation() | |
| 31 | + ForEach(items.filter { enabled.contains($0.universeID) }) { item in | |
| 32 | + if let lat = item.latitude, let lon = item.longitude, | |
| 33 | + let u = Ecosystem.universe(item.universeID) { | |
| 34 | + Marker(item.title, systemImage: u.symbol, | |
| 35 | + coordinate: .init(latitude: lat, longitude: lon)) | |
| 36 | + .tint(u.accent) | |
| 37 | + .tag(item) | |
| 38 | + } | |
| 39 | + } | |
| 40 | + } | |
| 41 | + .mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll)) | |
| 42 | + .onMapCameraChange(frequency: .onEnd) { ctx in visibleRegion = ctx.region } | |
| 43 | + .safeAreaInset(edge: .top) { chips } | |
| 44 | + .safeAreaInset(edge: .bottom) { bottomBar } | |
| 45 | + .sheet(item: $selection) { item in | |
| 46 | + NavigationStack { ItemDetailView(item: item) } | |
| 47 | + .presentationDetents([.medium, .large]) | |
| 48 | + } | |
| 49 | + .navigationTitle("Carte") | |
| 50 | + .navigationBarTitleDisplayMode(.inline) | |
| 51 | + .toolbar { | |
| 52 | + ToolbarItem(placement: .topBarLeading) { | |
| 53 | + Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } | |
| 54 | + .accessibilityLabel("Fermer la carte") | |
| 55 | + } | |
| 56 | + } | |
| 57 | + .task { | |
| 58 | + location.request() | |
| 59 | + await load() | |
| 60 | + } | |
| 61 | + .onChange(of: location.coordinate != nil) { | |
| 62 | + if let c = location.coordinate { | |
| 63 | + camera = .region(.init(center: c, span: .init(latitudeDelta: 0.25, longitudeDelta: 0.25))) | |
| 64 | + Task { await load() } | |
| 65 | + } | |
| 66 | + } | |
| 67 | + } | |
| 68 | + } | |
| 69 | + | |
| 70 | + private var chips: some View { | |
| 71 | + ScrollView(.horizontal, showsIndicators: false) { | |
| 72 | + HStack(spacing: 8) { | |
| 73 | + ForEach(mapUniverses) { u in | |
| 74 | + let on = enabled.contains(u.id) | |
| 75 | + Button { | |
| 76 | + Haptics.tap() | |
| 77 | + if on { enabled.remove(u.id) } else { enabled.insert(u.id) } | |
| 78 | + } label: { | |
| 79 | + Label(u.wordmark, systemImage: u.symbol) | |
| 80 | + .font(.system(.caption, design: .monospaced).weight(.bold)) | |
| 81 | + .padding(.horizontal, 11).padding(.vertical, 8) | |
| 82 | + .background(on ? u.accent : KATheme.surface(scheme), in: Capsule()) | |
| 83 | + .foregroundStyle(on ? .white : .secondary) | |
| 84 | + .overlay(Capsule().strokeBorder(.black.opacity(0.25), lineWidth: 1)) | |
| 85 | + } | |
| 86 | + } | |
| 87 | + } | |
| 88 | + .padding(.horizontal, 14).padding(.vertical, 8) | |
| 89 | + } | |
| 90 | + .background(.ultraThinMaterial) | |
| 91 | + } | |
| 92 | + | |
| 93 | + private var bottomBar: some View { | |
| 94 | + HStack { | |
| 95 | + Text(loading ? "Chargement autour d'ici…" | |
| 96 | + : "\(items.filter { enabled.contains($0.universeID) && $0.latitude != nil }.count) résultats sur la carte") | |
| 97 | + .font(.system(.caption, design: .monospaced).weight(.bold)) | |
| 98 | + Spacer() | |
| 99 | + Button { | |
| 100 | + Haptics.rigid() | |
| 101 | + Task { await load() } | |
| 102 | + } label: { | |
| 103 | + Label("Chercher ici", systemImage: "arrow.clockwise") | |
| 104 | + .font(.caption.weight(.bold)) | |
| 105 | + .padding(.horizontal, 13).padding(.vertical, 9) | |
| 106 | + .background(KATheme.inkLight, in: Capsule()) | |
| 107 | + .foregroundStyle(KATheme.lime) | |
| 108 | + } | |
| 109 | + } | |
| 110 | + .padding(.horizontal, 14).padding(.vertical, 9) | |
| 111 | + .background(.ultraThinMaterial) | |
| 112 | + } | |
| 113 | + | |
| 114 | + /// Charge les items géolocalisés des univers actifs (autour de la vue). | |
| 115 | + private func load() async { | |
| 116 | + loading = true | |
| 117 | + var all: [KAItem] = [] | |
| 118 | + await withTaskGroup(of: [KAItem].self) { group in | |
| 119 | + for u in mapUniverses { | |
| 120 | + group.addTask { (try? await UniverseService.fetch(u, limit: 80)) ?? [] } | |
| 121 | + } | |
| 122 | + for await batch in group { all.append(contentsOf: batch) } | |
| 123 | + } | |
| 124 | + items = all.filter { $0.latitude != nil && $0.longitude != nil } | |
| 125 | + loading = false | |
| 126 | + if !items.isEmpty { Haptics.tap() } | |
| 127 | + } | |
| 128 | +} | |
| 129 | + | |
| 130 | +/// Demande de localisation « une fois » (jamais de suivi continu). | |
| 131 | +final class LocationOnce: NSObject, ObservableObject, CLLocationManagerDelegate { | |
| 132 | + @Published var coordinate: CLLocationCoordinate2D? | |
| 133 | + private let manager = CLLocationManager() | |
| 134 | + | |
| 135 | + func request() { | |
| 136 | + manager.delegate = self | |
| 137 | + if manager.authorizationStatus == .notDetermined { | |
| 138 | + manager.requestWhenInUseAuthorization() | |
| 139 | + } else { | |
| 140 | + manager.requestLocation() | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + func locationManagerDidChangeAuthorization(_ m: CLLocationManager) { | |
| 145 | + if m.authorizationStatus == .authorizedWhenInUse || m.authorizationStatus == .authorizedAlways { | |
| 146 | + m.requestLocation() | |
| 147 | + } | |
| 148 | + } | |
| 149 | + func locationManager(_ m: CLLocationManager, didUpdateLocations locs: [CLLocation]) { | |
| 150 | + coordinate = locs.first?.coordinate | |
| 151 | + } | |
| 152 | + func locationManager(_ m: CLLocationManager, didFailWithError error: Error) {} | |
| 153 | +} | |
added
KA/Features/ProfileView.swift
+110 −0
@@ -0,0 +1,110 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// ProfileView.swift — profil : KA ID (compte unique de l'écosystème), univers | |
| 3 | +// favoris, apparence, contacts complets du Groupe KA, pages légales, à propos. | |
| 4 | +import SwiftUI | |
| 5 | + | |
| 6 | +struct ProfileView: View { | |
| 7 | + @AppStorage("ka.favUniverses") private var favUniversesRaw = "" | |
| 8 | + @AppStorage("ka.appearance") private var appearance = "auto" | |
| 9 | + @Environment(\.colorScheme) private var scheme | |
| 10 | + | |
| 11 | + private var favIDs: Set<String> { | |
| 12 | + Set(favUniversesRaw.split(separator: ",").map(String.init)) | |
| 13 | + } | |
| 14 | + | |
| 15 | + var body: some View { | |
| 16 | + NavigationStack { | |
| 17 | + List { | |
| 18 | + Section { | |
| 19 | + HStack(spacing: 14) { | |
| 20 | + ZStack { | |
| 21 | + RoundedRectangle(cornerRadius: 14, style: .continuous) | |
| 22 | + .fill(KATheme.inkLight) | |
| 23 | + .frame(width: 56, height: 56) | |
| 24 | + Text("KA").font(.system(size: 22, weight: .bold, design: .rounded)) | |
| 25 | + .foregroundStyle(KATheme.lime) | |
| 26 | + .rotationEffect(.degrees(-4)) | |
| 27 | + } | |
| 28 | + VStack(alignment: .leading, spacing: 3) { | |
| 29 | + Text("Votre KA ID").font(.headline) | |
| 30 | + Text("Un seul compte pour tout l'écosystème — création et gestion sur groupe-ka.com.") | |
| 31 | + .font(.caption).foregroundStyle(.secondary) | |
| 32 | + } | |
| 33 | + } | |
| 34 | + Link(destination: Ecosystem.signupURL) { | |
| 35 | + Label("Se connecter / créer un compte KA ID", systemImage: "person.crop.circle.badge.checkmark") | |
| 36 | + } | |
| 37 | + } | |
| 38 | + | |
| 39 | + Section { | |
| 40 | + ForEach(Ecosystem.all) { u in | |
| 41 | + Button { | |
| 42 | + var ids = favIDs | |
| 43 | + if ids.contains(u.id) { ids.remove(u.id) } else { ids.insert(u.id) } | |
| 44 | + favUniversesRaw = ids.sorted().joined(separator: ",") | |
| 45 | + Haptics.tap() | |
| 46 | + } label: { | |
| 47 | + HStack { | |
| 48 | + Image(systemName: u.symbol).foregroundStyle(u.accent).frame(width: 26) | |
| 49 | + Text(u.name).foregroundStyle(.primary) | |
| 50 | + Spacer() | |
| 51 | + if favIDs.contains(u.id) { | |
| 52 | + Image(systemName: "star.fill").foregroundStyle(.yellow) | |
| 53 | + } | |
| 54 | + } | |
| 55 | + } | |
| 56 | + } | |
| 57 | + } header: { | |
| 58 | + Text("Mes univers favoris") | |
| 59 | + } footer: { | |
| 60 | + Text("Vos univers favoris remontent en tête de l'accueil.") | |
| 61 | + } | |
| 62 | + | |
| 63 | + Section("Apparence") { | |
| 64 | + Picker("Thème", selection: $appearance) { | |
| 65 | + Text("Automatique").tag("auto") | |
| 66 | + Text("Clair").tag("clair") | |
| 67 | + Text("Sombre").tag("sombre") | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + Section("Joindre le Groupe KA") { | |
| 72 | + ForEach(Ecosystem.contacts, id: \.email) { c in | |
| 73 | + Link(destination: URL(string: "mailto:\(c.email)")!) { | |
| 74 | + VStack(alignment: .leading, spacing: 2) { | |
| 75 | + Text(c.email).font(.subheadline.weight(.semibold)) | |
| 76 | + Text(c.role).font(.caption).foregroundStyle(.secondary) | |
| 77 | + } | |
| 78 | + } | |
| 79 | + } | |
| 80 | + Link(destination: Ecosystem.hubURL) { | |
| 81 | + Label("groupe-ka.com — le portail", systemImage: "globe") | |
| 82 | + } | |
| 83 | + Link(destination: Ecosystem.statusURL) { | |
| 84 | + Label("État des services en direct", systemImage: "waveform.path.ecg") | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + Section("Légal") { | |
| 89 | + ForEach(Ecosystem.legal, id: \.path) { l in | |
| 90 | + Link(destination: Ecosystem.hubURL.appendingPathComponent(l.path)) { | |
| 91 | + Text(l.label) | |
| 92 | + } | |
| 93 | + } | |
| 94 | + Text(Ecosystem.disclaimer) | |
| 95 | + .font(.caption2).foregroundStyle(.secondary) | |
| 96 | + } | |
| 97 | + | |
| 98 | + Section("À propos") { | |
| 99 | + LabeledContent("Version", value: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0") | |
| 100 | + LabeledContent("Écosystème", value: "\(Ecosystem.all.count) univers") | |
| 101 | + Text("KA — tout l'écosystème Groupe-KA dans votre poche. Fait au Québec. 💚") | |
| 102 | + .font(.caption).foregroundStyle(.secondary) | |
| 103 | + } | |
| 104 | + } | |
| 105 | + .navigationTitle("Profil") | |
| 106 | + .scrollContentBackground(.hidden) | |
| 107 | + .background(KATheme.paper(scheme)) | |
| 108 | + } | |
| 109 | + } | |
| 110 | +} | |
added
KA/Features/SearchView.swift
+167 −0
@@ -0,0 +1,167 @@ | ||
| 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 mélangés groupés par univers, filtres par univers, | |
| 5 | +// historique local. | |
| 6 | +import SwiftUI | |
| 7 | + | |
| 8 | +struct SearchView: View { | |
| 9 | + @State private var query = "" | |
| 10 | + @State private var submitted = "" | |
| 11 | + @State private var sections: [(universe: Universe, items: [KAItem])] = [] | |
| 12 | + @State private var searching = false | |
| 13 | + @State private var selected: Set<String> = [] // filtres univers (vide = tous) | |
| 14 | + @AppStorage("ka.search.history") private var historyRaw = "" | |
| 15 | + @Environment(\.colorScheme) private var scheme | |
| 16 | + | |
| 17 | + private var history: [String] { historyRaw.split(separator: "\n").map(String.init) } | |
| 18 | + private var searchables: [Universe] { Ecosystem.all.filter { $0.map != nil } } | |
| 19 | + | |
| 20 | + var body: some View { | |
| 21 | + NavigationStack { | |
| 22 | + ScrollView { | |
| 23 | + LazyVStack(alignment: .leading, spacing: 16) { | |
| 24 | + filterChips | |
| 25 | + if searching { | |
| 26 | + HStack(spacing: 10) { | |
| 27 | + ProgressView() | |
| 28 | + Text("KA interroge \(selected.isEmpty ? searchables.count : selected.count) univers…") | |
| 29 | + .font(.subheadline).foregroundStyle(.secondary) | |
| 30 | + } | |
| 31 | + .padding(24).frame(maxWidth: .infinity) | |
| 32 | + } else if submitted.isEmpty { | |
| 33 | + suggestions | |
| 34 | + } else if sections.allSatisfy({ $0.items.isEmpty }) { | |
| 35 | + KAEmptyState(symbol: "magnifyingglass", title: "Rien trouvé pour « \(submitted) »", | |
| 36 | + message: "Essayez un autre mot, ou élargissez les univers filtrés.") | |
| 37 | + } else { | |
| 38 | + results | |
| 39 | + } | |
| 40 | + } | |
| 41 | + .padding(16) | |
| 42 | + } | |
| 43 | + .background(KATheme.paper(scheme)) | |
| 44 | + .navigationTitle("Recherche") | |
| 45 | + .searchable(text: $query, prompt: "appartement Rouyn, resto italien, Corolla…") | |
| 46 | + .onSubmit(of: .search) { Task { await search() } } | |
| 47 | + .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } | |
| 48 | + } | |
| 49 | + } | |
| 50 | + | |
| 51 | + private var filterChips: some View { | |
| 52 | + ScrollView(.horizontal, showsIndicators: false) { | |
| 53 | + HStack(spacing: 8) { | |
| 54 | + ForEach(searchables) { u in | |
| 55 | + let on = selected.isEmpty || selected.contains(u.id) | |
| 56 | + Button { | |
| 57 | + Haptics.tap() | |
| 58 | + if selected.contains(u.id) { selected.remove(u.id) } | |
| 59 | + else { selected.insert(u.id) } | |
| 60 | + if selected.count == searchables.count { selected = [] } | |
| 61 | + if !submitted.isEmpty { Task { await search(submitted) } } | |
| 62 | + } label: { | |
| 63 | + Text(u.wordmark) | |
| 64 | + .font(.system(.caption, design: .monospaced).weight(.bold)) | |
| 65 | + .padding(.horizontal, 11).padding(.vertical, 7) | |
| 66 | + .background(on ? u.accent.opacity(0.9) : KATheme.surface(scheme), in: Capsule()) | |
| 67 | + .foregroundStyle(on ? .white : .secondary) | |
| 68 | + .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1)) | |
| 69 | + } | |
| 70 | + .accessibilityLabel("\(on ? "Masquer" : "Afficher") \(u.name)") | |
| 71 | + } | |
| 72 | + } | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + private var suggestions: some View { | |
| 77 | + VStack(alignment: .leading, spacing: 14) { | |
| 78 | + if !history.isEmpty { | |
| 79 | + Text("Récemment cherché").font(.headline) | |
| 80 | + ForEach(history.prefix(6), id: \.self) { h in | |
| 81 | + Button { | |
| 82 | + query = h | |
| 83 | + Task { await search(h) } | |
| 84 | + } label: { | |
| 85 | + HStack { | |
| 86 | + Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary) | |
| 87 | + Text(h) | |
| 88 | + Spacer() | |
| 89 | + } | |
| 90 | + .padding(12).kaCard() | |
| 91 | + } | |
| 92 | + .buttonStyle(.plain) | |
| 93 | + } | |
| 94 | + } | |
| 95 | + Text("Idées").font(.headline) | |
| 96 | + ForEach(["4½ à Québec", "resto italien Montréal", "Toyota Corolla", "emploi infirmière", "spectacle ce week-end"], id: \.self) { s in | |
| 97 | + Button { query = s; Task { await search(s) } } label: { | |
| 98 | + HStack { | |
| 99 | + Image(systemName: "sparkle.magnifyingglass").foregroundStyle(KATheme.green) | |
| 100 | + Text(s); Spacer() | |
| 101 | + } | |
| 102 | + .padding(12).kaCard() | |
| 103 | + } | |
| 104 | + .buttonStyle(.plain) | |
| 105 | + } | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + private var results: some View { | |
| 110 | + ForEach(sections.filter { !$0.items.isEmpty }, id: \.universe.id) { section in | |
| 111 | + VStack(alignment: .leading, spacing: 10) { | |
| 112 | + HStack { | |
| 113 | + KAWordmark(universe: section.universe, size: 16) | |
| 114 | + Spacer() | |
| 115 | + Text("\(section.items.count)") | |
| 116 | + .font(.system(.caption, design: .monospaced).weight(.bold)) | |
| 117 | + .foregroundStyle(section.universe.accent) | |
| 118 | + } | |
| 119 | + ForEach(section.items.prefix(5)) { item in | |
| 120 | + NavigationLink(value: item) { KAItemRow(item: item) } | |
| 121 | + .buttonStyle(.plain) | |
| 122 | + } | |
| 123 | + NavigationLink(value: section.universe.id) { | |
| 124 | + Text("Tout voir dans \(section.universe.name) →") | |
| 125 | + .font(.subheadline.weight(.semibold)) | |
| 126 | + .foregroundStyle(section.universe.accent) | |
| 127 | + } | |
| 128 | + } | |
| 129 | + } | |
| 130 | + .navigationDestination(for: String.self) { id in | |
| 131 | + if let u = Ecosystem.universe(id) { UniverseHomeView(universe: u) } | |
| 132 | + } | |
| 133 | + } | |
| 134 | + | |
| 135 | + private func search(_ text: String? = nil) async { | |
| 136 | + let q = (text ?? query).trimmingCharacters(in: .whitespaces) | |
| 137 | + guard !q.isEmpty else { return } | |
| 138 | + submitted = q | |
| 139 | + searching = true | |
| 140 | + Haptics.rigid() | |
| 141 | + var hist = history.filter { $0 != q } | |
| 142 | + hist.insert(q, at: 0) | |
| 143 | + historyRaw = hist.prefix(10).joined(separator: "\n") | |
| 144 | + | |
| 145 | + let targets = searchables.filter { selected.isEmpty || selected.contains($0.id) } | |
| 146 | + var found: [String: [KAItem]] = [:] | |
| 147 | + await withTaskGroup(of: (String, [KAItem]).self) { group in | |
| 148 | + for u in targets { | |
| 149 | + group.addTask { | |
| 150 | + let items = (try? await UniverseService.fetch(u, query: q, limit: 8)) ?? [] | |
| 151 | + return (u.id, items) | |
| 152 | + } | |
| 153 | + } | |
| 154 | + for await (id, items) in group { found[id] = items } | |
| 155 | + } | |
| 156 | + // ordre : univers avec le plus de résultats d'abord, web québécois à la fin | |
| 157 | + sections = targets | |
| 158 | + .map { ($0, found[$0.id] ?? []) } | |
| 159 | + .sorted { | |
| 160 | + if $0.0.id == "trouve-ka" { return false } | |
| 161 | + if $1.0.id == "trouve-ka" { return true } | |
| 162 | + return $0.1.count > $1.1.count | |
| 163 | + } | |
| 164 | + searching = false | |
| 165 | + if sections.contains(where: { !$0.items.isEmpty }) { Haptics.success() } | |
| 166 | + } | |
| 167 | +} | |
added
KA/Features/UniversesView.swift
+266 −0
@@ -0,0 +1,266 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// UniversesView.swift — la grille des 12 univers (cartes accent + compteur en | |
| 3 | +// direct), l'explorateur générique d'un univers (liste + recherche) et la | |
| 4 | +// fiche universelle (faits, favori, partage, lien web). | |
| 5 | +import SwiftUI | |
| 6 | +import SafariServices | |
| 7 | + | |
| 8 | +// MARK: - Grille des univers | |
| 9 | + | |
| 10 | +struct UniversesView: View { | |
| 11 | + @State private var totals: [String: Int] = [:] | |
| 12 | + @Environment(\.colorScheme) private var scheme | |
| 13 | + | |
| 14 | + private let columns = [GridItem(.adaptive(minimum: 160), spacing: 14)] | |
| 15 | + @State private var showMap = false | |
| 16 | + | |
| 17 | + var body: some View { | |
| 18 | + NavigationStack { | |
| 19 | + ScrollView { | |
| 20 | + LazyVGrid(columns: columns, spacing: 14) { | |
| 21 | + ForEach(Ecosystem.all) { u in | |
| 22 | + NavigationLink(value: u.id) { | |
| 23 | + UniverseCard(universe: u, total: totals[u.id]) | |
| 24 | + } | |
| 25 | + .buttonStyle(.plain) | |
| 26 | + } | |
| 27 | + } | |
| 28 | + .padding(16) | |
| 29 | + } | |
| 30 | + .background(KATheme.paper(scheme)) | |
| 31 | + .navigationTitle("Univers") | |
| 32 | + .toolbar { | |
| 33 | + ToolbarItem(placement: .topBarTrailing) { | |
| 34 | + Button { showMap = true } label: { Image(systemName: "map.fill") } | |
| 35 | + .accessibilityLabel("Carte unifiée de l'écosystème") | |
| 36 | + } | |
| 37 | + } | |
| 38 | + .fullScreenCover(isPresented: $showMap) { | |
| 39 | + UnifiedMapView() | |
| 40 | + } | |
| 41 | + .navigationDestination(for: String.self) { id in | |
| 42 | + if let u = Ecosystem.universe(id) { | |
| 43 | + UniverseHomeView(universe: u) | |
| 44 | + } | |
| 45 | + } | |
| 46 | + .task { | |
| 47 | + await withTaskGroup(of: (String, Int?).self) { group in | |
| 48 | + for u in Ecosystem.all { | |
| 49 | + group.addTask { (u.id, await UniverseService.liveTotal(u)) } | |
| 50 | + } | |
| 51 | + for await (id, n) in group { | |
| 52 | + if let n { totals[id] = n } | |
| 53 | + } | |
| 54 | + } | |
| 55 | + } | |
| 56 | + } | |
| 57 | + } | |
| 58 | +} | |
| 59 | + | |
| 60 | +struct UniverseCard: View { | |
| 61 | + let universe: Universe | |
| 62 | + let total: Int? | |
| 63 | + @Environment(\.colorScheme) private var scheme | |
| 64 | + | |
| 65 | + var body: some View { | |
| 66 | + VStack(alignment: .leading, spacing: 10) { | |
| 67 | + HStack { | |
| 68 | + Image(systemName: universe.symbol) | |
| 69 | + .font(.title3.weight(.semibold)) | |
| 70 | + .foregroundStyle(universe.accent) | |
| 71 | + .frame(width: 40, height: 40) | |
| 72 | + .background(universe.accent.opacity(0.14), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) | |
| 73 | + Spacer() | |
| 74 | + Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) | |
| 75 | + } | |
| 76 | + KAWordmark(universe: universe, size: 17) | |
| 77 | + Text(universe.tagline) | |
| 78 | + .font(.caption).foregroundStyle(KATheme.ink2(scheme)) | |
| 79 | + .lineLimit(2, reservesSpace: true) | |
| 80 | + if let total { | |
| 81 | + Text("\(total.formatted(.number.locale(Locale(identifier: "fr_CA")))) \(universe.unit)") | |
| 82 | + .font(.system(.caption2, design: .monospaced).weight(.bold)) | |
| 83 | + .foregroundStyle(universe.accent) | |
| 84 | + .contentTransition(.numericText()) | |
| 85 | + } else { | |
| 86 | + Text("—").font(.system(.caption2, design: .monospaced)) | |
| 87 | + .foregroundStyle(.tertiary) | |
| 88 | + } | |
| 89 | + } | |
| 90 | + .padding(14) | |
| 91 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 92 | + .kaCard(accent: universe.accent) | |
| 93 | + .accessibilityElement(children: .combine) | |
| 94 | + } | |
| 95 | +} | |
| 96 | + | |
| 97 | +// MARK: - Explorateur d'un univers | |
| 98 | + | |
| 99 | +struct UniverseHomeView: View { | |
| 100 | + let universe: Universe | |
| 101 | + @State private var items: [KAItem] = [] | |
| 102 | + @State private var query = "" | |
| 103 | + @State private var loading = true | |
| 104 | + @State private var errorText: String? | |
| 105 | + @Environment(\.colorScheme) private var scheme | |
| 106 | + | |
| 107 | + var body: some View { | |
| 108 | + Group { | |
| 109 | + if universe.listPath == nil { | |
| 110 | + webUniverse | |
| 111 | + } else { | |
| 112 | + list | |
| 113 | + } | |
| 114 | + } | |
| 115 | + .background(KATheme.paper(scheme)) | |
| 116 | + .navigationTitle(universe.name) | |
| 117 | + .navigationBarTitleDisplayMode(.inline) | |
| 118 | + .toolbar { | |
| 119 | + ToolbarItem(placement: .topBarTrailing) { | |
| 120 | + Link(destination: universe.baseURL) { | |
| 121 | + Image(systemName: "safari").accessibilityLabel("Ouvrir le site \(universe.name)") | |
| 122 | + } | |
| 123 | + } | |
| 124 | + } | |
| 125 | + .tint(universe.accent) | |
| 126 | + } | |
| 127 | + | |
| 128 | + private var webUniverse: some View { | |
| 129 | + VStack(spacing: 18) { | |
| 130 | + Image(systemName: universe.symbol).font(.system(size: 52)).foregroundStyle(universe.accent) | |
| 131 | + KAWordmark(universe: universe, size: 30) | |
| 132 | + Text(universe.tagline).font(.subheadline).foregroundStyle(.secondary) | |
| 133 | + .multilineTextAlignment(.center) | |
| 134 | + Link(destination: universe.baseURL) { | |
| 135 | + Label("Ouvrir \(universe.name)", systemImage: "arrow.up.right.square") | |
| 136 | + .font(.headline).padding(.horizontal, 22).padding(.vertical, 13) | |
| 137 | + .background(universe.accent, in: Capsule()) | |
| 138 | + .foregroundStyle(.white) | |
| 139 | + } | |
| 140 | + } | |
| 141 | + .padding(30) | |
| 142 | + .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 143 | + } | |
| 144 | + | |
| 145 | + private var list: some View { | |
| 146 | + ScrollView { | |
| 147 | + LazyVStack(spacing: 12) { | |
| 148 | + if loading { | |
| 149 | + ProgressView().padding(40) | |
| 150 | + } else if let e = errorText, items.isEmpty { | |
| 151 | + KAEmptyState(symbol: "wifi.exclamationmark", title: "Impossible de charger", | |
| 152 | + message: e + "\nTirez pour réessayer.") | |
| 153 | + } else if items.isEmpty { | |
| 154 | + KAEmptyState(symbol: "tray", title: "Aucun résultat", | |
| 155 | + message: "Essayez d'autres mots-clés.") | |
| 156 | + } else { | |
| 157 | + ForEach(items) { item in | |
| 158 | + NavigationLink(value: item) { KAItemRow(item: item) } | |
| 159 | + .buttonStyle(.plain) | |
| 160 | + } | |
| 161 | + } | |
| 162 | + } | |
| 163 | + .padding(16) | |
| 164 | + } | |
| 165 | + .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } | |
| 166 | + .searchable(text: $query, prompt: "Chercher dans \(universe.name)…") | |
| 167 | + .onSubmit(of: .search) { Task { await load() } } | |
| 168 | + .refreshable { await load() } | |
| 169 | + .task { await load() } | |
| 170 | + } | |
| 171 | + | |
| 172 | + private func load() async { | |
| 173 | + loading = items.isEmpty | |
| 174 | + errorText = nil | |
| 175 | + do { | |
| 176 | + items = try await UniverseService.fetch(universe, query: query.isEmpty ? nil : query, limit: 40) | |
| 177 | + } catch { | |
| 178 | + errorText = (error as? URLError)?.code == .notConnectedToInternet | |
| 179 | + ? "Vous êtes hors ligne." : "Le service ne répond pas." | |
| 180 | + } | |
| 181 | + loading = false | |
| 182 | + } | |
| 183 | +} | |
| 184 | + | |
| 185 | +// MARK: - Fiche universelle | |
| 186 | + | |
| 187 | +struct ItemDetailView: View { | |
| 188 | + let item: KAItem | |
| 189 | + @EnvironmentObject private var favorites: FavoritesStore | |
| 190 | + @Environment(\.colorScheme) private var scheme | |
| 191 | + @State private var showCollections = false | |
| 192 | + | |
| 193 | + private var universe: Universe? { Ecosystem.universe(item.universeID) } | |
| 194 | + | |
| 195 | + var body: some View { | |
| 196 | + ScrollView { | |
| 197 | + VStack(alignment: .leading, spacing: 16) { | |
| 198 | + if let u = universe { | |
| 199 | + HStack { KAChip(text: u.wordmark, accent: u.accent); Spacer() } | |
| 200 | + } | |
| 201 | + Text(item.title).font(.title2.weight(.bold)) | |
| 202 | + if let sub = item.subtitle { Text(sub).font(.body).foregroundStyle(KATheme.ink2(scheme)) } | |
| 203 | + HStack(spacing: 10) { | |
| 204 | + if let p = item.priceLabel { | |
| 205 | + Text(p).font(.system(.title3, design: .rounded).weight(.bold)) | |
| 206 | + .foregroundStyle(universe?.accent ?? .primary) | |
| 207 | + } | |
| 208 | + if let c = item.city { KAChip(text: c) } | |
| 209 | + } | |
| 210 | + | |
| 211 | + if !item.facts.isEmpty { | |
| 212 | + VStack(spacing: 0) { | |
| 213 | + ForEach(item.facts, id: \.self) { f in | |
| 214 | + HStack { | |
| 215 | + Text(f.label).font(.subheadline).foregroundStyle(.secondary) | |
| 216 | + Spacer() | |
| 217 | + Text(f.value).font(.subheadline.weight(.semibold)) | |
| 218 | + .multilineTextAlignment(.trailing) | |
| 219 | + } | |
| 220 | + .padding(.vertical, 10).padding(.horizontal, 14) | |
| 221 | + if f != item.facts.last { Divider() } | |
| 222 | + } | |
| 223 | + } | |
| 224 | + .kaCard() | |
| 225 | + } | |
| 226 | + | |
| 227 | + if let url = item.url { | |
| 228 | + Link(destination: url) { | |
| 229 | + Label("Voir à la source", systemImage: "arrow.up.right.square") | |
| 230 | + .font(.headline) | |
| 231 | + .frame(maxWidth: .infinity).padding(.vertical, 14) | |
| 232 | + .background(universe?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) | |
| 233 | + .foregroundStyle(.white) | |
| 234 | + } | |
| 235 | + Text("Groupe KA est un agrégateur : la transaction se fait chez la source originale.") | |
| 236 | + .font(.caption2).foregroundStyle(.tertiary) | |
| 237 | + } | |
| 238 | + } | |
| 239 | + .padding(16) | |
| 240 | + } | |
| 241 | + .background(KATheme.paper(scheme)) | |
| 242 | + .navigationBarTitleDisplayMode(.inline) | |
| 243 | + .toolbar { | |
| 244 | + ToolbarItemGroup(placement: .topBarTrailing) { | |
| 245 | + Button { | |
| 246 | + if favorites.isFavorite(item) { favorites.toggle(item) } | |
| 247 | + else if favorites.collections.count > 1 { showCollections = true } | |
| 248 | + else { favorites.toggle(item) } | |
| 249 | + } label: { | |
| 250 | + Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart") | |
| 251 | + .foregroundStyle(favorites.isFavorite(item) ? .red : .primary) | |
| 252 | + } | |
| 253 | + .accessibilityLabel(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris") | |
| 254 | + if let url = item.url { | |
| 255 | + ShareLink(item: url, subject: Text(item.title)) | |
| 256 | + } | |
| 257 | + } | |
| 258 | + } | |
| 259 | + .confirmationDialog("Ajouter à…", isPresented: $showCollections, titleVisibility: .visible) { | |
| 260 | + ForEach(favorites.collections) { c in | |
| 261 | + Button(c.name) { favorites.toggle(item, in: c.id) } | |
| 262 | + } | |
| 263 | + } | |
| 264 | + .tint(universe?.accent) | |
| 265 | + } | |
| 266 | +} | |
added
KA/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +{ | |
| 2 | + "images": [ | |
| 3 | + { "filename": "icon-1024.png", "idiom": "universal", "platform": "ios", "size": "1024x1024" } | |
| 4 | + ], | |
| 5 | + "info": { "author": "xcode", "version": 1 } | |
| 6 | +} | |
added
KA/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png
+0 −0
Binary file not shown.
added
KA/Resources/Assets.xcassets/Contents.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +{ "info": { "author": "xcode", "version": 1 } } | |
added
KATests/KATests.swift
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KATests.swift — tests des parcours critiques hors réseau : mapping JSON → | |
| 3 | +// KAItem pour chaque adaptateur d'univers (échantillons réels des API), | |
| 4 | +// construction des URL de liste, favoris/collections. | |
| 5 | +import XCTest | |
| 6 | +@testable import KA | |
| 7 | + | |
| 8 | +final class AdapterTests: XCTestCase { | |
| 9 | + | |
| 10 | + private func decode(_ json: String) -> [String: JSONValue] { | |
| 11 | + let v = try! JSONDecoder().decode(JSONValue.self, from: Data(json.utf8)) | |
| 12 | + return v.object! | |
| 13 | + } | |
| 14 | + | |
| 15 | + func testLouKaMapping() { | |
| 16 | + let u = Ecosystem.universe("lou-ka")! | |
| 17 | + let o = decode(#"{"uid":"a:1","title":"4½ Limoilou","city":"Québec","unit_type":"4½","price":1200,"url":"https://x.com/a","lat":46.8,"lng":-71.2,"address":"12 rue X"}"#) | |
| 18 | + let item = u.map!(o)! | |
| 19 | + XCTAssertEqual(item.universeID, "lou-ka") | |
| 20 | + XCTAssertEqual(item.title, "4½ Limoilou") | |
| 21 | + XCTAssertEqual(item.priceLabel?.contains("1"), true) | |
| 22 | + XCTAssertEqual(item.city, "Québec") | |
| 23 | + XCTAssertNotNil(item.latitude) | |
| 24 | + XCTAssertTrue(item.facts.contains { $0.label == "Taille" && $0.value == "4½" }) | |
| 25 | + } | |
| 26 | + | |
| 27 | + func testAutoKaMapping() { | |
| 28 | + let u = Ecosystem.universe("auto-ka")! | |
| 29 | + let o = decode(#"{"uid":"v:1","title":"Kia Forte 2010","make":"Kia","model":"Forte","year":2010,"price":1595,"price_label":"1 595 $","mileage_label":"260 789 km","url":"https://x.com/v"}"#) | |
| 30 | + let item = u.map!(o)! | |
| 31 | + XCTAssertEqual(item.priceLabel, "1 595 $") | |
| 32 | + XCTAssertTrue(item.subtitle?.contains("2010") == true) | |
| 33 | + XCTAssertTrue(item.facts.contains { $0.label == "Marque" && $0.value == "Kia" }) | |
| 34 | + } | |
| 35 | + | |
| 36 | + func testJobKaSalary() { | |
| 37 | + let u = Ecosystem.universe("job-ka")! | |
| 38 | + let o = decode(#"{"uid":"j:1","title":"Analyste","employer":"TD","city":"Montréal","salary_year_min":65000}"#) | |
| 39 | + let item = u.map!(o)! | |
| 40 | + XCTAssertTrue(item.priceLabel?.contains("/an") == true) | |
| 41 | + XCTAssertTrue(item.subtitle?.contains("TD") == true) | |
| 42 | + } | |
| 43 | + | |
| 44 | + func testTrouveKaStripsHTML() { | |
| 45 | + let u = Ecosystem.universe("trouve-ka")! | |
| 46 | + let o = decode(#"{"url":"https://y.qc","title":"La <em>poutine</em>","snippet":"un plat & une légende","domain":"y.qc"}"#) | |
| 47 | + let item = u.map!(o)! | |
| 48 | + XCTAssertEqual(item.title, "La poutine") | |
| 49 | + XCTAssertEqual(item.subtitle, "un plat & une légende") | |
| 50 | + } | |
| 51 | + | |
| 52 | + func testFoodKaIgnoresPlaceholderPrice() { | |
| 53 | + let u = Ecosystem.universe("food-ka")! | |
| 54 | + let o = decode(#"{"uid":"p:1","name":"Prunes","price":0.01,"brand":"PA"}"#) | |
| 55 | + let item = u.map!(o)! | |
| 56 | + XCTAssertNil(item.priceLabel, "un prix placeholder (0,01 $) ne doit pas s'afficher") | |
| 57 | + } | |
| 58 | + | |
| 59 | + func testEveryUniverseHasDistinctAccent() { | |
| 60 | + let accents = Ecosystem.all.map(\.accentHex) | |
| 61 | + XCTAssertEqual(accents.count, Set(accents).count, "chaque univers doit avoir un accent unique") | |
| 62 | + // 12 univers — Groupe-KA (portail mère) est l'app elle-même, pas un univers listé | |
| 63 | + XCTAssertEqual(Ecosystem.all.count, 12) | |
| 64 | + } | |
| 65 | + | |
| 66 | + func testListURLBuilding() { | |
| 67 | + let sorti = Ecosystem.universe("sorti-ka")! | |
| 68 | + let url = UniverseService.listURL(sorti, query: "jazz", limit: 10)! | |
| 69 | + let s = url.absoluteString | |
| 70 | + XCTAssertTrue(s.contains("upcoming=true"), "la query embarquée du listPath doit survivre") | |
| 71 | + XCTAssertTrue(s.contains("q=jazz")) | |
| 72 | + XCTAssertTrue(s.contains("limit=10")) | |
| 73 | + XCTAssertTrue(s.hasPrefix("https://www.sorti-ka.com/api/events")) | |
| 74 | + } | |
| 75 | + | |
| 76 | + @MainActor | |
| 77 | + func testFavoritesToggleAndCollections() { | |
| 78 | + let store = FavoritesStore() | |
| 79 | + let item = KAItem(id: "t:1", universeID: "lou-ka", title: "Test", | |
| 80 | + subtitle: nil, priceLabel: nil, city: nil, url: nil, | |
| 81 | + imageURL: nil, latitude: nil, longitude: nil, facts: []) | |
| 82 | + XCTAssertFalse(store.isFavorite(item)) | |
| 83 | + store.toggle(item) | |
| 84 | + XCTAssertTrue(store.isFavorite(item)) | |
| 85 | + store.addCollection("Week-end") | |
| 86 | + let dest = store.collections.last! | |
| 87 | + store.move(item, to: dest.id) | |
| 88 | + XCTAssertTrue(store.collections.last!.items.contains { $0.id == item.id }) | |
| 89 | + store.toggle(item) | |
| 90 | + XCTAssertFalse(store.isFavorite(item)) | |
| 91 | + } | |
| 92 | +} | |
added
KAWidgets/Info.plist
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 3 | +<plist version="1.0"> | |
| 4 | +<dict> | |
| 5 | + <key>CFBundleDevelopmentRegion</key> | |
| 6 | + <string>$(DEVELOPMENT_LANGUAGE)</string> | |
| 7 | + <key>CFBundleExecutable</key> | |
| 8 | + <string>$(EXECUTABLE_NAME)</string> | |
| 9 | + <key>CFBundleIdentifier</key> | |
| 10 | + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> | |
| 11 | + <key>CFBundleInfoDictionaryVersion</key> | |
| 12 | + <string>6.0</string> | |
| 13 | + <key>CFBundleName</key> | |
| 14 | + <string>$(PRODUCT_NAME)</string> | |
| 15 | + <key>CFBundlePackageType</key> | |
| 16 | + <string>XPC!</string> | |
| 17 | + <key>CFBundleShortVersionString</key> | |
| 18 | + <string>1.0</string> | |
| 19 | + <key>CFBundleVersion</key> | |
| 20 | + <string>1</string> | |
| 21 | + <key>NSExtension</key> | |
| 22 | + <dict> | |
| 23 | + <key>NSExtensionPointIdentifier</key> | |
| 24 | + <string>com.apple.widgetkit-extension</string> | |
| 25 | + </dict> | |
| 26 | +</dict> | |
| 27 | +</plist> | |
added
KAWidgets/KAWidgets.swift
+111 −0
@@ -0,0 +1,111 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KAWidgets.swift — widget « Le pouls » : les compteurs EN DIRECT de | |
| 3 | +// l'écosystème Groupe-KA sur l'écran d'accueil (petit : 1 chiffre-choc ; | |
| 4 | +// moyen : 3 univers ; grand : 6 univers). Rafraîchi ~toutes les 30 min. | |
| 5 | +import WidgetKit | |
| 6 | +import SwiftUI | |
| 7 | + | |
| 8 | +struct PulseEntry: TimelineEntry { | |
| 9 | + let date: Date | |
| 10 | + let counts: [(name: String, unit: String, value: Int, hex: String)] | |
| 11 | +} | |
| 12 | + | |
| 13 | +struct PulseProvider: TimelineProvider { | |
| 14 | + static let sources: [(name: String, unit: String, url: String, keys: [String], hex: String)] = [ | |
| 15 | + ("Lou·Ka", "logements à louer", "https://www.lou-ka.com/api/stats", ["total"], "#ff6a00"), | |
| 16 | + ("Immo·Ka", "propriétés à vendre", "https://www.immo-ka.com/api/stats", ["total"], "#e23744"), | |
| 17 | + ("Job·Ka", "offres d'emploi", "https://www.job-ka.com/api/stats", ["total"], "#0c8599"), | |
| 18 | + ("Sorti·Ka", "événements", "https://www.sorti-ka.com/api/stats", ["total_active", "total"], "#d6336c"), | |
| 19 | + ("Auto·Ka", "véhicules", "https://www.auto-ka.com/api/stats", ["total"], "#ff5a2a"), | |
| 20 | + ("Resto·Ka", "restaurants", "https://www.resto-ka.com/api/stats", ["restaurants", "total"], "#f08c00"), | |
| 21 | + ] | |
| 22 | + | |
| 23 | + static let placeholderCounts: [(String, String, Int, String)] = [ | |
| 24 | + ("Lou·Ka", "logements à louer", 33900, "#ff6a00"), | |
| 25 | + ("Immo·Ka", "propriétés à vendre", 67000, "#e23744"), | |
| 26 | + ("Job·Ka", "offres d'emploi", 4400, "#0c8599"), | |
| 27 | + ("Sorti·Ka", "événements", 15300, "#d6336c"), | |
| 28 | + ("Auto·Ka", "véhicules", 17000, "#ff5a2a"), | |
| 29 | + ("Resto·Ka", "restaurants", 14000, "#f08c00"), | |
| 30 | + ] | |
| 31 | + | |
| 32 | + func placeholder(in context: Context) -> PulseEntry { | |
| 33 | + PulseEntry(date: .now, counts: Self.placeholderCounts) | |
| 34 | + } | |
| 35 | + | |
| 36 | + func getSnapshot(in context: Context, completion: @escaping (PulseEntry) -> Void) { | |
| 37 | + completion(placeholder(in: context)) | |
| 38 | + } | |
| 39 | + | |
| 40 | + func getTimeline(in context: Context, completion: @escaping (Timeline<PulseEntry>) -> Void) { | |
| 41 | + Task { | |
| 42 | + var counts: [(String, String, Int, String)] = [] | |
| 43 | + for s in Self.sources { | |
| 44 | + if let url = URL(string: s.url), | |
| 45 | + let (data, _) = try? await URLSession.shared.data(from: url), | |
| 46 | + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { | |
| 47 | + for k in s.keys { | |
| 48 | + if let n = obj[k] as? Int { counts.append((s.name, s.unit, n, s.hex)); break } | |
| 49 | + if let n = obj[k] as? Double { counts.append((s.name, s.unit, Int(n), s.hex)); break } | |
| 50 | + } | |
| 51 | + } | |
| 52 | + } | |
| 53 | + let entry = PulseEntry(date: .now, counts: counts.isEmpty ? Self.placeholderCounts : counts) | |
| 54 | + completion(Timeline(entries: [entry], policy: .after(.now.addingTimeInterval(1800)))) | |
| 55 | + } | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +struct PulseWidgetView: View { | |
| 60 | + var entry: PulseEntry | |
| 61 | + @Environment(\.widgetFamily) private var family | |
| 62 | + | |
| 63 | + private func hexColor(_ h: String) -> Color { | |
| 64 | + let s = h.dropFirst(); let v = UInt64(s, radix: 16) ?? 0 | |
| 65 | + return Color(.sRGB, red: Double((v >> 16) & 0xFF) / 255, | |
| 66 | + green: Double((v >> 8) & 0xFF) / 255, blue: Double(v & 0xFF) / 255) | |
| 67 | + } | |
| 68 | + | |
| 69 | + var body: some View { | |
| 70 | + VStack(alignment: .leading, spacing: family == .systemSmall ? 4 : 8) { | |
| 71 | + HStack(spacing: 4) { | |
| 72 | + Text("Groupe").font(.system(.caption, design: .rounded).weight(.bold)) | |
| 73 | + Text("KA").font(.system(.caption2, design: .rounded).weight(.bold)) | |
| 74 | + .foregroundStyle(Color(.sRGB, red: 0.85, green: 0.95, blue: 0.42)) | |
| 75 | + .padding(.horizontal, 5).padding(.vertical, 1) | |
| 76 | + .background(.black, in: RoundedRectangle(cornerRadius: 5)) | |
| 77 | + Spacer() | |
| 78 | + Text("en direct").font(.system(size: 8, design: .monospaced)).foregroundStyle(.secondary) | |
| 79 | + } | |
| 80 | + let rows = Array(entry.counts.prefix(family == .systemSmall ? 1 : family == .systemMedium ? 3 : 6)) | |
| 81 | + ForEach(rows, id: \.name) { c in | |
| 82 | + VStack(alignment: .leading, spacing: 0) { | |
| 83 | + Text(c.value.formatted(.number.locale(Locale(identifier: "fr_CA")))) | |
| 84 | + .font(.system(family == .systemSmall ? .title2 : .headline, design: .rounded).weight(.bold)) | |
| 85 | + .foregroundStyle(hexColor(c.hex)) | |
| 86 | + .minimumScaleFactor(0.6).lineLimit(1) | |
| 87 | + Text("\(c.name) · \(c.unit)") | |
| 88 | + .font(.system(size: 9)).foregroundStyle(.secondary).lineLimit(1) | |
| 89 | + } | |
| 90 | + } | |
| 91 | + Spacer(minLength: 0) | |
| 92 | + } | |
| 93 | + .containerBackground(for: .widget) { Color(.systemBackground) } | |
| 94 | + } | |
| 95 | +} | |
| 96 | + | |
| 97 | +@main | |
| 98 | +struct KAWidgets: WidgetBundle { | |
| 99 | + var body: some Widget { PulseWidget() } | |
| 100 | +} | |
| 101 | + | |
| 102 | +struct PulseWidget: Widget { | |
| 103 | + var body: some WidgetConfiguration { | |
| 104 | + StaticConfiguration(kind: "KAPulse", provider: PulseProvider()) { entry in | |
| 105 | + PulseWidgetView(entry: entry) | |
| 106 | + } | |
| 107 | + .configurationDisplayName("Le pouls de l'écosystème") | |
| 108 | + .description("Les compteurs en direct du Groupe KA : logements, propriétés, emplois, sorties…") | |
| 109 | + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) | |
| 110 | + } | |
| 111 | +} | |
added
README.md
+62 −0
@@ -0,0 +1,62 @@ | ||
| 1 | +<!-- | |
| 2 | +============================================================================= | |
| 3 | +KA — super-app iOS de l'écosystème Groupe-KA | |
| 4 | +Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 5 | +Créé : 2026-08-17 | |
| 6 | +============================================================================= | |
| 7 | +--> | |
| 8 | + | |
| 9 | +# KA — Tout l'écosystème Groupe-KA | |
| 10 | + | |
| 11 | +Application iOS native (Swift + SwiftUI, iOS 17+) qui regroupe les **12 | |
| 12 | +univers** du Groupe-KA : Lou·Ka, Immo·Ka, Vrai-Prix, Auto·Ka, Fabri·Ka, | |
| 13 | +Food·Ka, Resto·Ka, Sorti·Ka, Créa·Ka, Job·Ka, Trouve·Ka et API·Ka — avec de | |
| 14 | +**vraies données en direct** (aucune maquette : les API publiques des | |
| 15 | +plateformes alimentent chaque écran). | |
| 16 | + | |
| 17 | +## Ce que fait la v1 | |
| 18 | + | |
| 19 | +- **Onboarding animé** (logo KA, pitch, choix des univers favoris — Reduce Motion respecté) | |
| 20 | +- **Accueil vivant** : salutation selon l'heure, « pouls » de l'écosystème | |
| 21 | + (compteurs live), suggestions contextuelles (restos le soir, sorties le | |
| 22 | + week-end, emplois le matin) avec du vrai contenu | |
| 23 | +- **Recherche universelle** : une barre qui interroge tous les univers EN | |
| 24 | + PARALLÈLE (+ le web québécois via Trouve·Ka), résultats groupés, filtres par | |
| 25 | + univers, historique | |
| 26 | +- **Grille des 12 univers** avec compteurs en direct + **explorateur | |
| 27 | + générique** (liste, recherche, fiche universelle : faits, favori, partage, | |
| 28 | + lien vers la source) | |
| 29 | +- **Carte unifiée** (MapKit) : logements, propriétés, emplois, restos et | |
| 30 | + sorties géolocalisés, épingles teintées par univers, filtre | |
| 31 | +- **Favoris & collections unifiés** multi-univers, hors ligne, partage natif | |
| 32 | +- **KA Agent natif** : l'assistant IA de l'écosystème (Claude Haiku via l'API | |
| 33 | + centrale api-ka) en chat plein écran, flux token par token + indicateurs | |
| 34 | + d'outils — bulle flottante sur toute l'app | |
| 35 | +- **Widget « Le pouls »** (petit/moyen/grand) : compteurs live sur l'écran d'accueil | |
| 36 | +- **Profil** : KA ID, univers favoris, apparence, contacts complets du Groupe | |
| 37 | + KA, pages légales (Loi 25) | |
| 38 | +- **Modes clair et sombre** (papier/encre inversés), haptique, Dynamic Type, | |
| 39 | + VoiceOver labels, cache disque + mode hors ligne de consultation | |
| 40 | + | |
| 41 | +## Bâtir | |
| 42 | + | |
| 43 | +```bash | |
| 44 | +brew install xcodegen # si absent | |
| 45 | +cd KA && xcodegen generate | |
| 46 | +xcodebuild -project KA.xcodeproj -scheme KA \ | |
| 47 | + -destination "platform=iOS Simulator,name=iPhone 17 Pro" build # ou : open KA.xcodeproj | |
| 48 | +xcodebuild … test # tests unitaires (adaptateurs, favoris, URL) | |
| 49 | +``` | |
| 50 | + | |
| 51 | +Aucune dépendance externe (100 % SwiftUI + Foundation + MapKit + WidgetKit). | |
| 52 | +`project.yml` (XcodeGen) est la source de vérité du projet — ne pas éditer le | |
| 53 | +`.xcodeproj` à la main. | |
| 54 | + | |
| 55 | +## Documentation | |
| 56 | + | |
| 57 | +- `docs/ARCHITECTURE.md` — architecture, ajout d'un univers, connexion aux API | |
| 58 | +- `docs/APP-STORE.md` — préparation App Store (nom, textes, captures, review) | |
| 59 | +- `docs/IDEES-LEGENDAIRES.md` — les idées « niveau légendaire » classées par impact | |
| 60 | +- `docs/screenshots/` — captures simulateur (clair + sombre) | |
| 61 | + | |
| 62 | +© Groupe-KA — Simon-Pierre Boucher. | |
added
docs/APP-STORE.md
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +# KA — Préparation App Store | |
| 2 | + | |
| 3 | +## Fiche | |
| 4 | + | |
| 5 | +- **Nom** : KA | |
| 6 | +- **Sous-titre** : Tout l'écosystème Groupe-KA | |
| 7 | +- **Catégorie** : Style de vie (secondaire : Utilitaires) | |
| 8 | +- **Mots-clés** : québec,logement,immobilier,auto,emploi,resto,sorties,épicerie,recherche,groupe ka | |
| 9 | +- **Description** (proposition) : | |
| 10 | + | |
| 11 | +> Mille sites. Un seul KA. | |
| 12 | +> | |
| 13 | +> KA met tout l'écosystème Groupe-KA dans votre poche : les logements à louer | |
| 14 | +> (Lou·Ka), les propriétés à vendre (Immo·Ka), la valeur de chaque maison | |
| 15 | +> (Vrai-Prix), les voitures usagées (Auto·Ka), les produits d'ici (Fabri·Ka), | |
| 16 | +> les prix d'épicerie (Food·Ka), les restos et leurs menus (Resto·Ka), les | |
| 17 | +> sorties (Sorti·Ka), les créateurs (Créa·Ka), les emplois (Job·Ka) et le | |
| 18 | +> moteur de recherche du web québécois (Trouve·Ka). | |
| 19 | +> | |
| 20 | +> • Une recherche, tous les univers à la fois | |
| 21 | +> • Des favoris qui mélangent logements, autos, restos et sorties | |
| 22 | +> • Une carte unifiée de tout ce qui vous entoure | |
| 23 | +> • KA Agent : posez une question, l'IA fouille les vraies données | |
| 24 | +> • Le pouls de l'écosystème en widget | |
| 25 | +> | |
| 26 | +> Groupe KA est un agrégateur : rien d'inventé, tout est traçable à la source, | |
| 27 | +> et la transaction se fait toujours chez la source originale. | |
| 28 | + | |
| 29 | +- **Notes de version 1.0.0** : « Première version — les 12 univers, la | |
| 30 | + recherche universelle, les favoris unifiés, la carte, KA Agent et le widget | |
| 31 | + Le pouls. » | |
| 32 | + | |
| 33 | +## Captures d'écran | |
| 34 | + | |
| 35 | +Générer sur simulateurs iPhone 17 Pro Max (6,9″) et iPhone 17 (6,3″), clair ET | |
| 36 | +sombre : onboarding, accueil, recherche avec résultats multi-univers, grille | |
| 37 | +des univers, une fiche (Immo·Ka), la carte, KA Agent, le widget. Les captures | |
| 38 | +de développement sont dans `docs/screenshots/`. | |
| 39 | + | |
| 40 | +## Confidentialité (App Privacy) | |
| 41 | + | |
| 42 | +- **Données collectées : aucune.** Pas de compte requis, pas d'analytique, pas | |
| 43 | + de traceur (ATT non requis car aucun suivi). Localisation « lorsque l'app est | |
| 44 | + active » uniquement pour centrer la carte, jamais transmise à un serveur. | |
| 45 | +- Étiquette de confidentialité : « Données non collectées ». | |
| 46 | +- Politique de confidentialité : https://www.groupe-ka.com/confidentialite | |
| 47 | + (+ Loi 25 : /loi-25). | |
| 48 | + | |
| 49 | +## Points de vigilance App Review | |
| 50 | + | |
| 51 | +- Guideline 4.2 (fonctionnalité native suffisante) : OK — recherche native, | |
| 52 | + carte, favoris, widget, agent ; les liens « Voir à la source » ouvrent | |
| 53 | + Safari, c'est le modèle d'agrégateur affiché clairement. | |
| 54 | +- Guideline 5.1.1 : app entièrement utilisable sans compte. Le bouton KA ID | |
| 55 | + ouvre le site web (pas d'achat, pas de paywall). | |
| 56 | +- Contenu tiers : chaque fiche cite sa source et pointe vers elle. | |
| 57 | +- Chiffrement : `ITSAppUsesNonExemptEncryption = false` (HTTPS standard). | |
| 58 | + | |
| 59 | +## Publication | |
| 60 | + | |
| 61 | +1. Ouvrir `KA.xcodeproj`, sélectionner l'équipe de développement (Signing). | |
| 62 | +2. Icône : déjà dans `Assets.xcassets` (1024, style encre + lime). | |
| 63 | +3. `Product → Archive` → App Store Connect → TestFlight → soumission. | |
| 64 | +4. Versionnage : MARKETING_VERSION dans project.yml (semver), notes de mise à | |
| 65 | + jour en français d'abord. | |
added
docs/ARCHITECTURE.md
+58 −0
@@ -0,0 +1,58 @@ | ||
| 1 | +# Architecture de KA | |
| 2 | + | |
| 3 | +## Vue d'ensemble | |
| 4 | + | |
| 5 | +``` | |
| 6 | +KA/ | |
| 7 | + App/ KAApp (racine 5 onglets + bulle KA Agent), OnboardingView | |
| 8 | + Core/ Ecosystem (les univers + mapping JSON), Services (APIClient cache | |
| 9 | + disque, UniverseService, AgentService SSE), FavoritesStore | |
| 10 | + Design/ KATheme (papier/encre, accents), composants (KACard ombre décalée, | |
| 11 | + KAWordmark, KAChip, KAItemRow, états vides), Haptics | |
| 12 | + Features/ Home, Search (universelle), Universes (grille + explorateur + | |
| 13 | + fiche), MapView (carte unifiée), Favorites, Profile, AgentChat | |
| 14 | +KAWidgets/ widget « Le pouls » (WidgetKit, timeline réseau 30 min) | |
| 15 | +KATests/ tests des adaptateurs / favoris / URL (hors réseau) | |
| 16 | +project.yml XcodeGen — source de vérité du projet | |
| 17 | +``` | |
| 18 | + | |
| 19 | +## Le principe : tout est configuration | |
| 20 | + | |
| 21 | +Un univers = une entrée `Universe` dans `Ecosystem.all` : | |
| 22 | +identité (wordmark, accent, SF Symbol, tagline, domaine) + API (`listPath`, | |
| 23 | +`itemsKey`, `searchParam`, clés du total dans /api/stats) + un **mapping** | |
| 24 | +`[String: JSONValue] → KAItem`. TOUTES les vues (grille, explorateur, fiche, | |
| 25 | +recherche universelle, carte, accueil, favoris) consomment le `KAItem` | |
| 26 | +universel — aucune vue par univers. | |
| 27 | + | |
| 28 | +### Ajouter un futur univers (ex. Ora·Ka) | |
| 29 | + | |
| 30 | +1. Ajouter l'entrée dans `Ecosystem.all` (accent officiel d'ecosystem.json du | |
| 31 | + design system ka-ui, mapping calqué sur un adaptateur voisin). | |
| 32 | +2. C'est tout : grille, recherche, carte (si lat/lng), accueil et favoris le | |
| 33 | + prennent en charge automatiquement. Ajouter un test de mapping dans | |
| 34 | + `KATests` avec un échantillon JSON réel de son API. | |
| 35 | + | |
| 36 | +## Connexion aux API | |
| 37 | + | |
| 38 | +- **Listes/recherche** : les API publiques des plateformes | |
| 39 | + (`/api/listings|vehicles|jobs|products|restaurants|events|creators`, | |
| 40 | + `?q=&limit=`), JSON décodé en `JSONValue` (forme libre) puis mappé. | |
| 41 | +- **Compteurs live** : `/api/stats` de chaque plateforme (`/api/status` pour | |
| 42 | + Trouve·Ka). | |
| 43 | +- **KA Agent** : `POST https://www.api-ka.com/api/agent/chat` (SSE — events | |
| 44 | + delta/tool/done/error), parsé par `AgentService.stream` (URLSession.bytes). | |
| 45 | + La clé Anthropic vit côté serveur, jamais dans l'app. | |
| 46 | +- **Cache/hors ligne** : `APIClient` écrit chaque réponse sur disque ; TTL | |
| 47 | + 120 s (300 s pour les stats) ; en cas d'échec réseau, la dernière copie est | |
| 48 | + servie. Les favoris sont persistés localement (Application Support). | |
| 49 | + | |
| 50 | +## Conventions | |
| 51 | + | |
| 52 | +- Swift 5, zéro dépendance externe. Couleurs par univers via `--accent` du | |
| 53 | + design system web (mêmes hex que ka-ui/ecosystem.json). | |
| 54 | +- Sombre = papier/encre inversés (`KATheme.paper/ink(scheme)`), cartes avec | |
| 55 | + bordure encre + ombre décalée (signature Groupe-KA), coins continus. | |
| 56 | +- Aucune donnée inventée : prix placeholders (≤ 0,2 $ épicerie, ≤ 50 $ loyers, | |
| 57 | + ≤ 1 000 $ propriétés) masqués ; états vides propres partout. | |
| 58 | +- Haptique sur les gestes clés (favori, recherche, envoi agent). | |
added
docs/IDEES-LEGENDAIRES.md
+51 −0
@@ -0,0 +1,51 @@ | ||
| 1 | +# KA — Idées « niveau légendaire » pour les prochaines versions | |
| 2 | + | |
| 3 | +Classées par impact (retenue utilisateur × effet waouh) ÷ effort. La v1 couvre | |
| 4 | +déjà : recherche universelle, favoris unifiés, carte, KA Agent natif, widget, | |
| 5 | +onboarding animé, clair/sombre, hors ligne. | |
| 6 | + | |
| 7 | +## Impact majeur | |
| 8 | + | |
| 9 | +1. **KA ID natif + favoris synchronisés** — Sign in with Apple relié au hub | |
| 10 | + KA ID (le backend SSO existe déjà) : favoris/collections synchronisés entre | |
| 11 | + l'app et les 13 sites via l'API favoris du hub. LA killer feature de | |
| 12 | + rétention. (Prérequis : endpoint token pour app mobile côté hub.) | |
| 13 | +2. **Alertes intelligentes (notifications push)** — baisse de prix (Vrai-Prix, | |
| 14 | + Auto·Ka), nouvelle annonce correspondant à une recherche sauvegardée | |
| 15 | + (Lou·Ka/Immo·Ka/Job·Ka), rappel d'événement (Sorti·Ka). Nécessite un petit | |
| 16 | + service d'abonnements + APNs côté api-ka (les données et les deltas | |
| 17 | + existent déjà dans les stats). | |
| 18 | +3. **Liens universels (deep links)** — apple-app-site-association sur les 13 | |
| 19 | + domaines : immo-ka.com/annonce/123 ouvre la fiche dans l'app, chaque fiche | |
| 20 | + partageable en lien web riche. (Config AASA à servir par chaque site + | |
| 21 | + Associated Domains.) | |
| 22 | +4. **Recherche vocale + App Shortcuts Siri** — « Montre les sorties de ce | |
| 23 | + soir », « Ouvre mes alertes prix » (App Intents, effort modéré, très | |
| 24 | + démo-génique). | |
| 25 | + | |
| 26 | +## Effet waouh | |
| 27 | + | |
| 28 | +5. **Live Activities / Dynamic Island** — décompte avant un événement Sorti·Ka | |
| 29 | + favori ; suivi d'une alerte prix en direct. | |
| 30 | +6. **Spotlight** — favoris et fiches consultées indexés (CoreSpotlight) : on | |
| 31 | + cherche « Corolla » dans iOS, la fiche KA sort. | |
| 32 | +7. **Icônes alternatives débloquables** — une icône par univers (la cyan | |
| 33 | + Job·Ka, la framboise Sorti·Ka…) ; petit plaisir de personnalisation. | |
| 34 | +8. **Widget interactif + widget « Autour de moi »** — boutons App Intents | |
| 35 | + (rafraîchir, ouvrir l'univers), petit widget carte des sorties du soir. | |
| 36 | +9. **App Clip** — scanner un QR Resto·Ka/Sorti·Ka ouvre la fiche | |
| 37 | + instantanément sans installer l'app. | |
| 38 | + | |
| 39 | +## Fond de roulement | |
| 40 | + | |
| 41 | +10. **Clustering de la carte + heatmaps** (MKClusterAnnotation) quand les | |
| 42 | + volumes montent ; filtre par prix directement sur la carte. | |
| 43 | +11. **Comparateur transverse** — épingler 2-3 fiches (autos, logements) et les | |
| 44 | + comparer côte à côte, PDF Groupe-KA à l'appui (l'API report existe). | |
| 45 | +12. **Mode anglais complet** (structure Localizable déjà prête) puis espagnol. | |
| 46 | +13. **Handoff** — continuer sur le site web exactement où on était dans l'app | |
| 47 | + (NSUserActivity + les URL web déjà présentes sur chaque item). | |
| 48 | +14. **iPad + Vision Pro** — la grille des univers en colonnes, la carte en | |
| 49 | + grand ; le design system s'y prête déjà. | |
| 50 | +15. **Historique intelligent** — « vu récemment » transverse + suggestions de | |
| 51 | + l'accueil apprises des habitudes réelles (sur l'appareil, privé). | |
added
docs/screenshots/00-springboard.png
+0 −0
Binary file not shown.
added
docs/screenshots/01-onboarding.png
+0 −0
Binary file not shown.
added
docs/screenshots/02-accueil-clair.png
+0 −0
Binary file not shown.
added
docs/screenshots/03-accueil-sombre.png
+0 −0
Binary file not shown.
added
project.yml
+70 −0
@@ -0,0 +1,70 @@ | ||
| 1 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +# KA — super-app iOS de l'écosystème Groupe-KA (XcodeGen) | |
| 3 | +name: KA | |
| 4 | +options: | |
| 5 | + bundleIdPrefix: com.groupeka | |
| 6 | + deploymentTarget: | |
| 7 | + iOS: "17.0" | |
| 8 | + createIntermediateGroups: true | |
| 9 | +settings: | |
| 10 | + base: | |
| 11 | + SWIFT_VERSION: "5.0" | |
| 12 | + MARKETING_VERSION: "1.0.0" | |
| 13 | + CURRENT_PROJECT_VERSION: "1" | |
| 14 | + DEVELOPMENT_TEAM: "3YM54G49SN" | |
| 15 | + CODE_SIGN_STYLE: Automatic | |
| 16 | + GENERATE_INFOPLIST_FILE: true | |
| 17 | + ENABLE_USER_SCRIPT_SANDBOXING: true | |
| 18 | +targets: | |
| 19 | + KA: | |
| 20 | + type: application | |
| 21 | + platform: iOS | |
| 22 | + sources: | |
| 23 | + - path: KA | |
| 24 | + dependencies: | |
| 25 | + - target: KAWidgets | |
| 26 | + embed: true | |
| 27 | + settings: | |
| 28 | + base: | |
| 29 | + PRODUCT_BUNDLE_IDENTIFIER: com.groupeka.ka | |
| 30 | + INFOPLIST_KEY_CFBundleDisplayName: KA | |
| 31 | + INFOPLIST_KEY_UILaunchScreen_Generation: true | |
| 32 | + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" | |
| 33 | + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription: "KA affiche les logements, restos, sorties et emplois autour de vous sur la carte unifiée." | |
| 34 | + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: false | |
| 35 | + INFOPLIST_KEY_CFBundleDevelopmentRegion: fr | |
| 36 | + TARGETED_DEVICE_FAMILY: "1" | |
| 37 | + KAWidgets: | |
| 38 | + type: app-extension | |
| 39 | + platform: iOS | |
| 40 | + sources: | |
| 41 | + - path: KAWidgets | |
| 42 | + settings: | |
| 43 | + base: | |
| 44 | + PRODUCT_BUNDLE_IDENTIFIER: com.groupeka.ka.widgets | |
| 45 | + INFOPLIST_KEY_CFBundleDisplayName: KA | |
| 46 | + INFOPLIST_KEY_NSExtension_NSExtensionPointIdentifier: com.apple.widgetkit-extension | |
| 47 | + info: | |
| 48 | + path: KAWidgets/Info.plist | |
| 49 | + properties: | |
| 50 | + NSExtension: | |
| 51 | + NSExtensionPointIdentifier: com.apple.widgetkit-extension | |
| 52 | + KATests: | |
| 53 | + type: bundle.unit-test | |
| 54 | + platform: iOS | |
| 55 | + sources: | |
| 56 | + - path: KATests | |
| 57 | + dependencies: | |
| 58 | + - target: KA | |
| 59 | + settings: | |
| 60 | + base: | |
| 61 | + PRODUCT_BUNDLE_IDENTIFIER: com.groupeka.ka.tests | |
| 62 | +schemes: | |
| 63 | + KA: | |
| 64 | + build: | |
| 65 | + targets: | |
| 66 | + KA: all | |
| 67 | + KATests: [test] | |
| 68 | + test: | |
| 69 | + targets: | |
| 70 | + - KATests | |
| 71 | ||