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>
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 | |