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