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