SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%
22.4 KB · 532 lines swift
Raw Blame History
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