SPB Git

spb/ka-ios

Public

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>
Simon-Pierre Boucher committed (Aug 18, 2026) Browse files

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: "&amp;", with: "&")
309+ .replacingOccurrences(of: "&nbsp;", 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 &amp; 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
Menu

Theme