Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// TrajetView.swift — Ka Trajet, le GPS maison du Groupe Ka, intégré comme3// onglet de la super-app : carte plein écran, recherche, fiche du lieu,4// itinéraires (modes, alternatives, étapes), mode conduite 3D, couches POI.5import SwiftUI6import MapKit78struct TrajetView: View {9 @EnvironmentObject var model: TrajetModel10 @Environment(\.colorScheme) private var scheme11 @FocusState private var searchFocused: Bool1213 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: { poi in27 if model.discovering { model.showDiscoverCard(poi) }28 else { model.selectPOI(poi) }29 })30 .ignoresSafeArea()3132 // pulsations radar — la caméra suit le marcheur, donc l'utilisateur33 // est toujours au centre de l'écran34 if model.discovering { RadarPulse() }3536 VStack(spacing: 0) {37 if model.navigating {38 navBanner39 } else if model.discovering {40 DiscoverHUD()41 } else {42 header43 if !model.results.isEmpty && searchFocused { resultsList }44 else { layerChips }45 }46 Spacer()47 if model.navigating {48 navBottomBar49 } else if model.discovering {50 if let card = model.discoverCard {51 DiscoverCard(poi: card)52 }53 } else {54 bottomPanel55 }56 }5758 if !model.navigating && !model.discovering { floatingButtons }5960 // bouton d'entrée du mode Découvrir (carte libre, rien de sélectionné)61 if !model.navigating && !model.discovering && model.selected == nil {62 VStack {63 Spacer()64 DiscoverEntryButton()65 .padding(.bottom, 10)66 }67 .transition(.move(edge: .bottom).combined(with: .opacity))68 }69 }70 .onAppear {71 model.locator.request()72 // pilotage debug/captures : lance Découvrir dès l'ouverture73 if UserDefaults.standard.bool(forKey: "ka.debug.discover"), !model.discovering {74 model.startDiscover()75 }76 }77 .sheet(isPresented: $model.showSteps) { StepsSheet() }78 .animation(.spring(duration: 0.3), value: model.selected)79 .animation(.spring(duration: 0.3), value: model.routes.count)80 .animation(.spring(duration: 0.3), value: model.navigating)81 .animation(.spring(duration: 0.35), value: model.discovering)82 .animation(.spring(response: 0.45, dampingFraction: 0.75), value: model.discoverCard?.id)83 }8485 // MARK: couches (stations, commerces, annonces Lou-Ka / Immo-Ka)8687 private var layerChips: some View {88 ScrollView(.horizontal, showsIndicators: false) {89 HStack(spacing: 8) {90 ForEach(POIKind.allCases) { kind in91 let active = model.layers.contains(kind)92 Button {93 model.toggleLayer(kind)94 } label: {95 Label(kind.label, systemImage: kind.icon)96 .font(.system(.caption, design: .rounded, weight: .bold))97 .padding(.horizontal, 12)98 .padding(.vertical, 8)99 .background { if active { Capsule().fill(Ka.green) } else { Capsule().fill(.regularMaterial) } }100 .foregroundStyle(active ? Ka.lime : Ka.ink(scheme))101 .shadow(color: .black.opacity(0.12), radius: 5, y: 2)102 }103 }104 }105 .padding(.horizontal, 16)106 .padding(.vertical, 8)107 }108 }109110 // MARK: mode conduite111112 private var navBanner: some View {113 HStack(spacing: 14) {114 Image(systemName: model.currentStep?.icon ?? "arrow.up")115 .font(.system(size: 30, weight: .bold))116 .foregroundStyle(Ka.lime)117 .frame(width: 44)118 VStack(alignment: .leading, spacing: 2) {119 if !model.stepDistanceText.isEmpty {120 Text("Dans \(model.stepDistanceText)")121 .font(.system(.caption, design: .rounded, weight: .semibold))122 .foregroundStyle(Ka.lime.opacity(0.85))123 }124 Text(model.currentStep?.instruction ?? "Suivez la route")125 .font(.system(.title3, design: .rounded, weight: .bold))126 .foregroundStyle(.white)127 .lineLimit(3)128 .minimumScaleFactor(0.7)129 }130 Spacer()131 }132 .padding(16)133 .background(Ka.green, in: RoundedRectangle(cornerRadius: 20))134 .shadow(color: .black.opacity(0.25), radius: 10, y: 4)135 .padding(.horizontal, 12)136 .padding(.top, 6)137 .transition(.move(edge: .top).combined(with: .opacity))138 }139140 private var navBottomBar: some View {141 HStack(spacing: 12) {142 Button {143 model.stopNavigation()144 } label: {145 Image(systemName: "xmark")146 .font(.system(size: 17, weight: .bold))147 .foregroundStyle(.white)148 .frame(width: 46, height: 46)149 .background(Color.red.opacity(0.9), in: Circle())150 }151 if let route = model.route {152 VStack(alignment: .leading, spacing: 1) {153 HStack(alignment: .firstTextBaseline, spacing: 6) {154 Text(route.durationText)155 .font(.system(.title3, design: .rounded, weight: .bold))156 .foregroundStyle(Ka.green)157 Text("· arrivée \(arrivalText(route))")158 .font(.system(.caption, design: .rounded, weight: .semibold))159 .foregroundStyle(Ka.ink2(scheme))160 }161 Text("\(route.distanceText) · \(model.selected?.name ?? "")")162 .font(.system(.caption, design: .rounded))163 .foregroundStyle(Ka.ink2(scheme))164 .lineLimit(1)165 }166 }167 Spacer()168 speedometer169 Button {170 model.showSteps = true171 } label: {172 Image(systemName: "list.bullet")173 .font(.system(size: 17, weight: .bold))174 .foregroundStyle(Ka.inkLight)175 .frame(width: 46, height: 46)176 .background(Ka.lime, in: Circle())177 }178 }179 .padding(.horizontal, 14)180 .padding(.vertical, 12)181 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22))182 .shadow(color: .black.opacity(0.18), radius: 12, y: 4)183 .padding(.horizontal, 12)184 .padding(.bottom, 8)185 .transition(.move(edge: .bottom).combined(with: .opacity))186 }187188 /// Vitesse GPS en temps réel (compteur du mode conduite).189 private var speedometer: some View {190 VStack(spacing: -2) {191 Text(model.locator.speedKmh.map { String(Int($0.rounded())) } ?? "—")192 .font(.system(size: 19, weight: .heavy, design: .rounded))193 .foregroundStyle(Ka.lime)194 .contentTransition(.numericText())195 Text("km/h")196 .font(.system(size: 9, weight: .bold, design: .rounded))197 .foregroundStyle(Ka.lime.opacity(0.7))198 }199 .frame(width: 52, height: 52)200 .background(Ka.inkLight, in: Circle())201 .overlay(Circle().strokeBorder(Ka.lime.opacity(0.5), lineWidth: 2))202 .animation(.snappy, value: model.locator.speedKmh.map { Int($0.rounded()) })203 }204205 private func arrivalText(_ route: Route) -> String {206 Date().addingTimeInterval(route.duration)207 .formatted(date: .omitted, time: .shortened)208 }209210 // MARK: barre de recherche211212 private var header: some View {213 HStack(spacing: 10) {214 KALogo(assetID: "groupe-ka", size: 26)215 Image(systemName: "magnifyingglass")216 .foregroundStyle(Ka.ink2(scheme))217 TextField("Chercher un lieu, une adresse…", text: $model.query)218 .focused($searchFocused)219 .autocorrectionDisabled()220 .submitLabel(.search)221 if model.searching {222 ProgressView().controlSize(.small)223 } else if !model.query.isEmpty {224 Button {225 model.query = ""226 } label: {227 Image(systemName: "xmark.circle.fill")228 .foregroundStyle(Ka.ink2(scheme))229 }230 }231 }232 .padding(.horizontal, 14)233 .frame(height: 52)234 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))235 .shadow(color: .black.opacity(0.15), radius: 8, y: 3)236 .padding(.horizontal, 16)237 .padding(.top, 8)238 }239240 private var resultsList: some View {241 VStack(spacing: 0) {242 ForEach(model.results) { place in243 Button {244 searchFocused = false245 model.choose(place)246 } label: {247 HStack(spacing: 12) {248 Image(systemName: "mappin.circle.fill")249 .font(.title3)250 .foregroundStyle(Ka.green)251 VStack(alignment: .leading, spacing: 2) {252 Text(place.name)253 .font(.system(.subheadline, design: .rounded, weight: .semibold))254 .foregroundStyle(Ka.ink(scheme))255 Text(place.address)256 .font(.caption)257 .foregroundStyle(Ka.ink2(scheme))258 .lineLimit(1)259 }260 Spacer()261 }262 .padding(.horizontal, 14)263 .padding(.vertical, 10)264 }265 if place != model.results.last { Divider().padding(.leading, 44) }266 }267 }268 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))269 .shadow(color: .black.opacity(0.15), radius: 8, y: 3)270 .padding(.horizontal, 16)271 .padding(.top, 6)272 }273274 // MARK: boutons flottants275276 private var floatingButtons: some View {277 VStack {278 Spacer()279 HStack {280 Spacer()281 VStack(spacing: 10) {282 RoundButton(icon: model.pitch3D ? "view.2d" : "view.3d") {283 model.pitch3D.toggle()284 }285 RoundButton(icon: "location.fill") {286 model.centerOnUser()287 }288 }289 .padding(.trailing, 14)290 }291 .padding(.bottom, model.selected == nil ? 40 : 210)292 }293 .animation(.spring(duration: 0.3), value: model.selected == nil)294 }295296 // MARK: panneau bas297298 @ViewBuilder299 private var bottomPanel: some View {300 if let place = model.selected {301 VStack(spacing: 12) {302 if let msg = model.errorMessage {303 Text(msg)304 .font(.caption)305 .foregroundStyle(.red)306 .frame(maxWidth: .infinity, alignment: .leading)307 }308 HStack(alignment: .top) {309 VStack(alignment: .leading, spacing: 3) {310 if let poi = model.selectedPOI {311 Label(poi.kind.label, systemImage: poi.kind.icon)312 .font(.system(.caption2, design: .rounded, weight: .bold))313 .foregroundStyle(Ka.green)314 }315 Text(place.name)316 .font(.system(.headline, design: .rounded))317 .foregroundStyle(Ka.ink(scheme))318 .lineLimit(2)319 if let price = model.selectedPOI?.priceLabel {320 Text(price)321 .font(.system(.subheadline, design: .rounded, weight: .bold))322 .foregroundStyle(Ka.green)323 }324 if let extra = model.selectedPOI?.extra {325 Text(extra)326 .font(.system(.caption2, design: .rounded))327 .foregroundStyle(Ka.ink2(scheme))328 .lineLimit(2)329 }330 Text(place.address)331 .font(.caption)332 .foregroundStyle(Ka.ink2(scheme))333 .lineLimit(2)334 }335 Spacer()336 Button {337 model.clearAll()338 } label: {339 Image(systemName: "xmark.circle.fill")340 .font(.title2)341 .foregroundStyle(Ka.ink2(scheme).opacity(0.6))342 }343 }344345 if model.hasRoute || model.routing {346 routeSection347 } else {348 HStack(spacing: 10) {349 Button {350 model.computeRoute()351 } label: {352 Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill")353 .font(.system(.body, design: .rounded, weight: .bold))354 .frame(maxWidth: .infinity)355 .padding(.vertical, 12)356 .background(Ka.green, in: RoundedRectangle(cornerRadius: 14))357 .foregroundStyle(Ka.lime)358 }359 if let url = model.selectedPOI?.url {360 Link(destination: url) {361 Label("Voir l'annonce", systemImage: "arrow.up.right.square.fill")362 .font(.system(.body, design: .rounded, weight: .bold))363 .frame(maxWidth: .infinity)364 .padding(.vertical, 12)365 .background(Ka.lime, in: RoundedRectangle(cornerRadius: 14))366 .foregroundStyle(Ka.inkLight)367 }368 }369 }370 }371 }372 .padding(16)373 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22))374 .shadow(color: .black.opacity(0.18), radius: 12, y: 4)375 .padding(.horizontal, 12)376 .padding(.bottom, 8)377 .transition(.move(edge: .bottom).combined(with: .opacity))378 }379 }380381 private var routeSection: some View {382 VStack(spacing: 12) {383 // modes de transport384 HStack(spacing: 8) {385 ForEach(TransportMode.allCases) { m in386 Button {387 model.setMode(m)388 } label: {389 Label(m.label, systemImage: m.icon)390 .font(.system(.caption, design: .rounded, weight: .semibold))391 .padding(.horizontal, 12)392 .padding(.vertical, 8)393 .background(model.mode == m ? Ka.green : Ka.paper(scheme),394 in: Capsule())395 .foregroundStyle(model.mode == m ? Ka.lime : Ka.ink(scheme))396 }397 }398 Spacer()399 if model.routing { ProgressView().controlSize(.small) }400 }401402 if let route = model.route {403 HStack(alignment: .center, spacing: 8) {404 Text(route.durationText)405 .font(.system(.title2, design: .rounded, weight: .bold))406 .foregroundStyle(Ka.green)407 Text("\(route.distanceText) · \(arrivalText(route))")408 .font(.system(.subheadline, design: .rounded))409 .foregroundStyle(Ka.ink2(scheme))410 Spacer()411 Button {412 model.showSteps = true413 } label: {414 Label("Étapes", systemImage: "list.bullet")415 .font(.system(.caption, design: .rounded, weight: .bold))416 .padding(.horizontal, 12)417 .padding(.vertical, 8)418 .background(Ka.lime, in: Capsule())419 .foregroundStyle(Ka.inkLight)420 }421 Button {422 model.startNavigation()423 } label: {424 Label("Démarrer", systemImage: "location.north.fill")425 .font(.system(.caption, design: .rounded, weight: .bold))426 .padding(.horizontal, 12)427 .padding(.vertical, 8)428 .background(Ka.green, in: Capsule())429 .foregroundStyle(Ka.lime)430 }431 }432433 // alternatives434 if model.routes.count > 1 {435 HStack(spacing: 8) {436 ForEach(Array(model.routes.enumerated()), id: \.element.id) { i, r in437 Button {438 model.routeIndex = i439 } label: {440 Text("\(r.durationText) · \(r.distanceText)")441 .font(.system(.caption2, design: .rounded, weight: .semibold))442 .padding(.horizontal, 10)443 .padding(.vertical, 6)444 .background(model.routeIndex == i ? Ka.green.opacity(0.15) : Ka.paper(scheme),445 in: Capsule())446 .overlay(Capsule().strokeBorder(model.routeIndex == i ? Ka.green : .clear, lineWidth: 1.5))447 .foregroundStyle(Ka.ink(scheme))448 }449 }450 Spacer()451 }452 }453 }454 }455 }456}457458struct RoundButton: View {459 @Environment(\.colorScheme) private var scheme460 let icon: String461 let action: () -> Void462463 var body: some View {464 Button(action: action) {465 Image(systemName: icon)466 .font(.system(size: 17, weight: .semibold))467 .foregroundStyle(Ka.green)468 .frame(width: 46, height: 46)469 .background(.regularMaterial, in: Circle())470 .shadow(color: .black.opacity(0.15), radius: 6, y: 2)471 }472 }473}474475// MARK: - feuille des étapes turn-by-turn476477struct StepsSheet: View {478 @EnvironmentObject var model: TrajetModel479 @Environment(\.colorScheme) private var scheme480481 var body: some View {482 NavigationStack {483 Group {484 if let route = model.route {485 List {486 Section {487 HStack(spacing: 10) {488 Image(systemName: model.mode.icon)489 .foregroundStyle(Ka.green)490 Text(route.durationText)491 .font(.system(.title3, design: .rounded, weight: .bold))492 Text(route.distanceText)493 .foregroundStyle(Ka.ink2(scheme))494 }495 }496 Section("Étapes") {497 ForEach(route.steps) { step in498 HStack(spacing: 12) {499 Image(systemName: step.icon)500 .font(.body.weight(.semibold))501 .foregroundStyle(Ka.green)502 .frame(width: 28)503 VStack(alignment: .leading, spacing: 2) {504 Text(step.instruction)505 .font(.system(.subheadline, design: .rounded))506 if step.distance > 0 {507 Text(Route.format(meters: step.distance))508 .font(.caption)509 .foregroundStyle(Ka.ink2(scheme))510 }511 }512 }513 .padding(.vertical, 2)514 }515 }516 }517 } else {518 ContentUnavailableView("Aucun trajet", systemImage: "map")519 }520 }521 .navigationTitle("Trajet — \(model.selected?.name ?? "")")522 .navigationBarTitleDisplayMode(.inline)523 .toolbar {524 ToolbarItem(placement: .topBarTrailing) {525 Button("Fermer") { model.showSteps = false }526 }527 }528 }529 .presentationDetents([.medium, .large])530 }531}532