SPB Git

spb/ka-ios

Public

KA 1.0.0 (1) — super-app iOS de l'écosystème Groupe-KA : 12 univers config-driven (données live), recherche universelle, favoris/collections unifiés, carte MapKit, KA Agent natif (SSE), widget Le pouls, onboarding animé, clair/sombre, hors ligne, tests ; build téléversée sur TestFlight

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 18, 2026) Browse files

29 changed files +2,663 −0

added .gitignore +4 −0
@@ -0,0 +1,4 @@
1+build/
2+*.xcodeproj
3+DerivedData/
4+.DS_Store
added KA/App/KAApp.swift +79 −0
@@ -0,0 +1,79 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// KAApp.swift — point d'entrée : onboarding animé au premier lancement, puis
3+// la super-app (5 onglets + bulle KA Agent flottante partout).
4+import SwiftUI
5+
6+@main
7+struct KAApp: App {
8+ @AppStorage("ka.onboarded") private var onboarded = false
9+ @AppStorage("ka.appearance") private var appearance = "auto"
10+ @StateObject private var favorites = FavoritesStore.shared
11+
12+ var body: some Scene {
13+ WindowGroup {
14+ Group {
15+ if onboarded {
16+ RootView()
17+ } else {
18+ OnboardingView()
19+ }
20+ }
21+ .environmentObject(favorites)
22+ .preferredColorScheme(appearance == "clair" ? .light : appearance == "sombre" ? .dark : nil)
23+ }
24+ }
25+}
26+
27+// MARK: - Racine : 5 onglets + bulle Agent
28+
29+struct RootView: View {
30+ @State private var showAgent = false
31+ @State private var tab = 0
32+
33+ var body: some View {
34+ ZStack(alignment: .bottomTrailing) {
35+ TabView(selection: $tab) {
36+ HomeView()
37+ .tabItem { Label("Accueil", systemImage: "house.fill") }.tag(0)
38+ SearchView()
39+ .tabItem { Label("Recherche", systemImage: "magnifyingglass") }.tag(1)
40+ UniversesView()
41+ .tabItem { Label("Univers", systemImage: "circle.grid.3x3.fill") }.tag(2)
42+ FavoritesView()
43+ .tabItem { Label("Favoris", systemImage: "heart.fill") }.tag(3)
44+ MoreTab()
45+ .tabItem { Label("Profil", systemImage: "person.crop.circle") }.tag(4)
46+ }
47+ .tint(KATheme.green)
48+
49+ // Bulle KA Agent — flottante au-dessus de la tab bar, partout
50+ Button {
51+ Haptics.rigid()
52+ showAgent = true
53+ } label: {
54+ Text("Ka")
55+ .font(.system(size: 19, weight: .bold, design: .rounded))
56+ .frame(width: 54, height: 54)
57+ .background(KATheme.lime, in: Circle())
58+ .foregroundStyle(KATheme.inkLight)
59+ .overlay(Circle().strokeBorder(KATheme.inkLight, lineWidth: 2))
60+ .shadow(color: .black.opacity(0.25), radius: 1, x: 3, y: 3)
61+ }
62+ .padding(.trailing, 16)
63+ .padding(.bottom, 64)
64+ .accessibilityLabel("Ouvrir KA Agent, l'assistant de l'écosystème")
65+ }
66+ .sheet(isPresented: $showAgent) {
67+ AgentChatView()
68+ .presentationDetents([.large])
69+ }
70+ }
71+}
72+
73+/// Onglet Profil (contient aussi Favoris pour garder 5 onglets nets — les
74+/// favoris ont leur raccourci au sommet).
75+struct MoreTab: View {
76+ var body: some View {
77+ ProfileView()
78+ }
79+}
added KA/App/OnboardingView.swift +128 −0
@@ -0,0 +1,128 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// OnboardingView.swift — le « waouh » de la première ouverture : animation du
3+// logo KA (boîte encre qui se redresse, lime qui s'allume), pitch en 3 lignes,
4+// choix des univers favoris, puis c'est parti. Court, spectaculaire, utile.
5+// Reduce Motion respecté (l'animation se fige proprement).
6+import SwiftUI
7+
8+struct OnboardingView: View {
9+ @AppStorage("ka.onboarded") private var onboarded = false
10+ @AppStorage("ka.favUniverses") private var favUniversesRaw = ""
11+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
12+ @Environment(\.colorScheme) private var scheme
13+
14+ @State private var step = 0
15+ @State private var logoRotation: Double = -14
16+ @State private var logoScale: CGFloat = 0.6
17+ @State private var glow = false
18+ @State private var picked: Set<String> = []
19+
20+ var body: some View {
21+ VStack(spacing: 0) {
22+ Spacer()
23+ logo
24+ Spacer()
25+ if step == 0 { pitch } else { pickUniverses }
26+ actions
27+ }
28+ .padding(24)
29+ .background(KATheme.paper(scheme))
30+ .onAppear {
31+ guard !reduceMotion else { logoRotation = -4; logoScale = 1; glow = true; return }
32+ withAnimation(.spring(response: 0.9, dampingFraction: 0.55).delay(0.15)) {
33+ logoRotation = -4
34+ logoScale = 1
35+ }
36+ withAnimation(.easeInOut(duration: 1.1).delay(0.5)) { glow = true }
37+ }
38+ }
39+
40+ private var logo: some View {
41+ VStack(spacing: 18) {
42+ ZStack {
43+ RoundedRectangle(cornerRadius: 30, style: .continuous)
44+ .fill(KATheme.inkLight)
45+ .frame(width: 128, height: 128)
46+ .shadow(color: KATheme.lime.opacity(glow ? 0.55 : 0), radius: glow ? 36 : 0)
47+ Text("KA")
48+ .font(.system(size: 56, weight: .bold, design: .rounded))
49+ .foregroundStyle(KATheme.lime)
50+ }
51+ .rotationEffect(.degrees(logoRotation))
52+ .scaleEffect(logoScale)
53+ .accessibilityHidden(true)
54+ Text("Mille sites. Un seul KA.")
55+ .font(.system(.title2, design: .rounded).weight(.bold))
56+ .multilineTextAlignment(.center)
57+ }
58+ }
59+
60+ private var pitch: some View {
61+ VStack(alignment: .leading, spacing: 14) {
62+ pitchRow("magnifyingglass", "Une recherche, tout le Québec", "Logements, propriétés, autos, emplois, restos, sorties — interrogés d'un coup.")
63+ pitchRow("heart.fill", "Des favoris qui traversent les univers", "Un 4½, une auto et un resto dans une même collection.")
64+ pitchRow("sparkles", "KA Agent, votre IA d'ici", "Posez une question, il fouille les vraies données de l'écosystème.")
65+ }
66+ .padding(.bottom, 12)
67+ }
68+
69+ private func pitchRow(_ symbol: String, _ title: String, _ sub: String) -> some View {
70+ HStack(alignment: .top, spacing: 12) {
71+ Image(systemName: symbol)
72+ .font(.headline).foregroundStyle(KATheme.green)
73+ .frame(width: 34, height: 34)
74+ .background(KATheme.green.opacity(0.12), in: RoundedRectangle(cornerRadius: 9))
75+ VStack(alignment: .leading, spacing: 2) {
76+ Text(title).font(.subheadline.weight(.bold))
77+ Text(sub).font(.caption).foregroundStyle(.secondary)
78+ }
79+ }
80+ }
81+
82+ private var pickUniverses: some View {
83+ VStack(alignment: .leading, spacing: 10) {
84+ Text("Vos univers du quotidien ?")
85+ .font(.headline)
86+ Text("Ils remonteront en tête de votre accueil (modifiable dans Profil).")
87+ .font(.caption).foregroundStyle(.secondary)
88+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 105), spacing: 8)], spacing: 8) {
89+ ForEach(Ecosystem.all) { u in
90+ let on = picked.contains(u.id)
91+ Button {
92+ Haptics.tap()
93+ if on { picked.remove(u.id) } else { picked.insert(u.id) }
94+ } label: {
95+ VStack(spacing: 4) {
96+ Image(systemName: u.symbol).font(.subheadline)
97+ Text(u.name).font(.caption2.weight(.bold)).lineLimit(1)
98+ }
99+ .frame(maxWidth: .infinity).padding(.vertical, 10)
100+ .background(on ? u.accent : KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
101+ .foregroundStyle(on ? .white : .primary)
102+ .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(.primary.opacity(0.2), lineWidth: 1))
103+ }
104+ }
105+ }
106+ }
107+ .padding(.bottom, 12)
108+ }
109+
110+ private var actions: some View {
111+ Button {
112+ Haptics.success()
113+ if step == 0 {
114+ withAnimation(.snappy) { step = 1 }
115+ } else {
116+ favUniversesRaw = picked.sorted().joined(separator: ",")
117+ withAnimation(.easeOut) { onboarded = true }
118+ }
119+ } label: {
120+ Text(step == 0 ? "Continuer" : picked.isEmpty ? "Explorer sans choisir" : "C'est parti 🚀")
121+ .font(.headline)
122+ .frame(maxWidth: .infinity).padding(.vertical, 15)
123+ .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
124+ .foregroundStyle(KATheme.lime)
125+ }
126+ .padding(.top, 4)
127+ }
128+}
added KA/Core/Ecosystem.swift +323 −0
@@ -0,0 +1,323 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// Ecosystem.swift — la source de vérité des 13 univers Groupe-KA dans l'app :
3+// identité (nom, accent, symbole, tagline) + configuration d'API (endpoint de
4+// liste, clé des items, mapping JSON → KAItem). Ajouter un univers = ajouter
5+// une entrée ici, rien d'autre (voir docs/ARCHITECTURE.md).
6+import SwiftUI
7+
8+// MARK: - JSON générique (les API des univers ont chacune leur forme)
9+
10+enum JSONValue: Decodable {
11+ case string(String), number(Double), bool(Bool)
12+ case object([String: JSONValue]), array([JSONValue]), null
13+
14+ init(from decoder: Decoder) throws {
15+ let c = try decoder.singleValueContainer()
16+ if c.decodeNil() { self = .null }
17+ else if let b = try? c.decode(Bool.self) { self = .bool(b) }
18+ else if let n = try? c.decode(Double.self) { self = .number(n) }
19+ else if let s = try? c.decode(String.self) { self = .string(s) }
20+ else if let a = try? c.decode([JSONValue].self) { self = .array(a) }
21+ else { self = .object(try c.decode([String: JSONValue].self)) }
22+ }
23+
24+ var string: String? { if case .string(let s) = self { return s }; return nil }
25+ var number: Double? { if case .number(let n) = self { return n }; return nil }
26+ var array: [JSONValue]? { if case .array(let a) = self { return a }; return nil }
27+ var object: [String: JSONValue]? { if case .object(let o) = self { return o }; return nil }
28+ /// Texte « au mieux » (string, nombre formaté, liste jointe)
29+ var text: String? {
30+ switch self {
31+ case .string(let s): return s.isEmpty ? nil : s
32+ case .number(let n): return n == n.rounded() ? String(Int(n)) : String(n)
33+ case .array(let a): let parts = a.compactMap(\.text); return parts.isEmpty ? nil : parts.joined(separator: ", ")
34+ default: return nil
35+ }
36+ }
37+}
38+
39+extension [String: JSONValue] {
40+ func str(_ keys: String...) -> String? {
41+ for k in keys { if let v = self[k]?.text { return v } }
42+ return nil
43+ }
44+ func num(_ keys: String...) -> Double? {
45+ for k in keys { if let v = self[k]?.number { return v } }
46+ return nil
47+ }
48+}
49+
50+// MARK: - L'élément universel
51+
52+struct KAItem: Identifiable, Hashable, Codable {
53+ var id: String
54+ var universeID: String
55+ var title: String
56+ var subtitle: String?
57+ var priceLabel: String?
58+ var city: String?
59+ var url: URL?
60+ var imageURL: URL?
61+ var latitude: Double?
62+ var longitude: Double?
63+ /// Paires libres affichées sur la fiche (« Année : 2021 », « Salaire : 65 000 $ »…)
64+ var facts: [Fact]
65+
66+ struct Fact: Hashable, Codable { var label: String; var value: String }
67+}
68+
69+// MARK: - Univers
70+
71+struct Universe: Identifiable {
72+ let id: String
73+ let wordmark: String // « Lou·Ka »
74+ let name: String // « Lou-KA »
75+ let tagline: String
76+ let accentHex: String
77+ let symbol: String // SF Symbol
78+ let domain: String
79+ let unit: String // « logements », « offres »…
80+ /// Chemin de la liste (nil = univers sans liste native, ex. groupe-ka)
81+ let listPath: String?
82+ let itemsKey: String
83+ let searchParam: String
84+ let statTotalKeys: [String] // clés du total dans /api/stats
85+ let map: (@Sendable ([String: JSONValue]) -> KAItem?)?
86+
87+ var accent: Color { Color(hex: accentHex) }
88+ var baseURL: URL { URL(string: "https://\(domain)")! }
89+}
90+
91+enum Ecosystem {
92+ static let hubURL = URL(string: "https://www.groupe-ka.com")!
93+ static let signupURL = URL(string: "https://www.groupe-ka.com/connexion")!
94+ static let statusURL = URL(string: "https://www.groupe-ka.com/status")!
95+ static let contacts: [(email: String, role: String)] = [
96+ ("contact@groupe-ka.com", "Projets, partenariats & données"),
97+ ("info@groupe-ka.com", "Médias & questions générales"),
98+ ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),
99+ ]
100+ static let legal: [(label: String, path: String)] = [
101+ ("Conditions d'utilisation", "/conditions"),
102+ ("Politique de confidentialité", "/confidentialite"),
103+ ("Renseignements personnels (Loi 25)", "/loi-25"),
104+ ("Transparence des robots", "/bots"),
105+ ]
106+ static let disclaimer = "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction. Données lues à la source — rien d'inventé, tout est traçable."
107+
108+ static func universe(_ id: String) -> Universe? { all.first { $0.id == id } }
109+
110+ // Aides de mapping communes
111+ private static func base(_ o: [String: JSONValue], universe: String, idKeys: [String] = ["uid"]) -> KAItem {
112+ var id: String?
113+ for k in idKeys { if let v = o[k]?.text { id = v; break } }
114+ return KAItem(
115+ id: "\(universe):\(id ?? UUID().uuidString)",
116+ universeID: universe,
117+ title: o.str("title", "name", "display_name") ?? "Sans titre",
118+ subtitle: nil, priceLabel: nil,
119+ city: o.str("city"),
120+ url: o.str("url").flatMap(URL.init(string:)),
121+ imageURL: o.str("image", "image_url", "photo", "thumbnail").flatMap(URL.init(string:)),
122+ latitude: o.num("lat", "latitude"),
123+ longitude: o.num("lng", "lon", "longitude"),
124+ facts: []
125+ )
126+ }
127+
128+ // MARK: les 12 univers (+ le hub, sans liste)
129+ static let all: [Universe] = [
130+ Universe(id: "lou-ka", wordmark: "Lou·Ka", name: "Lou-KA",
131+ tagline: "Tous les logements à louer", accentHex: "#ff6a00",
132+ symbol: "key.fill", domain: "www.lou-ka.com", unit: "logements",
133+ listPath: "/api/listings", itemsKey: "listings", searchParam: "q",
134+ statTotalKeys: ["total"],
135+ map: { o in
136+ var it = base(o, universe: "lou-ka")
137+ it.subtitle = [o.str("unit_type"), o.str("address")].compactMap { $0 }.joined(separator: " · ")
138+ if let p = o.num("price"), p > 50 { it.priceLabel = p.money0 + "/mois" }
139+ it.facts = [
140+ o.str("unit_type").map { .init(label: "Taille", value: $0) },
141+ o.str("address").map { .init(label: "Adresse", value: $0) },
142+ o.str("available_by").map { .init(label: "Disponible", value: $0) },
143+ ].compactMap { $0 }
144+ return it
145+ }),
146+ Universe(id: "immo-ka", wordmark: "Immo·Ka", name: "Immo-KA",
147+ tagline: "Toutes les propriétés à vendre", accentHex: "#e23744",
148+ symbol: "house.fill", domain: "www.immo-ka.com", unit: "propriétés",
149+ listPath: "/api/listings", itemsKey: "listings", searchParam: "q",
150+ statTotalKeys: ["total"],
151+ map: { o in
152+ var it = base(o, universe: "immo-ka")
153+ it.subtitle = [o.str("property_type"), o.str("region")].compactMap { $0 }.joined(separator: " · ")
154+ if let p = o.num("price"), p > 1000 { it.priceLabel = p.money0 }
155+ it.facts = [
156+ o.num("bedrooms").map { .init(label: "Chambres", value: String(Int($0))) },
157+ o.num("bathrooms").map { .init(label: "Salles de bain", value: String(Int($0))) },
158+ o.str("address").map { .init(label: "Adresse", value: $0) },
159+ ].compactMap { $0 }
160+ return it
161+ }),
162+ Universe(id: "vrai-prix", wordmark: "Vrai-Prix", name: "Vrai-Prix",
163+ tagline: "La valeur réelle de chaque propriété", accentHex: "#ff5148",
164+ symbol: "chart.line.uptrend.xyaxis", domain: "www.vrai-prix.com", unit: "propriétés estimées",
165+ listPath: nil, itemsKey: "", searchParam: "q",
166+ statTotalKeys: ["units_total"], map: nil),
167+ Universe(id: "auto-ka", wordmark: "Auto·Ka", name: "Auto-KA",
168+ tagline: "Les voitures usagées du Québec", accentHex: "#ff5a2a",
169+ symbol: "car.fill", domain: "www.auto-ka.com", unit: "véhicules",
170+ listPath: "/api/vehicles", itemsKey: "vehicles", searchParam: "q",
171+ statTotalKeys: ["total"],
172+ map: { o in
173+ var it = base(o, universe: "auto-ka")
174+ it.subtitle = [o.num("year").map { String(Int($0)) }, o.str("mileage_label")].compactMap { $0 }.joined(separator: " · ")
175+ it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money0)
176+ it.facts = [
177+ o.str("make").map { .init(label: "Marque", value: $0) },
178+ o.str("model").map { .init(label: "Modèle", value: $0) },
179+ o.str("transmission").map { .init(label: "Boîte", value: $0) },
180+ o.str("fuel").map { .init(label: "Carburant", value: $0) },
181+ ].compactMap { $0 }
182+ return it
183+ }),
184+ Universe(id: "fabri-ka", wordmark: "Fabri·Ka", name: "Fabri-KA",
185+ tagline: "Les produits fabriqués au Québec", accentHex: "#c4532e",
186+ symbol: "shippingbox.fill", domain: "www.fabri-ka.com", unit: "produits",
187+ listPath: "/api/products", itemsKey: "items", searchParam: "q",
188+ statTotalKeys: ["totals.products", "total"],
189+ map: { o in
190+ var it = base(o, universe: "fabri-ka")
191+ it.subtitle = o.str("store_id", "store")
192+ it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money2)
193+ it.facts = [o.str("category").map { .init(label: "Catégorie", value: $0) }].compactMap { $0 }
194+ return it
195+ }),
196+ Universe(id: "food-ka", wordmark: "Food·Ka", name: "Food-KA",
197+ tagline: "Les prix d'épicerie, suivis à la source", accentHex: "#1f9d55",
198+ symbol: "cart.fill", domain: "www.food-ka.com", unit: "produits",
199+ listPath: "/api/products", itemsKey: "products", searchParam: "q",
200+ statTotalKeys: ["total"],
201+ map: { o in
202+ var it = base(o, universe: "food-ka")
203+ it.subtitle = [o.str("brand"), o.str("size_label")].compactMap { $0 }.joined(separator: " · ")
204+ if let p = o.num("price"), p > 0.2 { it.priceLabel = p.money2 }
205+ it.facts = [
206+ o.str("category").map { .init(label: "Catégorie", value: $0) },
207+ (o["on_sale"].flatMap { if case .bool(true) = $0 { return KAItem.Fact(label: "Solde", value: "Oui 🏷️") } ; return nil }),
208+ ].compactMap { $0 }
209+ return it
210+ }),
211+ Universe(id: "resto-ka", wordmark: "Resto·Ka", name: "Resto-KA",
212+ tagline: "Chaque resto, chaque plat, chaque prix", accentHex: "#f08c00",
213+ symbol: "fork.knife", domain: "www.resto-ka.com", unit: "restaurants",
214+ listPath: "/api/restaurants", itemsKey: "restaurants", searchParam: "q",
215+ statTotalKeys: ["restaurants", "total"],
216+ map: { o in
217+ var it = base(o, universe: "resto-ka")
218+ let cuisines = o["cuisines"]?.text
219+ it.subtitle = [cuisines, o.str("price_range")].compactMap { $0 }.joined(separator: " · ")
220+ it.facts = [
221+ o.str("address").map { .init(label: "Adresse", value: $0) },
222+ o.str("chain").map { .init(label: "Chaîne", value: $0) },
223+ ].compactMap { $0 }
224+ return it
225+ }),
226+ Universe(id: "sorti-ka", wordmark: "Sorti·Ka", name: "Sorti-KA",
227+ tagline: "Toutes les sorties, dans les 17 régions", accentHex: "#d6336c",
228+ symbol: "ticket.fill", domain: "www.sorti-ka.com", unit: "événements",
229+ listPath: "/api/events?upcoming=true", itemsKey: "events", searchParam: "q",
230+ statTotalKeys: ["total_active", "total"],
231+ map: { o in
232+ var it = base(o, universe: "sorti-ka")
233+ it.subtitle = [o.str("start_date"), o.str("venue")].compactMap { $0 }.joined(separator: " · ")
234+ it.facts = [
235+ o.str("start_date").map { .init(label: "Début", value: $0) },
236+ o.str("venue").map { .init(label: "Lieu", value: $0) },
237+ (o["is_free"].flatMap { if case .bool(true) = $0 { return KAItem.Fact(label: "Entrée", value: "Gratuite 🎉") } ; return nil }),
238+ ].compactMap { $0 }
239+ return it
240+ }),
241+ Universe(id: "crea-ka", wordmark: "Créa·Ka", name: "Créa-KA",
242+ tagline: "Les créateurs d'ici, tous leurs liens", accentHex: "#7048e8",
243+ symbol: "sparkles", domain: "www.crea-ka.com", unit: "créateurs",
244+ listPath: "/api/creators", itemsKey: "items", searchParam: "q",
245+ statTotalKeys: ["creators", "total"],
246+ map: { o in
247+ var it = base(o, universe: "crea-ka", idKeys: ["cid", "uid", "display_name"])
248+ it.subtitle = [o["niches"]?.text, o.str("primary_platform")].compactMap { $0 }.joined(separator: " · ")
249+ if it.url == nil, let plats = o["platforms"]?.array?.first?.object {
250+ it.url = plats.str("url").flatMap(URL.init(string:))
251+ }
252+ return it
253+ }),
254+ Universe(id: "job-ka", wordmark: "Job·Ka", name: "Job-KA",
255+ tagline: "Tous les emplois des employeurs québécois", accentHex: "#0c8599",
256+ symbol: "briefcase.fill", domain: "www.job-ka.com", unit: "offres d'emploi",
257+ listPath: "/api/jobs", itemsKey: "jobs", searchParam: "q",
258+ statTotalKeys: ["total"],
259+ map: { o in
260+ var it = base(o, universe: "job-ka")
261+ it.subtitle = [o.str("employer"), o.str("city")].compactMap { $0 }.joined(separator: " · ")
262+ if let a = o.num("salary_year_min"), a > 20000 { it.priceLabel = a.money0 + "/an" }
263+ it.facts = [
264+ o.str("employer").map { .init(label: "Employeur", value: $0) },
265+ o.str("work_mode").map { .init(label: "Mode", value: $0) },
266+ o.str("employment_type").map { .init(label: "Type", value: $0) },
267+ ].compactMap { $0 }
268+ return it
269+ }),
270+ Universe(id: "trouve-ka", wordmark: "Trouve·Ka", name: "Trouve-KA",
271+ tagline: "Le moteur de recherche du web québécois", accentHex: "#1c7ed6",
272+ symbol: "magnifyingglass", domain: "www.trouve-ka.com", unit: "pages indexées",
273+ listPath: "/api/search", itemsKey: "results", searchParam: "q",
274+ statTotalKeys: ["pages_indexed"],
275+ map: { o in
276+ var it = base(o, universe: "trouve-ka", idKeys: ["url"])
277+ it.title = (o.str("title") ?? "Page web").strippingHTML
278+ it.subtitle = o.str("snippet")?.strippingHTML
279+ it.facts = [o.str("domain").map { .init(label: "Domaine", value: $0) }].compactMap { $0 }
280+ return it
281+ }),
282+ Universe(id: "api-ka", wordmark: "API·Ka", name: "API-KA",
283+ tagline: "La donnée de l'écosystème, par API", accentHex: "#3b5bdb",
284+ symbol: "terminal.fill", domain: "www.api-ka.com", unit: "appels API",
285+ listPath: nil, itemsKey: "", searchParam: "q",
286+ statTotalKeys: ["total"], map: nil),
287+ ]
288+}
289+
290+// MARK: - petites extensions
291+
292+extension Double {
293+ var money0: String {
294+ let f = NumberFormatter(); f.numberStyle = .currency
295+ f.locale = Locale(identifier: "fr_CA"); f.maximumFractionDigits = 0
296+ return f.string(from: NSNumber(value: self)) ?? "\(Int(self)) $"
297+ }
298+ var money2: String {
299+ let f = NumberFormatter(); f.numberStyle = .currency
300+ f.locale = Locale(identifier: "fr_CA"); f.maximumFractionDigits = 2
301+ return f.string(from: NSNumber(value: self)) ?? "\(self) $"
302+ }
303+}
304+
305+extension String {
306+ var strippingHTML: String {
307+ replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
308+ .replacingOccurrences(of: "&amp;", with: "&")
309+ .replacingOccurrences(of: "&nbsp;", with: " ")
310+ }
311+}
312+
313+extension Color {
314+ init(hex: String) {
315+ var h = hex.trimmingCharacters(in: .alphanumerics.inverted)
316+ if h.count == 3 { h = h.map { "\($0)\($0)" }.joined() }
317+ let v = UInt64(h, radix: 16) ?? 0
318+ self.init(.sRGB,
319+ red: Double((v >> 16) & 0xFF) / 255,
320+ green: Double((v >> 8) & 0xFF) / 255,
321+ blue: Double(v & 0xFF) / 255)
322+ }
323+}
added KA/Core/FavoritesStore.swift +81 −0
@@ -0,0 +1,81 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// FavoritesStore.swift — favoris et collections UNIFIÉS (tous univers dans une
3+// même collection : « Déménagement à Val-d'Or » peut contenir un 4½, une auto
4+// et un resto). Persistance JSON locale (consultation hors ligne).
5+import Foundation
6+import SwiftUI
7+
8+@MainActor
9+final class FavoritesStore: ObservableObject {
10+ static let shared = FavoritesStore()
11+
12+ struct FavCollection: Identifiable, Codable, Hashable {
13+ var id: UUID = UUID()
14+ var name: String
15+ var items: [KAItem] = []
16+ }
17+
18+ @Published private(set) var collections: [FavCollection] = [] {
19+ didSet { save() }
20+ }
21+
22+ private var fileURL: URL {
23+ let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
24+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
25+ return dir.appendingPathComponent("ka-favoris.json")
26+ }
27+
28+ init() {
29+ if let data = try? Data(contentsOf: fileURL),
30+ let saved = try? JSONDecoder().decode([FavCollection].self, from: data) {
31+ collections = saved
32+ }
33+ if collections.isEmpty {
34+ collections = [FavCollection(name: "Mes favoris")]
35+ }
36+ }
37+
38+ private func save() {
39+ if let data = try? JSONEncoder().encode(collections) {
40+ try? data.write(to: fileURL, options: .atomic)
41+ }
42+ }
43+
44+ // MARK: API
45+
46+ var allItems: [KAItem] { collections.flatMap(\.items) }
47+
48+ func isFavorite(_ item: KAItem) -> Bool {
49+ collections.contains { $0.items.contains { $0.id == item.id } }
50+ }
51+
52+ func toggle(_ item: KAItem, in collectionID: UUID? = nil) {
53+ if isFavorite(item) {
54+ for i in collections.indices {
55+ collections[i].items.removeAll { $0.id == item.id }
56+ }
57+ } else {
58+ let idx = collections.firstIndex { $0.id == collectionID } ?? 0
59+ collections[idx].items.insert(item, at: 0)
60+ }
61+ Haptics.tap()
62+ }
63+
64+ func addCollection(_ name: String) {
65+ let trimmed = name.trimmingCharacters(in: .whitespaces)
66+ guard !trimmed.isEmpty else { return }
67+ collections.append(FavCollection(name: trimmed))
68+ }
69+
70+ func removeCollection(_ id: UUID) {
71+ guard collections.count > 1 else { return }
72+ collections.removeAll { $0.id == id }
73+ }
74+
75+ func move(_ item: KAItem, to collectionID: UUID) {
76+ for i in collections.indices { collections[i].items.removeAll { $0.id == item.id } }
77+ if let idx = collections.firstIndex(where: { $0.id == collectionID }) {
78+ collections[idx].items.insert(item, at: 0)
79+ }
80+ }
81+}
added KA/Core/Services.swift +143 −0
@@ -0,0 +1,143 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// Services.swift — réseau : client JSON avec cache disque (mode hors ligne de
3+// consultation), service d'univers (listes/recherche), stats live, et
4+// KA Agent (chat SSE vers l'API centrale api-ka).
5+import Foundation
6+
7+// MARK: - Client HTTP + cache disque
8+
9+actor APIClient {
10+ static let shared = APIClient()
11+ private let session: URLSession
12+ private let cacheDir: URL
13+
14+ init() {
15+ let cfg = URLSessionConfiguration.default
16+ cfg.timeoutIntervalForRequest = 15
17+ cfg.waitsForConnectivity = false
18+ session = URLSession(configuration: cfg)
19+ cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
20+ .appendingPathComponent("ka-json", isDirectory: true)
21+ try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
22+ }
23+
24+ private func cacheFile(for url: URL) -> URL {
25+ let name = url.absoluteString.data(using: .utf8)!.base64EncodedString()
26+ .replacingOccurrences(of: "/", with: "_")
27+ return cacheDir.appendingPathComponent(String(name.suffix(120)) + ".json")
28+ }
29+
30+ /// JSON brut ; en cas d'échec réseau, sert la dernière copie disque (hors ligne).
31+ func json(_ url: URL, ttl: TimeInterval = 120) async throws -> JSONValue {
32+ let file = cacheFile(for: url)
33+ if let attrs = try? FileManager.default.attributesOfItem(atPath: file.path),
34+ let date = attrs[.modificationDate] as? Date, Date().timeIntervalSince(date) < ttl,
35+ let data = try? Data(contentsOf: file),
36+ let cached = try? JSONDecoder().decode(JSONValue.self, from: data) {
37+ return cached
38+ }
39+ do {
40+ var req = URLRequest(url: url)
41+ req.setValue("KA-iOS/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")
42+ let (data, resp) = try await session.data(for: req)
43+ guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else {
44+ throw URLError(.badServerResponse)
45+ }
46+ let value = try JSONDecoder().decode(JSONValue.self, from: data)
47+ try? data.write(to: file)
48+ return value
49+ } catch {
50+ if let data = try? Data(contentsOf: file),
51+ let cached = try? JSONDecoder().decode(JSONValue.self, from: data) {
52+ return cached // hors ligne : dernier contenu connu
53+ }
54+ throw error
55+ }
56+ }
57+}
58+
59+// MARK: - Univers : listes, recherche, stats
60+
61+enum UniverseService {
62+ static func listURL(_ u: Universe, query: String?, city: String? = nil, limit: Int = 30) -> URL? {
63+ guard let path = u.listPath else { return nil }
64+ var comps = URLComponents(url: u.baseURL.appendingPathComponent(""), resolvingAgainstBaseURL: false)!
65+ // listPath peut contenir déjà une query (ex. events?upcoming=true)
66+ let split = path.split(separator: "?", maxSplits: 1)
67+ comps.path = String(split[0])
68+ var items: [URLQueryItem] = split.count > 1
69+ ? split[1].split(separator: "&").map {
70+ let kv = $0.split(separator: "=", maxSplits: 1)
71+ return URLQueryItem(name: String(kv[0]), value: kv.count > 1 ? String(kv[1]) : nil)
72+ } : []
73+ if let q = query, !q.isEmpty { items.append(.init(name: u.searchParam, value: q)) }
74+ if let c = city, !c.isEmpty { items.append(.init(name: "city", value: c)) }
75+ items.append(.init(name: "limit", value: String(limit)))
76+ comps.queryItems = items
77+ return comps.url
78+ }
79+
80+ static func fetch(_ u: Universe, query: String? = nil, city: String? = nil, limit: Int = 30) async throws -> [KAItem] {
81+ guard let url = listURL(u, query: query, city: city, limit: limit), let map = u.map else { return [] }
82+ let root = try await APIClient.shared.json(url)
83+ let obj = root.object ?? [:]
84+ let raw = obj[u.itemsKey]?.array
85+ ?? obj["items"]?.array ?? obj["results"]?.array ?? obj["hits"]?.array
86+ ?? root.array ?? []
87+ return raw.compactMap { $0.object.flatMap(map) }
88+ }
89+
90+ /// Total « en direct » depuis /api/stats (clé selon l'univers, chemins a.b acceptés)
91+ static func liveTotal(_ u: Universe) async -> Int? {
92+ let path = u.id == "trouve-ka" ? "/api/status" : "/api/stats"
93+ guard let url = URL(string: "https://\(u.domain)\(path)"),
94+ let root = try? await APIClient.shared.json(url, ttl: 300) else { return nil }
95+ for key in u.statTotalKeys {
96+ var cur: JSONValue? = root
97+ for part in key.split(separator: ".") {
98+ cur = cur?.object?[String(part)]
99+ }
100+ if let n = cur?.number { return Int(n) }
101+ }
102+ return nil
103+ }
104+}
105+
106+// MARK: - KA Agent (SSE)
107+
108+struct AgentEvent { let kind: Kind; enum Kind { case delta(String), tool(String), done, error(String) } }
109+
110+enum AgentService {
111+ static let endpoint = URL(string: "https://www.api-ka.com/api/agent/chat")!
112+
113+ static func stream(site: String, messages: [[String: String]]) -> AsyncThrowingStream<AgentEvent, Error> {
114+ AsyncThrowingStream { continuation in
115+ let task = Task {
116+ var req = URLRequest(url: endpoint)
117+ req.httpMethod = "POST"
118+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
119+ req.timeoutInterval = 90
120+ req.httpBody = try JSONSerialization.data(withJSONObject: ["site": site, "messages": messages])
121+ let (bytes, resp) = try await URLSession.shared.bytes(for: req)
122+ guard (resp as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) }
123+ var event = ""
124+ for try await line in bytes.lines {
125+ if line.hasPrefix("event: ") { event = String(line.dropFirst(7)) }
126+ else if line.hasPrefix("data: ") {
127+ let data = Data(line.dropFirst(6).utf8)
128+ let obj = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
129+ switch event {
130+ case "delta": if let t = obj["text"] as? String { continuation.yield(.init(kind: .delta(t))) }
131+ case "tool": continuation.yield(.init(kind: .tool(obj["name"] as? String ?? "recherche")))
132+ case "done": continuation.yield(.init(kind: .done)); continuation.finish(); return
133+ case "error": continuation.yield(.init(kind: .error(obj["message"] as? String ?? "erreur")))
134+ default: break
135+ }
136+ }
137+ }
138+ continuation.finish()
139+ }
140+ continuation.onTermination = { _ in task.cancel() }
141+ }
142+ }
143+}
added KA/Design/Theme.swift +186 −0
@@ -0,0 +1,186 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// Theme.swift — design system Groupe-KA adapté iOS (HIG) : papier/encre en
3+// clair, encre/papier en sombre, accents par univers, composants signature
4+// (carte à ombre décalée, puce, wordmark) + haptique.
5+import SwiftUI
6+import UIKit
7+
8+// MARK: - Palette
9+
10+enum KATheme {
11+ static let paperLight = Color(hex: "#f5f3ee")
12+ static let paperDark = Color(hex: "#101410")
13+ static let inkLight = Color(hex: "#141814")
14+ static let inkDark = Color(hex: "#f0efe8")
15+ static let lime = Color(hex: "#d9f26b")
16+ static let green = Color(hex: "#1c5c41")
17+
18+ static func paper(_ scheme: ColorScheme) -> Color { scheme == .dark ? paperDark : paperLight }
19+ static func ink(_ scheme: ColorScheme) -> Color { scheme == .dark ? inkDark : inkLight }
20+ static func surface(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: "#1a1f1a") : .white }
21+ static func ink2(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: "#a9b0a9") : Color(hex: "#4d5551") }
22+}
23+
24+// MARK: - Haptique
25+
26+enum Haptics {
27+ static func tap() { UIImpactFeedbackGenerator(style: .light).impactOccurred() }
28+ static func success() { UINotificationFeedbackGenerator().notificationOccurred(.success) }
29+ static func rigid() { UIImpactFeedbackGenerator(style: .rigid).impactOccurred() }
30+}
31+
32+// MARK: - Carte « éditorial sharp » (bordure encre + ombre décalée)
33+
34+struct KACard: ViewModifier {
35+ @Environment(\.colorScheme) private var scheme
36+ var accent: Color? = nil
37+
38+ func body(content: Content) -> some View {
39+ content
40+ .background(KATheme.surface(scheme))
41+ .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
42+ .overlay(
43+ RoundedRectangle(cornerRadius: 14, style: .continuous)
44+ .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.4)
45+ )
46+ .background(
47+ RoundedRectangle(cornerRadius: 14, style: .continuous)
48+ .fill((accent ?? KATheme.ink(scheme)).opacity(scheme == .dark ? 0.25 : 0.16))
49+ .offset(x: 5, y: 5)
50+ )
51+ }
52+}
53+
54+extension View {
55+ func kaCard(accent: Color? = nil) -> some View { modifier(KACard(accent: accent)) }
56+}
57+
58+// MARK: - Wordmark (« Lou » + boîte [Ka] accent)
59+
60+struct KAWordmark: View {
61+ let universe: Universe
62+ var size: CGFloat = 22
63+ @Environment(\.colorScheme) private var scheme
64+
65+ var body: some View {
66+ let parts = universe.wordmark.split(separator: "·", maxSplits: 1)
67+ HStack(alignment: .firstTextBaseline, spacing: 3) {
68+ Text(parts.first.map(String.init) ?? universe.wordmark)
69+ .font(.system(size: size, weight: .bold, design: .rounded))
70+ if parts.count > 1 {
71+ Text(String(parts[1]))
72+ .font(.system(size: size * 0.86, weight: .bold, design: .rounded))
73+ .foregroundStyle(universe.accent)
74+ .padding(.horizontal, size * 0.28)
75+ .padding(.vertical, size * 0.08)
76+ .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.26, style: .continuous))
77+ .rotationEffect(.degrees(-2))
78+ }
79+ }
80+ .foregroundStyle(KATheme.ink(scheme))
81+ .accessibilityLabel(universe.name)
82+ }
83+}
84+
85+struct GroupeKAMark: View {
86+ var size: CGFloat = 24
87+ @Environment(\.colorScheme) private var scheme
88+ var body: some View {
89+ HStack(alignment: .firstTextBaseline, spacing: 4) {
90+ Text("Groupe").font(.system(size: size, weight: .bold, design: .rounded))
91+ Text("KA")
92+ .font(.system(size: size * 0.9, weight: .bold, design: .rounded))
93+ .foregroundStyle(KATheme.lime)
94+ .padding(.horizontal, size * 0.3).padding(.vertical, size * 0.1)
95+ .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.28, style: .continuous))
96+ .rotationEffect(.degrees(-2))
97+ }
98+ .fixedSize()
99+ .foregroundStyle(KATheme.ink(scheme))
100+ .accessibilityLabel("Groupe KA")
101+ }
102+}
103+
104+// MARK: - Puce mono (klabel)
105+
106+struct KAChip: View {
107+ let text: String
108+ var accent: Color? = nil
109+ @Environment(\.colorScheme) private var scheme
110+ var body: some View {
111+ Text(text)
112+ .font(.system(.caption2, design: .monospaced).weight(.bold))
113+ .textCase(.uppercase)
114+ .padding(.horizontal, 9).padding(.vertical, 4)
115+ .background((accent ?? KATheme.ink(scheme)).opacity(0.12), in: Capsule())
116+ .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.4), lineWidth: 1))
117+ }
118+}
119+
120+// MARK: - Rangée d'item universelle
121+
122+struct KAItemRow: View {
123+ let item: KAItem
124+ @Environment(\.colorScheme) private var scheme
125+ @EnvironmentObject private var favorites: FavoritesStore
126+
127+ private var universe: Universe? { Ecosystem.universe(item.universeID) }
128+
129+ var body: some View {
130+ HStack(alignment: .top, spacing: 12) {
131+ RoundedRectangle(cornerRadius: 8, style: .continuous)
132+ .fill(universe?.accent.opacity(0.9) ?? .gray)
133+ .frame(width: 5)
134+ .padding(.vertical, 2)
135+ VStack(alignment: .leading, spacing: 4) {
136+ Text(item.title)
137+ .font(.headline)
138+ .lineLimit(2)
139+ if let sub = item.subtitle, !sub.isEmpty {
140+ Text(sub).font(.subheadline)
141+ .foregroundStyle(KATheme.ink2(scheme)).lineLimit(2)
142+ }
143+ HStack(spacing: 8) {
144+ if let price = item.priceLabel {
145+ Text(price).font(.system(.subheadline, design: .rounded).weight(.bold))
146+ .foregroundStyle(universe?.accent ?? .primary)
147+ }
148+ if let city = item.city, !city.isEmpty {
149+ Text(city).font(.caption).foregroundStyle(KATheme.ink2(scheme))
150+ }
151+ Spacer(minLength: 0)
152+ if let u = universe {
153+ KAChip(text: u.wordmark, accent: u.accent)
154+ }
155+ }
156+ }
157+ if favorites.isFavorite(item) {
158+ Image(systemName: "heart.fill")
159+ .foregroundStyle(.red).font(.caption)
160+ .accessibilityLabel("Dans vos favoris")
161+ }
162+ }
163+ .padding(12)
164+ .kaCard(accent: universe?.accent)
165+ }
166+}
167+
168+// MARK: - États
169+
170+struct KAEmptyState: View {
171+ let symbol: String
172+ let title: String
173+ var message: String? = nil
174+ var body: some View {
175+ VStack(spacing: 10) {
176+ Image(systemName: symbol).font(.system(size: 40)).foregroundStyle(.secondary)
177+ Text(title).font(.headline)
178+ if let m = message {
179+ Text(m).font(.subheadline).foregroundStyle(.secondary)
180+ .multilineTextAlignment(.center)
181+ }
182+ }
183+ .padding(30)
184+ .frame(maxWidth: .infinity)
185+ }
186+}
added KA/Features/AgentChatView.swift +201 −0
@@ -0,0 +1,201 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// AgentChatView.swift — KA Agent natif : le même assistant IA que sur les
3+// sites (Claude Haiku via l'API centrale), en chat plein écran, flux token
4+// par token + indicateurs d'outils.
5+import SwiftUI
6+
7+struct AgentMessage: Identifiable, Equatable {
8+ let id = UUID()
9+ var role: String // user / assistant / tool
10+ var content: String
11+}
12+
13+@MainActor
14+final class AgentChat: ObservableObject {
15+ @Published var messages: [AgentMessage] = []
16+ @Published var busy = false
17+ var site: String = "groupe-ka"
18+
19+ func send(_ text: String) {
20+ let q = text.trimmingCharacters(in: .whitespacesAndNewlines)
21+ guard !q.isEmpty, !busy else { return }
22+ messages.append(AgentMessage(role: "user", content: q))
23+ messages.append(AgentMessage(role: "assistant", content: ""))
24+ busy = true
25+ Haptics.rigid()
26+ let history = messages
27+ .filter { $0.role == "user" || ($0.role == "assistant" && !$0.content.isEmpty) }
28+ .suffix(16)
29+ .map { ["role": $0.role, "content": $0.content] }
30+
31+ Task {
32+ do {
33+ for try await event in AgentService.stream(site: site, messages: Array(history)) {
34+ switch event.kind {
35+ case .delta(let t):
36+ if let i = messages.lastIndex(where: { $0.role == "assistant" }) {
37+ messages[i].content += t
38+ }
39+ case .tool(let name):
40+ // insérer la puce outil AVANT la bulle assistante en cours
41+ if let i = messages.lastIndex(where: { $0.role == "assistant" }) {
42+ messages.insert(AgentMessage(role: "tool", content: name.replacingOccurrences(of: "_", with: " ")), at: i)
43+ }
44+ case .error(let m):
45+ if let i = messages.lastIndex(where: { $0.role == "assistant" }), messages[i].content.isEmpty {
46+ messages[i].content = "Désolé, une erreur est survenue (\(m)). Réessayez."
47+ }
48+ case .done: break
49+ }
50+ }
51+ } catch {
52+ if let i = messages.lastIndex(where: { $0.role == "assistant" }), messages[i].content.isEmpty {
53+ messages[i].content = "Impossible de joindre KA Agent — vérifiez votre connexion."
54+ }
55+ }
56+ busy = false
57+ Haptics.success()
58+ }
59+ }
60+}
61+
62+struct AgentChatView: View {
63+ @StateObject private var chat = AgentChat()
64+ @State private var input = ""
65+ @Environment(\.dismiss) private var dismiss
66+ @Environment(\.colorScheme) private var scheme
67+ @FocusState private var focused: Bool
68+
69+ var body: some View {
70+ NavigationStack {
71+ VStack(spacing: 0) {
72+ ScrollViewReader { proxy in
73+ ScrollView {
74+ LazyVStack(alignment: .leading, spacing: 10) {
75+ hello
76+ ForEach(chat.messages) { m in
77+ bubble(m).id(m.id)
78+ }
79+ }
80+ .padding(14)
81+ }
82+ .onChange(of: chat.messages.last?.content) {
83+ if let last = chat.messages.last { proxy.scrollTo(last.id, anchor: .bottom) }
84+ }
85+ }
86+ inputBar
87+ }
88+ .background(KATheme.paper(scheme))
89+ .navigationTitle("")
90+ .toolbar {
91+ ToolbarItem(placement: .topBarLeading) {
92+ HStack(spacing: 6) {
93+ Text("KA").font(.system(.headline, design: .rounded).weight(.bold))
94+ Text("Agent")
95+ .font(.system(.subheadline, design: .rounded).weight(.bold))
96+ .foregroundStyle(KATheme.lime)
97+ .padding(.horizontal, 7).padding(.vertical, 2)
98+ .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 6))
99+ .rotationEffect(.degrees(-2))
100+ Text("· IA de l'écosystème").font(.caption2).foregroundStyle(.secondary)
101+ }
102+ }
103+ ToolbarItem(placement: .topBarTrailing) {
104+ Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) }
105+ .accessibilityLabel("Fermer KA Agent")
106+ }
107+ }
108+ }
109+ }
110+
111+ private var hello: some View {
112+ Group {
113+ if chat.messages.isEmpty {
114+ VStack(alignment: .leading, spacing: 8) {
115+ Text("👋 Je suis KA Agent.")
116+ .font(.headline)
117+ Text("Posez-moi n'importe quelle question sur l'écosystème Groupe KA et ses données : logements, propriétés, autos, emplois, prix d'épicerie, restos, sorties, créateurs, statistiques…")
118+ .font(.subheadline).foregroundStyle(.secondary)
119+ FlowSuggestions { s in
120+ input = s
121+ chat.send(s); input = ""
122+ }
123+ }
124+ .padding(4)
125+ }
126+ }
127+ }
128+
129+ @ViewBuilder
130+ private func bubble(_ m: AgentMessage) -> some View {
131+ switch m.role {
132+ case "user":
133+ Text(m.content)
134+ .padding(.horizontal, 13).padding(.vertical, 9)
135+ .background(KATheme.lime, in: RoundedRectangle(cornerRadius: 13, style: .continuous))
136+ .foregroundStyle(KATheme.inkLight)
137+ .frame(maxWidth: .infinity, alignment: .trailing)
138+ case "tool":
139+ Label(m.content, systemImage: "magnifyingglass")
140+ .font(.system(.caption2, design: .monospaced).weight(.bold))
141+ .textCase(.uppercase)
142+ .padding(.horizontal, 10).padding(.vertical, 5)
143+ .overlay(Capsule().strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3])))
144+ .foregroundStyle(.secondary)
145+ default:
146+ Group {
147+ if m.content.isEmpty {
148+ ProgressView().padding(10)
149+ } else {
150+ Text(LocalizedStringKey(m.content)) // rend **gras** et liens Markdown
151+ .textSelection(.enabled)
152+ .padding(.horizontal, 13).padding(.vertical, 9)
153+ .kaCard()
154+ }
155+ }
156+ .frame(maxWidth: .infinity, alignment: .leading)
157+ }
158+ }
159+
160+ private var inputBar: some View {
161+ HStack(spacing: 8) {
162+ TextField("Posez votre question…", text: $input, axis: .vertical)
163+ .lineLimit(1...4)
164+ .padding(.horizontal, 13).padding(.vertical, 10)
165+ .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
166+ .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(.primary.opacity(0.35), lineWidth: 1.2))
167+ .focused($focused)
168+ .onSubmit { chat.send(input); input = "" }
169+ Button {
170+ chat.send(input); input = ""
171+ } label: {
172+ Image(systemName: "arrow.up")
173+ .font(.headline)
174+ .frame(width: 44, height: 44)
175+ .background(KATheme.inkLight, in: Circle())
176+ .foregroundStyle(KATheme.lime)
177+ }
178+ .disabled(chat.busy || input.trimmingCharacters(in: .whitespaces).isEmpty)
179+ .accessibilityLabel("Envoyer")
180+ }
181+ .padding(12)
182+ .background(.bar)
183+ }
184+}
185+
186+private struct FlowSuggestions: View {
187+ let action: (String) -> Void
188+ private let ideas = ["Combien de logements à louer ?", "Un resto italien à Montréal", "Les sorties gratuites ce week-end", "C'est quoi le Groupe KA ?"]
189+ var body: some View {
190+ VStack(alignment: .leading, spacing: 6) {
191+ ForEach(ideas, id: \.self) { s in
192+ Button { action(s) } label: {
193+ Text(s).font(.caption.weight(.semibold))
194+ .padding(.horizontal, 11).padding(.vertical, 7)
195+ .background(.quaternary, in: Capsule())
196+ }
197+ .buttonStyle(.plain)
198+ }
199+ }
200+ }
201+}
added KA/Features/FavoritesView.swift +90 −0
@@ -0,0 +1,90 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// FavoritesView.swift — favoris & collections unifiés (multi-univers dans une
3+// même collection), consultables hors ligne, partage natif.
4+import SwiftUI
5+
6+struct FavoritesView: View {
7+ @EnvironmentObject private var favorites: FavoritesStore
8+ @State private var newName = ""
9+ @State private var showNew = false
10+ @Environment(\.colorScheme) private var scheme
11+
12+ var body: some View {
13+ NavigationStack {
14+ ScrollView {
15+ VStack(alignment: .leading, spacing: 18) {
16+ if favorites.allItems.isEmpty {
17+ KAEmptyState(symbol: "heart", title: "Aucun favori pour l'instant",
18+ message: "Touchez ♥ sur un logement, une auto, un resto ou une sortie — tout se retrouve ici, même hors ligne.")
19+ }
20+ ForEach(favorites.collections) { collection in
21+ collectionSection(collection)
22+ }
23+ }
24+ .padding(16)
25+ }
26+ .background(KATheme.paper(scheme))
27+ .navigationTitle("Favoris")
28+ .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }
29+ .toolbar {
30+ ToolbarItem(placement: .topBarTrailing) {
31+ Button { showNew = true } label: { Image(systemName: "folder.badge.plus") }
32+ .accessibilityLabel("Nouvelle collection")
33+ }
34+ }
35+ .alert("Nouvelle collection", isPresented: $showNew) {
36+ TextField("Ex. Déménagement à Val-d'Or", text: $newName)
37+ Button("Créer") {
38+ favorites.addCollection(newName)
39+ newName = ""
40+ Haptics.success()
41+ }
42+ Button("Annuler", role: .cancel) { newName = "" }
43+ } message: {
44+ Text("Une collection peut mélanger tous les univers : un 4½, une auto et un resto ensemble.")
45+ }
46+ }
47+ }
48+
49+ @ViewBuilder
50+ private func collectionSection(_ c: FavoritesStore.FavCollection) -> some View {
51+ VStack(alignment: .leading, spacing: 10) {
52+ HStack {
53+ Label(c.name, systemImage: "folder.fill")
54+ .font(.headline)
55+ Spacer()
56+ Text("\(c.items.count)")
57+ .font(.system(.caption, design: .monospaced).weight(.bold))
58+ .foregroundStyle(.secondary)
59+ if favorites.collections.count > 1 {
60+ Menu {
61+ Button(role: .destructive) {
62+ favorites.removeCollection(c.id)
63+ } label: { Label("Supprimer la collection", systemImage: "trash") }
64+ } label: {
65+ Image(systemName: "ellipsis.circle").foregroundStyle(.secondary)
66+ }
67+ }
68+ }
69+ if c.items.isEmpty {
70+ Text("Vide — ajoutez-y des trouvailles depuis n'importe quel univers.")
71+ .font(.caption).foregroundStyle(.tertiary)
72+ }
73+ ForEach(c.items) { item in
74+ NavigationLink(value: item) { KAItemRow(item: item) }
75+ .buttonStyle(.plain)
76+ .contextMenu {
77+ ForEach(favorites.collections.filter { $0.id != c.id }) { other in
78+ Button { favorites.move(item, to: other.id) } label: {
79+ Label("Déplacer vers \(other.name)", systemImage: "folder")
80+ }
81+ }
82+ if let url = item.url { ShareLink(item: url) { Label("Partager", systemImage: "square.and.arrow.up") } }
83+ Button(role: .destructive) { favorites.toggle(item) } label: {
84+ Label("Retirer", systemImage: "heart.slash")
85+ }
86+ }
87+ }
88+ }
89+ }
90+}
added KA/Features/HomeView.swift +189 −0
@@ -0,0 +1,189 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// HomeView.swift — l'accueil VIVANT : salutation selon l'heure, suggestions
3+// contextuelles (restos le soir, sorties le week-end, emplois le matin…) avec
4+// du VRAI contenu en direct, et le pouls de l'écosystème (compteurs live).
5+import SwiftUI
6+
7+struct HomeView: View {
8+ @State private var featured: [(universe: Universe, items: [KAItem])] = []
9+ @State private var pulse: [(Universe, Int)] = []
10+ @State private var loading = true
11+ @AppStorage("ka.favUniverses") private var favUniversesRaw = ""
12+ @Environment(\.colorScheme) private var scheme
13+
14+ var body: some View {
15+ NavigationStack {
16+ ScrollView {
17+ VStack(alignment: .leading, spacing: 22) {
18+ header
19+ pulseStrip
20+ ForEach(featured, id: \.universe.id) { section in
21+ featuredSection(section.universe, section.items)
22+ }
23+ if loading && featured.isEmpty {
24+ ProgressView("KA prépare votre accueil…")
25+ .frame(maxWidth: .infinity).padding(40)
26+ }
27+ disclaimer
28+ }
29+ .padding(16)
30+ }
31+ .background(KATheme.paper(scheme))
32+ .navigationTitle("")
33+ .toolbar {
34+ ToolbarItem(placement: .topBarLeading) {
35+ GroupeKAMark(size: 16)
36+ }
37+ }
38+ .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }
39+ .navigationDestination(for: String.self) { id in
40+ if let u = Ecosystem.universe(id) { UniverseHomeView(universe: u) }
41+ }
42+ .refreshable { await load() }
43+ .task { await load() }
44+ }
45+ }
46+
47+ // MARK: sous-vues
48+
49+ private var greeting: (title: String, sub: String) {
50+ let h = Calendar.current.component(.hour, from: Date())
51+ let weekend = Calendar.current.isDateInWeekend(Date())
52+ switch h {
53+ case 5..<12: return ("Bon matin ☀️", weekend ? "On planifie la fin de semaine ?" : "L'écosystème a travaillé toute la nuit.")
54+ case 12..<17: return ("Bon après-midi 👋", "Tout le Québec, dans votre poche.")
55+ case 17..<22: return ("Bonsoir 🌆", weekend ? "Resto ? Sortie ? KA a des idées." : "On soupe où ce soir ?")
56+ default: return ("Bonne nuit 🌙", "Les connecteurs veillent — revenez demain matin.")
57+ }
58+ }
59+
60+ private var header: some View {
61+ VStack(alignment: .leading, spacing: 5) {
62+ Text(greeting.title).font(.system(.largeTitle, design: .rounded).weight(.bold))
63+ Text(greeting.sub).font(.subheadline).foregroundStyle(KATheme.ink2(scheme))
64+ }
65+ }
66+
67+ private var pulseStrip: some View {
68+ ScrollView(.horizontal, showsIndicators: false) {
69+ HStack(spacing: 10) {
70+ ForEach(pulse, id: \.0.id) { (u, n) in
71+ NavigationLink(value: u.id) {
72+ VStack(alignment: .leading, spacing: 3) {
73+ Text(n.formatted(.number.locale(Locale(identifier: "fr_CA"))))
74+ .font(.system(.headline, design: .rounded).weight(.bold))
75+ .foregroundStyle(u.accent)
76+ .contentTransition(.numericText())
77+ Text(u.unit).font(.caption2).foregroundStyle(.secondary)
78+ }
79+ .padding(.horizontal, 13).padding(.vertical, 9)
80+ .kaCard(accent: u.accent)
81+ }
82+ .buttonStyle(.plain)
83+ }
84+ if pulse.isEmpty {
85+ Text("Le pouls de l'écosystème…").font(.caption).foregroundStyle(.tertiary).padding(10)
86+ }
87+ }
88+ }
89+ .accessibilityLabel("Le pouls de l'écosystème en direct")
90+ }
91+
92+ private func featuredSection(_ u: Universe, _ items: [KAItem]) -> some View {
93+ VStack(alignment: .leading, spacing: 10) {
94+ HStack {
95+ KAWordmark(universe: u, size: 17)
96+ Spacer()
97+ NavigationLink(value: u.id) {
98+ Text("Tout voir").font(.caption.weight(.semibold)).foregroundStyle(u.accent)
99+ }
100+ }
101+ ScrollView(.horizontal, showsIndicators: false) {
102+ HStack(spacing: 12) {
103+ ForEach(items.prefix(8)) { item in
104+ NavigationLink(value: item) {
105+ VStack(alignment: .leading, spacing: 6) {
106+ Text(item.title).font(.subheadline.weight(.semibold))
107+ .lineLimit(2, reservesSpace: true)
108+ .multilineTextAlignment(.leading)
109+ if let s = item.subtitle {
110+ Text(s).font(.caption).foregroundStyle(.secondary).lineLimit(1)
111+ }
112+ Spacer(minLength: 0)
113+ HStack {
114+ if let p = item.priceLabel {
115+ Text(p).font(.system(.caption, design: .rounded).weight(.bold))
116+ .foregroundStyle(u.accent)
117+ }
118+ Spacer()
119+ if let c = item.city { Text(c).font(.caption2).foregroundStyle(.tertiary) }
120+ }
121+ }
122+ .padding(12)
123+ .frame(width: 190, height: 110, alignment: .topLeading)
124+ .kaCard(accent: u.accent)
125+ }
126+ .buttonStyle(.plain)
127+ }
128+ }
129+ .padding(.vertical, 4)
130+ }
131+ }
132+ }
133+
134+ private var disclaimer: some View {
135+ Text(Ecosystem.disclaimer)
136+ .font(.caption2).foregroundStyle(.tertiary)
137+ .padding(.top, 8)
138+ }
139+
140+ // MARK: données
141+
142+ /// Univers mis en avant selon l'heure + les préférences d'onboarding.
143+ private var contextualUniverses: [Universe] {
144+ let h = Calendar.current.component(.hour, from: Date())
145+ let weekend = Calendar.current.isDateInWeekend(Date())
146+ var ids: [String]
147+ switch h {
148+ case 5..<11: ids = ["job-ka", "food-ka", "immo-ka"]
149+ case 11..<14: ids = ["resto-ka", "food-ka", "sorti-ka"]
150+ case 14..<17: ids = weekend ? ["sorti-ka", "resto-ka", "auto-ka"] : ["immo-ka", "auto-ka", "lou-ka"]
151+ case 17..<23: ids = ["resto-ka", "sorti-ka", "crea-ka"]
152+ default: ids = ["sorti-ka", "crea-ka", "lou-ka"]
153+ }
154+ let favs = favUniversesRaw.split(separator: ",").map(String.init)
155+ for f in favs.reversed() where !ids.contains(f) { ids.insert(f, at: 0) }
156+ return ids.prefix(4).compactMap(Ecosystem.universe).filter { $0.map != nil }
157+ }
158+
159+ private func load() async {
160+ loading = true
161+ let universes = contextualUniverses
162+ var found: [String: [KAItem]] = [:]
163+ await withTaskGroup(of: (String, [KAItem]).self) { group in
164+ for u in universes {
165+ group.addTask { (u.id, (try? await UniverseService.fetch(u, limit: 8)) ?? []) }
166+ }
167+ for u in Ecosystem.all where u.statTotalKeys.first != nil {
168+ _ = u // pouls géré plus bas
169+ }
170+ for await (id, items) in group { found[id] = items }
171+ }
172+ featured = universes.compactMap { u in
173+ let items = found[u.id] ?? []
174+ return items.isEmpty ? nil : (u, items)
175+ }
176+ loading = false
177+ // pouls (compteurs live) — après le contenu pour ne pas retarder l'accueil
178+ var counts: [(Universe, Int)] = []
179+ await withTaskGroup(of: (String, Int?).self) { group in
180+ for u in Ecosystem.all {
181+ group.addTask { (u.id, await UniverseService.liveTotal(u)) }
182+ }
183+ for await (id, n) in group {
184+ if let n, let u = Ecosystem.universe(id) { counts.append((u, n)) }
185+ }
186+ }
187+ pulse = counts.sorted { $0.1 > $1.1 }
188+ }
189+}
added KA/Features/MapView.swift +153 −0
@@ -0,0 +1,153 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// MapView.swift — la CARTE UNIFIÉE : les résultats de plusieurs univers autour
3+// de l'utilisateur (logements, propriétés, emplois, restos, sorties) sur une
4+// même carte, épingles teintées par univers, filtre par univers.
5+import SwiftUI
6+import MapKit
7+import CoreLocation
8+
9+struct UnifiedMapView: View {
10+ @State private var camera: MapCameraPosition = .region(
11+ MKCoordinateRegion(center: .init(latitude: 46.81, longitude: -71.21), // Québec
12+ span: .init(latitudeDelta: 0.35, longitudeDelta: 0.35))
13+ )
14+ @State private var items: [KAItem] = []
15+ @State private var enabled: Set<String> = ["lou-ka", "immo-ka", "resto-ka", "sorti-ka", "job-ka"]
16+ @State private var loading = false
17+ @State private var selection: KAItem?
18+ @State private var visibleRegion: MKCoordinateRegion?
19+ @StateObject private var location = LocationOnce()
20+ @Environment(\.colorScheme) private var scheme
21+ @Environment(\.dismiss) private var dismiss
22+
23+ private var mapUniverses: [Universe] {
24+ Ecosystem.all.filter { ["lou-ka", "immo-ka", "resto-ka", "sorti-ka", "job-ka"].contains($0.id) }
25+ }
26+
27+ var body: some View {
28+ NavigationStack {
29+ Map(position: $camera, selection: $selection) {
30+ UserAnnotation()
31+ ForEach(items.filter { enabled.contains($0.universeID) }) { item in
32+ if let lat = item.latitude, let lon = item.longitude,
33+ let u = Ecosystem.universe(item.universeID) {
34+ Marker(item.title, systemImage: u.symbol,
35+ coordinate: .init(latitude: lat, longitude: lon))
36+ .tint(u.accent)
37+ .tag(item)
38+ }
39+ }
40+ }
41+ .mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll))
42+ .onMapCameraChange(frequency: .onEnd) { ctx in visibleRegion = ctx.region }
43+ .safeAreaInset(edge: .top) { chips }
44+ .safeAreaInset(edge: .bottom) { bottomBar }
45+ .sheet(item: $selection) { item in
46+ NavigationStack { ItemDetailView(item: item) }
47+ .presentationDetents([.medium, .large])
48+ }
49+ .navigationTitle("Carte")
50+ .navigationBarTitleDisplayMode(.inline)
51+ .toolbar {
52+ ToolbarItem(placement: .topBarLeading) {
53+ Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) }
54+ .accessibilityLabel("Fermer la carte")
55+ }
56+ }
57+ .task {
58+ location.request()
59+ await load()
60+ }
61+ .onChange(of: location.coordinate != nil) {
62+ if let c = location.coordinate {
63+ camera = .region(.init(center: c, span: .init(latitudeDelta: 0.25, longitudeDelta: 0.25)))
64+ Task { await load() }
65+ }
66+ }
67+ }
68+ }
69+
70+ private var chips: some View {
71+ ScrollView(.horizontal, showsIndicators: false) {
72+ HStack(spacing: 8) {
73+ ForEach(mapUniverses) { u in
74+ let on = enabled.contains(u.id)
75+ Button {
76+ Haptics.tap()
77+ if on { enabled.remove(u.id) } else { enabled.insert(u.id) }
78+ } label: {
79+ Label(u.wordmark, systemImage: u.symbol)
80+ .font(.system(.caption, design: .monospaced).weight(.bold))
81+ .padding(.horizontal, 11).padding(.vertical, 8)
82+ .background(on ? u.accent : KATheme.surface(scheme), in: Capsule())
83+ .foregroundStyle(on ? .white : .secondary)
84+ .overlay(Capsule().strokeBorder(.black.opacity(0.25), lineWidth: 1))
85+ }
86+ }
87+ }
88+ .padding(.horizontal, 14).padding(.vertical, 8)
89+ }
90+ .background(.ultraThinMaterial)
91+ }
92+
93+ private var bottomBar: some View {
94+ HStack {
95+ Text(loading ? "Chargement autour d'ici…"
96+ : "\(items.filter { enabled.contains($0.universeID) && $0.latitude != nil }.count) résultats sur la carte")
97+ .font(.system(.caption, design: .monospaced).weight(.bold))
98+ Spacer()
99+ Button {
100+ Haptics.rigid()
101+ Task { await load() }
102+ } label: {
103+ Label("Chercher ici", systemImage: "arrow.clockwise")
104+ .font(.caption.weight(.bold))
105+ .padding(.horizontal, 13).padding(.vertical, 9)
106+ .background(KATheme.inkLight, in: Capsule())
107+ .foregroundStyle(KATheme.lime)
108+ }
109+ }
110+ .padding(.horizontal, 14).padding(.vertical, 9)
111+ .background(.ultraThinMaterial)
112+ }
113+
114+ /// Charge les items géolocalisés des univers actifs (autour de la vue).
115+ private func load() async {
116+ loading = true
117+ var all: [KAItem] = []
118+ await withTaskGroup(of: [KAItem].self) { group in
119+ for u in mapUniverses {
120+ group.addTask { (try? await UniverseService.fetch(u, limit: 80)) ?? [] }
121+ }
122+ for await batch in group { all.append(contentsOf: batch) }
123+ }
124+ items = all.filter { $0.latitude != nil && $0.longitude != nil }
125+ loading = false
126+ if !items.isEmpty { Haptics.tap() }
127+ }
128+}
129+
130+/// Demande de localisation « une fois » (jamais de suivi continu).
131+final class LocationOnce: NSObject, ObservableObject, CLLocationManagerDelegate {
132+ @Published var coordinate: CLLocationCoordinate2D?
133+ private let manager = CLLocationManager()
134+
135+ func request() {
136+ manager.delegate = self
137+ if manager.authorizationStatus == .notDetermined {
138+ manager.requestWhenInUseAuthorization()
139+ } else {
140+ manager.requestLocation()
141+ }
142+ }
143+
144+ func locationManagerDidChangeAuthorization(_ m: CLLocationManager) {
145+ if m.authorizationStatus == .authorizedWhenInUse || m.authorizationStatus == .authorizedAlways {
146+ m.requestLocation()
147+ }
148+ }
149+ func locationManager(_ m: CLLocationManager, didUpdateLocations locs: [CLLocation]) {
150+ coordinate = locs.first?.coordinate
151+ }
152+ func locationManager(_ m: CLLocationManager, didFailWithError error: Error) {}
153+}
added KA/Features/ProfileView.swift +110 −0
Large diff (110 lines) is not loaded by default to keep the page fast.Load diff
added KA/Features/SearchView.swift +167 −0
Large diff (167 lines) is not loaded by default to keep the page fast.Load diff
added KA/Features/UniversesView.swift +266 −0
Large diff (266 lines) is not loaded by default to keep the page fast.Load diff
added KA/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
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
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added KATests/KATests.swift +92 −0
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added KAWidgets/Info.plist +27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added KAWidgets/KAWidgets.swift +111 −0
Large diff (111 lines) is not loaded by default to keep the page fast.Load diff
added README.md +62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added docs/APP-STORE.md +65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added docs/ARCHITECTURE.md +58 −0
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
added docs/IDEES-LEGENDAIRES.md +51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
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
Large diff (71 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme