// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // TrajetView.swift — Ka Trajet, le GPS maison du Groupe Ka, intégré comme // onglet de la super-app : carte plein écran, recherche, fiche du lieu, // itinéraires (modes, alternatives, étapes), mode conduite 3D, couches POI. import SwiftUI import MapKit struct TrajetView: View { @EnvironmentObject var model: TrajetModel @Environment(\.colorScheme) private var scheme @FocusState private var searchFocused: Bool var body: some View { ZStack { TrajetMapView(destination: model.selected, routes: model.routes, routeIndex: model.routeIndex, programRegion: model.programRegion, programEpoch: model.programEpoch, pitch3D: model.pitch3D, navCamera: model.navCamera, pois: model.pois, selectedPOIID: model.selectedPOI?.id, onLongPress: { model.dropPin(at: $0) }, onRegionChange: { model.regionSubject.send($0) }, onSelectPOI: { poi in if model.discovering { model.showDiscoverCard(poi) } else { model.selectPOI(poi) } }) .ignoresSafeArea() // pulsations radar — la caméra suit le marcheur, donc l'utilisateur // est toujours au centre de l'écran if model.discovering { RadarPulse() } VStack(spacing: 0) { if model.navigating { navBanner } else if model.discovering { DiscoverHUD() } else { header if !model.results.isEmpty && searchFocused { resultsList } else { layerChips } } Spacer() if model.navigating { navBottomBar } else if model.discovering { if let card = model.discoverCard { DiscoverCard(poi: card) } } else { bottomPanel } } if !model.navigating && !model.discovering { floatingButtons } // bouton d'entrée du mode Découvrir (carte libre, rien de sélectionné) if !model.navigating && !model.discovering && model.selected == nil { VStack { Spacer() DiscoverEntryButton() .padding(.bottom, 10) } .transition(.move(edge: .bottom).combined(with: .opacity)) } } .onAppear { model.locator.request() // pilotage debug/captures : lance Découvrir dès l'ouverture if UserDefaults.standard.bool(forKey: "ka.debug.discover"), !model.discovering { model.startDiscover() } } .sheet(isPresented: $model.showSteps) { StepsSheet() } .animation(.spring(duration: 0.3), value: model.selected) .animation(.spring(duration: 0.3), value: model.routes.count) .animation(.spring(duration: 0.3), value: model.navigating) .animation(.spring(duration: 0.35), value: model.discovering) .animation(.spring(response: 0.45, dampingFraction: 0.75), value: model.discoverCard?.id) } // MARK: couches (stations, commerces, annonces Lou-Ka / Immo-Ka) private var layerChips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(POIKind.allCases) { kind in let active = model.layers.contains(kind) Button { model.toggleLayer(kind) } label: { Label(kind.label, systemImage: kind.icon) .font(.system(.caption, design: .rounded, weight: .bold)) .padding(.horizontal, 12) .padding(.vertical, 8) .background { if active { Capsule().fill(Ka.green) } else { Capsule().fill(.regularMaterial) } } .foregroundStyle(active ? Ka.lime : Ka.ink(scheme)) .shadow(color: .black.opacity(0.12), radius: 5, y: 2) } } } .padding(.horizontal, 16) .padding(.vertical, 8) } } // MARK: mode conduite private var navBanner: some View { HStack(spacing: 14) { Image(systemName: model.currentStep?.icon ?? "arrow.up") .font(.system(size: 30, weight: .bold)) .foregroundStyle(Ka.lime) .frame(width: 44) VStack(alignment: .leading, spacing: 2) { if !model.stepDistanceText.isEmpty { Text("Dans \(model.stepDistanceText)") .font(.system(.caption, design: .rounded, weight: .semibold)) .foregroundStyle(Ka.lime.opacity(0.85)) } Text(model.currentStep?.instruction ?? "Suivez la route") .font(.system(.title3, design: .rounded, weight: .bold)) .foregroundStyle(.white) .lineLimit(3) .minimumScaleFactor(0.7) } Spacer() } .padding(16) .background(Ka.green, in: RoundedRectangle(cornerRadius: 20)) .shadow(color: .black.opacity(0.25), radius: 10, y: 4) .padding(.horizontal, 12) .padding(.top, 6) .transition(.move(edge: .top).combined(with: .opacity)) } private var navBottomBar: some View { HStack(spacing: 12) { Button { model.stopNavigation() } label: { Image(systemName: "xmark") .font(.system(size: 17, weight: .bold)) .foregroundStyle(.white) .frame(width: 46, height: 46) .background(Color.red.opacity(0.9), in: Circle()) } if let route = model.route { VStack(alignment: .leading, spacing: 1) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(route.durationText) .font(.system(.title3, design: .rounded, weight: .bold)) .foregroundStyle(Ka.green) Text("· arrivée \(arrivalText(route))") .font(.system(.caption, design: .rounded, weight: .semibold)) .foregroundStyle(Ka.ink2(scheme)) } Text("\(route.distanceText) · \(model.selected?.name ?? "")") .font(.system(.caption, design: .rounded)) .foregroundStyle(Ka.ink2(scheme)) .lineLimit(1) } } Spacer() speedometer Button { model.showSteps = true } label: { Image(systemName: "list.bullet") .font(.system(size: 17, weight: .bold)) .foregroundStyle(Ka.inkLight) .frame(width: 46, height: 46) .background(Ka.lime, in: Circle()) } } .padding(.horizontal, 14) .padding(.vertical, 12) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22)) .shadow(color: .black.opacity(0.18), radius: 12, y: 4) .padding(.horizontal, 12) .padding(.bottom, 8) .transition(.move(edge: .bottom).combined(with: .opacity)) } /// Vitesse GPS en temps réel (compteur du mode conduite). private var speedometer: some View { VStack(spacing: -2) { Text(model.locator.speedKmh.map { String(Int($0.rounded())) } ?? "—") .font(.system(size: 19, weight: .heavy, design: .rounded)) .foregroundStyle(Ka.lime) .contentTransition(.numericText()) Text("km/h") .font(.system(size: 9, weight: .bold, design: .rounded)) .foregroundStyle(Ka.lime.opacity(0.7)) } .frame(width: 52, height: 52) .background(Ka.inkLight, in: Circle()) .overlay(Circle().strokeBorder(Ka.lime.opacity(0.5), lineWidth: 2)) .animation(.snappy, value: model.locator.speedKmh.map { Int($0.rounded()) }) } private func arrivalText(_ route: Route) -> String { Date().addingTimeInterval(route.duration) .formatted(date: .omitted, time: .shortened) } // MARK: barre de recherche private var header: some View { HStack(spacing: 10) { KALogo(assetID: "groupe-ka", size: 26) Image(systemName: "magnifyingglass") .foregroundStyle(Ka.ink2(scheme)) TextField("Chercher un lieu, une adresse…", text: $model.query) .focused($searchFocused) .autocorrectionDisabled() .submitLabel(.search) if model.searching { ProgressView().controlSize(.small) } else if !model.query.isEmpty { Button { model.query = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(Ka.ink2(scheme)) } } } .padding(.horizontal, 14) .frame(height: 52) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) .shadow(color: .black.opacity(0.15), radius: 8, y: 3) .padding(.horizontal, 16) .padding(.top, 8) } private var resultsList: some View { VStack(spacing: 0) { ForEach(model.results) { place in Button { searchFocused = false model.choose(place) } label: { HStack(spacing: 12) { Image(systemName: "mappin.circle.fill") .font(.title3) .foregroundStyle(Ka.green) VStack(alignment: .leading, spacing: 2) { Text(place.name) .font(.system(.subheadline, design: .rounded, weight: .semibold)) .foregroundStyle(Ka.ink(scheme)) Text(place.address) .font(.caption) .foregroundStyle(Ka.ink2(scheme)) .lineLimit(1) } Spacer() } .padding(.horizontal, 14) .padding(.vertical, 10) } if place != model.results.last { Divider().padding(.leading, 44) } } } .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) .shadow(color: .black.opacity(0.15), radius: 8, y: 3) .padding(.horizontal, 16) .padding(.top, 6) } // MARK: boutons flottants private var floatingButtons: some View { VStack { Spacer() HStack { Spacer() VStack(spacing: 10) { RoundButton(icon: model.pitch3D ? "view.2d" : "view.3d") { model.pitch3D.toggle() } RoundButton(icon: "location.fill") { model.centerOnUser() } } .padding(.trailing, 14) } .padding(.bottom, model.selected == nil ? 40 : 210) } .animation(.spring(duration: 0.3), value: model.selected == nil) } // MARK: panneau bas @ViewBuilder private var bottomPanel: some View { if let place = model.selected { VStack(spacing: 12) { if let msg = model.errorMessage { Text(msg) .font(.caption) .foregroundStyle(.red) .frame(maxWidth: .infinity, alignment: .leading) } HStack(alignment: .top) { VStack(alignment: .leading, spacing: 3) { if let poi = model.selectedPOI { Label(poi.kind.label, systemImage: poi.kind.icon) .font(.system(.caption2, design: .rounded, weight: .bold)) .foregroundStyle(Ka.green) } Text(place.name) .font(.system(.headline, design: .rounded)) .foregroundStyle(Ka.ink(scheme)) .lineLimit(2) if let price = model.selectedPOI?.priceLabel { Text(price) .font(.system(.subheadline, design: .rounded, weight: .bold)) .foregroundStyle(Ka.green) } if let extra = model.selectedPOI?.extra { Text(extra) .font(.system(.caption2, design: .rounded)) .foregroundStyle(Ka.ink2(scheme)) .lineLimit(2) } Text(place.address) .font(.caption) .foregroundStyle(Ka.ink2(scheme)) .lineLimit(2) } Spacer() Button { model.clearAll() } label: { Image(systemName: "xmark.circle.fill") .font(.title2) .foregroundStyle(Ka.ink2(scheme).opacity(0.6)) } } if model.hasRoute || model.routing { routeSection } else { HStack(spacing: 10) { Button { model.computeRoute() } label: { Label("Itinéraire", systemImage: "arrow.triangle.turn.up.right.diamond.fill") .font(.system(.body, design: .rounded, weight: .bold)) .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(Ka.green, in: RoundedRectangle(cornerRadius: 14)) .foregroundStyle(Ka.lime) } if let url = model.selectedPOI?.url { Link(destination: url) { Label("Voir l'annonce", systemImage: "arrow.up.right.square.fill") .font(.system(.body, design: .rounded, weight: .bold)) .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(Ka.lime, in: RoundedRectangle(cornerRadius: 14)) .foregroundStyle(Ka.inkLight) } } } } } .padding(16) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22)) .shadow(color: .black.opacity(0.18), radius: 12, y: 4) .padding(.horizontal, 12) .padding(.bottom, 8) .transition(.move(edge: .bottom).combined(with: .opacity)) } } private var routeSection: some View { VStack(spacing: 12) { // modes de transport HStack(spacing: 8) { ForEach(TransportMode.allCases) { m in Button { model.setMode(m) } label: { Label(m.label, systemImage: m.icon) .font(.system(.caption, design: .rounded, weight: .semibold)) .padding(.horizontal, 12) .padding(.vertical, 8) .background(model.mode == m ? Ka.green : Ka.paper(scheme), in: Capsule()) .foregroundStyle(model.mode == m ? Ka.lime : Ka.ink(scheme)) } } Spacer() if model.routing { ProgressView().controlSize(.small) } } if let route = model.route { HStack(alignment: .center, spacing: 8) { Text(route.durationText) .font(.system(.title2, design: .rounded, weight: .bold)) .foregroundStyle(Ka.green) Text("\(route.distanceText) · \(arrivalText(route))") .font(.system(.subheadline, design: .rounded)) .foregroundStyle(Ka.ink2(scheme)) Spacer() Button { model.showSteps = true } label: { Label("Étapes", systemImage: "list.bullet") .font(.system(.caption, design: .rounded, weight: .bold)) .padding(.horizontal, 12) .padding(.vertical, 8) .background(Ka.lime, in: Capsule()) .foregroundStyle(Ka.inkLight) } Button { model.startNavigation() } label: { Label("Démarrer", systemImage: "location.north.fill") .font(.system(.caption, design: .rounded, weight: .bold)) .padding(.horizontal, 12) .padding(.vertical, 8) .background(Ka.green, in: Capsule()) .foregroundStyle(Ka.lime) } } // alternatives if model.routes.count > 1 { HStack(spacing: 8) { ForEach(Array(model.routes.enumerated()), id: \.element.id) { i, r in Button { model.routeIndex = i } label: { Text("\(r.durationText) · \(r.distanceText)") .font(.system(.caption2, design: .rounded, weight: .semibold)) .padding(.horizontal, 10) .padding(.vertical, 6) .background(model.routeIndex == i ? Ka.green.opacity(0.15) : Ka.paper(scheme), in: Capsule()) .overlay(Capsule().strokeBorder(model.routeIndex == i ? Ka.green : .clear, lineWidth: 1.5)) .foregroundStyle(Ka.ink(scheme)) } } Spacer() } } } } } } struct RoundButton: View { @Environment(\.colorScheme) private var scheme let icon: String let action: () -> Void var body: some View { Button(action: action) { Image(systemName: icon) .font(.system(size: 17, weight: .semibold)) .foregroundStyle(Ka.green) .frame(width: 46, height: 46) .background(.regularMaterial, in: Circle()) .shadow(color: .black.opacity(0.15), radius: 6, y: 2) } } } // MARK: - feuille des étapes turn-by-turn struct StepsSheet: View { @EnvironmentObject var model: TrajetModel @Environment(\.colorScheme) private var scheme var body: some View { NavigationStack { Group { if let route = model.route { List { Section { HStack(spacing: 10) { Image(systemName: model.mode.icon) .foregroundStyle(Ka.green) Text(route.durationText) .font(.system(.title3, design: .rounded, weight: .bold)) Text(route.distanceText) .foregroundStyle(Ka.ink2(scheme)) } } Section("Étapes") { ForEach(route.steps) { step in HStack(spacing: 12) { Image(systemName: step.icon) .font(.body.weight(.semibold)) .foregroundStyle(Ka.green) .frame(width: 28) VStack(alignment: .leading, spacing: 2) { Text(step.instruction) .font(.system(.subheadline, design: .rounded)) if step.distance > 0 { Text(Route.format(meters: step.distance)) .font(.caption) .foregroundStyle(Ka.ink2(scheme)) } } } .padding(.vertical, 2) } } } } else { ContentUnavailableView("Aucun trajet", systemImage: "map") } } .navigationTitle("Trajet — \(model.selected?.name ?? "")") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Fermer") { model.showSteps = false } } } } .presentationDetents([.medium, .large]) } }