SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%

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

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