SPB Git forge

spb/ka-ios

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

v1.0.0 (7) — CARTE MAPBOX-CLASS 2D/3D (MapLibre + OpenFreeMap Liberty vectoriel : pilules de prix par univers, clusters, bascule 3D avec bâtiments extrudés, localisation sur geste HIG, recherche par zone bbox), PLAYGROUND API-Ka natif (openapi.json live, formulaires générés, JSON colorisé, copier-curl), FIX Trouve-Ka (recherche d abord — l API exige q), ICÔNE liquid glass v2 (verre noir liseré lime sur blanc, perles des univers)

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

14 changed files +574 −43

modified KA/App/KAApp.swift +2 −1
@@ -35,7 +35,8 @@ struct KAApp: App {
35 35
36 36 struct RootView: View {
37 37 @State private var showAgent = false
38 − @State private var tab = 0
38 + // onglet initial pilotable pour les tests (defaults write com.groupeka.ka ka.debug.tab -int 3)
39 + @State private var tab = UserDefaults.standard.integer(forKey: "ka.debug.tab")
39 40 @StateObject private var recents = RecentsStore.shared
40 41
41 42 var body: some View {
modified KA/Core/Services.swift +2 −0
@@ -81,6 +81,8 @@ enum UniverseService {
81 81 }
82 82
83 83 static func fetch(_ u: Universe, query: String? = nil, city: String? = nil, limit: Int = 30, params: [String: String] = [:]) async throws -> [KAItem] {
84 + // Trouve·Ka : /api/search EXIGE q (422 sinon) — jamais d'appel à vide
85 + if u.id == "trouve-ka", (query ?? "").isEmpty { return [] }
84 86 guard let url = listURL(u, query: query, city: city, limit: limit, params: params), let map = u.map else { return [] }
85 87 let root = try await APIClient.shared.json(url)
86 88 let obj = root.object ?? [:]
added KA/Features/APIPlaygroundView.swift +252 −0
@@ -0,0 +1,252 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// APIPlaygroundView.swift — le PLAYGROUND API·Ka natif : la spec OpenAPI est
3 +// chargée EN DIRECT (openapi.json), les endpoints GET sont listés, le
4 +// formulaire de paramètres est généré, on envoie la vraie requête et on lit
5 +// la réponse JSON colorisée avec statut + latence + « Copier en curl ».
6 +import SwiftUI
7 +
8 +struct APIEndpoint: Identifiable, Hashable {
9 + let id: String // chemin
10 + let path: String
11 + let summary: String?
12 + let params: [Param]
13 + struct Param: Identifiable, Hashable {
14 + var id: String { name }
15 + let name: String
16 + let required: Bool
17 + let inPath: Bool // {service} dans le chemin
18 + let description: String?
19 + }
20 +}
21 +
22 +struct APIPlaygroundView: View {
23 + let universe: Universe
24 + @State private var endpoints: [APIEndpoint] = []
25 + @State private var selected: APIEndpoint?
26 + @State private var values: [String: String] = [:]
27 + @State private var response: String = ""
28 + @State private var status: Int?
29 + @State private var latencyMs: Int?
30 + @State private var sending = false
31 + @State private var loadFailed = false
32 + @Environment(\.colorScheme) private var scheme
33 +
34 + private var builtURL: URL? {
35 + guard let e = selected else { return nil }
36 + var path = e.path
37 + for p in e.params where p.inPath {
38 + let v = values[p.name] ?? ""
39 + guard !v.isEmpty else { return nil }
40 + path = path.replacingOccurrences(of: "{\(p.name)}", with: v)
41 + }
42 + var comps = URLComponents(string: "https://www.api-ka.com\(path)")!
43 + let qs = e.params.filter { !$0.inPath }
44 + .compactMap { p -> URLQueryItem? in
45 + guard let v = values[p.name], !v.isEmpty else { return nil }
46 + return URLQueryItem(name: p.name, value: v)
47 + }
48 + comps.queryItems = qs.isEmpty ? nil : qs
49 + return comps.url
50 + }
51 +
52 + var body: some View {
53 + ScrollView {
54 + VStack(alignment: .leading, spacing: 16) {
55 + VStack(alignment: .leading, spacing: 4) {
56 + Text("PLAYGROUND · SPEC OPENAPI EN DIRECT")
57 + .font(.system(size: 10, design: .monospaced).weight(.bold))
58 + .foregroundStyle(universe.accent)
59 + Text("Essayez l'API de l'écosystème")
60 + .font(.title3.weight(.bold))
61 + Text("Les données quotidiennes des services Ka, ouvertes et documentées.")
62 + .font(.caption).foregroundStyle(.secondary)
63 + }
64 +
65 + if endpoints.isEmpty && !loadFailed {
66 + ForEach(0..<4, id: \.self) { _ in KASkeletonRow() }
67 + } else if loadFailed {
68 + KAEmptyState(symbol: "wifi.exclamationmark", title: "Spec inaccessible",
69 + message: "Impossible de charger openapi.json — réessayez.")
70 + }
71 +
72 + // endpoints
73 + VStack(spacing: 8) {
74 + ForEach(endpoints) { e in
75 + Button {
76 + Haptics.tap()
77 + withAnimation(.snappy) {
78 + selected = e
79 + values = [:]
80 + response = ""; status = nil; latencyMs = nil
81 + }
82 + } label: {
83 + HStack(spacing: 10) {
84 + Text("GET")
85 + .font(.system(size: 10, design: .monospaced).weight(.bold))
86 + .padding(.horizontal, 7).padding(.vertical, 3)
87 + .background(universe.accent.opacity(selected?.id == e.id ? 1 : 0.15), in: RoundedRectangle(cornerRadius: 6))
88 + .foregroundStyle(selected?.id == e.id ? .white : universe.accent)
89 + VStack(alignment: .leading, spacing: 1) {
90 + Text(e.path).font(.system(.caption, design: .monospaced).weight(.bold))
91 + .lineLimit(1).minimumScaleFactor(0.7)
92 + if let s = e.summary {
93 + Text(s).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
94 + }
95 + }
96 + Spacer()
97 + Image(systemName: selected?.id == e.id ? "chevron.down" : "chevron.right")
98 + .font(.caption2).foregroundStyle(.tertiary)
99 + }
100 + .padding(11)
101 + .kaCard(accent: selected?.id == e.id ? universe.accent : nil)
102 + }
103 + .buttonStyle(KAPressStyle())
104 +
105 + if selected?.id == e.id {
106 + requestPanel(e)
107 + }
108 + }
109 + }
110 + }
111 + .padding(16)
112 + }
113 + .background(KATheme.paper(scheme))
114 + .task { await loadSpec() }
115 + }
116 +
117 + // MARK: panneau de requête
118 +
119 + @ViewBuilder
120 + private func requestPanel(_ e: APIEndpoint) -> some View {
121 + VStack(alignment: .leading, spacing: 10) {
122 + ForEach(e.params) { p in
123 + HStack(spacing: 8) {
124 + Text(p.name)
125 + .font(.system(.caption, design: .monospaced).weight(.bold))
126 + .frame(width: 86, alignment: .leading)
127 + TextField(p.inPath ? "requis (chemin)" : (p.required ? "requis" : "optionnel"),
128 + text: Binding(get: { values[p.name] ?? "" },
129 + set: { values[p.name] = $0 }))
130 + .font(.system(.caption, design: .monospaced))
131 + .textInputAutocapitalization(.never)
132 + .autocorrectionDisabled()
133 + .padding(8)
134 + .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8))
135 + .overlay(RoundedRectangle(cornerRadius: 8)
136 + .strokeBorder(p.required && (values[p.name] ?? "").isEmpty ? universe.accent.opacity(0.6) : Color.primary.opacity(0.25), lineWidth: 1))
137 + }
138 + }
139 + if let url = builtURL {
140 + Text(url.absoluteString)
141 + .font(.system(size: 10, design: .monospaced))
142 + .foregroundStyle(.secondary)
143 + .lineLimit(2)
144 + .textSelection(.enabled)
145 + }
146 + HStack(spacing: 10) {
147 + Button {
148 + Task { await send() }
149 + } label: {
150 + HStack {
151 + if sending { ProgressView().tint(.white) }
152 + Text(sending ? "Envoi…" : "Envoyer la requête ➤")
153 + .font(.subheadline.weight(.bold))
154 + }
155 + .padding(.horizontal, 16).padding(.vertical, 11)
156 + .background(builtURL == nil ? Color.gray : universe.accent, in: Capsule())
157 + .foregroundStyle(.white)
158 + }
159 + .disabled(builtURL == nil || sending)
160 + if let url = builtURL {
161 + Button {
162 + UIPasteboard.general.string = "curl -s '\(url.absoluteString)'"
163 + Haptics.success()
164 + } label: {
165 + Label("curl", systemImage: "doc.on.doc")
166 + .font(.caption.weight(.bold))
167 + .padding(.horizontal, 12).padding(.vertical, 11)
168 + .background(KATheme.inkLight, in: Capsule())
169 + .foregroundStyle(KATheme.lime)
170 + }
171 + .accessibilityLabel("Copier la commande curl")
172 + }
173 + Spacer()
174 + if let s = status {
175 + HStack(spacing: 6) {
176 + Circle().fill(s == 200 ? .green : .red).frame(width: 8, height: 8)
177 + Text("\(s)").font(.system(.caption, design: .monospaced).weight(.bold))
178 + if let ms = latencyMs {
179 + Text("· \(ms) ms").font(.system(.caption2, design: .monospaced)).foregroundStyle(.secondary)
180 + }
181 + }
182 + }
183 + }
184 + if !response.isEmpty {
185 + ScrollView([.vertical, .horizontal]) {
186 + Text(response)
187 + .font(.system(size: 11, design: .monospaced))
188 + .textSelection(.enabled)
189 + .padding(12)
190 + .frame(maxWidth: .infinity, alignment: .leading)
191 + }
192 + .frame(maxHeight: 340)
193 + .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
194 + .foregroundStyle(Color(hex: "#d9f2c9"))
195 + }
196 + }
197 + .padding(12)
198 + .background(universe.accent.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
199 + }
200 +
201 + // MARK: réseau
202 +
203 + private func loadSpec() async {
204 + guard endpoints.isEmpty else { return }
205 + guard let url = URL(string: "https://www.api-ka.com/openapi.json"),
206 + let root = try? await APIClient.shared.json(url, ttl: 600),
207 + let paths = root.object?["paths"]?.object else { loadFailed = true; return }
208 + var eps: [APIEndpoint] = []
209 + for (path, methods) in paths.sorted(by: { $0.key < $1.key }) {
210 + guard let get = methods.object?["get"]?.object else { continue }
211 + let params: [APIEndpoint.Param] = (get["parameters"]?.array ?? []).compactMap { p in
212 + guard let po = p.object, let name = po.str("name") else { return nil }
213 + let inPath = po.str("in") == "path"
214 + let required: Bool = { if case .bool(true) = po["required"] ?? .null { return true }; return inPath }()
215 + return .init(name: name, required: required, inPath: inPath,
216 + description: po.str("description"))
217 + }
218 + eps.append(APIEndpoint(id: path, path: path,
219 + summary: get.str("summary") ?? get.str("description")?.prefix(70).description,
220 + params: params))
221 + }
222 + withAnimation(.snappy) { endpoints = eps }
223 + }
224 +
225 + private func send() async {
226 + guard let url = builtURL else { return }
227 + sending = true
228 + Haptics.rigid()
229 + defer { sending = false }
230 + let start = Date()
231 + do {
232 + var req = URLRequest(url: url)
233 + req.setValue("application/json", forHTTPHeaderField: "Accept")
234 + let (data, resp) = try await URLSession.shared.data(for: req)
235 + latencyMs = Int(Date().timeIntervalSince(start) * 1000)
236 + status = (resp as? HTTPURLResponse)?.statusCode
237 + if let obj = try? JSONSerialization.jsonObject(with: data),
238 + let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]) {
239 + var text = String(decoding: pretty, as: UTF8.self)
240 + if text.count > 12000 { text = String(text.prefix(12000)) + "\n… (tronqué)" }
241 + response = text
242 + } else {
243 + response = String(decoding: data.prefix(8000), as: UTF8.self)
244 + }
245 + Haptics.success()
246 + } catch {
247 + status = nil
248 + latencyMs = nil
249 + response = "Erreur réseau : \(error.localizedDescription)"
250 + }
251 + }
252 +}
added KA/Features/KaMapLibre.swift +160 −0
@@ -0,0 +1,160 @@
1 +// Auteur : Simon-Pierre Boucher — contact @spboucher.ai
2 +// KaMapLibre.swift — la carte MAPBOX 2D/3D de l'écosystème (comme Ka Maps sur
3 +// lou-ka.com) rendue par MapLibre Native : style Mapbox Streets v12 (jeton
4 +// public du web), BASCULE 2D ↔ 3D (inclinaison + bâtiments extrudés),
5 +// marqueurs-pilules SwiftUI hébergés dans des MLNAnnotationView.
6 +import SwiftUI
7 +import MapLibre
8 +import MapKit
9 +
10 +enum KaMapStyle {
11 + // Tuiles vectorielles OpenFreeMap (style Liberty) : libres, sans clé, rendu
12 + // vectoriel 2D/3D de classe Mapbox — les styles Mapbox natifs utilisent des
13 + // sources mapbox:// que MapLibre ne résout pas.
14 + static var url: URL { URL(string: "https://tiles.openfreemap.org/styles/liberty")! }
15 +}
16 +
17 +/// Annotation portant un cluster (1 item = pilule de prix ; n = badge).
18 +final class ClusterAnnotation: NSObject, MLNAnnotation {
19 + let cluster: MapCluster
20 + var coordinate: CLLocationCoordinate2D {
21 + .init(latitude: cluster.latitude, longitude: cluster.longitude)
22 + }
23 + var title: String? { nil }
24 + init(_ c: MapCluster) { cluster = c }
25 +}
26 +
27 +struct KaMapLibreView: UIViewRepresentable {
28 + var clusters: [MapCluster]
29 + var programRegion: MKCoordinateRegion
30 + var programEpoch: Int
31 + var pitch3D: Bool
32 + var selectedID: String?
33 + var onRegionChange: (MKCoordinateRegion) -> Void
34 + var onSelect: (MapCluster) -> Void
35 +
36 + func makeUIView(context: Context) -> MLNMapView {
37 + let map = MLNMapView(frame: .zero, styleURL: KaMapStyle.url)
38 + map.delegate = context.coordinator
39 + map.logoView.isHidden = false // attribution Mapbox requise
40 + map.attributionButtonPosition = .bottomLeft
41 + map.setCenter(programRegion.center, zoomLevel: zoom(from: programRegion), animated: false)
42 + map.allowsRotating = true
43 + map.allowsTilting = true
44 + // n'affiche le point bleu (et ne déclenche la permission) que si déjà accordée
45 + let auth = CLLocationManager().authorizationStatus
46 + map.showsUserLocation = (auth == .authorizedWhenInUse || auth == .authorizedAlways)
47 + return map
48 + }
49 +
50 + func updateUIView(_ map: MLNMapView, context: Context) {
51 + context.coordinator.parent = self
52 + // recentrage programmé (localisation, dégroupage, liste) — piloté par epoch
53 + if context.coordinator.lastEpoch != programEpoch {
54 + context.coordinator.lastEpoch = programEpoch
55 + let cam = map.camera
56 + cam.centerCoordinate = programRegion.center
57 + cam.pitch = pitch3D ? 58 : 0
58 + map.setCamera(cam, animated: false)
59 + map.setZoomLevel(zoom(from: programRegion), animated: true)
60 + }
61 + // bascule 2D/3D
62 + if context.coordinator.lastPitch3D != pitch3D {
63 + context.coordinator.lastPitch3D = pitch3D
64 + let cam = map.camera
65 + cam.pitch = pitch3D ? 58 : 0
66 + map.fly(to: cam, withDuration: 0.7, completionHandler: nil)
67 + }
68 + // annotations : resynchroniser (jeu de clusters OU sélection changée)
69 + let currentIDs = Set(clusters.map(\.id))
70 + let existing = (map.annotations ?? []).compactMap { $0 as? ClusterAnnotation }
71 + let existingIDs = Set(existing.map(\.cluster.id))
72 + if currentIDs != existingIDs || context.coordinator.lastSelectedID != selectedID {
73 + context.coordinator.lastSelectedID = selectedID
74 + map.removeAnnotations(existing)
75 + map.addAnnotations(clusters.map(ClusterAnnotation.init))
76 + }
77 + }
78 +
79 + func makeCoordinator() -> Coordinator { Coordinator(self) }
80 +
81 + private func zoom(from region: MKCoordinateRegion) -> Double {
82 + // approximation classique span → niveau de zoom Web Mercator
83 + let span = max(region.span.longitudeDelta, 0.0005)
84 + return max(1, min(18, log2(360 / span) ))
85 + }
86 +
87 + final class Coordinator: NSObject, MLNMapViewDelegate {
88 + var parent: KaMapLibreView
89 + var lastPitch3D = false
90 + var lastEpoch = Int.min
91 + var lastSelectedID: String?
92 + private var buildingsAdded = false
93 +
94 + init(_ p: KaMapLibreView) { parent = p }
95 +
96 + // ---- style chargé : ajouter les BÂTIMENTS 3D (fill-extrusion) ----
97 + func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) {
98 + guard !buildingsAdded else { return }
99 + // source vectorielle openmaptiles (Liberty) — repli : première source dispo
100 + let source = style.source(withIdentifier: "openmaptiles")
101 + ?? style.source(withIdentifier: "composite")
102 + ?? style.sources.first
103 + guard let composite = source else { return }
104 + buildingsAdded = true
105 + let layer = MLNFillExtrusionStyleLayer(identifier: "ka-3d-buildings", source: composite)
106 + layer.sourceLayerIdentifier = "building"
107 + // OpenMapTiles : render_height / render_min_height
108 + layer.fillExtrusionHeight = NSExpression(forKeyPath: "render_height")
109 + layer.fillExtrusionBase = NSExpression(forKeyPath: "render_min_height")
110 + layer.fillExtrusionColor = NSExpression(forConstantValue: UIColor(red: 0.82, green: 0.83, blue: 0.80, alpha: 1))
111 + layer.fillExtrusionOpacity = NSExpression(forConstantValue: 0.75)
112 + // insérer sous les étiquettes pour garder les noms lisibles
113 + if let firstSymbol = style.layers.first(where: { $0 is MLNSymbolStyleLayer }) {
114 + style.insertLayer(layer, below: firstSymbol)
115 + } else {
116 + style.addLayer(layer)
117 + }
118 + }
119 +
120 + // ---- pilules SwiftUI comme vues d'annotation ----
121 + func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? {
122 + guard let ann = annotation as? ClusterAnnotation else { return nil }
123 + let view = MLNAnnotationView(reuseIdentifier: nil)
124 + let cluster = ann.cluster
125 + let selected = parent.selectedID == cluster.id
126 + let content: AnyView = cluster.isSingle
127 + ? AnyView(PricePill(item: cluster.item, selected: selected))
128 + : AnyView(ClusterBadge(cluster: cluster))
129 + let host = UIHostingController(rootView: content)
130 + host.view.backgroundColor = .clear
131 + let size = host.sizeThatFits(in: CGSize(width: 200, height: 60))
132 + host.view.frame = CGRect(origin: .zero, size: size)
133 + view.frame = host.view.frame
134 + view.addSubview(host.view)
135 + view.isUserInteractionEnabled = true
136 + let tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
137 + view.addGestureRecognizer(tap)
138 + view.accessibilityLabel = cluster.isSingle ? cluster.item.title : "Groupe de \(cluster.items.count) résultats"
139 + objc_setAssociatedObject(view, &AssocKeys.cluster, cluster, .OBJC_ASSOCIATION_RETAIN)
140 + return view
141 + }
142 +
143 + @objc private func tapped(_ g: UITapGestureRecognizer) {
144 + guard let v = g.view,
145 + let cluster = objc_getAssociatedObject(v, &AssocKeys.cluster) as? MapCluster else { return }
146 + parent.onSelect(cluster)
147 + }
148 +
149 + func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) {
150 + let bounds = mapView.visibleCoordinateBounds
151 + let region = MKCoordinateRegion(
152 + center: mapView.centerCoordinate,
153 + span: .init(latitudeDelta: abs(bounds.ne.latitude - bounds.sw.latitude),
154 + longitudeDelta: abs(bounds.ne.longitude - bounds.sw.longitude)))
155 + parent.onRegionChange(region)
156 + }
157 + }
158 +}
159 +
160 +private enum AssocKeys { static var cluster: UInt8 = 0 }
modified KA/Features/MapView.swift +74 −39
@@ -7,6 +7,7 @@
7 7 import SwiftUI
8 8 import MapKit
9 9 import CoreLocation
10 +// Rendu : MapLibre (style Mapbox streets v12 + bâtiments 3D) — voir KaMapLibre.swift
10 11
11 12 // MARK: - Clustering par grille (pur, testé dans KATests)
12 13
@@ -47,9 +48,11 @@ enum ClusterEngine {
47 48 struct UnifiedMapView: View {
48 49 /// true quand la carte est un ONGLET (pas de bouton fermer)
49 50 var embedded: Bool = false
50 − @State private var camera: MapCameraPosition = .region(
51 − MKCoordinateRegion(center: .init(latitude: 46.81, longitude: -71.21),
52 − span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3)))
51 + @State private var programRegion = MKCoordinateRegion(
52 + center: .init(latitude: 46.81, longitude: -71.21),
53 + span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3))
54 + @State private var programEpoch = 0
55 + @State private var pitch3D = false
53 56 @State private var visibleRegion = MKCoordinateRegion(
54 57 center: .init(latitude: 46.81, longitude: -71.21),
55 58 span: .init(latitudeDelta: 0.3, longitudeDelta: 0.3))
@@ -108,12 +111,12 @@ struct UnifiedMapView: View {
108 111 .presentationBackgroundInteraction(.enabled(upThrough: .medium))
109 112 }
110 113 .task {
111 − location.request()
114 + location.requestIfAuthorized() // HIG : pas de dialogue à l'ouverture
112 115 await loadZone()
113 116 }
114 117 .onChange(of: location.coordinate != nil) {
115 118 if let c = location.coordinate {
116 − withAnimation { camera = .region(.init(center: c, span: .init(latitudeDelta: 0.18, longitudeDelta: 0.18))) }
119 + recenter(.init(center: c, span: .init(latitudeDelta: 0.18, longitudeDelta: 0.18)))
117 120 Task { await loadZone() }
118 121 }
119 122 }
@@ -123,39 +126,35 @@ struct UnifiedMapView: View {
123 126 // MARK: couches
124 127
125 128 private var mapLayer: some View {
126 − Map(position: $camera) {
127 − UserAnnotation()
128 − ForEach(clusters) { cluster in
129 − Annotation("", coordinate: .init(latitude: cluster.latitude, longitude: cluster.longitude)) {
130 − if cluster.isSingle {
131 − PricePill(item: cluster.item,
132 − selected: selectedCluster?.id == cluster.id)
133 − .onTapGesture {
134 − Haptics.tap()
135 − withAnimation(.snappy) { selectedCluster = cluster }
136 − }
137 − } else {
138 − ClusterBadge(cluster: cluster)
139 − .onTapGesture {
140 − Haptics.rigid()
141 − // dégroupage : zoom sur le groupe
142 − withAnimation(.easeInOut(duration: 0.4)) {
143 − camera = .region(.init(
144 − center: .init(latitude: cluster.latitude, longitude: cluster.longitude),
145 − span: .init(latitudeDelta: visibleRegion.span.latitudeDelta / 3.2,
146 − longitudeDelta: visibleRegion.span.longitudeDelta / 3.2)))
147 − }
148 − }
149 − }
129 + KaMapLibreView(
130 + clusters: clusters,
131 + programRegion: programRegion,
132 + programEpoch: programEpoch,
133 + pitch3D: pitch3D,
134 + selectedID: selectedCluster?.id,
135 + onRegionChange: { region in
136 + visibleRegion = region
137 + zoneDirty = true
138 + },
139 + onSelect: { cluster in
140 + if cluster.isSingle {
141 + Haptics.tap()
142 + withAnimation(.snappy) { selectedCluster = cluster }
143 + } else {
144 + Haptics.rigid()
145 + // dégroupage : zoom sur le groupe
146 + recenter(.init(center: .init(latitude: cluster.latitude, longitude: cluster.longitude),
147 + span: .init(latitudeDelta: visibleRegion.span.latitudeDelta / 3.2,
148 + longitudeDelta: visibleRegion.span.longitudeDelta / 3.2)))
150 149 }
151 − .annotationTitles(.hidden)
152 − }
153 − }
154 − .mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll))
155 − .onMapCameraChange(frequency: .onEnd) { ctx in
156 − visibleRegion = ctx.region
157 − zoneDirty = true
158 − }
150 + })
151 + .ignoresSafeArea(edges: .bottom)
152 + }
153 +
154 + /// Recentrage programmé de la carte
155 + private func recenter(_ region: MKCoordinateRegion) {
156 + programRegion = region
157 + programEpoch += 1
159 158 }
160 159
161 160 private var chips: some View {
@@ -186,6 +185,34 @@ struct UnifiedMapView: View {
186 185
187 186 private var bottomBar: some View {
188 187 HStack(spacing: 10) {
188 + Button {
189 + Haptics.rigid()
190 + pitch3D.toggle()
191 + } label: {
192 + Text(pitch3D ? "2D" : "3D")
193 + .font(.system(.caption, design: .rounded).weight(.bold))
194 + .frame(width: 40, height: 36)
195 + .background(pitch3D ? KATheme.inkLight : .clear)
196 + .background(.ultraThinMaterial, in: Capsule())
197 + .foregroundStyle(pitch3D ? KATheme.lime : .primary)
198 + .overlay(Capsule().strokeBorder(.primary.opacity(0.3), lineWidth: 1))
199 + }
200 + .accessibilityLabel(pitch3D ? "Repasser en vue 2D" : "Passer en vue 3D avec bâtiments")
201 + Button {
202 + Haptics.tap()
203 + location.request() // la permission n'est demandée QU'ICI, sur geste
204 + if let c = location.coordinate {
205 + recenter(.init(center: c, span: .init(latitudeDelta: 0.12, longitudeDelta: 0.12)))
206 + Task { await loadZone() }
207 + }
208 + } label: {
209 + Image(systemName: "location.fill")
210 + .font(.caption.weight(.bold))
211 + .frame(width: 36, height: 36)
212 + .background(.ultraThinMaterial, in: Circle())
213 + .overlay(Circle().strokeBorder(.primary.opacity(0.3), lineWidth: 1))
214 + }
215 + .accessibilityLabel("Autour de moi")
189 216 Text(loading ? "Chargement…" : "\(visibleItems.count) résultats")
190 217 .font(.system(.caption, design: .monospaced).weight(.bold))
191 218 .padding(.horizontal, 11).padding(.vertical, 9)
@@ -257,8 +284,8 @@ struct UnifiedMapView: View {
257 284 withAnimation(.snappy) {
258 285 showList = false
259 286 if let la = item.latitude, let lo = item.longitude {
260 − camera = .region(.init(center: .init(latitude: la, longitude: lo),
261 − span: .init(latitudeDelta: 0.02, longitudeDelta: 0.02)))
287 + recenter(.init(center: .init(latitude: la, longitude: lo),
288 + span: .init(latitudeDelta: 0.02, longitudeDelta: 0.02)))
262 289 }
263 290 selectedCluster = MapCluster(id: item.id, latitude: item.latitude ?? 0,
264 291 longitude: item.longitude ?? 0, items: [item])
@@ -358,6 +385,14 @@ final class LocationOnce: NSObject, ObservableObject, CLLocationManagerDelegate
358 385 manager.requestLocation()
359 386 }
360 387 }
388 +
389 + /// Ne demande JAMAIS la permission — n'agit que si elle est déjà accordée.
390 + func requestIfAuthorized() {
391 + manager.delegate = self
392 + if manager.authorizationStatus == .authorizedWhenInUse || manager.authorizationStatus == .authorizedAlways {
393 + manager.requestLocation()
394 + }
395 + }
361 396 func locationManagerDidChangeAuthorization(_ m: CLLocationManager) {
362 397 if m.authorizationStatus == .authorizedWhenInUse || m.authorizationStatus == .authorizedAlways {
363 398 m.requestLocation()
modified KA/Features/UniversesView.swift +10 −2
@@ -147,6 +147,8 @@ struct UniverseHomeView: View {
147 147 Group {
148 148 if universe.id == "vrai-prix" {
149 149 VraiPrixView(universe: universe)
150 + } else if universe.id == "api-ka" {
151 + APIPlaygroundView(universe: universe)
150 152 } else if universe.listPath == nil {
151 153 webUniverse
152 154 } else {
@@ -214,8 +216,14 @@ struct UniverseHomeView: View {
214 216 KAEmptyState(symbol: "wifi.exclamationmark", title: "Impossible de charger",
215 217 message: e + "\nTirez pour réessayer.")
216 218 } else if items.isEmpty {
217 − KAEmptyState(symbol: "tray", title: "Aucun résultat",
218 − message: "Essayez d'autres mots-clés.")
219 + if universe.id == "trouve-ka" && query.isEmpty {
220 + KAEmptyState(symbol: "magnifyingglass",
221 + title: "Cherchez le web québécois",
222 + message: "1,2 M de pages d'ici indexées — tapez un mot dans la barre de recherche ci-dessus.")
223 + } else {
224 + KAEmptyState(symbol: "tray", title: "Aucun résultat",
225 + message: "Essayez d'autres mots-clés.")
226 + }
219 227 } else {
220 228 ForEach(items) { item in
221 229 NavigationLink(value: item) { KAItemRow(item: item) }
modified KA/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png +0 −0

Binary file not shown.

added docs/icon-glass-1024.png +0 −0

Binary file not shown.

added docs/icon-glass.svg +67 −0
@@ -0,0 +1,67 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
2 + <!-- Icône KA « liquid glass » v2 — verre fumé encre TRÈS lisible sur blanc -->
3 + <defs>
4 + <radialGradient id="bg" cx="35%" cy="22%" r="95%">
5 + <stop offset="0%" stop-color="#ffffff"/>
6 + <stop offset="65%" stop-color="#f6f8f2"/>
7 + <stop offset="100%" stop-color="#e9eee0"/>
8 + </radialGradient>
9 + <linearGradient id="inkGlass" x1="0%" y1="0%" x2="12%" y2="100%">
10 + <stop offset="0%" stop-color="#3a463a"/>
11 + <stop offset="50%" stop-color="#161c16"/>
12 + <stop offset="100%" stop-color="#060906"/>
13 + </linearGradient>
14 + <linearGradient id="limeRim" x1="0%" y1="0%" x2="0%" y2="100%">
15 + <stop offset="0%" stop-color="#eaffa3"/>
16 + <stop offset="100%" stop-color="#b8d94e"/>
17 + </linearGradient>
18 + <linearGradient id="blobA" x1="0%" y1="0%" x2="100%" y2="100%">
19 + <stop offset="0%" stop-color="#d9f26b" stop-opacity="0.5"/>
20 + <stop offset="100%" stop-color="#1c5c41" stop-opacity="0.15"/>
21 + </linearGradient>
22 + <filter id="softShadow" x="-40%" y="-40%" width="180%" height="180%">
23 + <feDropShadow dx="0" dy="24" stdDeviation="30" flood-color="#12240e" flood-opacity="0.35"/>
24 + </filter>
25 + <filter id="blur16"><feGaussianBlur stdDeviation="16"/></filter>
26 + <filter id="blur6"><feGaussianBlur stdDeviation="6"/></filter>
27 + </defs>
28 +
29 + <rect width="1024" height="1024" fill="url(#bg)"/>
30 +
31 + <!-- gouttes liquides lime -->
32 + <ellipse cx="822" cy="180" rx="150" ry="115" fill="url(#blobA)" filter="url(#blur16)"/>
33 + <ellipse cx="170" cy="820" rx="165" ry="125" fill="url(#blobA)" filter="url(#blur16)"/>
34 +
35 + <!-- KA en verre fumé encre : liseré lime + ombre portée + reflets -->
36 + <g transform="rotate(-4 512 512)">
37 + <text x="512" y="678" text-anchor="middle" filter="url(#softShadow)"
38 + font-family="-apple-system,'SF Pro Rounded','Arial Rounded MT Bold',sans-serif"
39 + font-size="560" font-weight="800" letter-spacing="-22"
40 + fill="url(#inkGlass)" stroke="url(#limeRim)" stroke-width="12"
41 + paint-order="stroke fill">KA</text>
42 + <!-- reflet spéculaire dans le verre -->
43 + <path d="M 200 300 Q 512 214 824 300 L 824 372 Q 512 292 200 388 Z"
44 + fill="#ffffff" opacity="0.30" filter="url(#blur6)"/>
45 + <!-- goutte de lumière -->
46 + <ellipse cx="352" cy="330" rx="46" ry="20" fill="#ffffff" opacity="0.75"
47 + filter="url(#blur6)" transform="rotate(-12 352 330)"/>
48 + </g>
49 +
50 + <!-- perles de verre des 12 univers -->
51 + <g transform="translate(512 888)">
52 + <g transform="translate(-341 0)">
53 + <g><circle r="26" fill="#1c7ed6"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g>
54 + <g transform="translate(62 0)"><circle r="26" fill="#ff6a00"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g>
55 + <g transform="translate(124 0)"><circle r="26" fill="#e23744"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g>
56 + <g transform="translate(186 0)"><circle r="26" fill="#ff5148"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g>
57 + <g transform="translate(248 0)"><circle r="26" fill="#ff5a2a"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g>
58 + <g transform="translate(310 0)"><circle r="26" fill="#c4532e"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g>
59 + <g transform="translate(372 0)"><circle r="26" fill="#1f9d55"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g>
60 + <g transform="translate(434 0)"><circle r="26" fill="#f08c00"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g>
61 + <g transform="translate(496 0)"><circle r="26" fill="#d6336c"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g>
62 + <g transform="translate(558 0)"><circle r="26" fill="#7048e8"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g>
63 + <g transform="translate(620 0)"><circle r="26" fill="#3b5bdb"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g>
64 + <g transform="translate(682 0)"><circle r="26" fill="#0c8599"/><circle cx="-7" cy="-8" r="8" fill="#fff" opacity="0.65"/></g>
65 + </g>
66 + </g>
67 +</svg>
added docs/screenshots/carte-2d.png +0 −0

Binary file not shown.

added docs/screenshots/carte-3d.png +0 −0

Binary file not shown.

added docs/screenshots/carte-maplibre-2d.png +0 −0

Binary file not shown.

added docs/screenshots/carte-maplibre.png +0 −0

Binary file not shown.

modified project.yml +7 −1
@@ -10,11 +10,15 @@ settings:
10 10 base:
11 11 SWIFT_VERSION: "5.0"
12 12 MARKETING_VERSION: "1.0.0"
13 − CURRENT_PROJECT_VERSION: "6"
13 + CURRENT_PROJECT_VERSION: "7"
14 14 DEVELOPMENT_TEAM: "3YM54G49SN"
15 15 CODE_SIGN_STYLE: Automatic
16 16 GENERATE_INFOPLIST_FILE: true
17 17 ENABLE_USER_SCRIPT_SANDBOXING: true
18 +packages:
19 + MapLibre:
20 + url: https://github.com/maplibre/maplibre-gl-native-distribution
21 + from: 6.10.0
18 22 targets:
19 23 KA:
20 24 type: application
@@ -24,6 +28,8 @@ targets:
24 28 dependencies:
25 29 - target: KAWidgets
26 30 embed: true
31 + - package: MapLibre
32 + product: MapLibre
27 33 settings:
28 34 base:
29 35 PRODUCT_BUNDLE_IDENTIFIER: com.groupeka.ka
30 36