SPB Git forge

spb/ka-ios

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

v3.0.0 (9) — MAJOR : rendu légendaire + Ka Trajet intégré — dock flottant signature (capsule encre/lime, matchedGeometry, KA Agent intégré, s'efface en conduite), accueil héros éditorial (kicker mono, salutation surlignée lime animée, ticker encre marquee du pouls live, compteurs à chiffres roulants, kaScrollPop, aurora), onboarding 4 piliers + aurora, et FUSION de l'app Ka Trajet en 6e onglet (GPS complet : Mapbox recherche/itinéraires/conduite 3D, 9 couches POI, cartographie signature Ka) sous Features/Trajet — 12 tests verts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent f06ddb5

13 changed files +2,283 −50

modified KA/App/KAApp.swift +43 −36
@@ -1,6 +1,7 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 2 // KAApp.swift — point d'entrée : onboarding animé au premier lancement, puis
3 −// la super-app (5 onglets + bulle KA Agent flottante partout).
3 +// la super-app v3 : 6 onglets (dont Ka Trajet, le GPS maison) servis par le
4 +// dock flottant signature (encre + lime) avec KA Agent intégré.
4 5 import SwiftUI
5 6
6 7 @main
@@ -31,56 +32,62 @@ struct KAApp: App {
31 32 }
32 33 }
33 34
34 −// MARK: - Racine : 5 onglets + bulle Agent
35 +// MARK: - Racine : 6 onglets servis par le dock flottant signature
35 36
36 37 struct RootView: View {
37 38 @State private var showAgent = false
38 39 // onglet initial pilotable en debug — JAMAIS pendant les tests (les threads
39 40 // MapLibre de la carte feraient une course avec le exit() de XCTest)
40 − @State private var tab = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
41 − ? 0 : UserDefaults.standard.integer(forKey: "ka.debug.tab")
41 + @State private var tab: Int
42 + // onglets déjà visités : montés une fois, gardés vivants (état préservé,
43 + // cartes MapLibre non réinitialisées) — les autres restent paresseux
44 + @State private var loaded: Set<Int>
42 45 @StateObject private var recents = RecentsStore.shared
46 + @StateObject private var trajet = TrajetModel()
43 47
44 − var body: some View {
45 − ZStack(alignment: .bottomTrailing) {
46 − TabView(selection: $tab) {
47 − HomeView()
48 − .tabItem { Label("Accueil", systemImage: "house.fill") }.tag(0)
49 − SearchView()
50 − .tabItem { Label("Recherche", systemImage: "magnifyingglass") }.tag(1)
51 − UniversesView()
52 − .tabItem { Label("Explorer", systemImage: "circle.grid.3x3.fill") }.tag(2)
53 − MapTab()
54 − .tabItem { Label("Carte", systemImage: "map.fill") }.tag(3)
55 − MoreTab()
56 − .tabItem { Label("Profil", systemImage: "person.crop.circle") }.tag(4)
57 − .badge(recents.alertCount)
58 − }
59 − .tint(KATheme.green)
60 − .task { await recents.refreshAlerts() }
48 + init() {
49 + let initial = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
50 + ? 0 : UserDefaults.standard.integer(forKey: "ka.debug.tab")
51 + _tab = State(initialValue: initial)
52 + _loaded = State(initialValue: [initial])
53 + }
61 54
62 − // Bulle KA Agent — flottante au-dessus de la tab bar, partout
63 − Button {
64 − Haptics.rigid()
65 − showAgent = true
66 − } label: {
67 − Text("Ka")
68 − .font(.system(size: 19, weight: .bold, design: .rounded))
69 − .frame(width: 54, height: 54)
70 − .background(KATheme.lime, in: Circle())
71 − .foregroundStyle(KATheme.inkLight)
72 − .overlay(Circle().strokeBorder(KATheme.inkLight, lineWidth: 2))
73 − .shadow(color: .black.opacity(0.25), radius: 1, x: 3, y: 3)
55 + var body: some View {
56 + ZStack {
57 + pane(0) { HomeView() }
58 + pane(1) { SearchView() }
59 + pane(2) { UniversesView() }
60 + pane(3) { MapTab() }
61 + pane(4) { TrajetView().environmentObject(trajet) }
62 + pane(5) { MoreTab() }
63 + }
64 + .tint(KATheme.green)
65 + .safeAreaInset(edge: .bottom) {
66 + // le dock s'efface en mode conduite (Trajet plein écran)
67 + if !trajet.navigating {
68 + KADock(tab: $tab, alertCount: recents.alertCount) { showAgent = true }
69 + .padding(.bottom, 4)
70 + .transition(.move(edge: .bottom).combined(with: .opacity))
74 71 }
75 − .padding(.trailing, 16)
76 − .padding(.bottom, 64)
77 − .accessibilityLabel("Ouvrir KA Agent, l'assistant de l'écosystème")
78 72 }
73 + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: trajet.navigating)
74 + .onChange(of: tab) { _, t in loaded.insert(t) }
75 + .task { await recents.refreshAlerts() }
79 76 .sheet(isPresented: $showAgent) {
80 77 AgentChatView()
81 78 .presentationDetents([.large])
82 79 }
83 80 }
81 +
82 + @ViewBuilder
83 + private func pane(_ i: Int, @ViewBuilder content: () -> some View) -> some View {
84 + if loaded.contains(i) {
85 + content()
86 + .opacity(tab == i ? 1 : 0)
87 + .allowsHitTesting(tab == i)
88 + .accessibilityHidden(tab != i)
89 + }
90 + }
84 91 }
85 92
86 93 /// Onglet Carte : la carte unifiée directement (pas de fermeture — c'est un onglet)
modified KA/App/OnboardingView.swift +8 −1
@@ -26,7 +26,13 @@ struct OnboardingView: View {
26 26 actions
27 27 }
28 28 .padding(24)
29 − .background(KATheme.paper(scheme))
29 + .background {
30 + ZStack {
31 + KATheme.paper(scheme)
32 + KAAurora()
33 + }
34 + .ignoresSafeArea()
35 + }
30 36 .onAppear {
31 37 guard !reduceMotion else { logoRotation = -4; logoScale = 1; glow = true; return }
32 38 withAnimation(.spring(response: 0.9, dampingFraction: 0.55).delay(0.15)) {
@@ -62,6 +68,7 @@ struct OnboardingView: View {
62 68 pitchRow("magnifyingglass", "Une recherche, tout le Québec", "Logements, propriétés, autos, emplois, restos, sorties — interrogés d'un coup.")
63 69 pitchRow("heart.fill", "Des favoris qui traversent les univers", "Un 4½, une auto et un resto dans une même collection.")
64 70 pitchRow("sparkles", "KA Agent, votre IA d'ici", "Posez une question, il fouille les vraies données de l'écosystème.")
71 + pitchRow("location.north.fill", "Ka Trajet, le GPS maison", "Itinéraires, conduite 3D et les adresses de l'écosystème sur la carte.")
65 72 }
66 73 .padding(.bottom, 12)
67 74 }
added KA/Design/KAMotion.swift +253 −0
@@ -0,0 +1,253 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KAMotion.swift — le langage de mouvement v3 « légendaire » de la super-app :
3 +// dock flottant signature (encre + lime, ombre dure), ticker encre en marquee
4 +// (comme les héros des sites), compteurs à chiffres roulants, fond aurora
5 +// discret, transitions de défilement. Reduce Motion respecté partout.
6 +import SwiftUI
7 +
8 +// MARK: - Dock flottant (remplace la tab bar système)
9 +
10 +struct KADock: View {
11 + @Binding var tab: Int
12 + var alertCount: Int
13 + var onAgent: () -> Void
14 + @Namespace private var ns
15 + @Environment(\.colorScheme) private var scheme
16 +
17 + static let items: [(icon: String, label: String)] = [
18 + ("house.fill", "Accueil"),
19 + ("magnifyingglass", "Recherche"),
20 + ("circle.grid.3x3.fill", "Explorer"),
21 + ("map.fill", "Carte"),
22 + ("location.north.fill", "Trajet"),
23 + ("person.crop.circle.fill", "Profil"),
24 + ]
25 +
26 + var body: some View {
27 + HStack(spacing: 2) {
28 + ForEach(Array(Self.items.enumerated()), id: \.offset) { i, item in
29 + Button {
30 + guard tab != i else { return }
31 + Haptics.tap()
32 + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { tab = i }
33 + } label: {
34 + ZStack(alignment: .topTrailing) {
35 + Image(systemName: item.icon)
36 + .font(.system(size: 17, weight: .semibold))
37 + .foregroundStyle(tab == i ? KATheme.inkLight : KATheme.inkDark.opacity(0.72))
38 + .frame(width: 43, height: 43)
39 + .background {
40 + if tab == i {
41 + Circle().fill(KATheme.lime)
42 + .matchedGeometryEffect(id: "ka.dock.active", in: ns)
43 + }
44 + }
45 + if i == Self.items.count - 1 && alertCount > 0 {
46 + Circle().fill(.red).frame(width: 8, height: 8)
47 + .offset(x: -5, y: 5)
48 + .accessibilityHidden(true)
49 + }
50 + }
51 + }
52 + .accessibilityLabel(item.label + (i == Self.items.count - 1 && alertCount > 0 ? ", \(alertCount) alertes" : ""))
53 + .accessibilityAddTraits(tab == i ? [.isSelected] : [])
54 + }
55 + Rectangle().fill(KATheme.inkDark.opacity(0.25))
56 + .frame(width: 1, height: 24)
57 + .padding(.horizontal, 4)
58 + .accessibilityHidden(true)
59 + Button {
60 + Haptics.rigid()
61 + onAgent()
62 + } label: {
63 + Text("Ka")
64 + .font(KAFont.display(15))
65 + .foregroundStyle(KATheme.lime)
66 + .frame(width: 43, height: 43)
67 + .background(Circle().strokeBorder(KATheme.lime, lineWidth: 1.5))
68 + }
69 + .accessibilityLabel("Ouvrir KA Agent, l'assistant de l'écosystème")
70 + }
71 + .padding(6)
72 + .background(KATheme.inkLight, in: Capsule())
73 + .overlay(Capsule().strokeBorder(.white.opacity(0.10), lineWidth: 1))
74 + // signature Ka : ombre décalée DURE, lime en clair, feutrée en sombre
75 + .background(
76 + Capsule()
77 + .fill(scheme == .dark ? KATheme.lime.opacity(0.30) : KATheme.lime)
78 + .offset(x: 5, y: 5)
79 + )
80 + .shadow(color: .black.opacity(0.22), radius: 14, y: 8)
81 + }
82 +}
83 +
84 +// MARK: - Ticker encre (marquee des héros de sites : mono lime qui défile)
85 +
86 +struct KATicker: View {
87 + let items: [String]
88 + @State private var textWidth: CGFloat = 0
89 + @Environment(\.accessibilityReduceMotion) private var reduceMotion
90 +
91 + private var line: String {
92 + let parts = items.isEmpty ? ["GROUPE KA — LE QUÉBEC EN DIRECT"] : items
93 + return parts.map { $0.uppercased() }.joined(separator: " ✦ ") + " ✦ "
94 + }
95 +
96 + var body: some View {
97 + Group {
98 + if reduceMotion {
99 + Text(line)
100 + .font(KAFont.mono(11))
101 + .lineLimit(1)
102 + .padding(.horizontal, 10)
103 + .frame(maxWidth: .infinity, alignment: .leading)
104 + } else {
105 + // le marquee vit en OVERLAY d'un espace fixe : sa largeur réelle
106 + // (des milliers de points) ne se propage jamais à la mise en page
107 + Color.clear
108 + .frame(height: 15)
109 + .overlay(alignment: .leading) {
110 + TimelineView(.animation(minimumInterval: 1.0 / 30)) { tl in
111 + let t = tl.date.timeIntervalSinceReferenceDate
112 + let x = textWidth > 0
113 + ? -CGFloat((t * 42).truncatingRemainder(dividingBy: textWidth))
114 + : 0
115 + HStack(spacing: 0) {
116 + tickerText
117 + .background(GeometryReader { g in
118 + Color.clear
119 + .onAppear { textWidth = g.size.width }
120 + .onChange(of: g.size.width) { _, w in textWidth = w }
121 + })
122 + tickerText
123 + }
124 + .fixedSize()
125 + .offset(x: x)
126 + }
127 + }
128 + .clipped()
129 + }
130 + }
131 + .padding(.vertical, 9)
132 + .foregroundStyle(KATheme.lime)
133 + .background(KATheme.inkLight)
134 + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
135 + .background(
136 + RoundedRectangle(cornerRadius: 8, style: .continuous)
137 + .fill(KATheme.green.opacity(0.9))
138 + .offset(x: 4, y: 4)
139 + )
140 + .accessibilityElement(children: .ignore)
141 + .accessibilityLabel("Le pouls de l'écosystème : " + items.joined(separator: ", "))
142 + }
143 +
144 + private var tickerText: some View {
145 + Text(line)
146 + .font(KAFont.mono(11))
147 + .fixedSize()
148 + .padding(.leading, 2)
149 + }
150 +}
151 +
152 +// MARK: - Compteur à chiffres roulants (pouls, stats héros)
153 +
154 +struct KACountUp: View {
155 + let value: Int
156 + var font: Font = .system(.headline, design: .rounded).weight(.bold)
157 + var color: Color = .primary
158 + @State private var shown = 0
159 + @Environment(\.accessibilityReduceMotion) private var reduceMotion
160 +
161 + var body: some View {
162 + Text(shown.formatted(.number.locale(Locale(identifier: "fr_CA"))))
163 + .font(font)
164 + .foregroundStyle(color)
165 + .contentTransition(.numericText(value: Double(shown)))
166 + .onAppear {
167 + if reduceMotion { shown = value }
168 + else { withAnimation(.easeOut(duration: 1.1)) { shown = value } }
169 + }
170 + .onChange(of: value) { _, v in
171 + withAnimation(.easeOut(duration: 0.8)) { shown = v }
172 + }
173 + }
174 +}
175 +
176 +// MARK: - Surligneur lime animé (le « titre surligné » des sites)
177 +
178 +struct KAHighlighted: ViewModifier {
179 + @State private var on = false
180 + @Environment(\.accessibilityReduceMotion) private var reduceMotion
181 +
182 + func body(content: Content) -> some View {
183 + content
184 + .padding(.horizontal, 7)
185 + .padding(.vertical, 1)
186 + .background(
187 + RoundedRectangle(cornerRadius: 6, style: .continuous)
188 + .fill(KATheme.lime)
189 + .rotationEffect(.degrees(-1.4))
190 + .scaleEffect(x: on ? 1 : 0.02, anchor: .leading)
191 + )
192 + .foregroundStyle(KATheme.inkLight)
193 + .onAppear {
194 + if reduceMotion { on = true }
195 + else { withAnimation(.spring(response: 0.7, dampingFraction: 0.8).delay(0.25)) { on = true } }
196 + }
197 + }
198 +}
199 +
200 +extension View {
201 + /// Surligne le texte d'un marqueur lime qui se déploie (signature des sites).
202 + func kaHighlighted() -> some View { modifier(KAHighlighted()) }
203 +
204 + /// Pop de défilement : les cartes entrent/sortent avec échelle + fondu.
205 + func kaScrollPop(axis: Axis = .horizontal) -> some View {
206 + scrollTransition(.interactive, axis: axis) { content, phase in
207 + content
208 + .scaleEffect(phase.isIdentity ? 1 : 0.93)
209 + .opacity(phase.isIdentity ? 1 : 0.55)
210 + }
211 + }
212 +}
213 +
214 +// MARK: - Aurora — deux lueurs lime/vert qui dérivent, très discrètes
215 +
216 +struct KAAurora: View {
217 + @Environment(\.accessibilityReduceMotion) private var reduceMotion
218 + @Environment(\.colorScheme) private var scheme
219 +
220 + var body: some View {
221 + GeometryReader { geo in
222 + let w = geo.size.width, h = geo.size.height
223 + let strength: Double = scheme == .dark ? 0.14 : 0.20
224 + if reduceMotion {
225 + blobs(w: w, h: h, t: 0, strength: strength)
226 + } else {
227 + TimelineView(.animation(minimumInterval: 1.0 / 20)) { tl in
228 + let t = tl.date.timeIntervalSinceReferenceDate / 14
229 + blobs(w: w, h: h, t: t, strength: strength)
230 + }
231 + }
232 + }
233 + .allowsHitTesting(false)
234 + .accessibilityHidden(true)
235 + }
236 +
237 + private func blobs(w: CGFloat, h: CGFloat, t: Double, strength: Double) -> some View {
238 + ZStack {
239 + Circle()
240 + .fill(KATheme.lime.opacity(strength))
241 + .frame(width: w * 0.9)
242 + .blur(radius: 70)
243 + .position(x: w * (0.75 + 0.18 * CGFloat(sin(t))),
244 + y: h * (0.10 + 0.08 * CGFloat(cos(t * 1.3))))
245 + Circle()
246 + .fill(KATheme.green.opacity(strength * 0.7))
247 + .frame(width: w * 0.8)
248 + .blur(radius: 80)
249 + .position(x: w * (0.12 + 0.15 * CGFloat(cos(t * 0.8))),
250 + y: h * (0.45 + 0.10 * CGFloat(sin(t * 1.1))))
251 + }
252 + }
253 +}
modified KA/Features/HomeView.swift +40 −11
@@ -1,7 +1,8 @@
1 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).
2 +// HomeView.swift — l'accueil LÉGENDAIRE : héros éditorial (kicker mono,
3 +// salutation surlignée lime, ticker encre en marquee du pouls live), pouls à
4 +// chiffres roulants, suggestions contextuelles avec du VRAI contenu en direct,
5 +// cartes qui « poppent » au défilement, fond aurora discret.
5 6 import SwiftUI
6 7
7 8 struct HomeView: View {
@@ -19,6 +20,7 @@ struct HomeView: View {
19 20 ScrollView {
20 21 VStack(alignment: .leading, spacing: 22) {
21 22 header
23 + tickerStrip
22 24 pulseStrip
23 25 resumeStrip
24 26 ForEach(featured, id: \.universe.id) { section in
@@ -33,7 +35,13 @@ struct HomeView: View {
33 35 }
34 36 .padding(16)
35 37 }
36 − .background(KATheme.paper(scheme))
38 + .background {
39 + ZStack {
40 + KATheme.paper(scheme)
41 + KAAurora()
42 + }
43 + .ignoresSafeArea()
44 + }
37 45 .navigationTitle("")
38 46 .toolbar {
39 47 ToolbarItem(placement: .topBarLeading) {
@@ -63,33 +71,52 @@ struct HomeView: View {
63 71 }
64 72
65 73 private var header: some View {
66 − VStack(alignment: .leading, spacing: 5) {
67 − Text(greeting.title).font(KAFont.display(32))
74 + VStack(alignment: .leading, spacing: 8) {
75 + // kicker mono — la micro-étiquette signature des sites
76 + HStack(spacing: 6) {
77 + Rectangle().fill(KATheme.lime).frame(width: 8, height: 8)
78 + Text("GROUPE KA — L'ÉCOSYSTÈME QUÉBÉCOIS")
79 + .font(KAFont.mono(10))
80 + .foregroundStyle(KATheme.ink2(scheme))
81 + }
82 + .accessibilityHidden(true)
83 + Text(greeting.title)
84 + .font(KAFont.display(34))
85 + .kaHighlighted()
68 86 Text(greeting.sub).font(.subheadline).foregroundStyle(KATheme.ink2(scheme))
69 87 }
70 88 }
71 89
90 + /// Ticker encre : le pouls live de l'écosystème qui défile, comme les héros des sites
91 + private var tickerStrip: some View {
92 + KATicker(items: pulse.prefix(8).map {
93 + "\($0.1.formatted(.number.locale(Locale(identifier: "fr_CA")))) \($0.0.unit)"
94 + })
95 + }
96 +
72 97 private var pulseStrip: some View {
73 98 ScrollView(.horizontal, showsIndicators: false) {
74 99 HStack(spacing: 10) {
75 100 ForEach(pulse, id: \.0.id) { (u, n) in
76 101 NavigationLink(value: u.id) {
77 102 VStack(alignment: .leading, spacing: 3) {
78 − Text(n.formatted(.number.locale(Locale(identifier: "fr_CA"))))
79 − .font(.system(.headline, design: .rounded).weight(.bold))
80 − .foregroundStyle(u.accent)
81 − .contentTransition(.numericText())
82 − Text(u.unit).font(.caption2).foregroundStyle(.secondary)
103 + KACountUp(value: n,
104 + font: .system(.headline, design: .rounded).weight(.bold),
105 + color: u.accent)
106 + Text(u.unit).font(KAFont.mono(9)).textCase(.uppercase)
107 + .foregroundStyle(.secondary)
83 108 }
84 109 .padding(.horizontal, 13).padding(.vertical, 9)
85 110 .kaCard(accent: u.accent)
86 111 }
87 112 .buttonStyle(.plain)
113 + .kaScrollPop()
88 114 }
89 115 if pulse.isEmpty {
90 116 Text("Le pouls de l'écosystème…").font(.caption).foregroundStyle(.tertiary).padding(10)
91 117 }
92 118 }
119 + .padding(.vertical, 4)
93 120 }
94 121 .accessibilityLabel("Le pouls de l'écosystème en direct")
95 122 }
@@ -134,6 +161,7 @@ struct HomeView: View {
134 161 .kaCard(accent: u.accent)
135 162 }
136 163 .buttonStyle(KAPressStyle())
164 + .kaScrollPop()
137 165 }
138 166 }
139 167 .scrollTargetLayout()
@@ -232,6 +260,7 @@ struct HomeView: View {
232 260 .kaCard(accent: u.accent)
233 261 }
234 262 .buttonStyle(KAPressStyle())
263 + .kaScrollPop()
235 264 }
236 265 }
237 266 .scrollTargetLayout()
added KA/Features/Trajet/KaTrajetTheme.swift +25 −0
@@ -0,0 +1,25 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KaTrajetTheme.swift — alias `Ka` de la palette pour le module Trajet
3 +// (code hérité de l'app Ka Trajet, fusionnée dans la super-app KA v3).
4 +// Color(hex:) vient d'Ecosystem.swift ; KATheme reste la source de vérité.
5 +import SwiftUI
6 +import UIKit
7 +
8 +enum Ka {
9 + static let paperLight = Color(hex: "#f5f3ee")
10 + static let paperDark = Color(hex: "#101410")
11 + static let inkLight = Color(hex: "#141814")
12 + static let inkDark = Color(hex: "#f0efe8")
13 + static let lime = Color(hex: "#d9f26b")
14 + static let green = Color(hex: "#1c5c41")
15 +
16 + static func paper(_ s: ColorScheme) -> Color { s == .dark ? paperDark : paperLight }
17 + static func ink(_ s: ColorScheme) -> Color { s == .dark ? inkDark : inkLight }
18 + static func surface(_ s: ColorScheme) -> Color { s == .dark ? Color(hex: "#1a1f1a") : .white }
19 + static func ink2(_ s: ColorScheme) -> Color { s == .dark ? Color(hex: "#a9b0a9") : Color(hex: "#4d5551") }
20 +
21 + // couleurs UIKit pour les couches de la carte (tracés d'itinéraire)
22 + static let uiGreen = UIColor(red: 0x1c / 255, green: 0x5c / 255, blue: 0x41 / 255, alpha: 1)
23 + static let uiLime = UIColor(red: 0xd9 / 255, green: 0xf2 / 255, blue: 0x6b / 255, alpha: 1)
24 + static let uiAlt = UIColor(red: 0.55, green: 0.58, blue: 0.56, alpha: 1)
25 +}
added KA/Features/Trajet/LocationManager.swift +49 −0
@@ -0,0 +1,49 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// LocationManager.swift — position de l'utilisateur (demande « pendant
3 +// l'utilisation », mises à jour continues pour le point bleu et l'origine
4 +// des itinéraires).
5 +import Foundation
6 +import CoreLocation
7 +
8 +@MainActor
9 +final class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
10 + @Published var location: CLLocationCoordinate2D?
11 + @Published var speedKmh: Double? // vitesse GPS en km/h (nil si inconnue)
12 + @Published var authorized = false
13 +
14 + private let manager = CLLocationManager()
15 +
16 + override init() {
17 + super.init()
18 + manager.delegate = self
19 + manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
20 + }
21 +
22 + func request() {
23 + switch manager.authorizationStatus {
24 + case .notDetermined: manager.requestWhenInUseAuthorization()
25 + case .authorizedWhenInUse, .authorizedAlways: manager.startUpdatingLocation()
26 + default: break
27 + }
28 + }
29 +
30 + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
31 + let status = manager.authorizationStatus
32 + Task { @MainActor in
33 + self.authorized = (status == .authorizedWhenInUse || status == .authorizedAlways)
34 + if self.authorized { self.manager.startUpdatingLocation() }
35 + }
36 + }
37 +
38 + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
39 + guard let loc = locations.last else { return }
40 + let c = loc.coordinate
41 + let kmh = loc.speed >= 0 ? loc.speed * 3.6 : nil
42 + Task { @MainActor in
43 + self.location = c
44 + self.speedKmh = kmh
45 + }
46 + }
47 +
48 + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {}
49 +}
added KA/Features/Trajet/MapboxAPI.swift +239 −0
@@ -0,0 +1,239 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// MapboxAPI.swift — les services Mapbox de Ka Trajet : Geocoding (recherche
3 +// de lieux, autocomplétion, géocodage inverse) et Directions (itinéraires
4 +// auto/vélo/marche avec alternatives et étapes turn-by-turn).
5 +// Jeton PUBLIC Mapbox (pk.…) partagé avec Lou-Ka (kamaps/config.ts) — conçu
6 +// pour être exposé côté client ; restrictions gérées au tableau de bord Mapbox.
7 +import Foundation
8 +import CoreLocation
9 +
10 +enum MapboxConfig {
11 + static let token = "pk.eyJ1Ijoic3Bib3VjaGVyIiwiYSI6ImNtc3Fyb3k4djAwOTgyenB3dWt6NHBjc2kifQ.poqLf0ADy3lIh28O-pFI2Q"
12 +}
13 +
14 +// MARK: - Modèles
15 +
16 +struct Place: Identifiable, Equatable {
17 + let id: String
18 + let name: String // ex. « Château Frontenac »
19 + let address: String // ex. « 1 rue des Carrières, Québec »
20 + let coordinate: CLLocationCoordinate2D
21 +
22 + static func == (l: Place, r: Place) -> Bool { l.id == r.id }
23 +}
24 +
25 +enum TransportMode: String, CaseIterable, Identifiable {
26 + case auto, velo, marche
27 + var id: String { rawValue }
28 + var profile: String {
29 + switch self {
30 + case .auto: return "driving-traffic"
31 + case .velo: return "cycling"
32 + case .marche: return "walking"
33 + }
34 + }
35 + var icon: String {
36 + switch self {
37 + case .auto: return "car.fill"
38 + case .velo: return "bicycle"
39 + case .marche: return "figure.walk"
40 + }
41 + }
42 + var label: String {
43 + switch self {
44 + case .auto: return "Auto"
45 + case .velo: return "Vélo"
46 + case .marche: return "Marche"
47 + }
48 + }
49 +}
50 +
51 +struct RouteStep: Identifiable {
52 + let id = UUID()
53 + let instruction: String
54 + let distance: Double // mètres
55 + let icon: String // symbole SF selon la manœuvre
56 + let coordinate: CLLocationCoordinate2D // où se fait la manœuvre
57 +}
58 +
59 +struct Route: Identifiable {
60 + let id = UUID()
61 + let coordinates: [CLLocationCoordinate2D]
62 + let distance: Double // mètres
63 + let duration: Double // secondes
64 + let steps: [RouteStep]
65 +
66 + var durationText: String {
67 + let m = Int((duration / 60).rounded())
68 + return m >= 60 ? "\(m / 60) h \(m % 60 == 0 ? "" : "\(m % 60) min")".trimmingCharacters(in: .whitespaces)
69 + : "\(max(m, 1)) min"
70 + }
71 + var distanceText: String { Route.format(meters: distance) }
72 +
73 + static func format(meters: Double) -> String {
74 + meters >= 1000 ? String(format: "%.1f km", meters / 1000) : "\(Int(meters.rounded())) m"
75 + }
76 +}
77 +
78 +// MARK: - Client HTTP
79 +
80 +enum MapboxAPI {
81 + // Recherche de lieux (autocomplétion), en français, biaisée vers la
82 + // position de l'utilisateur quand elle est connue.
83 + static func search(_ query: String, near: CLLocationCoordinate2D?) async throws -> [Place] {
84 + let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
85 + guard !q.isEmpty else { return [] }
86 + var comps = URLComponents(string: "https://api.mapbox.com/geocoding/v5/mapbox.places/\(q.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? q).json")!
87 + var items = [
88 + URLQueryItem(name: "access_token", value: MapboxConfig.token),
89 + URLQueryItem(name: "language", value: "fr"),
90 + URLQueryItem(name: "limit", value: "7"),
91 + URLQueryItem(name: "autocomplete", value: "true"),
92 + ]
93 + if let near {
94 + items.append(URLQueryItem(name: "proximity", value: "\(near.longitude),\(near.latitude)"))
95 + }
96 + comps.queryItems = items
97 + let (data, _) = try await URLSession.shared.data(from: comps.url!)
98 + let decoded = try JSONDecoder().decode(GeocodeResponse.self, from: data)
99 + return decoded.features.map { f in
100 + Place(id: f.id,
101 + name: f.text ?? f.place_name,
102 + address: f.place_name,
103 + coordinate: .init(latitude: f.center[1], longitude: f.center[0]))
104 + }
105 + }
106 +
107 + // Géocodage inverse : nomme un point déposé sur la carte.
108 + static func reverse(_ c: CLLocationCoordinate2D) async -> Place {
109 + let fallback = Place(id: "pin-\(c.latitude)-\(c.longitude)",
110 + name: "Repère sur la carte",
111 + address: String(format: "%.5f, %.5f", c.latitude, c.longitude),
112 + coordinate: c)
113 + var comps = URLComponents(string: "https://api.mapbox.com/geocoding/v5/mapbox.places/\(c.longitude),\(c.latitude).json")!
114 + comps.queryItems = [
115 + URLQueryItem(name: "access_token", value: MapboxConfig.token),
116 + URLQueryItem(name: "language", value: "fr"),
117 + URLQueryItem(name: "limit", value: "1"),
118 + ]
119 + guard let (data, _) = try? await URLSession.shared.data(from: comps.url!),
120 + let decoded = try? JSONDecoder().decode(GeocodeResponse.self, from: data),
121 + let f = decoded.features.first else { return fallback }
122 + return Place(id: fallback.id, name: f.text ?? f.place_name, address: f.place_name, coordinate: c)
123 + }
124 +
125 + // Ville au point donné (pour les APIs Ka filtrées par ville).
126 + static func cityName(at c: CLLocationCoordinate2D) async -> String? {
127 + var comps = URLComponents(string: "https://api.mapbox.com/geocoding/v5/mapbox.places/\(c.longitude),\(c.latitude).json")!
128 + comps.queryItems = [
129 + URLQueryItem(name: "access_token", value: MapboxConfig.token),
130 + URLQueryItem(name: "types", value: "place"),
131 + URLQueryItem(name: "language", value: "fr"),
132 + URLQueryItem(name: "limit", value: "1"),
133 + ]
134 + guard let (data, _) = try? await URLSession.shared.data(from: comps.url!),
135 + let decoded = try? JSONDecoder().decode(GeocodeResponse.self, from: data)
136 + else { return nil }
137 + return decoded.features.first?.text
138 + }
139 +
140 + // Coordonnées d'une ville québécoise (géocodage direct, pour les sources
141 + // Ka sans lat/lng comme les boutiques Fabri-Ka).
142 + static func geocodeCity(_ name: String) async -> CLLocationCoordinate2D? {
143 + let q = "\(name), Québec, Canada"
144 + var comps = URLComponents(string: "https://api.mapbox.com/geocoding/v5/mapbox.places/\(q.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? q).json")!
145 + comps.queryItems = [
146 + URLQueryItem(name: "access_token", value: MapboxConfig.token),
147 + URLQueryItem(name: "types", value: "place,locality,neighborhood"),
148 + URLQueryItem(name: "language", value: "fr"),
149 + URLQueryItem(name: "limit", value: "1"),
150 + ]
151 + guard let (data, _) = try? await URLSession.shared.data(from: comps.url!),
152 + let decoded = try? JSONDecoder().decode(GeocodeResponse.self, from: data),
153 + let f = decoded.features.first, f.center.count >= 2 else { return nil }
154 + return .init(latitude: f.center[1], longitude: f.center[0])
155 + }
156 +
157 + // Itinéraires avec alternatives + étapes, en français.
158 + static func directions(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D,
159 + mode: TransportMode) async throws -> [Route] {
160 + let path = "\(from.longitude),\(from.latitude);\(to.longitude),\(to.latitude)"
161 + var comps = URLComponents(string: "https://api.mapbox.com/directions/v5/mapbox/\(mode.profile)/\(path)")!
162 + comps.queryItems = [
163 + URLQueryItem(name: "access_token", value: MapboxConfig.token),
164 + URLQueryItem(name: "alternatives", value: "true"),
165 + URLQueryItem(name: "geometries", value: "geojson"),
166 + URLQueryItem(name: "overview", value: "full"),
167 + URLQueryItem(name: "steps", value: "true"),
168 + URLQueryItem(name: "language", value: "fr"),
169 + ]
170 + let (data, _) = try await URLSession.shared.data(from: comps.url!)
171 + let decoded = try JSONDecoder().decode(DirectionsResponse.self, from: data)
172 + guard decoded.code == "Ok" else { throw MapboxError.api(decoded.message ?? decoded.code) }
173 + return decoded.routes.map { r in
174 + Route(coordinates: r.geometry.coordinates.map { .init(latitude: $0[1], longitude: $0[0]) },
175 + distance: r.distance,
176 + duration: r.duration,
177 + steps: r.legs.flatMap(\.steps).map { s in
178 + RouteStep(instruction: s.maneuver.instruction,
179 + distance: s.distance,
180 + icon: Self.icon(for: s.maneuver),
181 + coordinate: .init(latitude: s.maneuver.location[1],
182 + longitude: s.maneuver.location[0]))
183 + })
184 + }
185 + }
186 +
187 + private static func icon(for m: DirectionsResponse.Maneuver) -> String {
188 + switch m.type {
189 + case "depart": return "location.fill"
190 + case "arrive": return "mappin.circle.fill"
191 + case "roundabout", "rotary": return "arrow.triangle.2.circlepath"
192 + case "merge": return "arrow.triangle.merge"
193 + case "on ramp", "off ramp": return "arrow.up.right"
194 + default:
195 + if let mod = m.modifier {
196 + if mod.contains("left") { return "arrow.turn.up.left" }
197 + if mod.contains("right") { return "arrow.turn.up.right" }
198 + if mod.contains("uturn") { return "arrow.uturn.down" }
199 + }
200 + return "arrow.up"
201 + }
202 + }
203 +}
204 +
205 +enum MapboxError: LocalizedError {
206 + case api(String)
207 + var errorDescription: String? {
208 + if case .api(let m) = self { return "Mapbox : \(m)" }
209 + return nil
210 + }
211 +}
212 +
213 +// MARK: - DTO
214 +
215 +private struct GeocodeResponse: Decodable {
216 + struct Feature: Decodable {
217 + let id: String
218 + let text: String?
219 + let place_name: String
220 + let center: [Double]
221 + }
222 + let features: [Feature]
223 +}
224 +
225 +private struct DirectionsResponse: Decodable {
226 + struct Geometry: Decodable { let coordinates: [[Double]] }
227 + struct Maneuver: Decodable { let type: String; let modifier: String?; let instruction: String; let location: [Double] }
228 + struct Step: Decodable { let distance: Double; let maneuver: Maneuver }
229 + struct Leg: Decodable { let steps: [Step] }
230 + struct RouteDTO: Decodable {
231 + let geometry: Geometry
232 + let distance: Double
233 + let duration: Double
234 + let legs: [Leg]
235 + }
236 + let code: String
237 + let message: String?
238 + let routes: [RouteDTO]
239 +}
added KA/Features/Trajet/POI.swift +361 −0
@@ -0,0 +1,361 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// POI.swift — les couches de la carte Ka Trajet : stations-service et
3 +// commerces (Mapbox Search Box par catégorie, jeton public Lou-Ka) +
4 +// annonces EN DIRECT de Lou-Ka et Immo-Ka (endpoints /api/listings.geojson
5 +// avec bbox réel, les mêmes que la carte unifiée de la super-app KA).
6 +import Foundation
7 +import CoreLocation
8 +import MapKit
9 +
10 +enum POIKind: String, CaseIterable, Identifiable {
11 + case stations, commerces, louka, immoka, restoka, sortika, jobka, autoka, fabrika
12 + var id: String { rawValue }
13 +
14 + var label: String {
15 + switch self {
16 + case .stations: return "Stations"
17 + case .commerces: return "Commerces"
18 + case .louka: return "Lou-Ka"
19 + case .immoka: return "Immo-Ka"
20 + case .restoka: return "Resto-Ka"
21 + case .sortika: return "Sorti-Ka"
22 + case .jobka: return "Job-Ka"
23 + case .autoka: return "Auto-Ka"
24 + case .fabrika: return "Fabri-Ka"
25 + }
26 + }
27 + var icon: String {
28 + switch self {
29 + case .stations: return "fuelpump.fill"
30 + case .commerces: return "bag.fill"
31 + case .louka: return "key.fill"
32 + case .immoka: return "house.fill"
33 + case .restoka: return "fork.knife"
34 + case .sortika: return "ticket.fill"
35 + case .jobka: return "briefcase.fill"
36 + case .autoka: return "car.fill"
37 + case .fabrika: return "shippingbox.fill"
38 + }
39 + }
40 +}
41 +
42 +struct POIItem: Identifiable {
43 + let id: String
44 + let kind: POIKind
45 + let name: String
46 + let address: String
47 + let priceLabel: String? // annonces Ka / prix de l'essence ordinaire
48 + let coordinate: CLLocationCoordinate2D
49 + let url: URL? // fiche de l'annonce sur le site
50 + let extra: String? // détail (ex. ordinaire/super/diesel + source)
51 +
52 + init(id: String, kind: POIKind, name: String, address: String,
53 + priceLabel: String?, coordinate: CLLocationCoordinate2D,
54 + url: URL?, extra: String? = nil) {
55 + self.id = id; self.kind = kind; self.name = name; self.address = address
56 + self.priceLabel = priceLabel; self.coordinate = coordinate
57 + self.url = url; self.extra = extra
58 + }
59 +}
60 +
61 +/// Cache de géocodage des villes + annuaire des boutiques Fabri-Ka
62 +/// (chargé une fois par session).
63 +actor CityGeoCache {
64 + private var coords: [String: CLLocationCoordinate2D] = [:]
65 + private var failed: Set<String> = []
66 + private var storeList: [[String: Any]]?
67 +
68 + func stores() async -> [[String: Any]]? {
69 + if let storeList { return storeList }
70 + var req = URLRequest(url: URL(string: "https://www.fabri-ka.com/api/stores?limit=300")!)
71 + req.setValue("KaTrajet/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")
72 + guard let (data, _) = try? await URLSession.shared.data(for: req),
73 + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
74 + let items = root["items"] as? [[String: Any]] else { return nil }
75 + storeList = items
76 + return items
77 + }
78 +
79 + func coordinate(for city: String) async -> CLLocationCoordinate2D? {
80 + if let c = coords[city] { return c }
81 + if failed.contains(city) { return nil }
82 + guard let c = await MapboxAPI.geocodeCity(city) else {
83 + failed.insert(city); return nil
84 + }
85 + coords[city] = c
86 + return c
87 + }
88 +}
89 +
90 +enum POIService {
91 + // au-delà de cette étendue, on ne charge pas (trop de territoire)
92 + static let maxSpan = 0.35
93 +
94 + static func fetch(_ kind: POIKind, region: MKCoordinateRegion) async -> [POIItem] {
95 + let west = region.center.longitude - region.span.longitudeDelta / 2
96 + let east = region.center.longitude + region.span.longitudeDelta / 2
97 + let south = region.center.latitude - region.span.latitudeDelta / 2
98 + let north = region.center.latitude + region.span.latitudeDelta / 2
99 + switch kind {
100 + case .stations: return await gasStations(region: region)
101 + case .commerces: return await searchbox("shopping", kind: kind, west, south, east, north)
102 + case .louka:
103 + return await kaListings(kind: kind, host: "www.lou-ka.com", detail: "/logement/",
104 + west, south, east, north)
105 + case .immoka:
106 + return await kaListings(kind: kind, host: "www.immo-ka.com", detail: "/propriete/",
107 + west, south, east, north)
108 + case .restoka: return await restoKa(region: region, west, south, east, north)
109 + case .sortika: return await sortiKa(region: region, west, south, east, north)
110 + case .jobka:
111 + return await kaListings(kind: kind, host: "www.job-ka.com", detail: "/emploi/",
112 + west, south, east, north, path: "/api/jobs.geojson")
113 + case .autoka: return await autoKa(region: region, west, south, east, north)
114 + case .fabrika: return await fabriKa(west, south, east, north)
115 + }
116 + }
117 +
118 + // MARK: Auto-Ka — véhicules géolocalisés chez leur concessionnaire
119 +
120 + private static func autoKa(region: MKCoordinateRegion,
121 + _ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] {
122 + guard let city = await MapboxAPI.cityName(at: region.center),
123 + let root = await cityJSON(host: "www.auto-ka.com", path: "/api/vehicles", city: city),
124 + let items = root["vehicles"] as? [[String: Any]] else { return [] }
125 + return items.compactMap { v -> POIItem? in
126 + guard let lat = v["lat"] as? Double, let lng = v["lng"] as? Double,
127 + inBBox(lat, lng, w, s, e, n),
128 + let uid = v["uid"] as? String, let title = v["title"] as? String else { return nil }
129 + let dealer = (v["dealer_name"] as? String).flatMap { $0.isEmpty ? nil : $0 }
130 + let km = v["mileage_label"] as? String
131 + return POIItem(id: "autoka:\(uid)",
132 + kind: .autoka,
133 + name: title,
134 + address: [dealer.map { "Concessionnaire : \($0)" },
135 + v["city"] as? String]
136 + .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "),
137 + priceLabel: v["price_label"] as? String,
138 + coordinate: .init(latitude: lat, longitude: lng),
139 + url: URL(string: "https://www.auto-ka.com/vehicule/\(uid.replacingOccurrences(of: "/", with: "%2F"))"),
140 + extra: km.map { "\($0) · \(v["fuel"] as? String ?? "")" })
141 + }.prefix(40).map { $0 }
142 + }
143 +
144 + // MARK: Fabri-Ka — boutiques québécoises, localisées par géocodage de
145 + // leur ville (aucune coordonnée dans l'API ; cache de géocodage partagé)
146 +
147 + private static let fabriGeo = CityGeoCache()
148 +
149 + private static func fabriKa(_ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] {
150 + guard let stores = await fabriGeo.stores() else { return [] }
151 + var out: [POIItem] = []
152 + for st in stores {
153 + guard out.count < 30,
154 + let id = st["id"] as? String,
155 + let name = st["name"] as? String,
156 + let city = (st["city"] as? String), !city.isEmpty,
157 + let coord = await fabriGeo.coordinate(for: city) else { continue }
158 + // léger décalage déterministe pour séparer les boutiques d'une même ville
159 + let h = Double(id.unicodeScalars.reduce(0) { ($0 &+ UInt32($1.value)) % 997 })
160 + let lat = coord.latitude + (h.truncatingRemainder(dividingBy: 31) - 15) * 0.0004
161 + let lng = coord.longitude + (h.truncatingRemainder(dividingBy: 29) - 14) * 0.0005
162 + guard inBBox(lat, lng, w, s, e, n) else { continue }
163 + let count = (st["product_count"] as? Double).map(Int.init) ?? (st["product_count"] as? Int) ?? 0
164 + out.append(POIItem(id: "fabrika:\(id)",
165 + kind: .fabrika,
166 + name: name,
167 + address: city,
168 + priceLabel: nil,
169 + coordinate: .init(latitude: lat, longitude: lng),
170 + url: (st["url"] as? String).flatMap(URL.init(string:)),
171 + extra: count > 0 ? "\(count) produits fabriqués au Québec" : nil))
172 + }
173 + return out
174 + }
175 +
176 + // MARK: Resto-Ka / Sorti-Ka — filtre par ville (pas de bbox côté API) :
177 + // la ville visible est résolue par géocodage inverse Mapbox, puis les
178 + // items sont refiltrés client sur la région (même approche que la
179 + // super-app KA).
180 +
181 + private static func cityJSON(host: String, path: String, city: String,
182 + extraQuery: [URLQueryItem] = []) async -> [String: Any]? {
183 + var comps = URLComponents(string: "https://\(host)\(path)")!
184 + comps.queryItems = [
185 + .init(name: "city", value: city),
186 + .init(name: "limit", value: "120"),
187 + ] + extraQuery
188 + var req = URLRequest(url: comps.url!)
189 + req.setValue("KaTrajet/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")
190 + guard let (data, _) = try? await URLSession.shared.data(for: req) else { return nil }
191 + return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
192 + }
193 +
194 + private static func inBBox(_ lat: Double, _ lng: Double,
195 + _ w: Double, _ s: Double, _ e: Double, _ n: Double) -> Bool {
196 + lat >= s && lat <= n && lng >= w && lng <= e
197 + }
198 +
199 + private static func restoKa(region: MKCoordinateRegion,
200 + _ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] {
201 + guard let city = await MapboxAPI.cityName(at: region.center),
202 + let root = await cityJSON(host: "www.resto-ka.com", path: "/api/restaurants", city: city),
203 + let items = root["restaurants"] as? [[String: Any]] else { return [] }
204 + return items.compactMap { r -> POIItem? in
205 + guard let lat = r["lat"] as? Double, let lng = r["lng"] as? Double,
206 + inBBox(lat, lng, w, s, e, n),
207 + let uid = r["uid"] as? String, let name = r["name"] as? String else { return nil }
208 + let cuisines = (r["cuisines"] as? [String] ?? []).prefix(3).joined(separator: " · ")
209 + return POIItem(id: "restoka:\(uid)",
210 + kind: .restoka,
211 + name: name,
212 + address: [r["address"] as? String, r["city"] as? String]
213 + .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "),
214 + priceLabel: (r["price_range"] as? String).flatMap { $0.isEmpty ? nil : $0 },
215 + coordinate: .init(latitude: lat, longitude: lng),
216 + url: URL(string: "https://www.resto-ka.com/resto/\(uid.replacingOccurrences(of: "/", with: "%2F"))"),
217 + extra: cuisines.isEmpty ? nil : cuisines)
218 + }.prefix(40).map { $0 }
219 + }
220 +
221 + private static func sortiKa(region: MKCoordinateRegion,
222 + _ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] {
223 + guard let city = await MapboxAPI.cityName(at: region.center),
224 + let root = await cityJSON(host: "www.sorti-ka.com", path: "/api/events", city: city,
225 + extraQuery: [.init(name: "upcoming", value: "true")]),
226 + let items = root["events"] as? [[String: Any]] else { return [] }
227 + return items.compactMap { ev -> POIItem? in
228 + guard let lat = ev["lat"] as? Double, let lng = ev["lng"] as? Double,
229 + inBBox(lat, lng, w, s, e, n),
230 + let uid = ev["uid"] as? String, let title = ev["title"] as? String else { return nil }
231 + var price: String?
232 + if (ev["is_free"] as? Bool) == true { price = "Gratuit" }
233 + else if let m = ev["price_min"] as? Double, m > 1 { price = "dès \(Int(m)) $" }
234 + let date = ((ev["starts_at"] as? String)?.prefix(10)).map(String.init)
235 + return POIItem(id: "sortika:\(uid)",
236 + kind: .sortika,
237 + name: title,
238 + address: [ev["venue_name"] as? String, date]
239 + .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · "),
240 + priceLabel: price,
241 + coordinate: .init(latitude: lat, longitude: lng),
242 + url: URL(string: "https://www.sorti-ka.com/evenement/\(uid.replacingOccurrences(of: "/", with: "%2F"))"),
243 + extra: nil)
244 + }.prefix(40).map { $0 }
245 + }
246 +
247 + // MARK: stations-service avec PRIX RÉELS (Gas Québec / Régie de l'énergie)
248 +
249 + private static func gasStations(region: MKCoordinateRegion) async -> [POIItem] {
250 + // rayon ≈ demi-étendue visible, borné 1–15 km (limite de l'API)
251 + let radiusKm = min(15.0, max(1.0, region.span.latitudeDelta * 111.0 / 2))
252 + var comps = URLComponents(string: "https://www.gasquebec.ca/api/stations/nearby")!
253 + comps.queryItems = [
254 + .init(name: "lat", value: "\(region.center.latitude)"),
255 + .init(name: "lng", value: "\(region.center.longitude)"),
256 + .init(name: "radius", value: String(format: "%.1f", radiusKm)),
257 + .init(name: "limit", value: "30"),
258 + ]
259 + var req = URLRequest(url: comps.url!)
260 + req.setValue("KaTrajet/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")
261 + guard let (data, _) = try? await URLSession.shared.data(for: req),
262 + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
263 + let stations = root["stations"] as? [[String: Any]] else { return [] }
264 +
265 + func cents(_ v: Any?) -> String? {
266 + guard let n = v as? Double else { return nil }
267 + return String(format: "%.1f", n).replacingOccurrences(of: ".", with: ",")
268 + }
269 + return stations.compactMap { s in
270 + guard let lat = s["lat"] as? Double, let lng = s["lng"] as? Double,
271 + let name = s["name"] as? String else { return nil }
272 + let sid = (s["stationId"] as? String) ?? "\(lat),\(lng)"
273 + let details = [
274 + cents(s["prixOrdinaire"]).map { "Ordinaire \($0)" },
275 + cents(s["prixSuper"]).map { "Super \($0)" },
276 + cents(s["prixDiesel"]).map { "Diesel \($0)" },
277 + ].compactMap { $0 }
278 + return POIItem(id: "stations:\(sid)",
279 + kind: .stations,
280 + name: name,
281 + address: (s["address"] as? String) ?? "",
282 + priceLabel: cents(s["prixOrdinaire"]).map { "\($0) ¢" },
283 + coordinate: .init(latitude: lat, longitude: lng),
284 + url: nil,
285 + extra: details.isEmpty ? nil
286 + : details.joined(separator: " · ") + " ¢/L — Régie de l'énergie")
287 + }
288 + }
289 +
290 + // MARK: Mapbox Search Box — POI par catégorie
291 +
292 + private static func searchbox(_ category: String, kind: POIKind,
293 + _ w: Double, _ s: Double, _ e: Double, _ n: Double) async -> [POIItem] {
294 + var comps = URLComponents(string: "https://api.mapbox.com/search/searchbox/v1/category/\(category)")!
295 + comps.queryItems = [
296 + .init(name: "access_token", value: MapboxConfig.token),
297 + .init(name: "bbox", value: "\(w),\(s),\(e),\(n)"),
298 + .init(name: "limit", value: "25"),
299 + .init(name: "language", value: "fr"),
300 + ]
301 + guard let (data, _) = try? await URLSession.shared.data(from: comps.url!),
302 + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
303 + let features = root["features"] as? [[String: Any]] else { return [] }
304 + return features.compactMap { f in
305 + guard let geo = f["geometry"] as? [String: Any],
306 + let coords = geo["coordinates"] as? [Double], coords.count >= 2,
307 + let props = f["properties"] as? [String: Any],
308 + let name = props["name"] as? String else { return nil }
309 + let mid = (props["mapbox_id"] as? String) ?? "\(coords[0]),\(coords[1])"
310 + return POIItem(id: "\(kind.rawValue):\(mid)",
311 + kind: kind,
312 + name: name,
313 + address: (props["full_address"] as? String)
314 + ?? (props["place_formatted"] as? String) ?? "",
315 + priceLabel: nil,
316 + coordinate: .init(latitude: coords[1], longitude: coords[0]),
317 + url: nil)
318 + }
319 + }
320 +
321 + // MARK: annonces Lou-Ka / Immo-Ka (geojson bbox en direct)
322 +
323 + private static func kaListings(kind: POIKind, host: String, detail: String,
324 + _ w: Double, _ s: Double, _ e: Double, _ n: Double,
325 + path: String = "/api/listings.geojson") async -> [POIItem] {
326 + var comps = URLComponents(string: "https://\(host)\(path)")!
327 + comps.queryItems = [
328 + .init(name: "bbox", value: "\(w),\(s),\(e),\(n)"),
329 + .init(name: "limit", value: "40"),
330 + ]
331 + guard let (data, _) = try? await URLSession.shared.data(from: comps.url!),
332 + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
333 + let features = root["features"] as? [[String: Any]] else { return [] }
334 + return features.compactMap { f in
335 + guard let geo = f["geometry"] as? [String: Any],
336 + let coords = geo["coordinates"] as? [Double], coords.count >= 2,
337 + let props = f["properties"] as? [String: Any],
338 + let uid = props["uid"] as? String else { return nil }
339 + let title = (props["title"] as? String).flatMap { $0.isEmpty ? nil : $0 }
340 + ?? (props["property_type"] as? String)
341 + ?? (props["unit_type"] as? String)
342 + ?? (kind == .louka ? "Logement" : kind == .jobka ? "Offre d'emploi" : "Propriété")
343 + // Job-Ka : salaire en pilule quand il est connu, employeur en adresse
344 + var price = props["price_label"] as? String
345 + if kind == .jobka, price == nil, let m = props["salary_min"] as? Double {
346 + let unit = (props["salary_unit"] as? String) ?? "h"
347 + price = "dès \(Int(m)) $/\(unit)"
348 + }
349 + let addr = kind == .jobka
350 + ? [props["employer"] as? String, props["city"] as? String]
351 + : [props["address"] as? String, props["city"] as? String]
352 + return POIItem(id: "\(kind.rawValue):\(uid)",
353 + kind: kind,
354 + name: title,
355 + address: addr.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "),
356 + priceLabel: price,
357 + coordinate: .init(latitude: coords[1], longitude: coords[0]),
358 + url: URL(string: "https://\(host)\(detail)\(uid)"))
359 + }
360 + }
361 +}
added KA/Features/Trajet/TrajetMapView.swift +452 −0
@@ -0,0 +1,452 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// TrajetMapView.swift — la carte de Ka Trajet rendue par MapLibre Native
3 +// (même moteur que la carte de l'app iOS KA / Lou-Ka) : style vectoriel
4 +// Liberty, bâtiments 3D extrudés, tracés d'itinéraires (sélectionné en vert
5 +// Ka + alternatives grises), marqueur de destination, appui long → repère.
6 +import SwiftUI
7 +import MapLibre
8 +import MapKit
9 +
10 +// KaMapStyle est déclaré dans KaMapLibre.swift (même style Liberty partagé).
11 +
12 +/// Caméra de conduite : derrière la voiture, inclinée, orientée selon la route.
13 +struct NavCamera: Equatable {
14 + var center: CLLocationCoordinate2D
15 + var heading: Double
16 + var epoch: Int
17 + static func == (l: NavCamera, r: NavCamera) -> Bool { l.epoch == r.epoch }
18 +}
19 +
20 +final class DestinationAnnotation: NSObject, MLNAnnotation {
21 + let place: Place
22 + var coordinate: CLLocationCoordinate2D { place.coordinate }
23 + var title: String? { place.name }
24 + init(_ p: Place) { place = p }
25 +}
26 +
27 +final class POIAnnotation: NSObject, MLNAnnotation {
28 + let poi: POIItem
29 + var coordinate: CLLocationCoordinate2D { poi.coordinate }
30 + var title: String? { poi.name }
31 + init(_ p: POIItem) { poi = p }
32 +}
33 +
34 +struct TrajetMapView: UIViewRepresentable {
35 + var destination: Place?
36 + var routes: [Route]
37 + var routeIndex: Int
38 + var programRegion: MKCoordinateRegion
39 + var programEpoch: Int
40 + var pitch3D: Bool
41 + var navCamera: NavCamera?
42 + var pois: [POIItem]
43 + var selectedPOIID: String?
44 + var onLongPress: (CLLocationCoordinate2D) -> Void
45 + var onRegionChange: (MKCoordinateRegion) -> Void
46 + var onSelectPOI: (POIItem) -> Void
47 +
48 + private static let selSourceID = "ka-route-sel"
49 + private static let altSourceID = "ka-route-alt"
50 +
51 + func makeUIView(context: Context) -> MLNMapView {
52 + let map = MLNMapView(frame: .zero, styleURL: KaMapStyle.url)
53 + map.delegate = context.coordinator
54 + map.logoView.isHidden = false
55 + map.attributionButtonPosition = .bottomLeft
56 + map.setCenter(programRegion.center, zoomLevel: zoom(from: programRegion), animated: false)
57 + map.allowsRotating = true
58 + map.allowsTilting = true
59 + let auth = CLLocationManager().authorizationStatus
60 + map.showsUserLocation = (auth == .authorizedWhenInUse || auth == .authorizedAlways)
61 + let long = UILongPressGestureRecognizer(target: context.coordinator,
62 + action: #selector(Coordinator.longPressed(_:)))
63 + map.addGestureRecognizer(long)
64 + return map
65 + }
66 +
67 + func updateUIView(_ map: MLNMapView, context: Context) {
68 + context.coordinator.parent = self
69 +
70 + // point bleu dès que la permission est accordée
71 + let auth = CLLocationManager().authorizationStatus
72 + let allowed = (auth == .authorizedWhenInUse || auth == .authorizedAlways)
73 + if map.showsUserLocation != allowed { map.showsUserLocation = allowed }
74 +
75 + // caméra de conduite : suit la position réelle de la voiture,
76 + // vue 3D inclinée orientée dans le sens de la route
77 + if let nav = navCamera {
78 + if context.coordinator.lastNavEpoch != nav.epoch {
79 + context.coordinator.lastNavEpoch = nav.epoch
80 + let cam = MLNMapCamera(lookingAtCenter: nav.center,
81 + altitude: 260,
82 + pitch: 62,
83 + heading: nav.heading)
84 + map.setCamera(cam, withDuration: 0.9,
85 + animationTimingFunction: CAMediaTimingFunction(name: .linear))
86 + }
87 + return // en navigation, la caméra de conduite a priorité sur tout
88 + }
89 + context.coordinator.lastNavEpoch = 0
90 +
91 + // caméra programmée (pattern epoch de la super-app KA)
92 + if context.coordinator.lastEpoch != programEpoch {
93 + context.coordinator.lastEpoch = programEpoch
94 + let cam = map.camera
95 + cam.centerCoordinate = programRegion.center
96 + cam.pitch = pitch3D ? 58 : 0
97 + map.setCamera(cam, animated: false)
98 + map.setZoomLevel(zoom(from: programRegion), animated: true)
99 + }
100 +
101 + // bascule 2D / 3D
102 + if context.coordinator.lastPitch3D != pitch3D {
103 + context.coordinator.lastPitch3D = pitch3D
104 + let cam = map.camera
105 + cam.pitch = pitch3D ? 58 : 0
106 + map.fly(to: cam, withDuration: 0.7, completionHandler: nil)
107 + }
108 +
109 + // marqueur de destination
110 + let destID = destination?.id
111 + if context.coordinator.lastDestID != destID {
112 + context.coordinator.lastDestID = destID
113 + let old = (map.annotations ?? []).compactMap { $0 as? DestinationAnnotation }
114 + map.removeAnnotations(old)
115 + if let d = destination { map.addAnnotation(DestinationAnnotation(d)) }
116 + }
117 +
118 + // tracés d'itinéraires
119 + let signature = routes.map(\.id.uuidString).joined() + "#\(routeIndex)"
120 + if context.coordinator.lastRouteSignature != signature {
121 + context.coordinator.lastRouteSignature = signature
122 + context.coordinator.syncRoutes(on: map)
123 + }
124 +
125 + // marqueurs des couches POI (stations, commerces, annonces, restos, sorties)
126 + let poiIDs = Set(pois.map(\.id))
127 + if context.coordinator.lastPOIIDs != poiIDs
128 + || context.coordinator.lastPOISelectedID != selectedPOIID {
129 + context.coordinator.lastPOIIDs = poiIDs
130 + context.coordinator.lastPOISelectedID = selectedPOIID
131 + let old = (map.annotations ?? []).compactMap { $0 as? POIAnnotation }
132 + map.removeAnnotations(old)
133 + map.addAnnotations(pois.map(POIAnnotation.init))
134 + }
135 + }
136 +
137 + func makeCoordinator() -> Coordinator { Coordinator(self) }
138 +
139 + private func zoom(from region: MKCoordinateRegion) -> Double {
140 + let span = max(region.span.longitudeDelta, 0.0005)
141 + return max(1, min(18, log2(360 / span)))
142 + }
143 +
144 + final class Coordinator: NSObject, MLNMapViewDelegate {
145 + var parent: TrajetMapView
146 + var lastEpoch = Int.min
147 + var lastPitch3D = false
148 + var lastNavEpoch = 0
149 + var lastDestID: String?
150 + var lastRouteSignature = ""
151 + var lastPOIIDs = Set<String>()
152 + var lastPOISelectedID: String?
153 + private var buildingsAdded = false
154 + private var styleReady = false
155 + private weak var mapView: MLNMapView?
156 +
157 + init(_ p: TrajetMapView) { parent = p }
158 +
159 + @objc func longPressed(_ g: UILongPressGestureRecognizer) {
160 + guard g.state == .began, let map = g.view as? MLNMapView else { return }
161 + let coord = map.convert(g.location(in: map), toCoordinateFrom: map)
162 + parent.onLongPress(coord)
163 + }
164 +
165 + // ---- style chargé : palette Ka + bâtiments 3D + couches d'itinéraire ----
166 + func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) {
167 + self.mapView = mapView
168 + styleReady = true
169 + applyKaCartography(style)
170 + addBuildings(style)
171 + ensureRouteLayers(style)
172 + syncRoutes(on: mapView)
173 + }
174 +
175 + // La carte SIGNATURE Groupe Ka : papier crème, eau vert forêt,
176 + // parcs lime pâle, autoroutes lime cerclées de vert, étiquettes encre.
177 + private func applyKaCartography(_ style: MLNStyle) {
178 + let paper = UIColor(red: 0.961, green: 0.953, blue: 0.933, alpha: 1) // #f5f3ee
179 + let paper2 = UIColor(red: 0.937, green: 0.925, blue: 0.894, alpha: 1) // #efece4
180 + let ink = UIColor(red: 0.078, green: 0.094, blue: 0.078, alpha: 1) // #141814
181 + let water = UIColor(red: 0x2e / 255, green: 0x6f / 255, blue: 0x58 / 255, alpha: 1) // vert forêt
182 + let park = UIColor(red: 0xe2 / 255, green: 0xee / 255, blue: 0xc4 / 255, alpha: 1) // lime pâle
183 + let building = UIColor(red: 0.898, green: 0.882, blue: 0.843, alpha: 1)
184 +
185 + for layer in style.layers {
186 + let id = layer.identifier.lowercased()
187 + if let bg = layer as? MLNBackgroundStyleLayer {
188 + bg.backgroundColor = NSExpression(forConstantValue: paper)
189 + } else if let fill = layer as? MLNFillStyleLayer {
190 + if id.contains("water") {
191 + fill.fillColor = NSExpression(forConstantValue: water)
192 + fill.fillOpacity = NSExpression(forConstantValue: 1)
193 + } else if id.contains("park") || id.contains("grass") || id.contains("wood")
194 + || id.contains("landcover") || id.contains("cemetery") || id.contains("golf")
195 + || id.contains("pitch") {
196 + fill.fillColor = NSExpression(forConstantValue: park)
197 + } else if id.contains("building") {
198 + fill.fillColor = NSExpression(forConstantValue: building)
199 + } else if id.contains("residential") || id.contains("landuse")
200 + || id.contains("sand") || id.contains("aeroway") {
201 + fill.fillColor = NSExpression(forConstantValue: paper2)
202 + }
203 + } else if let line = layer as? MLNLineStyleLayer {
204 + if id.contains("motorway") || id.contains("trunk") {
205 + line.lineColor = NSExpression(forConstantValue:
206 + id.contains("casing") ? Ka.uiGreen : Ka.uiLime)
207 + } else if id.contains("water") || id.contains("river") || id.contains("stream") {
208 + line.lineColor = NSExpression(forConstantValue: water)
209 + }
210 + } else if let sym = layer as? MLNSymbolStyleLayer {
211 + if sym.text != nil {
212 + sym.textColor = NSExpression(forConstantValue: ink)
213 + sym.textHaloColor = NSExpression(forConstantValue: paper.withAlphaComponent(0.92))
214 + }
215 + }
216 + }
217 + }
218 +
219 + private func addBuildings(_ style: MLNStyle) {
220 + guard !buildingsAdded else { return }
221 + let source = style.source(withIdentifier: "openmaptiles")
222 + ?? style.source(withIdentifier: "composite")
223 + ?? style.sources.first
224 + guard let composite = source else { return }
225 + buildingsAdded = true
226 + let layer = MLNFillExtrusionStyleLayer(identifier: "ka-3d-buildings", source: composite)
227 + layer.sourceLayerIdentifier = "building"
228 + layer.fillExtrusionHeight = NSExpression(forKeyPath: "render_height")
229 + layer.fillExtrusionBase = NSExpression(forKeyPath: "render_min_height")
230 + layer.fillExtrusionColor = NSExpression(forConstantValue: UIColor(red: 0.82, green: 0.83, blue: 0.80, alpha: 1))
231 + layer.fillExtrusionOpacity = NSExpression(forConstantValue: 0.75)
232 + if let firstSymbol = style.layers.first(where: { $0 is MLNSymbolStyleLayer }) {
233 + style.insertLayer(layer, below: firstSymbol)
234 + } else {
235 + style.addLayer(layer)
236 + }
237 + }
238 +
239 + // sources + couches (créées une fois, shapes remplacées ensuite)
240 + private func ensureRouteLayers(_ style: MLNStyle) {
241 + guard style.source(withIdentifier: TrajetMapView.selSourceID) == nil else { return }
242 + let altSource = MLNShapeSource(identifier: TrajetMapView.altSourceID,
243 + shape: MLNShapeCollectionFeature(shapes: []), options: nil)
244 + let selSource = MLNShapeSource(identifier: TrajetMapView.selSourceID,
245 + shape: MLNShapeCollectionFeature(shapes: []), options: nil)
246 + style.addSource(altSource)
247 + style.addSource(selSource)
248 +
249 + let alt = MLNLineStyleLayer(identifier: "ka-route-alt-line", source: altSource)
250 + alt.lineColor = NSExpression(forConstantValue: Ka.uiAlt)
251 + alt.lineWidth = NSExpression(forConstantValue: 5)
252 + alt.lineOpacity = NSExpression(forConstantValue: 0.65)
253 + alt.lineCap = NSExpression(forConstantValue: "round")
254 + alt.lineJoin = NSExpression(forConstantValue: "round")
255 +
256 + let casing = MLNLineStyleLayer(identifier: "ka-route-sel-casing", source: selSource)
257 + casing.lineColor = NSExpression(forConstantValue: UIColor.white)
258 + casing.lineWidth = NSExpression(forConstantValue: 9)
259 + casing.lineCap = NSExpression(forConstantValue: "round")
260 + casing.lineJoin = NSExpression(forConstantValue: "round")
261 +
262 + let sel = MLNLineStyleLayer(identifier: "ka-route-sel-line", source: selSource)
263 + sel.lineColor = NSExpression(forConstantValue: Ka.uiGreen)
264 + sel.lineWidth = NSExpression(forConstantValue: 6)
265 + sel.lineCap = NSExpression(forConstantValue: "round")
266 + sel.lineJoin = NSExpression(forConstantValue: "round")
267 +
268 + // au-dessus des routes mais sous les étiquettes
269 + if let firstSymbol = style.layers.first(where: { $0 is MLNSymbolStyleLayer }) {
270 + style.insertLayer(alt, below: firstSymbol)
271 + style.insertLayer(casing, above: alt)
272 + style.insertLayer(sel, above: casing)
273 + } else {
274 + style.addLayer(alt); style.addLayer(casing); style.addLayer(sel)
275 + }
276 + }
277 +
278 + func syncRoutes(on map: MLNMapView) {
279 + guard styleReady, let style = map.style else { return }
280 + ensureRouteLayers(style)
281 + guard let altSource = style.source(withIdentifier: TrajetMapView.altSourceID) as? MLNShapeSource,
282 + let selSource = style.source(withIdentifier: TrajetMapView.selSourceID) as? MLNShapeSource
283 + else { return }
284 +
285 + func line(_ r: Route) -> MLNPolylineFeature {
286 + var coords = r.coordinates
287 + return MLNPolylineFeature(coordinates: &coords, count: UInt(coords.count))
288 + }
289 + let routes = parent.routes
290 + let sel = parent.routeIndex
291 + let altShapes = routes.enumerated().filter { $0.offset != sel }.map { line($0.element) }
292 + let selShapes = routes.indices.contains(sel) ? [line(routes[sel])] : []
293 + altSource.shape = MLNShapeCollectionFeature(shapes: altShapes)
294 + selSource.shape = MLNShapeCollectionFeature(shapes: selShapes)
295 + }
296 +
297 + // ---- marqueurs : épinglette de destination + POI des couches ----
298 + func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? {
299 + if annotation is DestinationAnnotation {
300 + let view = MLNAnnotationView(reuseIdentifier: nil)
301 + let host = UIHostingController(rootView: DestinationPin())
302 + host.view.backgroundColor = .clear
303 + let size = host.sizeThatFits(in: CGSize(width: 60, height: 60))
304 + host.view.frame = CGRect(origin: .zero, size: size)
305 + view.frame = host.view.frame
306 + // ancrer la pointe de l'épinglette sur la coordonnée
307 + view.centerOffset = CGVector(dx: 0, dy: -size.height / 2)
308 + view.addSubview(host.view)
309 + return view
310 + }
311 + if let ann = annotation as? POIAnnotation {
312 + let view = MLNAnnotationView(reuseIdentifier: nil)
313 + let host = UIHostingController(rootView:
314 + POIMarker(poi: ann.poi, selected: parent.selectedPOIID == ann.poi.id))
315 + host.view.backgroundColor = .clear
316 + let size = host.sizeThatFits(in: CGSize(width: 170, height: 44))
317 + host.view.frame = CGRect(origin: .zero, size: size)
318 + view.frame = host.view.frame
319 + view.addSubview(host.view)
320 + view.isUserInteractionEnabled = true
321 + let tap = UITapGestureRecognizer(target: self, action: #selector(poiTapped(_:)))
322 + view.addGestureRecognizer(tap)
323 + view.accessibilityLabel = ann.poi.name
324 + objc_setAssociatedObject(view, &AssocKeys.poi, ann.poi, .OBJC_ASSOCIATION_RETAIN)
325 + return view
326 + }
327 + return nil
328 + }
329 +
330 + @objc private func poiTapped(_ g: UITapGestureRecognizer) {
331 + guard let v = g.view,
332 + let poi = objc_getAssociatedObject(v, &AssocKeys.poi) as? POIItem else { return }
333 + parent.onSelectPOI(poi)
334 + }
335 +
336 + // ---- région visible → modèle (rechargement des couches) ----
337 + func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) {
338 + let bounds = mapView.visibleCoordinateBounds
339 + let region = MKCoordinateRegion(
340 + center: mapView.centerCoordinate,
341 + span: .init(latitudeDelta: abs(bounds.ne.latitude - bounds.sw.latitude),
342 + longitudeDelta: abs(bounds.ne.longitude - bounds.sw.longitude)))
343 + parent.onRegionChange(region)
344 + }
345 + }
346 +}
347 +
348 +private enum AssocKeys { static var poi: UInt8 = 0 }
349 +
350 +/// Marqueur d'une couche POI : pastille icône (stations, commerces) ou
351 +/// pilule de prix (annonces Lou-Ka / Immo-Ka), aux couleurs Ka.
352 +struct POIMarker: View {
353 + let poi: POIItem
354 + var selected = false
355 +
356 + // accents des univers Ka (ecosystem.json)
357 + private static let restoOrange = Color(hex: "#f08c00")
358 + private static let sortiRose = Color(hex: "#d6336c")
359 + private static let jobTeal = Color(hex: "#0c8599")
360 + private static let autoOrange = Color(hex: "#ff5a2a")
361 + private static let fabriBrun = Color(hex: "#c4532e")
362 +
363 + var body: some View {
364 + switch poi.kind {
365 + case .stations:
366 + // prix de l'essence ordinaire en pilule (source Régie de l'énergie)
367 + if poi.priceLabel != nil { pill(bg: Ka.inkLight, fg: Ka.lime) }
368 + else { badge(bg: Ka.inkLight, fg: Ka.lime) }
369 + case .commerces:
370 + badge(bg: Ka.green, fg: .white)
371 + case .louka:
372 + pill(bg: Ka.lime, fg: Ka.inkLight)
373 + case .immoka:
374 + pill(bg: Ka.green, fg: Ka.lime)
375 + case .restoka:
376 + if poi.priceLabel != nil { pill(bg: Self.restoOrange, fg: .white) }
377 + else { badge(bg: Self.restoOrange, fg: .white) }
378 + case .sortika:
379 + if poi.priceLabel != nil { pill(bg: Self.sortiRose, fg: .white) }
380 + else { badge(bg: Self.sortiRose, fg: .white) }
381 + case .jobka:
382 + if poi.priceLabel != nil { pill(bg: Self.jobTeal, fg: .white) }
383 + else { badge(bg: Self.jobTeal, fg: .white) }
384 + case .autoka:
385 + if poi.priceLabel != nil { pill(bg: Self.autoOrange, fg: .white) }
386 + else { badge(bg: Self.autoOrange, fg: .white) }
387 + case .fabrika:
388 + badge(bg: Self.fabriBrun, fg: .white)
389 + }
390 + }
391 +
392 + private func badge(bg: Color, fg: Color) -> some View {
393 + ZStack {
394 + Circle().fill(bg)
395 + .frame(width: selected ? 36 : 30, height: selected ? 36 : 30)
396 + .shadow(color: .black.opacity(0.3), radius: 3, y: 1)
397 + Image(systemName: poi.kind.icon)
398 + .font(.system(size: selected ? 15 : 13, weight: .bold))
399 + .foregroundStyle(fg)
400 + }
401 + .overlay(Circle().strokeBorder(selected ? Ka.lime : .white, lineWidth: selected ? 2.5 : 1.5))
402 + .padding(3)
403 + }
404 +
405 + private func pill(bg: Color, fg: Color) -> some View {
406 + HStack(spacing: 4) {
407 + Image(systemName: poi.kind.icon)
408 + .font(.system(size: selected ? 12 : 10, weight: .bold))
409 + Text(poi.priceLabel ?? poi.name)
410 + .font(.system(size: selected ? 14 : 12, weight: .bold, design: .rounded))
411 + .lineLimit(1)
412 + }
413 + .padding(.horizontal, selected ? 11 : 9)
414 + .padding(.vertical, selected ? 7 : 5)
415 + .background(bg, in: Capsule())
416 + .overlay(Capsule().strokeBorder(selected ? Ka.lime : .white, lineWidth: selected ? 2.5 : 1.5))
417 + .foregroundStyle(fg)
418 + .shadow(color: .black.opacity(selected ? 0.45 : 0.3), radius: selected ? 5 : 3, y: 1)
419 + .padding(2)
420 + }
421 +}
422 +
423 +/// Épinglette de destination aux couleurs Ka.
424 +struct DestinationPin: View {
425 + var body: some View {
426 + VStack(spacing: -2) {
427 + ZStack {
428 + Circle().fill(Ka.green)
429 + .frame(width: 34, height: 34)
430 + .shadow(color: .black.opacity(0.35), radius: 4, y: 2)
431 + Image(systemName: "mappin")
432 + .font(.system(size: 16, weight: .bold))
433 + .foregroundStyle(Ka.lime)
434 + }
435 + Triangle()
436 + .fill(Ka.green)
437 + .frame(width: 12, height: 10)
438 + }
439 + .padding(.bottom, 2)
440 + }
441 +}
442 +
443 +struct Triangle: Shape {
444 + func path(in r: CGRect) -> Path {
445 + var p = Path()
446 + p.move(to: .init(x: r.midX, y: r.maxY))
447 + p.addLine(to: .init(x: r.minX, y: r.minY))
448 + p.addLine(to: .init(x: r.maxX, y: r.minY))
449 + p.closeSubpath()
450 + return p
451 + }
452 +}
added KA/Features/Trajet/TrajetModel.swift +298 −0
@@ -0,0 +1,298 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// TrajetModel.swift — l'état central de Ka Trajet : recherche (avec
3 +// anti-rebond), lieu sélectionné, itinéraires + alternative choisie,
4 +// caméra programmée (pattern epoch, comme la super-app KA).
5 +import Foundation
6 +import MapKit
7 +import Combine
8 +
9 +@MainActor
10 +final class TrajetModel: ObservableObject {
11 + // recherche
12 + @Published var query = ""
13 + @Published var results: [Place] = []
14 + @Published var searching = false
15 +
16 + // sélection / itinéraire
17 + @Published var selected: Place?
18 + @Published var mode: TransportMode = .auto
19 + @Published var routes: [Route] = []
20 + @Published var routeIndex = 0
21 + @Published var routing = false
22 + @Published var showSteps = false
23 + @Published var errorMessage: String?
24 +
25 + // couches POI (stations, commerces, annonces Lou-Ka / Immo-Ka)
26 + @Published var layers: Set<POIKind> = []
27 + @Published var pois: [POIItem] = []
28 + @Published var selectedPOI: POIItem?
29 + let regionSubject = PassthroughSubject<MKCoordinateRegion, Never>()
30 + private var visibleRegion: MKCoordinateRegion?
31 + private var poiGeneration = 0
32 +
33 + // navigation (vue conduite 3D : caméra derrière la voiture)
34 + @Published var navigating = false
35 + @Published var navCamera: NavCamera?
36 + @Published var currentStep: RouteStep?
37 + @Published var stepDistanceText = ""
38 + private var navEpoch = 0
39 +
40 + // caméra programmée (epoch → la carte n'obéit qu'aux changements voulus)
41 + @Published var programRegion = MKCoordinateRegion(
42 + center: .init(latitude: 46.8139, longitude: -71.2080), // Québec
43 + span: .init(latitudeDelta: 0.08, longitudeDelta: 0.08))
44 + @Published var programEpoch = 0
45 + @Published var pitch3D = false
46 +
47 + let locator = LocationManager()
48 + private var searchTask: Task<Void, Never>?
49 + private var cancellables = Set<AnyCancellable>()
50 + private var centeredOnUser = false
51 +
52 + var route: Route? { routes.indices.contains(routeIndex) ? routes[routeIndex] : nil }
53 + var hasRoute: Bool { route != nil }
54 +
55 + init() {
56 + // anti-rebond de la recherche (300 ms)
57 + $query
58 + .removeDuplicates()
59 + .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
60 + .sink { [weak self] q in self?.runSearch(q) }
61 + .store(in: &cancellables)
62 +
63 + // fixes GPS : premier → recentrage ; en navigation → caméra conduite
64 + locator.$location
65 + .compactMap { $0 }
66 + .sink { [weak self] c in
67 + guard let self else { return }
68 + if !self.centeredOnUser {
69 + self.centeredOnUser = true
70 + self.center(on: c, span: 0.03)
71 + }
72 + if self.navigating { self.updateNav() }
73 + }
74 + .store(in: &cancellables)
75 +
76 + // carte déplacée → recharger les couches actives (anti-rebond 700 ms)
77 + regionSubject
78 + .debounce(for: .milliseconds(700), scheduler: RunLoop.main)
79 + .sink { [weak self] region in
80 + self?.visibleRegion = region
81 + self?.refreshPOIs()
82 + }
83 + .store(in: &cancellables)
84 + }
85 +
86 + // MARK: couches POI
87 +
88 + func toggleLayer(_ kind: POIKind) {
89 + if layers.contains(kind) { layers.remove(kind) } else { layers.insert(kind) }
90 + refreshPOIs()
91 + }
92 +
93 + private func refreshPOIs() {
94 + poiGeneration += 1
95 + let gen = poiGeneration
96 + guard !layers.isEmpty else { pois = []; return }
97 + let region = visibleRegion ?? programRegion
98 + guard max(region.span.latitudeDelta, region.span.longitudeDelta) <= POIService.maxSpan else {
99 + pois = []; return // trop dézoomé — on attend un zoom de quartier
100 + }
101 + let kinds = layers
102 + Task {
103 + var all: [POIItem] = []
104 + await withTaskGroup(of: [POIItem].self) { group in
105 + for k in kinds { group.addTask { await POIService.fetch(k, region: region) } }
106 + for await items in group { all.append(contentsOf: items) }
107 + }
108 + guard gen == self.poiGeneration else { return } // réponse périmée
109 + self.pois = all
110 + }
111 + }
112 +
113 + func selectPOI(_ poi: POIItem) {
114 + stopNavigation() // un tap sur un POI sort toujours du mode conduite
115 + selectedPOI = poi
116 + query = ""
117 + results = []
118 + selected = Place(id: poi.id, name: poi.name,
119 + address: poi.address.isEmpty ? (poi.priceLabel ?? "") : poi.address,
120 + coordinate: poi.coordinate)
121 + routes = []; routeIndex = 0
122 + }
123 +
124 + // MARK: recherche
125 +
126 + private func runSearch(_ q: String) {
127 + searchTask?.cancel()
128 + guard q.trimmingCharacters(in: .whitespaces).count >= 2 else {
129 + results = []; searching = false; return
130 + }
131 + searching = true
132 + searchTask = Task { [weak self] in
133 + guard let self else { return }
134 + let found = (try? await MapboxAPI.search(q, near: self.locator.location)) ?? []
135 + if Task.isCancelled { return }
136 + self.results = found
137 + self.searching = false
138 + }
139 + }
140 +
141 + func choose(_ place: Place) {
142 + query = ""
143 + results = []
144 + selected = place
145 + selectedPOI = nil
146 + routes = []; routeIndex = 0
147 + center(on: place.coordinate, span: 0.02)
148 + }
149 +
150 + // point déposé par appui long sur la carte
151 + func dropPin(at c: CLLocationCoordinate2D) {
152 + Task {
153 + let place = await MapboxAPI.reverse(c)
154 + self.choose(place)
155 + }
156 + }
157 +
158 + // MARK: itinéraire
159 +
160 + func computeRoute() {
161 + guard let dest = selected else { return }
162 + guard let origin = locator.location else {
163 + errorMessage = "Position inconnue — autorisez la localisation pour calculer un trajet."
164 + locator.request()
165 + return
166 + }
167 + routing = true
168 + errorMessage = nil
169 + let m = mode
170 + Task {
171 + do {
172 + let found = try await MapboxAPI.directions(from: origin, to: dest.coordinate, mode: m)
173 + guard m == self.mode else { return } // réponse périmée
174 + self.routes = found
175 + self.routeIndex = 0
176 + self.routing = false
177 + if let r = found.first { self.fit(route: r, extra: origin) }
178 + if found.isEmpty { self.errorMessage = "Aucun trajet trouvé." }
179 + } catch {
180 + self.routing = false
181 + self.errorMessage = error.localizedDescription
182 + }
183 + }
184 + }
185 +
186 + func setMode(_ m: TransportMode) {
187 + guard m != mode else { return }
188 + mode = m
189 + if selected != nil, !routes.isEmpty || routing { computeRoute() }
190 + }
191 +
192 + func clearRoute() {
193 + stopNavigation()
194 + routes = []; routeIndex = 0; showSteps = false; errorMessage = nil
195 + }
196 +
197 + // MARK: navigation (vue conduite)
198 +
199 + func startNavigation() {
200 + guard route != nil else { return }
201 + guard locator.location != nil else {
202 + errorMessage = "Position inconnue — autorisez la localisation pour démarrer."
203 + locator.request()
204 + return
205 + }
206 + navigating = true
207 + updateNav()
208 + }
209 +
210 + func stopNavigation() {
211 + guard navigating else { return }
212 + navigating = false
213 + navCamera = nil
214 + currentStep = nil
215 + stepDistanceText = ""
216 + if let r = route, let pos = locator.location { fit(route: r, extra: pos) }
217 + }
218 +
219 + // caméra derrière la voiture : centre = position réelle, cap = direction
220 + // de la route ~60 m devant (stable), prochaine manœuvre affichée en bannière
221 + private func updateNav() {
222 + guard navigating, let r = route, let pos = locator.location,
223 + !r.coordinates.isEmpty else { return }
224 + var best = 0
225 + var bestD = Double.greatestFiniteMagnitude
226 + for (i, c) in r.coordinates.enumerated() {
227 + let d = Self.meters(pos, c)
228 + if d < bestD { bestD = d; best = i }
229 + }
230 + var j = best
231 + while j < r.coordinates.count - 1, Self.meters(pos, r.coordinates[j]) < 60 { j += 1 }
232 + let heading = Self.bearing(from: pos, to: r.coordinates[j])
233 + navEpoch += 1
234 + navCamera = NavCamera(center: pos, heading: heading, epoch: navEpoch)
235 +
236 + // première manœuvre encore devant nous le long de la route
237 + let ahead = r.steps.first { step in
238 + var k = 0
239 + var kd = Double.greatestFiniteMagnitude
240 + for (i, c) in r.coordinates.enumerated() {
241 + let d = Self.meters(step.coordinate, c)
242 + if d < kd { kd = d; k = i }
243 + }
244 + return k > best || (k == best && Self.meters(pos, step.coordinate) > 20)
245 + }
246 + currentStep = ahead ?? r.steps.last
247 + if let s = currentStep {
248 + stepDistanceText = Route.format(meters: Self.meters(pos, s.coordinate))
249 + }
250 + }
251 +
252 + static func meters(_ a: CLLocationCoordinate2D, _ b: CLLocationCoordinate2D) -> Double {
253 + CLLocation(latitude: a.latitude, longitude: a.longitude)
254 + .distance(from: CLLocation(latitude: b.latitude, longitude: b.longitude))
255 + }
256 +
257 + static func bearing(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double {
258 + let la1 = a.latitude * .pi / 180, la2 = b.latitude * .pi / 180
259 + let dLon = (b.longitude - a.longitude) * .pi / 180
260 + let y = sin(dLon) * cos(la2)
261 + let x = cos(la1) * sin(la2) - sin(la1) * cos(la2) * cos(dLon)
262 + let deg = atan2(y, x) * 180 / .pi
263 + return deg < 0 ? deg + 360 : deg
264 + }
265 +
266 + func clearAll() {
267 + clearRoute()
268 + selected = nil
269 + selectedPOI = nil
270 + }
271 +
272 + // MARK: caméra
273 +
274 + func center(on c: CLLocationCoordinate2D, span: Double) {
275 + programRegion = .init(center: c, span: .init(latitudeDelta: span, longitudeDelta: span))
276 + programEpoch += 1
277 + }
278 +
279 + func centerOnUser() {
280 + if let c = locator.location { center(on: c, span: 0.02) }
281 + else { locator.request() }
282 + }
283 +
284 + private func fit(route: Route, extra: CLLocationCoordinate2D) {
285 + var lats = route.coordinates.map(\.latitude); lats.append(extra.latitude)
286 + var lons = route.coordinates.map(\.longitude); lons.append(extra.longitude)
287 + guard let minLat = lats.min(), let maxLat = lats.max(),
288 + let minLon = lons.min(), let maxLon = lons.max() else { return }
289 + let center = CLLocationCoordinate2D(latitude: (minLat + maxLat) / 2,
290 + longitude: (minLon + maxLon) / 2)
291 + // marge ~35 % + place pour le panneau bas
292 + let span = max((maxLat - minLat) * 1.7, (maxLon - minLon) * 1.35, 0.01)
293 + programRegion = .init(center: .init(latitude: center.latitude - span * 0.12,
294 + longitude: center.longitude),
295 + span: .init(latitudeDelta: span, longitudeDelta: span))
296 + programEpoch += 1
297 + }
298 +}
added KA/Features/Trajet/TrajetView.swift +496 −0
@@ -0,0 +1,496 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// TrajetView.swift — Ka Trajet, le GPS maison du Groupe Ka, intégré comme
3 +// onglet de la super-app : carte plein écran, recherche, fiche du lieu,
4 +// itinéraires (modes, alternatives, étapes), mode conduite 3D, couches POI.
5 +import SwiftUI
6 +import MapKit
7 +
8 +struct TrajetView: View {
9 + @EnvironmentObject var model: TrajetModel
10 + @Environment(\.colorScheme) private var scheme
11 + @FocusState private var searchFocused: Bool
12 +
13 + var body: some View {
14 + ZStack {
15 + TrajetMapView(destination: model.selected,
16 + routes: model.routes,
17 + routeIndex: model.routeIndex,
18 + programRegion: model.programRegion,
19 + programEpoch: model.programEpoch,
20 + pitch3D: model.pitch3D,
21 + navCamera: model.navCamera,
22 + pois: model.pois,
23 + selectedPOIID: model.selectedPOI?.id,
24 + onLongPress: { model.dropPin(at: $0) },
25 + onRegionChange: { model.regionSubject.send($0) },
26 + onSelectPOI: { model.selectPOI($0) })
27 + .ignoresSafeArea()
28 +
29 + VStack(spacing: 0) {
30 + if model.navigating {
31 + navBanner
32 + } else {
33 + header
34 + if !model.results.isEmpty && searchFocused { resultsList }
35 + else { layerChips }
36 + }
37 + Spacer()
38 + if model.navigating { navBottomBar } else { bottomPanel }
39 + }
40 +
41 + if !model.navigating { floatingButtons }
42 + }
43 + .onAppear { model.locator.request() }
44 + .sheet(isPresented: $model.showSteps) { StepsSheet() }
45 + .animation(.spring(duration: 0.3), value: model.selected)
46 + .animation(.spring(duration: 0.3), value: model.routes.count)
47 + .animation(.spring(duration: 0.3), value: model.navigating)
48 + }
49 +
50 + // MARK: couches (stations, commerces, annonces Lou-Ka / Immo-Ka)
51 +
52 + private var layerChips: some View {
53 + ScrollView(.horizontal, showsIndicators: false) {
54 + HStack(spacing: 8) {
55 + ForEach(POIKind.allCases) { kind in
56 + let active = model.layers.contains(kind)
57 + Button {
58 + model.toggleLayer(kind)
59 + } label: {
60 + Label(kind.label, systemImage: kind.icon)
61 + .font(.system(.caption, design: .rounded, weight: .bold))
62 + .padding(.horizontal, 12)
63 + .padding(.vertical, 8)
64 + .background { if active { Capsule().fill(Ka.green) } else { Capsule().fill(.regularMaterial) } }
65 + .foregroundStyle(active ? Ka.lime : Ka.ink(scheme))
66 + .shadow(color: .black.opacity(0.12), radius: 5, y: 2)
67 + }
68 + }
69 + }
70 + .padding(.horizontal, 16)
71 + .padding(.vertical, 8)
72 + }
73 + }
74 +
75 + // MARK: mode conduite
76 +
77 + private var navBanner: some View {
78 + HStack(spacing: 14) {
79 + Image(systemName: model.currentStep?.icon ?? "arrow.up")
80 + .font(.system(size: 30, weight: .bold))
81 + .foregroundStyle(Ka.lime)
82 + .frame(width: 44)
83 + VStack(alignment: .leading, spacing: 2) {
84 + if !model.stepDistanceText.isEmpty {
85 + Text("Dans \(model.stepDistanceText)")
86 + .font(.system(.caption, design: .rounded, weight: .semibold))
87 + .foregroundStyle(Ka.lime.opacity(0.85))
88 + }
89 + Text(model.currentStep?.instruction ?? "Suivez la route")
90 + .font(.system(.title3, design: .rounded, weight: .bold))
91 + .foregroundStyle(.white)
92 + .lineLimit(3)
93 + .minimumScaleFactor(0.7)
94 + }
95 + Spacer()
96 + }
97 + .padding(16)
98 + .background(Ka.green, in: RoundedRectangle(cornerRadius: 20))
99 + .shadow(color: .black.opacity(0.25), radius: 10, y: 4)
100 + .padding(.horizontal, 12)
101 + .padding(.top, 6)
102 + .transition(.move(edge: .top).combined(with: .opacity))
103 + }
104 +
105 + private var navBottomBar: some View {
106 + HStack(spacing: 12) {
107 + Button {
108 + model.stopNavigation()
109 + } label: {
110 + Image(systemName: "xmark")
111 + .font(.system(size: 17, weight: .bold))
112 + .foregroundStyle(.white)
113 + .frame(width: 46, height: 46)
114 + .background(Color.red.opacity(0.9), in: Circle())
115 + }
116 + if let route = model.route {
117 + VStack(alignment: .leading, spacing: 1) {
118 + HStack(alignment: .firstTextBaseline, spacing: 6) {
119 + Text(route.durationText)
120 + .font(.system(.title3, design: .rounded, weight: .bold))
121 + .foregroundStyle(Ka.green)
122 + Text("· arrivée \(arrivalText(route))")
123 + .font(.system(.caption, design: .rounded, weight: .semibold))
124 + .foregroundStyle(Ka.ink2(scheme))
125 + }
126 + Text("\(route.distanceText) · \(model.selected?.name ?? "")")
127 + .font(.system(.caption, design: .rounded))
128 + .foregroundStyle(Ka.ink2(scheme))
129 + .lineLimit(1)
130 + }
131 + }
132 + Spacer()
133 + speedometer
134 + Button {
135 + model.showSteps = true
136 + } label: {
137 + Image(systemName: "list.bullet")
138 + .font(.system(size: 17, weight: .bold))
139 + .foregroundStyle(Ka.inkLight)
140 + .frame(width: 46, height: 46)
141 + .background(Ka.lime, in: Circle())
142 + }
143 + }
144 + .padding(.horizontal, 14)
145 + .padding(.vertical, 12)
146 + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22))
147 + .shadow(color: .black.opacity(0.18), radius: 12, y: 4)
148 + .padding(.horizontal, 12)
149 + .padding(.bottom, 8)
150 + .transition(.move(edge: .bottom).combined(with: .opacity))
151 + }
152 +
153 + /// Vitesse GPS en temps réel (compteur du mode conduite).
154 + private var speedometer: some View {
155 + VStack(spacing: -2) {
156 + Text(model.locator.speedKmh.map { String(Int($0.rounded())) } ?? "—")
157 + .font(.system(size: 19, weight: .heavy, design: .rounded))
158 + .foregroundStyle(Ka.lime)
159 + .contentTransition(.numericText())
160 + Text("km/h")
161 + .font(.system(size: 9, weight: .bold, design: .rounded))
162 + .foregroundStyle(Ka.lime.opacity(0.7))
163 + }
164 + .frame(width: 52, height: 52)
165 + .background(Ka.inkLight, in: Circle())
166 + .overlay(Circle().strokeBorder(Ka.lime.opacity(0.5), lineWidth: 2))
167 + .animation(.snappy, value: model.locator.speedKmh.map { Int($0.rounded()) })
168 + }
169 +
170 + private func arrivalText(_ route: Route) -> String {
171 + Date().addingTimeInterval(route.duration)
172 + .formatted(date: .omitted, time: .shortened)
173 + }
174 +
175 + // MARK: barre de recherche
176 +
177 + private var header: some View {
178 + HStack(spacing: 10) {
179 + KALogo(assetID: "groupe-ka", size: 26)
180 + Image(systemName: "magnifyingglass")
181 + .foregroundStyle(Ka.ink2(scheme))
182 + TextField("Chercher un lieu, une adresse…", text: $model.query)
183 + .focused($searchFocused)
184 + .autocorrectionDisabled()
185 + .submitLabel(.search)
186 + if model.searching {
187 + ProgressView().controlSize(.small)
188 + } else if !model.query.isEmpty {
189 + Button {
190 + model.query = ""
191 + } label: {
192 + Image(systemName: "xmark.circle.fill")
193 + .foregroundStyle(Ka.ink2(scheme))
194 + }
195 + }
196 + }
197 + .padding(.horizontal, 14)
198 + .frame(height: 52)
199 + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
200 + .shadow(color: .black.opacity(0.15), radius: 8, y: 3)
201 + .padding(.horizontal, 16)
202 + .padding(.top, 8)
203 + }
204 +
205 + private var resultsList: some View {
206 + VStack(spacing: 0) {
207 + ForEach(model.results) { place in
208 + Button {
209 + searchFocused = false
210 + model.choose(place)
211 + } label: {
212 + HStack(spacing: 12) {
213 + Image(systemName: "mappin.circle.fill")
214 + .font(.title3)
215 + .foregroundStyle(Ka.green)
216 + VStack(alignment: .leading, spacing: 2) {
217 + Text(place.name)
218 + .font(.system(.subheadline, design: .rounded, weight: .semibold))
219 + .foregroundStyle(Ka.ink(scheme))
220 + Text(place.address)
221 + .font(.caption)
222 + .foregroundStyle(Ka.ink2(scheme))
223 + .lineLimit(1)
224 + }
225 + Spacer()
226 + }
227 + .padding(.horizontal, 14)
228 + .padding(.vertical, 10)
229 + }
230 + if place != model.results.last { Divider().padding(.leading, 44) }
231 + }
232 + }
233 + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
234 + .shadow(color: .black.opacity(0.15), radius: 8, y: 3)
235 + .padding(.horizontal, 16)
236 + .padding(.top, 6)
237 + }
238 +
239 + // MARK: boutons flottants
240 +
241 + private var floatingButtons: some View {
242 + VStack {
243 + Spacer()
244 + HStack {
245 + Spacer()
246 + VStack(spacing: 10) {
247 + RoundButton(icon: model.pitch3D ? "view.2d" : "view.3d") {
248 + model.pitch3D.toggle()
249 + }
250 + RoundButton(icon: "location.fill") {
251 + model.centerOnUser()
252 + }
253 + }
254 + .padding(.trailing, 14)
255 + }
256 + .padding(.bottom, model.selected == nil ? 40 : 210)
257 + }
258 + .animation(.spring(duration: 0.3), value: model.selected == nil)
259 + }
260 +
261 + // MARK: panneau bas
262 +
263 + @ViewBuilder
264 + private var bottomPanel: some View {
265 + if let place = model.selected {
266 + VStack(spacing: 12) {
267 + if let msg = model.errorMessage {
268 + Text(msg)
269 + .font(.caption)
270 + .foregroundStyle(.red)
271 + .frame(maxWidth: .infinity, alignment: .leading)
272 + }
273 + HStack(alignment: .top) {
274 + VStack(alignment: .leading, spacing: 3) {
275 + if let poi = model.selectedPOI {
276 + Label(poi.kind.label, systemImage: poi.kind.icon)
277 + .font(.system(.caption2, design: .rounded, weight: .bold))
278 + .foregroundStyle(Ka.green)
279 + }
280 + Text(place.name)
281 + .font(.system(.headline, design: .rounded))
282 + .foregroundStyle(Ka.ink(scheme))
283 + .lineLimit(2)
284 + if let price = model.selectedPOI?.priceLabel {
285 + Text(price)
286 + .font(.system(.subheadline, design: .rounded, weight: .bold))
287 + .foregroundStyle(Ka.green)
288 + }
289 + if let extra = model.selectedPOI?.extra {
290 + Text(extra)
291 + .font(.system(.caption2, design: .rounded))
292 + .foregroundStyle(Ka.ink2(scheme))
293 + .lineLimit(2)
294 + }
295 + Text(place.address)
296 + .font(.caption)
297 + .foregroundStyle(Ka.ink2(scheme))
298 + .lineLimit(2)
299 + }
300 + Spacer()
301 + Button {
302 + model.clearAll()
303 + } label: {
304 + Image(systemName: "xmark.circle.fill")
305 + .font(.title2)
306 + .foregroundStyle(Ka.ink2(scheme).opacity(0.6))
307 + }
308 + }
309 +
310 + if model.hasRoute || model.routing {
311 + routeSection
312 + } else {
313 + HStack(spacing: 10) {
314 + Button {
315 + model.computeRoute()
316 + } label: {
317 + Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
318 + .font(.system(.body, design: .rounded, weight: .bold))
319 + .frame(maxWidth: .infinity)
320 + .padding(.vertical, 12)
321 + .background(Ka.green, in: RoundedRectangle(cornerRadius: 14))
322 + .foregroundStyle(Ka.lime)
323 + }
324 + if let url = model.selectedPOI?.url {
325 + Link(destination: url) {
326 + Label("Voir l'annonce", systemImage: "arrow.up.right.square.fill")
327 + .font(.system(.body, design: .rounded, weight: .bold))
328 + .frame(maxWidth: .infinity)
329 + .padding(.vertical, 12)
330 + .background(Ka.lime, in: RoundedRectangle(cornerRadius: 14))
331 + .foregroundStyle(Ka.inkLight)
332 + }
333 + }
334 + }
335 + }
336 + }
337 + .padding(16)
338 + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22))
339 + .shadow(color: .black.opacity(0.18), radius: 12, y: 4)
340 + .padding(.horizontal, 12)
341 + .padding(.bottom, 8)
342 + .transition(.move(edge: .bottom).combined(with: .opacity))
343 + }
344 + }
345 +
346 + private var routeSection: some View {
347 + VStack(spacing: 12) {
348 + // modes de transport
349 + HStack(spacing: 8) {
350 + ForEach(TransportMode.allCases) { m in
351 + Button {
352 + model.setMode(m)
353 + } label: {
354 + Label(m.label, systemImage: m.icon)
355 + .font(.system(.caption, design: .rounded, weight: .semibold))
356 + .padding(.horizontal, 12)
357 + .padding(.vertical, 8)
358 + .background(model.mode == m ? Ka.green : Ka.paper(scheme),
359 + in: Capsule())
360 + .foregroundStyle(model.mode == m ? Ka.lime : Ka.ink(scheme))
361 + }
362 + }
363 + Spacer()
364 + if model.routing { ProgressView().controlSize(.small) }
365 + }
366 +
367 + if let route = model.route {
368 + HStack(alignment: .center, spacing: 8) {
369 + Text(route.durationText)
370 + .font(.system(.title2, design: .rounded, weight: .bold))
371 + .foregroundStyle(Ka.green)
372 + Text("\(route.distanceText) · \(arrivalText(route))")
373 + .font(.system(.subheadline, design: .rounded))
374 + .foregroundStyle(Ka.ink2(scheme))
375 + Spacer()
376 + Button {
377 + model.showSteps = true
378 + } label: {
379 + Label("Étapes", systemImage: "list.bullet")
380 + .font(.system(.caption, design: .rounded, weight: .bold))
381 + .padding(.horizontal, 12)
382 + .padding(.vertical, 8)
383 + .background(Ka.lime, in: Capsule())
384 + .foregroundStyle(Ka.inkLight)
385 + }
386 + Button {
387 + model.startNavigation()
388 + } label: {
389 + Label("Démarrer", systemImage: "location.north.fill")
390 + .font(.system(.caption, design: .rounded, weight: .bold))
391 + .padding(.horizontal, 12)
392 + .padding(.vertical, 8)
393 + .background(Ka.green, in: Capsule())
394 + .foregroundStyle(Ka.lime)
395 + }
396 + }
397 +
398 + // alternatives
399 + if model.routes.count > 1 {
400 + HStack(spacing: 8) {
401 + ForEach(Array(model.routes.enumerated()), id: \.element.id) { i, r in
402 + Button {
403 + model.routeIndex = i
404 + } label: {
405 + Text("\(r.durationText) · \(r.distanceText)")
406 + .font(.system(.caption2, design: .rounded, weight: .semibold))
407 + .padding(.horizontal, 10)
408 + .padding(.vertical, 6)
409 + .background(model.routeIndex == i ? Ka.green.opacity(0.15) : Ka.paper(scheme),
410 + in: Capsule())
411 + .overlay(Capsule().strokeBorder(model.routeIndex == i ? Ka.green : .clear, lineWidth: 1.5))
412 + .foregroundStyle(Ka.ink(scheme))
413 + }
414 + }
415 + Spacer()
416 + }
417 + }
418 + }
419 + }
420 + }
421 +}
422 +
423 +struct RoundButton: View {
424 + @Environment(\.colorScheme) private var scheme
425 + let icon: String
426 + let action: () -> Void
427 +
428 + var body: some View {
429 + Button(action: action) {
430 + Image(systemName: icon)
431 + .font(.system(size: 17, weight: .semibold))
432 + .foregroundStyle(Ka.green)
433 + .frame(width: 46, height: 46)
434 + .background(.regularMaterial, in: Circle())
435 + .shadow(color: .black.opacity(0.15), radius: 6, y: 2)
436 + }
437 + }
438 +}
439 +
440 +// MARK: - feuille des étapes turn-by-turn
441 +
442 +struct StepsSheet: View {
443 + @EnvironmentObject var model: TrajetModel
444 + @Environment(\.colorScheme) private var scheme
445 +
446 + var body: some View {
447 + NavigationStack {
448 + Group {
449 + if let route = model.route {
450 + List {
451 + Section {
452 + HStack(spacing: 10) {
453 + Image(systemName: model.mode.icon)
454 + .foregroundStyle(Ka.green)
455 + Text(route.durationText)
456 + .font(.system(.title3, design: .rounded, weight: .bold))
457 + Text(route.distanceText)
458 + .foregroundStyle(Ka.ink2(scheme))
459 + }
460 + }
461 + Section("Étapes") {
462 + ForEach(route.steps) { step in
463 + HStack(spacing: 12) {
464 + Image(systemName: step.icon)
465 + .font(.body.weight(.semibold))
466 + .foregroundStyle(Ka.green)
467 + .frame(width: 28)
468 + VStack(alignment: .leading, spacing: 2) {
469 + Text(step.instruction)
470 + .font(.system(.subheadline, design: .rounded))
471 + if step.distance > 0 {
472 + Text(Route.format(meters: step.distance))
473 + .font(.caption)
474 + .foregroundStyle(Ka.ink2(scheme))
475 + }
476 + }
477 + }
478 + .padding(.vertical, 2)
479 + }
480 + }
481 + }
482 + } else {
483 + ContentUnavailableView("Aucun trajet", systemImage: "map")
484 + }
485 + }
486 + .navigationTitle("Trajet — \(model.selected?.name ?? "")")
487 + .navigationBarTitleDisplayMode(.inline)
488 + .toolbar {
489 + ToolbarItem(placement: .topBarTrailing) {
490 + Button("Fermer") { model.showSteps = false }
491 + }
492 + }
493 + }
494 + .presentationDetents([.medium, .large])
495 + }
496 +}
modified README.md +17 −0
@@ -14,6 +14,23 @@ Food·Ka, Resto·Ka, Sorti·Ka, Créa·Ka, Job·Ka, Trouve·Ka et API·Ka — av
14 14 **vraies données en direct** (aucune maquette : les API publiques des
15 15 plateformes alimentent chaque écran).
16 16
17 +## Nouveau en v3.0.0 — rendu « légendaire » + Ka Trajet intégré
18 +
19 +- **Ka Trajet fusionné dans la super-app** (6ᵉ onglet) : le GPS maison au
20 + complet — recherche Mapbox, itinéraires multi-modes avec alternatives,
21 + mode conduite 3D (caméra derrière la voiture, compteur km/h, bannière de
22 + manœuvre), couches POI de l'écosystème (stations essence, commerces,
23 + Lou·Ka, Immo·Ka, Resto·Ka, Sorti·Ka, Job·Ka, Auto·Ka, Fabri·Ka) et la
24 + cartographie signature Ka (eau vert forêt, autoroutes lime). Le dock
25 + s'efface en mode conduite. Sources sous `KA/Features/Trajet/`.
26 +- **Dock flottant signature** (remplace la tab bar système) : capsule encre à
27 + ombre dure lime, pastille active lime en matchedGeometry, KA Agent intégré
28 + au bout du dock, badge d'alertes sur Profil.
29 +- **Accueil héros éditorial** : kicker mono, salutation surlignée lime animée
30 + (le « titre surligné » des sites), **ticker encre en marquee** du pouls
31 + live, compteurs à chiffres roulants, cartes qui poppent au défilement,
32 + fond aurora discret. Reduce Motion respecté partout (`KAMotion.swift`).
33 +
17 34 ## Ce que fait la v1
18 35
19 36 - **Onboarding animé** (logo KA, pitch, choix des univers favoris — Reduce Motion respecté)
modified project.yml +2 −2
@@ -9,8 +9,8 @@ options:
9 9 settings:
10 10 base:
11 11 SWIFT_VERSION: "5.0"
12 − MARKETING_VERSION: "2.0.0"
13 − CURRENT_PROJECT_VERSION: "8"
12 + MARKETING_VERSION: "3.0.0"
13 + CURRENT_PROJECT_VERSION: "9"
14 14 DEVELOPMENT_TEAM: "3YM54G49SN"
15 15 CODE_SIGN_STYLE: Automatic
16 16 GENERATE_INFOPLIST_FILE: true
17 17