SPB Git forge

spb/ka-android

Public
4commits 1branches 0releases
1.6 MBsize
maindefault branch
1 mo agolast push
Kotlin 100%

v2.0.0 — refonte majeure : carte Mapbox 2D/3D, alertes honnêtes, playground API·Ka

- Carte LÉGENDAIRE (onglet) : MapLibre 10.3.1 + style Mapbox streets-v12,
  bascule 2D/3D (pitch 60° + fill-extrusion des bâtiments comme Ka Maps),
  marqueurs-pilules prix par univers, clustering grille (ClusterEngine pur,
  plafond 150, dégroupage au zoom), « Rechercher dans cette zone » (bbox
  geojson RÉEL lou/immo/job, filtre client resto/sorti), fiche compacte →
  fiche complète avec état de carte conservé, liste/carte synchronisées
- Refonte iOS-parité : galeries multi-photos + plein écran zoomable,
  itinéraire geo:, semblables, tri de recherche, recherches sauvegardées +
  alertes honnêtes (+N = delta réel du total API), historique consulté (30),
  collections éditoriales ×4, accueil « Reprendre », hub Profil
- FIX Trouve-Ka : recherche d'abord (q obligatoire, 422 sinon)
- Playground API·Ka : openapi.json live, formulaires GET, réponse JSON
  colorisée/pliable + statut + latence + copie curl
- Icône « liquid glass » + splash blanc ; keystore release (APK signé)
- 22 tests JVM verts ; assembleDebug + assembleRelease vérifiés

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

31 changed files +2,392 −104

modified README.md +116 −60
@@ -2,7 +2,10 @@
2 2
3 3 Portage Android natif complet de l'app iOS « KA » : tout l'écosystème Groupe-KA
4 4 (12 univers ·Ka) dans une seule app **Kotlin + Jetpack Compose**, compilée en
5 ligne de commande (aucun Android Studio requis).
5 +ligne de commande (aucun Android Studio requis). **v2.0.0** = refonte majeure
6 +alignée sur le build 6 iOS (AUDIT-REFONTE.md §5) : carte Mapbox 2D/3D,
7 +galeries, recherches sauvegardées + alertes honnêtes, historique, collections
8 +éditoriales, playground API·Ka.
6 9
7 10 ## Build
8 11
@@ -10,72 +13,125 @@ ligne de commande (aucun Android Studio requis).
10 13 export JAVA_HOME=$(brew --prefix openjdk@21)
11 14 # local.properties doit pointer vers le SDK :
12 15 # sdk.dir=/opt/homebrew/share/android-commandlinetools
13 ./gradlew assembleDebug # → app/build/outputs/apk/debug/app-debug.apk
14 ./gradlew test # tests JVM des adaptateurs d'univers (12 tests)
16 +./gradlew test # 22 tests JVM (adaptateurs + clustering + stores)
17 +./gradlew assembleDebug # → app/build/outputs/apk/debug/app-debug.apk
18 +./gradlew assembleRelease # → app/build/outputs/apk/release/app-release.apk (SIGNÉ)
15 19 ```
16 20
17 21 Trio de versions éprouvé : **Gradle 8.13 + AGP 8.9.2 + Kotlin 2.1.20**
18 (plugin Compose intégré à Kotlin 2.x), Compose BOM 2024.09.03, minSdk 26,
19 target/compileSdk 34. Portrait-first (téléphones).
22 +(plugin Compose intégré), Compose BOM 2024.09.03, **MapLibre GL 10.3.1**,
23 +minSdk 26, target/compileSdk 34. Portrait-first (téléphones).
20 24
21 ## Architecture — miroir exact de l'app iOS (`~/Desktop/KA`)
25 +### Signature release (APK téléchargeable publiquement)
26 +
27 +Le keystore est à la racine : `ka-release.keystore`
28 +- alias : `ka`
29 +- **storepass / keypass : `ka-groupe-2026`**
30 +- certificat : CN=Groupe KA, RSA 2048, validité 10 000 jours.
31 +
32 +`assembleRelease` signe automatiquement (signingConfig `release` dans
33 +`app/build.gradle.kts`). ⚠️ Conserver ce keystore : toute mise à jour de
34 +l'app doit être signée avec la même clé.
35 +
36 +## La carte (pièce maîtresse, onglet « Carte »)
37 +
38 +`ui/MapScreen.kt` — **MapLibre GL Android** (libre) rendant le style officiel
39 +**Mapbox streets-v12** (`Mapbox.getInstance(ctx, token, WellKnownTileServer.Mapbox)`
40 ++ `mapbox://styles/mapbox/streets-v12` ; token public déjà exposé sur
41 +lou-ka.com) :
42 +
43 +- **Bascule 2D/3D** : pitch 0° ↔ 60° animé + couche `fill-extrusion` des
44 + bâtiments (source composite, `source-layer building`, hauteur interpolée au
45 + zoom) — même recette que Ka Maps web.
46 +- **Marqueurs-pilules avec PRIX** teintés par univers (bitmaps dessinés au
47 + Canvas, cache), badge circulaire pour les groupes.
48 +- **Clustering par grille** : `core/ClusterEngine.kt` (pur, testé, miroir
49 + exact d'iOS) — ~9×9 cellules sur la région, plafond 150 rendus, dégroupage
50 + par zoom au tap.
51 +- **« Rechercher dans cette zone »** : bbox RÉEL côté API
52 + (`/api/listings.geojson?bbox=w,s,e,n` lou-ka/immo-ka,
53 + `/api/jobs.geojson` job-ka — vérifiés) ; resto-ka/sorti-ka : liste +
54 + filtre client sur la région.
55 +- **Fiche compacte** au tap → fiche complète (l'état de la carte — caméra,
56 + items, chips — est conservé via `MapStateHolder`), **bascule liste/carte**
57 + synchronisée, chips par univers aux couleurs officielles.
58 +
59 +## Refonte v2 (parité iOS build 6)
60 +
61 +- **Fiches** : galerie multi-photos (`images[]` → `KAItem.imageURLs`, pager +
62 + compteur + **plein écran zoomable** pincement/double-tap), **itinéraire**
63 + (intent `geo:`), **semblables** (même univers/ville), provenance datée
64 + (`Source : <hôte>`), enregistrement dans l'historique.
65 +- **Recherche** : tri (pertinence / prix ↑ / prix ↓), **sauvegarde + alerte**
66 + (🔔), partage de la recherche.
67 +- **Alertes honnêtes** : `core/RecentsStore.kt` recompte le champ `total`
68 + réel de l'API à l'ouverture (`UniverseService.total`) et affiche le delta
69 + « +N » — jamais de faux chiffre. « Marquer comme vues » replie le delta.
70 +- **Historique consulté** : 30 derniers, dédupliqués, persistés.
71 +- **Accueil** : bande « Reprendre » (recherches sauvegardées avec badge +N,
72 + derniers consultés), **collections éditoriales** ×4 (les mêmes requêtes
73 + réelles qu'iOS : sorties gratuites, soldes d'épicerie, autos < 15 000 $
74 + ≥ 2015, 4½ récents).
75 +- **Profil** : hub « Mes contenus » (Favoris / Historique / Recherches &
76 + alertes avec badge).
77 +- **Navigation** : Accueil · Recherche · Explorer · **Carte** · Profil
78 + (Favoris accessibles depuis le Profil, comme sur iOS).
79 +
80 +## Fix Trouve-Ka
81 +
82 +`/api/search` **exige `q`** (422 sinon). `UniverseService.fetch` garde le cas
83 +(retour vide sans appel réseau) et l'explorateur Trouve-Ka est « recherche
84 +d'abord » : aucun fetch initial, état vide dédié « Cherchez le web québécois ».
85 +Items sous la clé `results`.
86 +
87 +## Playground API·Ka
88 +
89 +`ui/ApiKaPlaygroundScreen.kt` — l'univers API·Ka est un playground natif :
90 +charge `https://www.api-ka.com/openapi.json` **en direct**, liste les
91 +endpoints **GET** (chemin, résumé, paramètres), génère le formulaire
92 +(paramètres query et path, requis marqués \*), **Envoyer** → statut HTTP +
93 +latence ms + **réponse JSON colorisée** (clés lime, chaînes vertes, nombres
94 +orange) **pliable**, et **« Copier en curl »**.
95 +
96 +## Architecture — miroir de l'app iOS (`~/Desktop/KA`)
22 97
23 98 | iOS (SwiftUI) | Android (Compose) |
24 99 |-----------------------------|-----------------------------------------------------|
25 | `Core/Ecosystem.swift` | `core/Ecosystem.kt` — les 12 univers config-driven |
26 | `Core/Services.swift` | `core/Services.kt` — client JSON + cache disque, fetch univers, liveTotal, **AgentService SSE**, menus resto, Vrai-Prix |
27 | `Core/KAFilters.swift` | `core/Filters.kt` — catalogue de filtres métier |
28 | `Core/FavoritesStore.swift` | `core/FavoritesStore.kt` — collections multi-univers persistées (JSON) |
29 | `Core/KAID.swift` | `auth/KaId.kt` — SSO hub via Chrome Custom Tabs (client `ka-android`, scheme `ka-android://auth/callback`, échange vérifié sur api-ka) |
30 | `Design/Theme.swift` | `ui/theme/Theme.kt` — papier/encre, carte à ombre décalée dure, wordmarks « Lou[Ka] » (-2°), chips mono |
31 | `Features/*` | `ui/*Screen.kt` — Accueil vivant, Recherche fan-out, grille Univers + explorateur + fiche, Vrai-Prix natif, menus resto, Favoris, Profil, chat KA Agent |
32 | `App/KAApp.swift` | `MainActivity.kt` + `KAApp.kt` — 5 onglets + bulle Agent flottante, onboarding |
33
34 Le **thème clair est le défaut** (papier `#f5f3ee` / encre `#141814`) ; sombre
35 et automatique dans Profil → Apparence. Tous les textes sont en français.
36
37 ### Le cœur : `core/` (pur JVM, testable sans Android)
38
39 - `Json.kt` — lecture « au mieux » sur `JsonElement` (miroir de `JSONValue`) :
40 `str()`, `num()`, `.text` (string / nombre formaté / liste jointe).
41 - `Ecosystem.kt` — chaque univers = id, wordmark, tagline, accent hex, domaine,
42 unité, `listPath`/`itemsKey`/`searchParam`, clés de stats, et **mapping
43 JSON → KAItem** avec toutes les subtilités iOS : première image http de
44 `images[]`, seuils anti-placeholders (prix ≤ 0,2 $ épicerie, ≤ 50 $ loyer,
45 ≤ 1000 $ propriété, salaires > 20 000 $/an ou > 10 $/h), prix barré Food·Ka,
46 fourchettes salariales Job·Ka, plateformes + abonnés cumulés + liens Créa·Ka,
47 strip-HTML Trouve·Ka.
48 - `Services.kt` — cache disque par URL (l'app sert la dernière copie hors
49 ligne), fan-out `liveTotal` (`/api/stats`, `/api/status` pour trouve-ka),
50 SSE OkHttp pour KA Agent (`POST https://www.api-ka.com/api/agent/chat`,
51 events `delta`/`tool`/`done`/`error`).
100 +| `Core/Ecosystem.swift` | `core/Ecosystem.kt` — 12 univers config-driven, galeries `imageURLs` |
101 +| `Core/Services.swift` | `core/Services.kt` — cache disque hors ligne, fetch, `total()`, `mapItems()` bbox geojson, liveTotal, AgentService SSE |
102 +| `Core/KAFilters.swift` | `core/Filters.kt` |
103 +| `Core/FavoritesStore.swift` | `core/FavoritesStore.kt` |
104 +| `Core/RecentsStore.swift` | `core/RecentsStore.kt` + `Editorial` |
105 +| `Features/MapView.swift` | `ui/MapScreen.kt` + `core/ClusterEngine.kt` |
106 +| `Core/KAID.swift` | `auth/KaId.kt` — SSO Custom Tabs, client `ka-android` |
107 +| `Design/Theme.swift` | `ui/theme/Theme.kt` — éditorial sharp, clair par défaut |
108 +| `Features/*` | `ui/*Screen.kt` |
109 +
110 +Icône : « liquid glass » (KA verre lime sur fond blanc, perles des univers) —
111 +mipmaps générés depuis `docs/icon-glass-1024.png` d'iOS.
52 112
53 113 ## Ajouter un univers
54 114
55 Ajouter une entrée dans `Ecosystem.all` (`core/Ecosystem.kt`) : identité,
56 config d'API et lambda de mapping. Rien d'autre — la grille, la recherche
57 universelle, l'accueil, les compteurs et la fiche le prennent en charge
58 automatiquement. (Ajouter des filtres métier dans `core/Filters.kt` et une
59 icône dans `universeIcon()` si souhaité.)
60
61 ## Écarts volontaires vs iOS (v1)
62
63 - **Carte unifiée absente** : la MapView iOS (MapKit) n'est pas portée —
64 prévue pour une version ultérieure (Google Maps/MapLibre à trancher).
65 Les latitudes/longitudes sont déjà mappées dans `KAItem`.
66 - **Widgets** : pas d'équivalent des widgets iOS (KAWidgets) en v1.
67 - Le SSO utilise Chrome Custom Tabs (au lieu d'`ASWebAuthenticationSession`) ;
68 le retour se fait par `ka-android://auth/callback` (intent-filter,
69 `launchMode=singleTask`), puis échange `{ka_token}` sur
70 `https://www.api-ka.com/api/ios/auth/exchange` (accepte l'audience
71 `ka-android`).
72 - Haptique légère (API Compose) plutôt que le moteur riche d'iOS.
73 - Icônes Material en remplacement des SF Symbols (mapping dans `universeIcon`).
74
75 ## Tests
76
77 `app/src/test/java/com/groupeka/ka/AdapterTests.kt` — mêmes échantillons JSON
78 que `KATests.swift` iOS : mapping Lou·Ka/Auto·Ka/Job·Ka/Food·Ka/Créa·Ka/
79 Trouve·Ka, première image de `images[]`, accents uniques (12 univers),
80 construction d'URL de liste (query embarquée `upcoming=true` préservée),
81 favoris/collections + persistance.
115 +Une entrée dans `Ecosystem.all` (`core/Ecosystem.kt`) : identité, config
116 +d'API, lambda de mapping. La grille, la recherche, l'accueil, les compteurs,
117 +la fiche — et la carte si l'API expose lat/lng — suivent automatiquement.
118 +
119 +## Écarts vs iOS (v2)
120 +
121 +- Localisation « ma position » non incluse (aucune permission de localisation
122 + demandée) — la carte démarre sur Québec.
123 +- Widgets iOS sans équivalent.
124 +- SF Symbols → Material Icons ; haptique simplifiée.
125 +- MapLibre 10.3.1 (packages `com.mapbox.mapboxsdk.*`) : dernière lignée qui
126 + résout nativement les URL `mapbox://` du style officiel via
127 + `WellKnownTileServer.Mapbox` — MapLibre 11 les a retirées.
128 +
129 +## Tests (22, tous verts)
130 +
131 +- `AdapterTests` (12) : mappings d'univers (mêmes échantillons que
132 + `KATests.swift`), URLs de liste, favoris/collections + persistance.
133 +- `RefonteTests` (10) : clustering (regroupement/centroïde, plafond 150,
134 + items sans coordonnées), historique (déduplication, plafond 30,
135 + persistance), alertes (markSeen replie le delta, somme des alertes
136 + actives seulement), galerie ≤ 10 images http, garde Trouve-Ka sans
137 + réseau, collections éditoriales pointant vers de vrais univers.
modified app/build.gradle.kts +13 −2
@@ -14,13 +14,23 @@ android {
14 14 applicationId = "com.groupeka.ka"
15 15 minSdk = 26
16 16 targetSdk = 34
17 versionCode = 1
18 versionName = "1.0.0"
17 + versionCode = 2
18 + versionName = "2.0.0"
19 + }
20 +
21 + signingConfigs {
22 + create("release") {
23 + storeFile = rootProject.file("ka-release.keystore")
24 + storePassword = "ka-groupe-2026"
25 + keyAlias = "ka"
26 + keyPassword = "ka-groupe-2026"
27 + }
19 28 }
20 29
21 30 buildTypes {
22 31 release {
23 32 isMinifyEnabled = false
33 + signingConfig = signingConfigs.getByName("release")
24 34 }
25 35 }
26 36 compileOptions {
@@ -50,6 +60,7 @@ dependencies {
50 60 implementation("com.squareup.okhttp3:okhttp:4.12.0")
51 61 implementation("androidx.browser:browser:1.8.0")
52 62 implementation("androidx.core:core-ktx:1.13.1")
63 + implementation("org.maplibre.gl:android-sdk:10.3.1")
53 64
54 65 testImplementation("junit:junit:4.13.2")
55 66 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
modified app/src/main/AndroidManifest.xml +1 −0
@@ -2,6 +2,7 @@
2 2 <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3 3
4 4 <uses-permission android:name="android.permission.INTERNET" />
5 + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
5 6
6 7 <application
7 8 android:name=".KAApp"
modified app/src/main/java/com/groupeka/ka/KAApp.kt +9 −0
@@ -8,6 +8,10 @@ import com.groupeka.ka.auth.KAIDManager
8 8 import com.groupeka.ka.core.ApiClient
9 9 import com.groupeka.ka.core.FavoritesStore
10 10 import com.groupeka.ka.core.Prefs
11 +import com.groupeka.ka.core.RecentsStore
12 +import kotlinx.coroutines.CoroutineScope
13 +import kotlinx.coroutines.Dispatchers
14 +import kotlinx.coroutines.launch
11 15 import java.io.File
12 16
13 17 class KAApp : Application() {
@@ -16,6 +20,11 @@ class KAApp : Application() {
16 20 ApiClient.cacheDir = File(cacheDir, "ka-json")
17 21 Prefs.init(this)
18 22 FavoritesStore.shared = FavoritesStore(File(filesDir, "ka-favoris.json"))
23 + RecentsStore.shared = RecentsStore(File(filesDir, "ka-recents.json"))
19 24 KAIDManager.restore(this)
25 + // alertes honnêtes : recompte des recherches sauvegardées à l'ouverture
26 + CoroutineScope(Dispatchers.IO).launch {
27 + runCatching { RecentsStore.shared?.refreshAlerts() }
28 + }
20 29 }
21 30 }
modified app/src/main/java/com/groupeka/ka/MainActivity.kt +13 −6
@@ -17,8 +17,8 @@ import androidx.compose.foundation.layout.padding
17 17 import androidx.compose.foundation.layout.size
18 18 import androidx.compose.foundation.shape.CircleShape
19 19 import androidx.compose.material.icons.Icons
20 import androidx.compose.material.icons.filled.Favorite
21 20 import androidx.compose.material.icons.filled.Home
21 +import androidx.compose.material.icons.filled.Map
22 22 import androidx.compose.material.icons.filled.Person
23 23 import androidx.compose.material.icons.filled.Search
24 24 import androidx.compose.material.icons.filled.Workspaces
@@ -55,10 +55,13 @@ import com.groupeka.ka.core.VPResult
55 55 import com.groupeka.ka.ui.AgentChatScreen
56 56 import com.groupeka.ka.ui.EstimateScreen
57 57 import com.groupeka.ka.ui.FavoritesScreen
58 +import com.groupeka.ka.ui.HistoryScreen
58 59 import com.groupeka.ka.ui.HomeScreen
59 60 import com.groupeka.ka.ui.ItemDetailScreen
61 +import com.groupeka.ka.ui.MapScreen
60 62 import com.groupeka.ka.ui.OnboardingScreen
61 63 import com.groupeka.ka.ui.ProfileScreen
64 +import com.groupeka.ka.ui.SavedSearchesScreen
62 65 import com.groupeka.ka.ui.SearchScreen
63 66 import com.groupeka.ka.ui.UniverseHomeScreen
64 67 import com.groupeka.ka.ui.UniversesScreen
@@ -127,8 +130,8 @@ private data class Tab(val route: String, val label: String, val icon: ImageVect
127 130 private val tabs = listOf(
128 131 Tab("home", "Accueil", Icons.Filled.Home),
129 132 Tab("search", "Recherche", Icons.Filled.Search),
130 Tab("universes", "Univers", Icons.Filled.Workspaces),
131 Tab("favorites", "Favoris", Icons.Filled.Favorite),
133 + Tab("universes", "Explorer", Icons.Filled.Workspaces),
134 + Tab("map", "Carte", Icons.Filled.Map),
132 135 Tab("profile", "Profil", Icons.Filled.Person),
133 136 )
134 137
@@ -172,8 +175,11 @@ fun RootView() {
172 175 composable("home") { HomeScreen(nav) }
173 176 composable("search") { SearchScreen(nav) }
174 177 composable("universes") { UniversesScreen(nav) }
178 + composable("map") { MapScreen(nav) }
175 179 composable("favorites") { FavoritesScreen(nav) }
176 composable("profile") { ProfileScreen() }
180 + composable("history") { HistoryScreen(nav) }
181 + composable("alerts") { SavedSearchesScreen(nav) }
182 + composable("profile") { ProfileScreen(nav) }
177 183 composable("universe/{id}") { entry ->
178 184 val id = entry.arguments?.getString("id")
179 185 Ecosystem.universe(id ?: "")?.let { UniverseHomeScreen(it, nav) }
@@ -189,8 +195,9 @@ fun RootView() {
189 195 composable("agent") { AgentChatScreen(onClose = { nav.popBackStack() }) }
190 196 }
191 197
192 // Bulle KA Agent — flottante au-dessus de la tab bar, partout sauf dans l'agent
193 if (route != "agent") {
198 + // Bulle KA Agent — flottante au-dessus de la tab bar, partout sauf
199 + // dans l'agent et sur la carte (contrôles de zone au même endroit)
200 + if (route != "agent" && route != "map") {
194 201 Box(
195 202 Modifier
196 203 .align(Alignment.BottomEnd)
added app/src/main/java/com/groupeka/ka/core/ClusterEngine.kt +46 −0
@@ -0,0 +1,46 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// ClusterEngine.kt — clustering par GRILLE, pur et testé (miroir exact de
3 +// l'engine iOS) : la région visible est découpée en ~grid × grid cellules ;
4 +// une cellule d'un seul élément reste un marqueur individuel ; plafond de
5 +// rendu à 150 groupes — jamais de carte qui rame.
6 +package com.groupeka.ka.core
7 +
8 +data class MapCluster(
9 + val id: String,
10 + val latitude: Double,
11 + val longitude: Double,
12 + val items: List<KAItem>,
13 +) {
14 + val isSingle: Boolean get() = items.size == 1
15 + val item: KAItem get() = items[0]
16 +}
17 +
18 +object ClusterEngine {
19 + /**
20 + * Regroupe les items en cellules de grille (~grid × grid cellules sur la
21 + * région définie par ses spans lat/lon).
22 + */
23 + fun clusterize(
24 + items: List<KAItem>,
25 + latSpan: Double,
26 + lonSpan: Double,
27 + grid: Double = 9.0,
28 + ): List<MapCluster> {
29 + val cellLat = maxOf(latSpan / grid, 0.0001)
30 + val cellLon = maxOf(lonSpan / grid, 0.0001)
31 + val cells = mutableMapOf<String, MutableList<KAItem>>()
32 + for (it in items) {
33 + val la = it.latitude ?: continue
34 + val lo = it.longitude ?: continue
35 + val key = "${Math.floor(la / cellLat).toInt()}|${Math.floor(lo / cellLon).toInt()}"
36 + cells.getOrPut(key) { mutableListOf() }.add(it)
37 + }
38 + return cells.map { (key, members) ->
39 + val la = members.mapNotNull { it.latitude }.sum() / members.size
40 + val lo = members.mapNotNull { it.longitude }.sum() / members.size
41 + MapCluster(id = "$key:${members.size}", latitude = la, longitude = lo, items = members)
42 + }
43 + .sortedByDescending { it.items.size }
44 + .take(150) // plafond de rendu — jamais de carte qui rame
45 + }
46 +}
modified app/src/main/java/com/groupeka/ka/core/Ecosystem.kt +12 −6
@@ -23,6 +23,8 @@ data class KAItem(
23 23 val city: String? = null,
24 24 var url: String? = null,
25 25 val imageURL: String? = null,
26 + /** Galerie complète (les sites en ont souvent plusieurs) */
27 + val imageURLs: List<String> = emptyList(),
26 28 val latitude: Double? = null,
27 29 val longitude: Double? = null,
28 30 /** Description longue (fiche) */
@@ -79,18 +81,22 @@ object Ecosystem {
79 81 // Aides de mapping communes
80 82 private fun base(o: JsonObject, universe: String, idKeys: List<String> = listOf("uid")): KAItem {
81 83 val id: String? = idKeys.firstNotNullOfOrNull { k -> o[k].text }
82 // première image utilisable : champ `images` (liste) ou `image`
83 var image: String? = o["images"].arrayOrNull
84 ?.mapNotNull { it.text }
85 ?.firstOrNull { it.startsWith("http") }
86 if (image == null) image = o.str("image", "image_url", "photo", "thumbnail")
84 + // galerie : champ `images` (liste) ou `image` — jusqu'à 10, http seulement
85 + var gallery: List<String> = (o["images"].arrayOrNull ?: emptyList())
86 + .mapNotNull { it.text }
87 + .filter { it.startsWith("http") }
88 + .take(10)
89 + if (gallery.isEmpty()) {
90 + o.str("image", "image_url", "photo", "thumbnail")?.let { gallery = listOf(it) }
91 + }
87 92 return KAItem(
88 93 id = "$universe:${id ?: UUID.randomUUID()}",
89 94 universeID = universe,
90 95 title = o.str("title", "name", "display_name") ?: "Sans titre",
91 96 city = o.str("city"),
92 97 url = o.str("url"),
93 imageURL = image,
98 + imageURL = gallery.firstOrNull(),
99 + imageURLs = gallery,
94 100 latitude = o.num("lat", "latitude"),
95 101 longitude = o.num("lng", "lon", "longitude"),
96 102 detail = o.str("description", "menu_summary", "bio")?.strippingHTML()?.trim(),
added app/src/main/java/com/groupeka/ka/core/RecentsStore.kt +172 −0
@@ -0,0 +1,172 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// RecentsStore.kt — historique des éléments consultés, recherches sauvegardées
3 +// et ALERTES honnêtes : à l'ouverture, on recompte le total réel de chaque
4 +// recherche sauvegardée via l'API et on affiche le delta (« +12 nouveautés »)
5 +// — jamais de faux chiffre. Persistance JSON locale. Miroir de RecentsStore.swift.
6 +package com.groupeka.ka.core
7 +
8 +import kotlinx.coroutines.flow.MutableStateFlow
9 +import kotlinx.coroutines.flow.StateFlow
10 +import kotlinx.serialization.Serializable
11 +import kotlinx.serialization.encodeToString
12 +import kotlinx.serialization.json.Json
13 +import java.io.File
14 +import java.util.UUID
15 +
16 +@Serializable
17 +data class SavedSearch(
18 + val id: String = UUID.randomUUID().toString(),
19 + val name: String,
20 + val universeID: String, // "tous" = recherche universelle
21 + val query: String,
22 + val params: Map<String, String> = emptyMap(),
23 + val alertsOn: Boolean = true,
24 + val lastTotal: Int? = null,
25 + val newCount: Int = 0,
26 + val lastChecked: Long? = null, // epoch millis
27 +)
28 +
29 +class RecentsStore(private val file: File) {
30 +
31 + companion object {
32 + @Volatile
33 + var shared: RecentsStore? = null
34 + }
35 +
36 + @Serializable
37 + private data class Snapshot(
38 + val viewed: List<KAItem> = emptyList(),
39 + val searches: List<SavedSearch> = emptyList(),
40 + )
41 +
42 + private val json = Json { ignoreUnknownKeys = true }
43 +
44 + private val _viewed = MutableStateFlow<List<KAItem>>(emptyList())
45 + val viewed: StateFlow<List<KAItem>> = _viewed
46 +
47 + private val _savedSearches = MutableStateFlow<List<SavedSearch>>(emptyList())
48 + val savedSearches: StateFlow<List<SavedSearch>> = _savedSearches
49 +
50 + val alertCount: Int get() = _savedSearches.value.filter { it.alertsOn }.sumOf { it.newCount }
51 +
52 + init {
53 + runCatching { json.decodeFromString<Snapshot>(file.readText()) }.getOrNull()?.let {
54 + _viewed.value = it.viewed
55 + _savedSearches.value = it.searches
56 + }
57 + }
58 +
59 + private fun save() {
60 + runCatching {
61 + file.parentFile?.mkdirs()
62 + file.writeText(json.encodeToString(Snapshot(_viewed.value, _savedSearches.value)))
63 + }
64 + }
65 +
66 + // MARK: historique consulté
67 +
68 + fun record(item: KAItem) {
69 + _viewed.value = (listOf(item) + _viewed.value.filterNot { it.id == item.id }).take(30)
70 + save()
71 + }
72 +
73 + fun clearHistory() {
74 + _viewed.value = emptyList()
75 + save()
76 + }
77 +
78 + // MARK: recherches sauvegardées + alertes
79 +
80 + /** Sauvegarde puis récupère le total initial (réseau) — appeler depuis une coroutine. */
81 + suspend fun saveSearch(name: String, universeID: String, query: String, params: Map<String, String>) {
82 + var s = SavedSearch(
83 + name = name.ifEmpty { query },
84 + universeID = universeID, query = query, params = params,
85 + )
86 + _savedSearches.value = listOf(s) + _savedSearches.value
87 + save()
88 + // total initial (honnête : le vrai champ `total` de l'API)
89 + Ecosystem.universe(universeID)?.let { u ->
90 + val total = UniverseService.total(u, query = query, params = params)
91 + s = s.copy(lastTotal = total, lastChecked = System.currentTimeMillis())
92 + _savedSearches.value = _savedSearches.value.map { if (it.id == s.id) s else it }
93 + save()
94 + }
95 + }
96 +
97 + fun remove(id: String) {
98 + _savedSearches.value = _savedSearches.value.filterNot { it.id == id }
99 + save()
100 + }
101 +
102 + fun setAlerts(id: String, on: Boolean) {
103 + _savedSearches.value = _savedSearches.value.map { if (it.id == id) it.copy(alertsOn = on) else it }
104 + save()
105 + }
106 +
107 + fun markSeen(id: String) {
108 + _savedSearches.value = _savedSearches.value.map {
109 + if (it.id == id) it.copy(lastTotal = (it.lastTotal ?: 0) + it.newCount, newCount = 0) else it
110 + }
111 + save()
112 + }
113 +
114 + /** Recompte chaque recherche (deltas réels). Appelé à l'ouverture de l'app. */
115 + suspend fun refreshAlerts() {
116 + val updated = _savedSearches.value.map { s ->
117 + if (!s.alertsOn) return@map s
118 + val u = Ecosystem.universe(s.universeID) ?: return@map s
119 + val total = UniverseService.total(u, query = s.query, params = s.params) ?: return@map s
120 + when {
121 + s.lastTotal != null && total > s.lastTotal ->
122 + s.copy(newCount = total - s.lastTotal, lastChecked = System.currentTimeMillis())
123 + s.lastTotal == null ->
124 + s.copy(lastTotal = total, lastChecked = System.currentTimeMillis())
125 + else -> s.copy(lastChecked = System.currentTimeMillis())
126 + }
127 + }
128 + _savedSearches.value = updated
129 + save()
130 + }
131 +}
132 +
133 +// MARK: - Collections éditoriales (requêtes RÉELLES, aucun contenu inventé)
134 +
135 +data class EditorialCollection(
136 + val id: String,
137 + val title: String,
138 + val subtitle: String,
139 + val symbol: String,
140 + val universeID: String,
141 + val query: String?,
142 + val params: Map<String, String>,
143 +)
144 +
145 +object Editorial {
146 + val collections: List<EditorialCollection> = listOf(
147 + EditorialCollection(
148 + id = "week-end", title = "Le week-end s'organise",
149 + subtitle = "Sorties gratuites à venir partout au Québec",
150 + symbol = "ticket", universeID = "sorti-ka",
151 + query = null, params = mapOf("free" to "true"),
152 + ),
153 + EditorialCollection(
154 + id = "aubaines-epicerie", title = "Le panier futé",
155 + subtitle = "Les soldes d'épicerie du moment",
156 + symbol = "cart", universeID = "food-ka",
157 + query = null, params = mapOf("on_sale" to "true"),
158 + ),
159 + EditorialCollection(
160 + id = "premiere-auto", title = "Première auto",
161 + subtitle = "Des véhicules récents sous 15 000 $",
162 + symbol = "car", universeID = "auto-ka",
163 + query = null, params = mapOf("price_max" to "15000", "year_min" to "2015"),
164 + ),
165 + EditorialCollection(
166 + id = "quatre-et-demi", title = "Le classique 4½",
167 + subtitle = "Les 4½ fraîchement affichés",
168 + symbol = "key", universeID = "lou-ka",
169 + query = null, params = mapOf("unit_type" to "4½"),
170 + ),
171 + )
172 +}
modified app/src/main/java/com/groupeka/ka/core/Services.kt +54 −0
@@ -116,6 +116,8 @@ object UniverseService {
116 116 limit: Int = 30,
117 117 params: Map<String, String> = emptyMap(),
118 118 ): List<KAItem> {
119 + // Trouve·Ka : /api/search exige `q` (422 sinon) — recherche d'abord.
120 + if (u.id == "trouve-ka" && query.isNullOrEmpty()) return emptyList()
119 121 val url = listURL(u, query, city, limit, params) ?: return emptyList()
120 122 val map = u.map ?: return emptyList()
121 123 val root = ApiClient.fetchJson(url)
@@ -126,6 +128,58 @@ object UniverseService {
126 128 return raw.mapNotNull { el -> el.objectOrNull?.let(map) }
127 129 }
128 130
131 + /** Total d'une requête (champ `total` de la réponse liste) — pour les alertes. */
132 + suspend fun total(u: Universe, query: String? = null, params: Map<String, String> = emptyMap()): Int? {
133 + if (u.id == "trouve-ka" && query.isNullOrEmpty()) return null
134 + val url = listURL(u, query = query, limit = 1, params = params) ?: return null
135 + val root = runCatching { ApiClient.fetchJson(url, ttlSeconds = 60) }.getOrNull() ?: return null
136 + return root.objectOrNull?.num("total", "count")?.toInt()
137 + }
138 +
139 + /**
140 + * Items GÉOLOCALISÉS pour une région de carte.
141 + * lou-ka / immo-ka / job-ka : endpoint geojson avec bbox RÉEL (vérifié).
142 + * Autres univers : liste standard filtrée client sur la région.
143 + */
144 + suspend fun mapItems(
145 + u: Universe,
146 + west: Double, south: Double, east: Double, north: Double,
147 + limit: Int = 150,
148 + ): List<KAItem> {
149 + val bboxCapable = mapOf(
150 + "lou-ka" to "/api/listings.geojson",
151 + "immo-ka" to "/api/listings.geojson",
152 + "job-ka" to "/api/jobs.geojson",
153 + )
154 + val path = bboxCapable[u.id]
155 + val map = u.map
156 + if (path != null && map != null) {
157 + val url = "https://${u.domain}$path?bbox=$west,$south,$east,$north&limit=$limit"
158 + val root = runCatching { ApiClient.fetchJson(url, ttlSeconds = 90) }.getOrNull() ?: return emptyList()
159 + val features = root.objectOrNull?.get("features").arrayOrNull ?: return emptyList()
160 + return features.mapNotNull { f ->
161 + val fo = f.objectOrNull ?: return@mapNotNull null
162 + val props = fo["properties"].objectOrNull ?: return@mapNotNull null
163 + val coords = fo["geometry"].objectOrNull?.get("coordinates").arrayOrNull ?: return@mapNotNull null
164 + if (coords.size < 2) return@mapNotNull null
165 + val lon = coords[0].numberOrNull ?: return@mapNotNull null
166 + val lat = coords[1].numberOrNull ?: return@mapNotNull null
167 + val enriched = JsonObject(props + mapOf(
168 + "lat" to kotlinx.serialization.json.JsonPrimitive(lat),
169 + "lng" to kotlinx.serialization.json.JsonPrimitive(lon),
170 + ))
171 + map(enriched)
172 + }
173 + }
174 + // repli : liste + filtre client
175 + val items = runCatching { fetch(u, limit = limit) }.getOrNull() ?: emptyList()
176 + return items.filter {
177 + val la = it.latitude ?: return@filter false
178 + val lo = it.longitude ?: return@filter false
179 + la in south..north && lo in west..east
180 + }
181 + }
182 +
129 183 /** Total « en direct » depuis /api/stats (clé selon l'univers, chemins a.b acceptés). */
130 184 suspend fun liveTotal(u: Universe): Int? {
131 185 val path = if (u.id == "trouve-ka") "/api/status" else "/api/stats"
added app/src/main/java/com/groupeka/ka/ui/ApiKaPlaygroundScreen.kt +459 −0
@@ -0,0 +1,459 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// ApiKaPlaygroundScreen.kt — le PLAYGROUND API·Ka légendaire : charge
3 +// https://www.api-ka.com/openapi.json en direct, liste les endpoints GET,
4 +// génère le formulaire de paramètres, envoie la requête et affiche la réponse
5 +// JSON colorisée/pliable + statut + latence + « Copier en curl ».
6 +package com.groupeka.ka.ui
7 +
8 +import android.content.ClipData
9 +import android.content.ClipboardManager
10 +import android.content.Context
11 +import androidx.compose.foundation.background
12 +import androidx.compose.foundation.border
13 +import androidx.compose.foundation.clickable
14 +import androidx.compose.foundation.horizontalScroll
15 +import androidx.compose.foundation.layout.Arrangement
16 +import androidx.compose.foundation.layout.Box
17 +import androidx.compose.foundation.layout.Column
18 +import androidx.compose.foundation.layout.PaddingValues
19 +import androidx.compose.foundation.layout.Row
20 +import androidx.compose.foundation.layout.Spacer
21 +import androidx.compose.foundation.layout.fillMaxWidth
22 +import androidx.compose.foundation.layout.padding
23 +import androidx.compose.foundation.layout.size
24 +import androidx.compose.foundation.layout.width
25 +import androidx.compose.foundation.lazy.LazyColumn
26 +import androidx.compose.foundation.rememberScrollState
27 +import androidx.compose.foundation.shape.CircleShape
28 +import androidx.compose.foundation.shape.RoundedCornerShape
29 +import androidx.compose.material.icons.Icons
30 +import androidx.compose.material.icons.automirrored.filled.ArrowBack
31 +import androidx.compose.material.icons.automirrored.filled.Send
32 +import androidx.compose.material.icons.filled.ContentCopy
33 +import androidx.compose.material.icons.filled.ExpandLess
34 +import androidx.compose.material.icons.filled.ExpandMore
35 +import androidx.compose.material3.CircularProgressIndicator
36 +import androidx.compose.material3.Icon
37 +import androidx.compose.material3.IconButton
38 +import androidx.compose.material3.MaterialTheme
39 +import androidx.compose.material3.OutlinedTextField
40 +import androidx.compose.material3.OutlinedTextFieldDefaults
41 +import androidx.compose.material3.Text
42 +import androidx.compose.runtime.Composable
43 +import androidx.compose.runtime.LaunchedEffect
44 +import androidx.compose.runtime.getValue
45 +import androidx.compose.runtime.mutableStateOf
46 +import androidx.compose.runtime.remember
47 +import androidx.compose.runtime.rememberCoroutineScope
48 +import androidx.compose.runtime.setValue
49 +import androidx.compose.ui.Alignment
50 +import androidx.compose.ui.Modifier
51 +import androidx.compose.ui.graphics.Color
52 +import androidx.compose.ui.platform.LocalContext
53 +import androidx.compose.ui.text.SpanStyle
54 +import androidx.compose.ui.text.buildAnnotatedString
55 +import androidx.compose.ui.text.font.FontFamily
56 +import androidx.compose.ui.text.font.FontWeight
57 +import androidx.compose.ui.text.withStyle
58 +import androidx.compose.ui.unit.dp
59 +import androidx.compose.ui.unit.sp
60 +import com.groupeka.ka.core.ApiClient
61 +import com.groupeka.ka.core.Universe
62 +import com.groupeka.ka.core.arrayOrNull
63 +import com.groupeka.ka.core.objectOrNull
64 +import com.groupeka.ka.core.stringOrNull
65 +import com.groupeka.ka.ui.theme.KATheme
66 +import com.groupeka.ka.ui.theme.KAWordmark
67 +import com.groupeka.ka.ui.theme.LocalKADark
68 +import com.groupeka.ka.ui.theme.accent
69 +import com.groupeka.ka.ui.theme.kaCard
70 +import kotlinx.coroutines.Dispatchers
71 +import kotlinx.coroutines.launch
72 +import kotlinx.coroutines.withContext
73 +import kotlinx.serialization.json.Json
74 +import kotlinx.serialization.json.JsonObject
75 +import okhttp3.Request
76 +import java.net.URLEncoder
77 +
78 +// MARK: - modèle d'endpoint
79 +
80 +data class PlaygroundParam(
81 + val name: String,
82 + val where: String, // query | path
83 + val required: Boolean,
84 + val type: String,
85 +)
86 +
87 +data class PlaygroundEndpoint(
88 + val path: String,
89 + val summary: String,
90 + val params: List<PlaygroundParam>,
91 +)
92 +
93 +private val prettyJson = Json { prettyPrint = true; prettyPrintIndent = " " }
94 +
95 +@Composable
96 +fun ApiKaPlaygroundScreen(universe: Universe) {
97 + val dark = LocalKADark.current
98 + val scope = rememberCoroutineScope()
99 + val context = LocalContext.current
100 +
101 + var endpoints by remember { mutableStateOf<List<PlaygroundEndpoint>>(emptyList()) }
102 + var loadError by remember { mutableStateOf(false) }
103 + var selected by remember { mutableStateOf<PlaygroundEndpoint?>(null) }
104 + var values by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
105 + var sending by remember { mutableStateOf(false) }
106 + var status by remember { mutableStateOf<Int?>(null) }
107 + var latencyMs by remember { mutableStateOf<Long?>(null) }
108 + var responseBody by remember { mutableStateOf<String?>(null) }
109 + var lastUrl by remember { mutableStateOf<String?>(null) }
110 +
111 + // — chargement du contrat OpenAPI en direct
112 + LaunchedEffect(Unit) {
113 + val root = runCatching { ApiClient.fetchJson("https://www.api-ka.com/openapi.json", ttlSeconds = 600) }
114 + .getOrNull()
115 + val paths = root.objectOrNull?.get("paths").objectOrNull
116 + if (paths == null) { loadError = true; return@LaunchedEffect }
117 + endpoints = paths.entries.mapNotNull { (path, ops) ->
118 + val get = ops.objectOrNull?.get("get").objectOrNull ?: return@mapNotNull null
119 + val params = (get["parameters"].arrayOrNull ?: emptyList()).mapNotNull { p ->
120 + val po = p.objectOrNull ?: return@mapNotNull null
121 + PlaygroundParam(
122 + name = po["name"].stringOrNull ?: return@mapNotNull null,
123 + where = po["in"].stringOrNull ?: "query",
124 + required = (po["required"] as? kotlinx.serialization.json.JsonPrimitive)?.content == "true",
125 + type = po["schema"].objectOrNull?.get("type").stringOrNull ?: "string",
126 + )
127 + }
128 + PlaygroundEndpoint(
129 + path = path,
130 + summary = get["summary"].stringOrNull ?: path,
131 + params = params,
132 + )
133 + }.sortedBy { it.path }
134 + }
135 +
136 + fun buildUrl(): String? {
137 + val ep = selected ?: return null
138 + var path = ep.path
139 + // paramètres de chemin {x}
140 + for (p in ep.params.filter { it.where == "path" }) {
141 + val v = values[p.name] ?: return null
142 + path = path.replace("{${p.name}}", URLEncoder.encode(v, "UTF-8"))
143 + }
144 + val query = ep.params
145 + .filter { it.where == "query" && !values[it.name].isNullOrBlank() }
146 + .joinToString("&") { "${it.name}=${URLEncoder.encode(values[it.name], "UTF-8")}" }
147 + return "https://www.api-ka.com$path" + if (query.isNotEmpty()) "?$query" else ""
148 + }
149 +
150 + fun send() {
151 + val url = buildUrl() ?: return
152 + sending = true
153 + status = null; latencyMs = null; responseBody = null
154 + lastUrl = url
155 + scope.launch {
156 + withContext(Dispatchers.IO) {
157 + val t0 = System.currentTimeMillis()
158 + try {
159 + val req = Request.Builder().url(url)
160 + .header("User-Agent", "KA-Android/2.0 (+https://www.groupe-ka.com)")
161 + .build()
162 + ApiClient.http.newCall(req).execute().use { resp ->
163 + status = resp.code
164 + latencyMs = System.currentTimeMillis() - t0
165 + val raw = resp.body?.string() ?: ""
166 + responseBody = runCatching {
167 + prettyJson.encodeToString(
168 + kotlinx.serialization.json.JsonElement.serializer(),
169 + Json.parseToJsonElement(raw),
170 + )
171 + }.getOrElse { raw.take(4000) }
172 + }
173 + } catch (e: Exception) {
174 + status = -1
175 + latencyMs = System.currentTimeMillis() - t0
176 + responseBody = "Erreur réseau : ${e.message}"
177 + }
178 + }
179 + sending = false
180 + }
181 + }
182 +
183 + fun copyCurl() {
184 + val url = lastUrl ?: buildUrl() ?: return
185 + val curl = "curl -s \"$url\""
186 + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
187 + cm.setPrimaryClip(ClipData.newPlainText("curl", curl))
188 + }
189 +
190 + LazyColumn(
191 + contentPadding = PaddingValues(16.dp),
192 + verticalArrangement = Arrangement.spacedBy(12.dp),
193 + modifier = Modifier.fillMaxWidth(),
194 + ) {
195 + item {
196 + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
197 + KAWordmark(universe, size = 22f)
198 + Text(
199 + "PLAYGROUND · CONTRAT OPENAPI EN DIRECT",
200 + fontSize = 10.sp,
201 + fontFamily = FontFamily.Monospace,
202 + fontWeight = FontWeight.Bold,
203 + color = universe.accent,
204 + )
205 + Text(
206 + "La donnée de l'écosystème, par API — choisissez un endpoint GET, remplissez les paramètres, envoyez.",
207 + style = MaterialTheme.typography.bodyMedium,
208 + color = KATheme.ink2(dark),
209 + )
210 + }
211 + }
212 +
213 + if (endpoints.isEmpty() && !loadError) {
214 + item {
215 + Row(Modifier.fillMaxWidth().padding(30.dp), horizontalArrangement = Arrangement.Center) {
216 + CircularProgressIndicator(color = universe.accent)
217 + }
218 + }
219 + }
220 + if (loadError) {
221 + item {
222 + Text("Impossible de charger le contrat OpenAPI — réessayez.", color = KATheme.ink2(dark))
223 + }
224 + }
225 +
226 + val sel = selected
227 + if (sel == null) {
228 + // — liste des endpoints GET
229 + endpoints.forEach { ep ->
230 + item(key = ep.path) {
231 + Column(
232 + Modifier
233 + .fillMaxWidth()
234 + .kaCard(universe.accent)
235 + .clickable {
236 + selected = ep
237 + values = emptyMap()
238 + status = null; responseBody = null; latencyMs = null
239 + }
240 + .padding(12.dp),
241 + verticalArrangement = Arrangement.spacedBy(3.dp),
242 + ) {
243 + Row(verticalAlignment = Alignment.CenterVertically) {
244 + Text(
245 + "GET",
246 + fontSize = 10.sp,
247 + fontFamily = FontFamily.Monospace,
248 + fontWeight = FontWeight.Bold,
249 + color = Color.White,
250 + modifier = Modifier
251 + .background(universe.accent, RoundedCornerShape(5.dp))
252 + .padding(horizontal = 6.dp, vertical = 2.dp),
253 + )
254 + Spacer(Modifier.width(8.dp))
255 + Text(
256 + ep.path,
257 + fontSize = 12.sp,
258 + fontFamily = FontFamily.Monospace,
259 + fontWeight = FontWeight.Bold,
260 + color = KATheme.ink(dark),
261 + )
262 + }
263 + Text(ep.summary, style = MaterialTheme.typography.labelMedium, color = KATheme.ink2(dark))
264 + if (ep.params.isNotEmpty()) {
265 + Text(
266 + ep.params.joinToString(" ") { "${it.name}${if (it.required) "*" else ""}" },
267 + fontSize = 10.sp,
268 + fontFamily = FontFamily.Monospace,
269 + color = universe.accent,
270 + )
271 + }
272 + }
273 + }
274 + }
275 + } else {
276 + // — formulaire de l'endpoint choisi
277 + item {
278 + Row(verticalAlignment = Alignment.CenterVertically) {
279 + IconButton(onClick = { selected = null }) {
280 + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Retour aux endpoints", tint = KATheme.ink(dark))
281 + }
282 + Text(
283 + "GET ${sel.path}",
284 + fontSize = 13.sp,
285 + fontFamily = FontFamily.Monospace,
286 + fontWeight = FontWeight.Bold,
287 + color = KATheme.ink(dark),
288 + modifier = Modifier.horizontalScroll(rememberScrollState()),
289 + )
290 + }
291 + }
292 + item { Text(sel.summary, style = MaterialTheme.typography.bodyMedium, color = KATheme.ink2(dark)) }
293 + sel.params.forEach { p ->
294 + item(key = "param-${p.name}") {
295 + OutlinedTextField(
296 + value = values[p.name] ?: "",
297 + onValueChange = { v -> values = if (v.isEmpty()) values - p.name else values + (p.name to v) },
298 + label = {
299 + Text("${p.name}${if (p.required || p.where == "path") " *" else ""} · ${p.type} (${p.where})")
300 + },
301 + singleLine = true,
302 + colors = OutlinedTextFieldDefaults.colors(
303 + focusedBorderColor = universe.accent,
304 + unfocusedBorderColor = KATheme.ink(dark).copy(alpha = 0.4f),
305 + ),
306 + modifier = Modifier.fillMaxWidth(),
307 + )
308 + }
309 + }
310 + item {
311 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
312 + val ready = sel.params.filter { it.where == "path" || it.required }
313 + .all { !values[it.name].isNullOrBlank() }
314 + Row(
315 + Modifier
316 + .background(if (ready && !sending) universe.accent else universe.accent.copy(alpha = 0.4f), CircleShape)
317 + .clickable(enabled = ready && !sending) { send() }
318 + .padding(horizontal = 18.dp, vertical = 11.dp),
319 + verticalAlignment = Alignment.CenterVertically,
320 + ) {
321 + if (sending) {
322 + CircularProgressIndicator(Modifier.size(15.dp), color = Color.White, strokeWidth = 2.dp)
323 + } else {
324 + Icon(Icons.AutoMirrored.Filled.Send, null, tint = Color.White, modifier = Modifier.size(15.dp))
325 + }
326 + Spacer(Modifier.width(7.dp))
327 + Text("Envoyer", fontWeight = FontWeight.Bold, color = Color.White)
328 + }
329 + Row(
330 + Modifier
331 + .border(1.dp, KATheme.ink(dark).copy(alpha = 0.35f), CircleShape)
332 + .clickable { copyCurl() }
333 + .padding(horizontal = 14.dp, vertical = 11.dp),
334 + verticalAlignment = Alignment.CenterVertically,
335 + ) {
336 + Icon(Icons.Filled.ContentCopy, null, tint = KATheme.ink(dark), modifier = Modifier.size(14.dp))
337 + Spacer(Modifier.width(6.dp))
338 + Text("Copier en curl", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = KATheme.ink(dark))
339 + }
340 + }
341 + }
342 + if (status != null) {
343 + item {
344 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
345 + val ok = status in 200..299
346 + Text(
347 + if (status == -1) "ERREUR" else "HTTP $status",
348 + fontSize = 11.sp,
349 + fontFamily = FontFamily.Monospace,
350 + fontWeight = FontWeight.Bold,
351 + color = Color.White,
352 + modifier = Modifier
353 + .background(if (ok) KATheme.green else Color(0xFFC0392B), CircleShape)
354 + .padding(horizontal = 9.dp, vertical = 4.dp),
355 + )
356 + latencyMs?.let {
357 + Text(
358 + "$it ms",
359 + fontSize = 11.sp,
360 + fontFamily = FontFamily.Monospace,
361 + fontWeight = FontWeight.Bold,
362 + color = KATheme.ink2(dark),
363 + )
364 + }
365 + }
366 + }
367 + }
368 + val body = responseBody
369 + if (body != null) {
370 + item {
371 + JsonViewer(body)
372 + }
373 + }
374 + }
375 + }
376 +}
377 +
378 +// MARK: - visionneuse JSON colorisée + pliable par section de 1er niveau
379 +
380 +@Composable
381 +private fun JsonViewer(pretty: String) {
382 + val dark = LocalKADark.current
383 + var collapsed by remember { mutableStateOf(false) }
384 + val lines = pretty.lines()
385 + val shown = if (collapsed) lines.take(20) else lines.take(800)
386 + Column(
387 + Modifier
388 + .fillMaxWidth()
389 + .background(KATheme.inkLight, RoundedCornerShape(12.dp))
390 + .padding(12.dp),
391 + verticalArrangement = Arrangement.spacedBy(6.dp),
392 + ) {
393 + Row(verticalAlignment = Alignment.CenterVertically) {
394 + Text(
395 + "RÉPONSE · ${lines.size} lignes",
396 + fontSize = 9.sp,
397 + fontFamily = FontFamily.Monospace,
398 + fontWeight = FontWeight.Bold,
399 + color = Color(0xFFf5f3ee).copy(alpha = 0.55f),
400 + )
401 + Spacer(Modifier.weight(1f))
402 + if (lines.size > 20) {
403 + Icon(
404 + if (collapsed) Icons.Filled.ExpandMore else Icons.Filled.ExpandLess,
405 + if (collapsed) "Déplier" else "Replier",
406 + tint = KATheme.lime,
407 + modifier = Modifier.clickable { collapsed = !collapsed }.size(20.dp),
408 + )
409 + }
410 + }
411 + Box(Modifier.horizontalScroll(rememberScrollState())) {
412 + Text(
413 + buildAnnotatedString {
414 + shown.forEach { line ->
415 + appendColoredJsonLine(line)
416 + append("\n")
417 + }
418 + if (collapsed && lines.size > 20) {
419 + withStyle(SpanStyle(color = Color(0xFFf5f3ee).copy(alpha = 0.5f))) {
420 + append("… ${lines.size - 20} lignes repliées")
421 + }
422 + }
423 + },
424 + fontSize = 11.sp,
425 + fontFamily = FontFamily.Monospace,
426 + lineHeight = 15.sp,
427 + )
428 + }
429 + }
430 +}
431 +
432 +/** colorisation simple : clés lime, chaînes vertes, nombres orange, ponctuation papier */
433 +private fun androidx.compose.ui.text.AnnotatedString.Builder.appendColoredJsonLine(line: String) {
434 + val paper = Color(0xFFf5f3ee)
435 + val keyRegex = Regex("^(\\s*)\"([^\"]+)\"(\\s*):(.*)$")
436 + val m = keyRegex.find(line)
437 + if (m != null) {
438 + append(m.groupValues[1])
439 + withStyle(SpanStyle(color = Color(0xFFd9f26b), fontWeight = FontWeight.Bold)) {
440 + append("\"${m.groupValues[2]}\"")
441 + }
442 + withStyle(SpanStyle(color = paper.copy(alpha = 0.7f))) { append(":") }
443 + appendColoredJsonValue(m.groupValues[4])
444 + } else {
445 + appendColoredJsonValue(line)
446 + }
447 +}
448 +
449 +private fun androidx.compose.ui.text.AnnotatedString.Builder.appendColoredJsonValue(v: String) {
450 + val paper = Color(0xFFf5f3ee)
451 + val trimmed = v.trim().removeSuffix(",")
452 + val color = when {
453 + trimmed.startsWith("\"") -> Color(0xFF8ce99a) // chaîne
454 + trimmed == "true" || trimmed == "false" || trimmed == "null" -> Color(0xFF74c0fc)
455 + trimmed.toDoubleOrNull() != null -> Color(0xFFffa94d) // nombre
456 + else -> paper.copy(alpha = 0.85f) // ponctuation / structures
457 + }
458 + withStyle(SpanStyle(color = color)) { append(v) }
459 +}
modified app/src/main/java/com/groupeka/ka/ui/HomeScreen.kt +164 −0
@@ -4,6 +4,7 @@
4 4 // du VRAI contenu en direct, et le pouls de l'écosystème (compteurs live).
5 5 package com.groupeka.ka.ui
6 6
7 +import androidx.compose.foundation.background
7 8 import androidx.compose.foundation.clickable
8 9 import androidx.compose.foundation.layout.Arrangement
9 10 import androidx.compose.foundation.layout.Box
@@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
15 16 import androidx.compose.foundation.layout.height
16 17 import androidx.compose.foundation.layout.padding
17 18 import androidx.compose.foundation.layout.width
19 +import androidx.compose.foundation.layout.widthIn
18 20 import androidx.compose.foundation.lazy.LazyColumn
19 21 import androidx.compose.foundation.lazy.LazyRow
20 22 import androidx.compose.material3.CircularProgressIndicator
@@ -34,14 +36,25 @@ import androidx.compose.ui.text.style.TextOverflow
34 36 import androidx.compose.ui.unit.dp
35 37 import androidx.compose.ui.unit.sp
36 38 import androidx.compose.foundation.shape.RoundedCornerShape
39 +import androidx.compose.foundation.layout.size
40 +import androidx.compose.material.icons.Icons
41 +import androidx.compose.material.icons.filled.NotificationsActive
42 +import androidx.compose.material3.Icon
43 +import androidx.compose.runtime.collectAsState
44 +import androidx.compose.ui.graphics.Color
45 +import androidx.compose.ui.text.font.FontFamily
46 +import androidx.compose.foundation.shape.CircleShape
37 47 import androidx.navigation.NavHostController
38 48 import com.groupeka.ka.core.Ecosystem
49 +import com.groupeka.ka.core.Editorial
39 50 import com.groupeka.ka.core.KAItem
40 51 import com.groupeka.ka.core.Prefs
52 +import com.groupeka.ka.core.RecentsStore
41 53 import com.groupeka.ka.core.Universe
42 54 import com.groupeka.ka.core.UniverseService
43 55 import com.groupeka.ka.core.frFormatted
44 56 import com.groupeka.ka.encodeNavArg
57 +import com.groupeka.ka.ui.theme.universeIcon
45 58 import com.groupeka.ka.ui.theme.GroupeKAMark
46 59 import com.groupeka.ka.ui.theme.KAImage
47 60 import com.groupeka.ka.ui.theme.KATheme
@@ -90,7 +103,11 @@ fun HomeScreen(nav: NavHostController) {
90 103 val dark = LocalKADark.current
91 104 var featured by remember { mutableStateOf<List<Pair<Universe, List<KAItem>>>>(emptyList()) }
92 105 var pulse by remember { mutableStateOf<List<Pair<Universe, Int>>>(emptyList()) }
106 + var editorialItems by remember { mutableStateOf<Map<String, List<KAItem>>>(emptyMap()) }
93 107 var loading by remember { mutableStateOf(true) }
108 + val recents = RecentsStore.shared
109 + val viewed = recents?.viewed?.collectAsState()
110 + val savedSearches = recents?.savedSearches?.collectAsState()
94 111
95 112 LaunchedEffect(Unit) {
96 113 loading = true
@@ -107,6 +124,18 @@ fun HomeScreen(nav: NavHostController) {
107 124 val counts = Ecosystem.all.map { u -> async { u to UniverseService.liveTotal(u) } }.awaitAll()
108 125 pulse = counts.mapNotNull { (u, n) -> n?.let { u to it } }.sortedByDescending { it.second }
109 126 }
127 + // collections éditoriales (requêtes réelles)
128 + coroutineScope {
129 + val results = Editorial.collections.mapNotNull { c ->
130 + val u = Ecosystem.universe(c.universeID) ?: return@mapNotNull null
131 + async {
132 + c.id to (runCatching {
133 + UniverseService.fetch(u, query = c.query, limit = 8, params = c.params)
134 + }.getOrNull() ?: emptyList<KAItem>())
135 + }
136 + }.awaitAll()
137 + editorialItems = results.toMap()
138 + }
110 139 }
111 140
112 141 val (title, sub) = greeting()
@@ -157,11 +186,146 @@ fun HomeScreen(nav: NavHostController) {
157 186 }
158 187 }
159 188 }
189 + // — Reprendre : recherches sauvegardées (+N honnête) + derniers consultés
190 + val saved = savedSearches?.value ?: emptyList()
191 + val seen = viewed?.value ?: emptyList()
192 + if (saved.isNotEmpty() || seen.isNotEmpty()) {
193 + item(key = "resume") {
194 + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
195 + Text("Reprendre", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
196 + LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
197 + items(saved.take(3).size) { i ->
198 + val s = saved[i]
199 + Row(
200 + Modifier
201 + .kaCard()
202 + .clickable { nav.navigate("universe/${s.universeID}") }
203 + .padding(horizontal = 12.dp, vertical = 9.dp),
204 + verticalAlignment = Alignment.CenterVertically,
205 + ) {
206 + Icon(Icons.Filled.NotificationsActive, null, tint = KATheme.green, modifier = Modifier.size(13.dp))
207 + Spacer(Modifier.width(6.dp))
208 + Text(s.name, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, maxLines = 1, color = KATheme.ink(dark))
209 + if (s.newCount > 0) {
210 + Spacer(Modifier.width(6.dp))
211 + Text(
212 + "+${s.newCount}",
213 + fontSize = 10.sp,
214 + fontFamily = FontFamily.Monospace,
215 + fontWeight = FontWeight.Bold,
216 + color = Color.White,
217 + modifier = Modifier
218 + .background(Color(0xFFD32F2F), CircleShape)
219 + .padding(horizontal = 6.dp, vertical = 2.dp),
220 + )
221 + }
222 + }
223 + }
224 + items(seen.take(6).size) { i ->
225 + val it2 = seen[i]
226 + val u2 = Ecosystem.universe(it2.universeID)
227 + Row(
228 + Modifier
229 + .kaCard(u2?.let { com.groupeka.ka.ui.theme.colorFromHex(it.accentHex) })
230 + .clickable { nav.navigate("item/${encodeNavArg(it2)}") }
231 + .padding(horizontal = 10.dp, vertical = 7.dp),
232 + verticalAlignment = Alignment.CenterVertically,
233 + ) {
234 + if (it2.imageURL != null) {
235 + KAImage(
236 + url = it2.imageURL,
237 + accent = u2?.accent ?: Color.Gray,
238 + symbol = u2?.symbol ?: "photo",
239 + modifier = Modifier
240 + .size(34.dp)
241 + .clip(RoundedCornerShape(7.dp)),
242 + )
243 + Spacer(Modifier.width(8.dp))
244 + }
245 + Text(
246 + it2.title,
247 + style = MaterialTheme.typography.labelMedium,
248 + fontWeight = FontWeight.SemiBold,
249 + maxLines = 1,
250 + overflow = TextOverflow.Ellipsis,
251 + color = KATheme.ink(dark),
252 + modifier = Modifier.widthIn(max = 180.dp),
253 + )
254 + }
255 + }
256 + }
257 + }
258 + }
259 + }
260 +
160 261 featured.forEach { (u, items) ->
161 262 item(key = u.id) {
162 263 FeaturedSection(u, items, nav)
163 264 }
164 265 }
266 +
267 + // — collections éditoriales : de vraies requêtes, du vrai contenu
268 + Editorial.collections.forEach { c ->
269 + val itemsC = editorialItems[c.id] ?: emptyList()
270 + val uC = Ecosystem.universe(c.universeID)
271 + if (itemsC.isNotEmpty() && uC != null) {
272 + item(key = "edit-${c.id}") {
273 + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
274 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
275 + Icon(universeIcon(c.symbol), null, tint = uC.accent, modifier = Modifier.size(20.dp))
276 + Column {
277 + Text(c.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
278 + Text(c.subtitle, style = MaterialTheme.typography.labelSmall, color = KATheme.ink2(dark))
279 + }
280 + Spacer(Modifier.weight(1f))
281 + Text(
282 + "Tout voir",
283 + style = MaterialTheme.typography.labelMedium,
284 + fontWeight = FontWeight.SemiBold,
285 + color = uC.accent,
286 + modifier = Modifier.clickable { nav.navigate("universe/${uC.id}") },
287 + )
288 + }
289 + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(vertical = 4.dp)) {
290 + items(itemsC.take(8).size) { i ->
291 + val it3 = itemsC[i]
292 + Column(
293 + Modifier
294 + .width(170.dp)
295 + .kaCard(uC.accent)
296 + .clickable { nav.navigate("item/${encodeNavArg(it3)}") },
297 + ) {
298 + KAImage(
299 + url = it3.imageURL, accent = uC.accent, symbol = uC.symbol,
300 + modifier = Modifier
301 + .fillMaxWidth()
302 + .height(96.dp)
303 + .clip(RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)),
304 + )
305 + Column(Modifier.padding(9.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
306 + Text(
307 + it3.title,
308 + style = MaterialTheme.typography.labelMedium,
309 + fontWeight = FontWeight.SemiBold,
310 + maxLines = 2, minLines = 2, overflow = TextOverflow.Ellipsis,
311 + color = KATheme.ink(dark),
312 + )
313 + if (it3.priceLabel != null) {
314 + Text(
315 + it3.priceLabel!!,
316 + style = MaterialTheme.typography.labelSmall,
317 + fontWeight = FontWeight.Bold,
318 + color = uC.accent,
319 + )
320 + }
321 + }
322 + }
323 + }
324 + }
325 + }
326 + }
327 + }
328 + }
165 329 if (loading && featured.isEmpty()) {
166 330 item {
167 331 Row(
modified app/src/main/java/com/groupeka/ka/ui/ItemDetailScreen.kt +204 −23
@@ -55,11 +55,27 @@ import androidx.compose.ui.text.font.FontWeight
55 55 import androidx.compose.ui.unit.dp
56 56 import androidx.compose.ui.unit.sp
57 57 import androidx.navigation.NavHostController
58 +import androidx.compose.foundation.gestures.detectTapGestures
59 +import androidx.compose.foundation.gestures.rememberTransformableState
60 +import androidx.compose.foundation.gestures.transformable
61 +import androidx.compose.foundation.horizontalScroll
62 +import androidx.compose.foundation.pager.HorizontalPager
63 +import androidx.compose.foundation.pager.rememberPagerState
64 +import androidx.compose.foundation.rememberScrollState
65 +import androidx.compose.material.icons.filled.Cancel
66 +import androidx.compose.material.icons.filled.Directions
67 +import androidx.compose.ui.graphics.graphicsLayer
68 +import androidx.compose.ui.input.pointer.pointerInput
69 +import androidx.compose.ui.window.Dialog
70 +import androidx.compose.ui.window.DialogProperties
58 71 import com.groupeka.ka.core.Ecosystem
59 72 import com.groupeka.ka.core.FavoritesStore
60 73 import com.groupeka.ka.core.KAItem
74 +import com.groupeka.ka.core.RecentsStore
61 75 import com.groupeka.ka.core.RestoMenuLoader
62 76 import com.groupeka.ka.core.RestoMenuSection
77 +import com.groupeka.ka.core.UniverseService
78 +import com.groupeka.ka.encodeNavArg
63 79 import com.groupeka.ka.ui.theme.KAChip
64 80 import com.groupeka.ka.ui.theme.KAImage
65 81 import com.groupeka.ka.ui.theme.KATheme
@@ -93,11 +109,20 @@ fun ItemDetailScreen(item: KAItem, nav: NavHostController) {
93 109 var showCollections by remember { mutableStateOf(false) }
94 110 var menu by remember { mutableStateOf<List<RestoMenuSection>>(emptyList()) }
95 111 var expandedSections by remember { mutableStateOf<Set<String>>(emptySet()) }
112 + var similar by remember { mutableStateOf<List<KAItem>>(emptyList()) }
113 + var fullScreenImage by remember { mutableStateOf<String?>(null) }
96 114
97 115 LaunchedEffect(item.id) {
116 + RecentsStore.shared?.record(item)
98 117 if (item.universeID == "resto-ka") {
99 118 menu = RestoMenuLoader.load(uid = item.id.removePrefix("resto-ka:"))
100 119 }
120 + // semblables : même univers, même ville
121 + if (universe?.map != null && similar.isEmpty()) {
122 + val batch = runCatching { UniverseService.fetch(universe, city = item.city, limit = 10) }
123 + .getOrNull() ?: emptyList()
124 + similar = batch.filter { it.id != item.id }.take(6)
125 + }
101 126 }
102 127
103 128 LazyColumn(
@@ -130,16 +155,42 @@ fun ItemDetailScreen(item: KAItem, nav: NavHostController) {
130 155 }
131 156 }
132 157 }
133 if (item.imageURL != null) {
158 + val gallery = if (item.imageURLs.isNotEmpty()) item.imageURLs else listOfNotNull(item.imageURL)
159 + if (gallery.isNotEmpty()) {
134 160 item {
135 KAImage(
136 url = item.imageURL, accent = accent, symbol = universe?.symbol ?: "photo",
137 modifier = Modifier
138 .fillMaxWidth()
139 .height(230.dp)
140 .clip(RoundedCornerShape(16.dp))
141 .border(1.2.dp, KATheme.ink(dark).copy(alpha = 0.3f), RoundedCornerShape(16.dp)),
142 )
161 + // galerie multi-photos : pager + badge compteur + plein écran au tap
162 + val pagerState = rememberPagerState(pageCount = { gallery.size })
163 + Box {
164 + HorizontalPager(
165 + state = pagerState,
166 + modifier = Modifier
167 + .fillMaxWidth()
168 + .height(240.dp)
169 + .clip(RoundedCornerShape(16.dp))
170 + .border(1.2.dp, KATheme.ink(dark).copy(alpha = 0.3f), RoundedCornerShape(16.dp)),
171 + ) { page ->
172 + KAImage(
173 + url = gallery[page], accent = accent, symbol = universe?.symbol ?: "photo",
174 + modifier = Modifier
175 + .fillMaxWidth()
176 + .height(240.dp)
177 + .clickable { fullScreenImage = gallery[page] },
178 + )
179 + }
180 + if (gallery.size > 1) {
181 + Text(
182 + "${pagerState.currentPage + 1}/${gallery.size} photos",
183 + fontSize = 10.sp,
184 + fontWeight = FontWeight.Bold,
185 + color = Color.White,
186 + modifier = Modifier
187 + .align(Alignment.TopEnd)
188 + .padding(8.dp)
189 + .background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(20.dp))
190 + .padding(horizontal = 8.dp, vertical = 4.dp),
191 + )
192 + }
193 + }
143 194 }
144 195 }
145 196 item {
@@ -269,31 +320,161 @@ fun ItemDetailScreen(item: KAItem, nav: NavHostController) {
269 320 }
270 321 }
271 322
272 if (item.url != null) {
323 + if (item.url != null || (item.latitude != null && item.longitude != null)) {
273 324 item {
274 325 Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
275 Box(
276 Modifier
277 .fillMaxWidth()
278 .background(accent, RoundedCornerShape(12.dp))
279 .clickable { openUrl(context, item.url!!) }
280 .padding(vertical = 14.dp),
281 contentAlignment = Alignment.Center,
282 ) {
283 Row(verticalAlignment = Alignment.CenterVertically) {
284 Icon(Icons.AutoMirrored.Filled.OpenInNew, null, tint = Color.White, modifier = Modifier.size(18.dp))
285 Spacer(Modifier.width(8.dp))
286 Text("Voir à la source", fontWeight = FontWeight.Bold, color = Color.White)
326 + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
327 + if (item.url != null) {
328 + Box(
329 + Modifier
330 + .weight(1f)
331 + .background(accent, RoundedCornerShape(12.dp))
332 + .clickable { openUrl(context, item.url!!) }
333 + .padding(vertical = 14.dp),
334 + contentAlignment = Alignment.Center,
335 + ) {
336 + Row(verticalAlignment = Alignment.CenterVertically) {
337 + Icon(Icons.AutoMirrored.Filled.OpenInNew, null, tint = Color.White, modifier = Modifier.size(18.dp))
338 + Spacer(Modifier.width(8.dp))
339 + Text("Voir à la source", fontWeight = FontWeight.Bold, color = Color.White)
340 + }
341 + }
342 + }
343 + if (item.latitude != null && item.longitude != null) {
344 + Box(
345 + Modifier
346 + .background(KATheme.inkLight, RoundedCornerShape(12.dp))
347 + .clickable {
348 + // itinéraire : intent geo: vers l'app de cartes
349 + val geo = "geo:${item.latitude},${item.longitude}?q=${item.latitude},${item.longitude}(${Uri.encode(item.title)})"
350 + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(geo))) }
351 + }
352 + .padding(horizontal = 16.dp, vertical = 14.dp),
353 + contentAlignment = Alignment.Center,
354 + ) {
355 + Row(verticalAlignment = Alignment.CenterVertically) {
356 + Icon(Icons.Filled.Directions, "Itinéraire vers ${item.title}", tint = KATheme.lime, modifier = Modifier.size(18.dp))
357 + Spacer(Modifier.width(6.dp))
358 + Text("Itinéraire", fontWeight = FontWeight.Bold, color = KATheme.lime)
359 + }
360 + }
287 361 }
288 362 }
363 + val host = item.url?.let { runCatching { Uri.parse(it).host }.getOrNull() }
289 364 Text(
290 "Groupe KA est un agrégateur : la transaction se fait chez la source originale.",
365 + if (host != null)
366 + "Source : $host — Groupe KA est un agrégateur, la transaction se fait chez la source originale."
367 + else
368 + "Groupe KA est un agrégateur : la transaction se fait chez la source originale.",
291 369 style = MaterialTheme.typography.labelSmall,
292 370 color = KATheme.ink2(dark).copy(alpha = 0.7f),
293 371 )
294 372 }
295 373 }
296 374 }
375 +
376 + if (similar.isNotEmpty()) {
377 + item {
378 + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
379 + Text(
380 + "Semblables" + (item.city?.let { " à $it" } ?: ""),
381 + style = MaterialTheme.typography.titleMedium,
382 + fontWeight = FontWeight.Bold,
383 + color = KATheme.ink(dark),
384 + )
385 + Row(
386 + Modifier.horizontalScroll(rememberScrollState()),
387 + horizontalArrangement = Arrangement.spacedBy(10.dp),
388 + ) {
389 + similar.forEach { s ->
390 + Column(
391 + Modifier
392 + .width(150.dp)
393 + .kaCard(accent)
394 + .clickable { nav.navigate("item/${encodeNavArg(s)}") },
395 + ) {
396 + KAImage(
397 + url = s.imageURL, accent = accent, symbol = universe?.symbol ?: "photo",
398 + modifier = Modifier
399 + .fillMaxWidth()
400 + .height(84.dp)
401 + .clip(RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)),
402 + )
403 + Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
404 + Text(
405 + s.title,
406 + style = MaterialTheme.typography.labelMedium,
407 + fontWeight = FontWeight.SemiBold,
408 + maxLines = 2, minLines = 2,
409 + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
410 + color = KATheme.ink(dark),
411 + )
412 + if (s.priceLabel != null) {
413 + Text(
414 + s.priceLabel!!,
415 + style = MaterialTheme.typography.labelSmall,
416 + fontWeight = FontWeight.Bold,
417 + color = accent,
418 + )
419 + }
420 + }
421 + }
422 + }
423 + }
424 + }
425 + }
426 + }
427 + }
428 +
429 + // — visionneuse plein écran avec zoom (pincement + double-tap)
430 + val fsi = fullScreenImage
431 + if (fsi != null) {
432 + Dialog(
433 + onDismissRequest = { fullScreenImage = null },
434 + properties = DialogProperties(usePlatformDefaultWidth = false),
435 + ) {
436 + var scale by remember { mutableStateOf(1f) }
437 + var offsetX by remember { mutableStateOf(0f) }
438 + var offsetY by remember { mutableStateOf(0f) }
439 + val transformState = rememberTransformableState { zoomChange, panChange, _ ->
440 + scale = (scale * zoomChange).coerceIn(1f, 5f)
441 + offsetX += panChange.x
442 + offsetY += panChange.y
443 + }
444 + Box(
445 + Modifier
446 + .fillMaxWidth()
447 + .background(Color.Black)
448 + .pointerInput(Unit) {
449 + detectTapGestures(onDoubleTap = {
450 + scale = if (scale > 1.5f) 1f else 2.5f
451 + offsetX = 0f; offsetY = 0f
452 + })
453 + },
454 + ) {
455 + KAImage(
456 + url = fsi, accent = accent, symbol = universe?.symbol ?: "photo",
457 + modifier = Modifier
458 + .fillMaxWidth()
459 + .height(520.dp)
460 + .align(Alignment.Center)
461 + .graphicsLayer(
462 + scaleX = scale, scaleY = scale,
463 + translationX = offsetX, translationY = offsetY,
464 + )
465 + .transformable(transformState),
466 + )
467 + Icon(
468 + Icons.Filled.Cancel, "Fermer la photo",
469 + tint = Color.White.copy(alpha = 0.85f),
470 + modifier = Modifier
471 + .align(Alignment.TopEnd)
472 + .padding(16.dp)
473 + .clickable { fullScreenImage = null }
474 + .size(30.dp),
475 + )
476 + }
477 + }
297 478 }
298 479
299 480 if (showCollections) {
added app/src/main/java/com/groupeka/ka/ui/MapScreen.kt +596 −0
@@ -0,0 +1,596 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// MapScreen.kt — la CARTE LÉGENDAIRE : Mapbox streets-v12 via MapLibre,
3 +// bascule 2D/3D (pitch + bâtiments en fill-extrusion comme Ka Maps web),
4 +// marqueurs-pilules avec PRIX teintés par univers, clustering par grille
5 +// (dégroupage au zoom), « Rechercher dans cette zone » (bbox RÉEL côté API
6 +// pour lou-ka/immo-ka/job-ka, filtre client sinon), fiche compacte au tap →
7 +// fiche complète (état de carte conservé), bascule liste/carte synchronisée.
8 +package com.groupeka.ka.ui
9 +
10 +import android.content.Context
11 +import android.graphics.Bitmap
12 +import android.graphics.Canvas
13 +import android.graphics.Paint
14 +import android.graphics.RectF
15 +import android.graphics.Typeface
16 +import androidx.compose.foundation.background
17 +import androidx.compose.foundation.border
18 +import androidx.compose.foundation.clickable
19 +import androidx.compose.foundation.layout.Arrangement
20 +import androidx.compose.foundation.layout.Box
21 +import androidx.compose.foundation.layout.Column
22 +import androidx.compose.foundation.layout.PaddingValues
23 +import androidx.compose.foundation.layout.Row
24 +import androidx.compose.foundation.layout.Spacer
25 +import androidx.compose.foundation.layout.fillMaxSize
26 +import androidx.compose.foundation.layout.fillMaxWidth
27 +import androidx.compose.foundation.layout.padding
28 +import androidx.compose.foundation.layout.size
29 +import androidx.compose.foundation.layout.width
30 +import androidx.compose.foundation.lazy.LazyColumn
31 +import androidx.compose.foundation.lazy.LazyRow
32 +import androidx.compose.foundation.shape.CircleShape
33 +import androidx.compose.foundation.shape.RoundedCornerShape
34 +import androidx.compose.material.icons.Icons
35 +import androidx.compose.material.icons.automirrored.filled.List
36 +import androidx.compose.material.icons.filled.Cancel
37 +import androidx.compose.material.icons.filled.Map
38 +import androidx.compose.material.icons.filled.Refresh
39 +import androidx.compose.material3.Icon
40 +import androidx.compose.material3.MaterialTheme
41 +import androidx.compose.material3.Text
42 +import androidx.compose.runtime.Composable
43 +import androidx.compose.runtime.DisposableEffect
44 +import androidx.compose.runtime.getValue
45 +import androidx.compose.runtime.mutableStateOf
46 +import androidx.compose.runtime.remember
47 +import androidx.compose.runtime.rememberCoroutineScope
48 +import androidx.compose.runtime.setValue
49 +import androidx.compose.ui.Alignment
50 +import androidx.compose.ui.Modifier
51 +import androidx.compose.ui.draw.clip
52 +import androidx.compose.ui.graphics.Color
53 +import androidx.compose.ui.platform.LocalLifecycleOwner
54 +import androidx.compose.ui.text.font.FontFamily
55 +import androidx.compose.ui.text.font.FontWeight
56 +import androidx.compose.ui.unit.dp
57 +import androidx.compose.ui.unit.sp
58 +import androidx.compose.ui.viewinterop.AndroidView
59 +import androidx.core.graphics.toColorInt
60 +import androidx.lifecycle.Lifecycle
61 +import androidx.lifecycle.LifecycleEventObserver
62 +import androidx.navigation.NavHostController
63 +import com.groupeka.ka.core.ClusterEngine
64 +import com.groupeka.ka.core.Ecosystem
65 +import com.groupeka.ka.core.KAItem
66 +import com.groupeka.ka.core.MapCluster
67 +import com.groupeka.ka.core.UniverseService
68 +import com.groupeka.ka.encodeNavArg
69 +import com.groupeka.ka.ui.theme.KAImage
70 +import com.groupeka.ka.ui.theme.KATheme
71 +import com.groupeka.ka.ui.theme.KAItemRow
72 +import com.groupeka.ka.ui.theme.LocalKADark
73 +import com.groupeka.ka.ui.theme.accent
74 +import com.groupeka.ka.ui.theme.universeIcon
75 +import com.mapbox.mapboxsdk.annotations.IconFactory
76 +import com.mapbox.mapboxsdk.annotations.Marker
77 +import com.mapbox.mapboxsdk.annotations.MarkerOptions
78 +import com.mapbox.mapboxsdk.camera.CameraPosition
79 +import com.mapbox.mapboxsdk.camera.CameraUpdateFactory
80 +import com.mapbox.mapboxsdk.geometry.LatLng
81 +import com.mapbox.mapboxsdk.maps.MapView
82 +import com.mapbox.mapboxsdk.maps.MapboxMap
83 +import com.mapbox.mapboxsdk.maps.Style
84 +import com.mapbox.mapboxsdk.style.expressions.Expression
85 +import com.mapbox.mapboxsdk.style.layers.FillExtrusionLayer
86 +import com.mapbox.mapboxsdk.style.layers.PropertyFactory
87 +import kotlinx.coroutines.async
88 +import kotlinx.coroutines.awaitAll
89 +import kotlinx.coroutines.coroutineScope
90 +import kotlinx.coroutines.launch
91 +
92 +/** Style Mapbox officiel (token public déjà exposé sur les sites Ka Maps). */
93 +const val MAPBOX_TOKEN =
94 + "pk.eyJ1Ijoic3Bib3VjaGVyIiwiYSI6ImNtc3Fyb3k4djAwOTgyenB3dWt6NHBjc2kifQ.poqLf0ADy3lIh28O-pFI2Q"
95 +const val MAPBOX_STYLE = "mapbox://styles/mapbox/streets-v12"
96 +
97 +/** État de carte conservé entre navigations (fiche complète → retour). */
98 +object MapStateHolder {
99 + var lat = 46.81
100 + var lon = -71.21
101 + var zoom = 10.5
102 + var pitch = 0.0
103 + var enabled: Set<String> = setOf("lou-ka", "immo-ka")
104 + var items: List<KAItem> = emptyList()
105 +}
106 +
107 +private val mapUniverseIDs = listOf("lou-ka", "immo-ka", "job-ka", "resto-ka", "sorti-ka")
108 +
109 +@Composable
110 +fun MapScreen(nav: NavHostController) {
111 + val dark = LocalKADark.current
112 + val scope = rememberCoroutineScope()
113 + val lifecycleOwner = LocalLifecycleOwner.current
114 +
115 + var items by remember { mutableStateOf(MapStateHolder.items) }
116 + var enabled by remember { mutableStateOf(MapStateHolder.enabled) }
117 + var loading by remember { mutableStateOf(false) }
118 + var zoneDirty by remember { mutableStateOf(false) }
119 + var selected by remember { mutableStateOf<KAItem?>(null) }
120 + var showList by remember { mutableStateOf(false) }
121 + var is3D by remember { mutableStateOf(MapStateHolder.pitch > 30) }
122 + var resultCount by remember { mutableStateOf(MapStateHolder.items.size) }
123 +
124 + // références vers la carte (hors état Compose)
125 + val mapRef = remember { arrayOfNulls<MapboxMap>(1) }
126 + val mapViewRef = remember { arrayOfNulls<MapView>(1) }
127 + val markerIndex = remember { mutableMapOf<Long, MapCluster>() }
128 +
129 + val mapUniverses = remember { mapUniverseIDs.mapNotNull { Ecosystem.universe(it) } }
130 +
131 + fun visibleItems(): List<KAItem> = items.filter { enabled.contains(it.universeID) }
132 +
133 + fun renderMarkers(context: Context) {
134 + val map = mapRef[0] ?: return
135 + val bounds = map.projection.visibleRegion.latLngBounds
136 + val clusters = ClusterEngine.clusterize(
137 + visibleItems(),
138 + latSpan = bounds.latitudeSpan,
139 + lonSpan = bounds.longitudeSpan,
140 + )
141 + map.clear()
142 + markerIndex.clear()
143 + val factory = IconFactory.getInstance(context)
144 + for (c in clusters) {
145 + val bmp = if (c.isSingle) {
146 + val u = Ecosystem.universe(c.item.universeID)
147 + pillBitmap(
148 + context,
149 + text = c.item.priceLabel?.split(" ")?.take(2)?.joinToString(" ") ?: (u?.name ?: "•"),
150 + colorHex = u?.accentHex ?: "#888888",
151 + selected = selected?.id == c.item.id,
152 + )
153 + } else {
154 + val counts = c.items.groupingBy { it.universeID }.eachCount()
155 + val top = counts.maxByOrNull { it.value }?.key
156 + clusterBitmap(context, c.items.size, Ecosystem.universe(top ?: "")?.accentHex ?: "#888888")
157 + }
158 + val marker = map.addMarker(
159 + MarkerOptions()
160 + .position(LatLng(c.latitude, c.longitude))
161 + .icon(factory.fromBitmap(bmp)),
162 + )
163 + markerIndex[marker.id] = c
164 + }
165 + resultCount = visibleItems().size
166 + }
167 +
168 + suspend fun loadZone(context: Context) {
169 + val map = mapRef[0] ?: return
170 + loading = true
171 + zoneDirty = false
172 + selected = null
173 + val b = map.projection.visibleRegion.latLngBounds
174 + val west = b.longitudeWest
175 + val east = b.longitudeEast
176 + val south = b.latitudeSouth
177 + val north = b.latitudeNorth
178 + val all = coroutineScope {
179 + mapUniverses.filter { enabled.contains(it.id) }.map { u ->
180 + async { UniverseService.mapItems(u, west = west, south = south, east = east, north = north) }
181 + }.awaitAll().flatten()
182 + }
183 + items = all
184 + MapStateHolder.items = all
185 + loading = false
186 + renderMarkers(context)
187 + }
188 +
189 + Box(Modifier.fillMaxSize()) {
190 + AndroidView(
191 + modifier = Modifier.fillMaxSize(),
192 + factory = { context ->
193 + com.mapbox.mapboxsdk.Mapbox.getInstance(
194 + context.applicationContext,
195 + MAPBOX_TOKEN,
196 + com.mapbox.mapboxsdk.WellKnownTileServer.Mapbox,
197 + )
198 + val mapView = MapView(context)
199 + mapViewRef[0] = mapView
200 + mapView.onCreate(null)
201 + mapView.onStart()
202 + mapView.onResume()
203 + mapView.getMapAsync { map ->
204 + mapRef[0] = map
205 + map.uiSettings.isRotateGesturesEnabled = true
206 + map.uiSettings.isTiltGesturesEnabled = true
207 + map.setStyle(Style.Builder().fromUri(MAPBOX_STYLE)) { style ->
208 + if (is3D) add3DBuildings(style)
209 + map.cameraPosition = CameraPosition.Builder()
210 + .target(LatLng(MapStateHolder.lat, MapStateHolder.lon))
211 + .zoom(MapStateHolder.zoom)
212 + .tilt(MapStateHolder.pitch)
213 + .build()
214 + // premier chargement de zone
215 + scope.launch { loadZone(context) }
216 + }
217 + map.addOnCameraIdleListener {
218 + val cam = map.cameraPosition
219 + MapStateHolder.lat = cam.target?.latitude ?: MapStateHolder.lat
220 + MapStateHolder.lon = cam.target?.longitude ?: MapStateHolder.lon
221 + MapStateHolder.zoom = cam.zoom
222 + MapStateHolder.pitch = cam.tilt
223 + zoneDirty = true
224 + renderMarkers(context)
225 + }
226 + map.setOnMarkerClickListener { marker: Marker ->
227 + val c = markerIndex[marker.id] ?: return@setOnMarkerClickListener false
228 + if (c.isSingle) {
229 + selected = c.item
230 + renderMarkers(context)
231 + } else {
232 + // dégroupage : zoom sur le groupe
233 + val cam = map.cameraPosition
234 + map.animateCamera(
235 + CameraUpdateFactory.newCameraPosition(
236 + CameraPosition.Builder()
237 + .target(LatLng(c.latitude, c.longitude))
238 + .zoom(cam.zoom + 1.7)
239 + .tilt(cam.tilt)
240 + .build(),
241 + ),
242 + 420,
243 + )
244 + }
245 + true
246 + }
247 + }
248 + mapView
249 + },
250 + )
251 +
252 + // — cycle de vie MapView (relayé depuis le cycle de vie Compose)
253 + val lifecycle = lifecycleOwner.lifecycle
254 + DisposableEffect(lifecycle) {
255 + val observer = LifecycleEventObserver { _, event ->
256 + val mv = mapViewRef[0] ?: return@LifecycleEventObserver
257 + when (event) {
258 + Lifecycle.Event.ON_START -> mv.onStart()
259 + Lifecycle.Event.ON_RESUME -> mv.onResume()
260 + Lifecycle.Event.ON_PAUSE -> mv.onPause()
261 + Lifecycle.Event.ON_STOP -> mv.onStop()
262 + else -> Unit
263 + }
264 + }
265 + lifecycle.addObserver(observer)
266 + onDispose {
267 + lifecycle.removeObserver(observer)
268 + // la composable quitte l'écran : libérer la surface GL proprement
269 + mapViewRef[0]?.let { mv ->
270 + runCatching { mv.onPause(); mv.onStop(); mv.onDestroy() }
271 + }
272 + mapViewRef[0] = null
273 + mapRef[0] = null
274 + }
275 + }
276 +
277 + // — chips univers (haut)
278 + LazyRow(
279 + horizontalArrangement = Arrangement.spacedBy(8.dp),
280 + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp),
281 + modifier = Modifier
282 + .align(Alignment.TopStart)
283 + .fillMaxWidth()
284 + .background(KATheme.paper(dark).copy(alpha = 0.88f)),
285 + ) {
286 + items(mapUniverses.size) { i ->
287 + val u = mapUniverses[i]
288 + val on = enabled.contains(u.id)
289 + val context = androidx.compose.ui.platform.LocalContext.current
290 + Row(
291 + Modifier
292 + .background(if (on) u.accent else KATheme.surface(dark), CircleShape)
293 + .border(1.dp, KATheme.ink(dark).copy(alpha = 0.25f), CircleShape)
294 + .clickable {
295 + enabled = if (on) enabled - u.id else enabled + u.id
296 + MapStateHolder.enabled = enabled
297 + selected = null
298 + scope.launch { loadZone(context) }
299 + }
300 + .padding(horizontal = 11.dp, vertical = 8.dp),
301 + verticalAlignment = Alignment.CenterVertically,
302 + ) {
303 + Icon(
304 + universeIcon(u.symbol), null,
305 + tint = if (on) Color.White else KATheme.ink2(dark),
306 + modifier = Modifier.size(13.dp),
307 + )
308 + Spacer(Modifier.width(5.dp))
309 + Text(
310 + u.wordmark,
311 + fontSize = 11.sp,
312 + fontWeight = FontWeight.Bold,
313 + fontFamily = FontFamily.Monospace,
314 + color = if (on) Color.White else KATheme.ink2(dark),
315 + )
316 + }
317 + }
318 + }
319 +
320 + // — liste synchronisée (recouvrement)
321 + if (showList) {
322 + LazyColumn(
323 + contentPadding = PaddingValues(start = 12.dp, end = 12.dp, top = 64.dp, bottom = 130.dp),
324 + verticalArrangement = Arrangement.spacedBy(10.dp),
325 + modifier = Modifier
326 + .fillMaxSize()
327 + .background(KATheme.paper(dark).copy(alpha = 0.97f)),
328 + ) {
329 + val vis = visibleItems().take(60)
330 + items(vis.size) { i ->
331 + val it2 = vis[i]
332 + val context = androidx.compose.ui.platform.LocalContext.current
333 + KAItemRow(it2, Modifier.clickable {
334 + showList = false
335 + selected = it2
336 + val map = mapRef[0]
337 + if (map != null && it2.latitude != null && it2.longitude != null) {
338 + map.animateCamera(
339 + CameraUpdateFactory.newCameraPosition(
340 + CameraPosition.Builder()
341 + .target(LatLng(it2.latitude, it2.longitude))
342 + .zoom(14.5).tilt(map.cameraPosition.tilt).build(),
343 + ),
344 + 420,
345 + )
346 + }
347 + renderMarkers(context)
348 + })
349 + }
350 + }
351 + }
352 +
353 + // — pile du bas : fiche compacte + barre d'actions
354 + Column(
355 + Modifier
356 + .align(Alignment.BottomCenter)
357 + .fillMaxWidth()
358 + .padding(horizontal = 12.dp)
359 + .padding(bottom = 10.dp),
360 + verticalArrangement = Arrangement.spacedBy(10.dp),
361 + ) {
362 + val sel = selected
363 + if (sel != null) {
364 + CompactCard(
365 + item = sel,
366 + onOpen = { nav.navigate("item/${encodeNavArg(sel)}") },
367 + onClose = { selected = null },
368 + )
369 + }
370 + Row(verticalAlignment = Alignment.CenterVertically) {
371 + // compteur
372 + Text(
373 + if (loading) "Chargement…" else "$resultCount résultats",
374 + fontSize = 11.sp,
375 + fontWeight = FontWeight.Bold,
376 + fontFamily = FontFamily.Monospace,
377 + color = KATheme.ink(dark),
378 + modifier = Modifier
379 + .background(KATheme.surface(dark).copy(alpha = 0.92f), CircleShape)
380 + .border(1.dp, KATheme.ink(dark).copy(alpha = 0.25f), CircleShape)
381 + .padding(horizontal = 11.dp, vertical = 9.dp),
382 + )
383 + Spacer(Modifier.width(8.dp))
384 + // 2D/3D
385 + val context = androidx.compose.ui.platform.LocalContext.current
386 + Text(
387 + if (is3D) "2D" else "3D",
388 + fontSize = 12.sp,
389 + fontWeight = FontWeight.Bold,
390 + fontFamily = FontFamily.Monospace,
391 + color = if (is3D) KATheme.lime else KATheme.ink(dark),
392 + modifier = Modifier
393 + .background(if (is3D) KATheme.inkLight else KATheme.surface(dark).copy(alpha = 0.92f), CircleShape)
394 + .border(1.dp, KATheme.ink(dark).copy(alpha = 0.25f), CircleShape)
395 + .clickable {
396 + val map = mapRef[0] ?: return@clickable
397 + is3D = !is3D
398 + val style = map.style
399 + if (is3D) style?.let { add3DBuildings(it) }
400 + else style?.removeLayer("ka-3d-buildings")
401 + val cam = map.cameraPosition
402 + map.animateCamera(
403 + CameraUpdateFactory.newCameraPosition(
404 + CameraPosition.Builder()
405 + .target(cam.target)
406 + .zoom(if (is3D) maxOf(cam.zoom, 15.0) else cam.zoom)
407 + .tilt(if (is3D) 60.0 else 0.0)
408 + .build(),
409 + ),
410 + 600,
411 + )
412 + }
413 + .padding(horizontal = 13.dp, vertical = 9.dp),
414 + )
415 + Spacer(Modifier.width(8.dp))
416 + // liste/carte
417 + Icon(
418 + if (showList) Icons.Filled.Map else Icons.AutoMirrored.Filled.List,
419 + if (showList) "Voir la carte" else "Voir la liste des résultats",
420 + tint = KATheme.ink(dark),
421 + modifier = Modifier
422 + .background(KATheme.surface(dark).copy(alpha = 0.92f), CircleShape)
423 + .border(1.dp, KATheme.ink(dark).copy(alpha = 0.25f), CircleShape)
424 + .clickable { showList = !showList }
425 + .padding(9.dp)
426 + .size(18.dp),
427 + )
428 + Spacer(Modifier.weight(1f))
429 + if (zoneDirty && !loading) {
430 + Row(
431 + Modifier
432 + .background(KATheme.inkLight, CircleShape)
433 + .clickable { scope.launch { loadZone(context) } }
434 + .padding(horizontal = 13.dp, vertical = 10.dp),
435 + verticalAlignment = Alignment.CenterVertically,
436 + ) {
437 + Icon(Icons.Filled.Refresh, null, tint = KATheme.lime, modifier = Modifier.size(14.dp))
438 + Spacer(Modifier.width(6.dp))
439 + Text(
440 + "Rechercher dans cette zone",
441 + fontSize = 11.sp,
442 + fontWeight = FontWeight.Bold,
443 + color = KATheme.lime,
444 + )
445 + }
446 + }
447 + }
448 + }
449 + }
450 +}
451 +
452 +// MARK: - fiche compacte
453 +
454 +@Composable
455 +private fun CompactCard(item: KAItem, onOpen: () -> Unit, onClose: () -> Unit) {
456 + val dark = LocalKADark.current
457 + val u = Ecosystem.universe(item.universeID)
458 + Row(
459 + Modifier
460 + .fillMaxWidth()
461 + .background(KATheme.surface(dark), RoundedCornerShape(14.dp))
462 + .border(1.4.dp, KATheme.ink(dark).copy(alpha = 0.5f), RoundedCornerShape(14.dp))
463 + .clickable { onOpen() }
464 + .padding(11.dp),
465 + verticalAlignment = Alignment.CenterVertically,
466 + ) {
467 + if (item.imageURL != null) {
468 + KAImage(
469 + url = item.imageURL, accent = u?.accent ?: Color.Gray, symbol = u?.symbol ?: "photo",
470 + modifier = Modifier.size(58.dp).clip(RoundedCornerShape(9.dp)),
471 + )
472 + Spacer(Modifier.width(10.dp))
473 + }
474 + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
475 + Text(
476 + item.title,
477 + style = MaterialTheme.typography.bodyMedium,
478 + fontWeight = FontWeight.Bold,
479 + maxLines = 1,
480 + color = KATheme.ink(dark),
481 + )
482 + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
483 + if (item.priceLabel != null) {
484 + Text(
485 + item.priceLabel!!,
486 + style = MaterialTheme.typography.labelMedium,
487 + fontWeight = FontWeight.Bold,
488 + color = u?.accent ?: KATheme.ink(dark),
489 + )
490 + }
491 + if (item.city != null) {
492 + Text(item.city, style = MaterialTheme.typography.labelSmall, color = KATheme.ink2(dark))
493 + }
494 + }
495 + Text(
496 + "Toucher pour la fiche complète",
497 + fontSize = 9.sp,
498 + fontFamily = FontFamily.Monospace,
499 + color = KATheme.ink2(dark).copy(alpha = 0.7f),
500 + )
501 + }
502 + Icon(
503 + Icons.Filled.Cancel, "Fermer l'aperçu",
504 + tint = KATheme.ink2(dark),
505 + modifier = Modifier.clickable { onClose() }.size(22.dp),
506 + )
507 + }
508 +}
509 +
510 +// MARK: - couche 3D (bâtiments fill-extrusion, comme Ka Maps web)
511 +
512 +private fun add3DBuildings(style: Style) {
513 + if (style.getLayer("ka-3d-buildings") != null) return
514 + val layer = FillExtrusionLayer("ka-3d-buildings", "composite")
515 + layer.sourceLayer = "building"
516 + layer.setFilter(Expression.eq(Expression.get("extrude"), Expression.literal("true")))
517 + layer.minZoom = 14f
518 + layer.setProperties(
519 + PropertyFactory.fillExtrusionColor("#cfc9bc"),
520 + PropertyFactory.fillExtrusionOpacity(0.78f),
521 + PropertyFactory.fillExtrusionHeight(
522 + Expression.interpolate(
523 + Expression.linear(), Expression.zoom(),
524 + Expression.stop(14f, Expression.literal(0f)),
525 + Expression.stop(15.05f, Expression.get("height")),
526 + ),
527 + ),
528 + PropertyFactory.fillExtrusionBase(Expression.get("min_height")),
529 + )
530 + style.addLayer(layer)
531 +}
532 +
533 +// MARK: - bitmaps de marqueurs (pilule prix / badge de groupe)
534 +
535 +private val bitmapCache = HashMap<String, Bitmap>()
536 +
537 +private fun pillBitmap(context: Context, text: String, colorHex: String, selected: Boolean): Bitmap {
538 + val key = "p|$text|$colorHex|$selected"
539 + bitmapCache[key]?.let { return it }
540 + val density = context.resources.displayMetrics.density
541 + val textSize = 12f * density
542 + val padH = 9f * density
543 + val padV = 6f * density
544 + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
545 + this.textSize = textSize
546 + typeface = Typeface.create(Typeface.DEFAULT_BOLD, Typeface.BOLD)
547 + }
548 + val tw = paint.measureText(text)
549 + val w = (tw + padH * 2).toInt().coerceAtLeast(1)
550 + val h = (textSize + padV * 2).toInt()
551 + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
552 + val canvas = Canvas(bmp)
553 + val bg = Paint(Paint.ANTI_ALIAS_FLAG).apply {
554 + color = if (selected) "#141814".toColorInt() else colorHex.toColorInt()
555 + }
556 + val r = h / 2f
557 + canvas.drawRoundRect(RectF(0f, 0f, w.toFloat(), h.toFloat()), r, r, bg)
558 + val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply {
559 + style = Paint.Style.STROKE
560 + strokeWidth = 1.6f * density
561 + color = "#F2FFFFFF".toColorInt()
562 + }
563 + canvas.drawRoundRect(RectF(1f, 1f, w - 1f, h - 1f), r, r, stroke)
564 + paint.color = if (selected) "#d9f26b".toColorInt() else android.graphics.Color.WHITE
565 + val y = h / 2f - (paint.descent() + paint.ascent()) / 2f
566 + canvas.drawText(text, padH, y, paint)
567 + bitmapCache[key] = bmp
568 + return bmp
569 +}
570 +
571 +private fun clusterBitmap(context: Context, count: Int, colorHex: String): Bitmap {
572 + val key = "c|$count|$colorHex"
573 + bitmapCache[key]?.let { return it }
574 + val density = context.resources.displayMetrics.density
575 + val d = (38f * density).toInt()
576 + val bmp = Bitmap.createBitmap(d, d, Bitmap.Config.ARGB_8888)
577 + val canvas = Canvas(bmp)
578 + val bg = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = colorHex.toColorInt() }
579 + canvas.drawCircle(d / 2f, d / 2f, d / 2f - density, bg)
580 + val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply {
581 + style = Paint.Style.STROKE
582 + strokeWidth = 2f * density
583 + color = android.graphics.Color.WHITE
584 + }
585 + canvas.drawCircle(d / 2f, d / 2f, d / 2f - 1.5f * density, stroke)
586 + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
587 + textSize = 13f * density
588 + typeface = Typeface.create(Typeface.DEFAULT_BOLD, Typeface.BOLD)
589 + color = android.graphics.Color.WHITE
590 + textAlign = Paint.Align.CENTER
591 + }
592 + val y = d / 2f - (paint.descent() + paint.ascent()) / 2f
593 + canvas.drawText(count.toString(), d / 2f, y, paint)
594 + bitmapCache[key] = bmp
595 + return bmp
596 +}
modified app/src/main/java/com/groupeka/ka/ui/ProfileScreen.kt +64 −1
@@ -26,8 +26,11 @@ import androidx.compose.material.icons.Icons
26 26 import androidx.compose.material.icons.automirrored.filled.Logout
27 27 import androidx.compose.material.icons.automirrored.filled.OpenInNew
28 28 import androidx.compose.material.icons.filled.AccountCircle
29 +import androidx.compose.material.icons.filled.Favorite
30 +import androidx.compose.material.icons.filled.History
29 31 import androidx.compose.material.icons.filled.Language
30 32 import androidx.compose.material.icons.filled.MonitorHeart
33 +import androidx.compose.material.icons.filled.NotificationsActive
31 34 import androidx.compose.material.icons.filled.Star
32 35 import androidx.compose.material.icons.filled.StarBorder
33 36 import androidx.compose.material3.CircularProgressIndicator
@@ -50,10 +53,13 @@ import androidx.compose.ui.text.font.FontWeight
50 53 import androidx.compose.ui.unit.dp
51 54 import androidx.compose.ui.unit.sp
52 55 import coil.compose.SubcomposeAsyncImage
56 +import androidx.navigation.NavHostController
53 57 import com.groupeka.ka.auth.KAIDManager
54 58 import com.groupeka.ka.auth.KAIDProfile
55 59 import com.groupeka.ka.core.Ecosystem
60 +import com.groupeka.ka.core.FavoritesStore
56 61 import com.groupeka.ka.core.Prefs
62 +import com.groupeka.ka.core.RecentsStore
57 63 import com.groupeka.ka.ui.theme.KATheme
58 64 import com.groupeka.ka.ui.theme.LocalKADark
59 65 import com.groupeka.ka.ui.theme.accent
@@ -62,13 +68,19 @@ import com.groupeka.ka.ui.theme.kaCard
62 68 import com.groupeka.ka.ui.theme.universeIcon
63 69
64 70 @Composable
65 fun ProfileScreen() {
71 +fun ProfileScreen(nav: NavHostController) {
66 72 val dark = LocalKADark.current
67 73 val context = LocalContext.current
68 74 val profile by KAIDManager.profile.collectAsState()
69 75 val busy by KAIDManager.busy.collectAsState()
70 76 val lastError by KAIDManager.lastError.collectAsState()
71 77 val favIDs = Prefs.favUniverses.split(",").filter { it.isNotEmpty() }.toSet()
78 + val favCollections = FavoritesStore.shared?.collections?.collectAsState()
79 + val favCount = favCollections?.value?.sumOf { it.items.size } ?: 0
80 + val recents = RecentsStore.shared
81 + val viewed = recents?.viewed?.collectAsState()
82 + val savedSearches = recents?.savedSearches?.collectAsState()
83 + val alertCount = savedSearches?.value?.filter { it.alertsOn }?.sumOf { it.newCount } ?: 0
72 84
73 85 LazyColumn(
74 86 contentPadding = PaddingValues(16.dp),
@@ -138,6 +150,25 @@ fun ProfileScreen() {
138 150 }
139 151 }
140 152
153 + // — Mes contenus : hub Favoris / Historique / Recherches & alertes
154 + item {
155 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
156 + Text("Mes contenus", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
157 + Column(Modifier.fillMaxWidth().kaCard()) {
158 + HubRow(Icons.Filled.Favorite, "Favoris & collections", "$favCount") { nav.navigate("favorites") }
159 + HorizontalDivider(color = KATheme.ink(dark).copy(alpha = 0.08f))
160 + HubRow(Icons.Filled.History, "Historique consulté", "${viewed?.value?.size ?: 0}") { nav.navigate("history") }
161 + HorizontalDivider(color = KATheme.ink(dark).copy(alpha = 0.08f))
162 + HubRow(
163 + Icons.Filled.NotificationsActive,
164 + "Recherches sauvegardées & alertes",
165 + if (alertCount > 0) "+$alertCount nouv." else "${savedSearches?.value?.size ?: 0}",
166 + badgeAccent = alertCount > 0,
167 + ) { nav.navigate("alerts") }
168 + }
169 + }
170 + }
171 +
141 172 // — Univers favoris
142 173 item {
143 174 Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
@@ -261,6 +292,38 @@ fun ProfileScreen() {
261 292 }
262 293 }
263 294
295 +@Composable
296 +private fun HubRow(
297 + icon: androidx.compose.ui.graphics.vector.ImageVector,
298 + label: String,
299 + badge: String,
300 + badgeAccent: Boolean = false,
301 + onClick: () -> Unit,
302 +) {
303 + val dark = LocalKADark.current
304 + Row(
305 + Modifier
306 + .fillMaxWidth()
307 + .clickable { onClick() }
308 + .padding(vertical = 12.dp, horizontal = 14.dp),
309 + verticalAlignment = Alignment.CenterVertically,
310 + ) {
311 + Icon(icon, null, tint = KATheme.green, modifier = Modifier.size(20.dp))
312 + Spacer(Modifier.width(10.dp))
313 + Text(label, style = MaterialTheme.typography.bodyMedium, color = KATheme.ink(dark), modifier = Modifier.weight(1f))
314 + Text(
315 + badge,
316 + fontSize = 10.sp,
317 + fontFamily = FontFamily.Monospace,
318 + fontWeight = FontWeight.Bold,
319 + color = if (badgeAccent) Color.White else KATheme.ink2(dark),
320 + modifier = if (badgeAccent) {
321 + Modifier.background(Color(0xFFD32F2F), CircleShape).padding(horizontal = 7.dp, vertical = 2.dp)
322 + } else Modifier,
323 + )
324 + }
325 +}
326 +
264 327 @Composable
265 328 private fun ProfileLinkRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, tint: Color? = null, onClick: () -> Unit) {
266 329 val dark = LocalKADark.current
added app/src/main/java/com/groupeka/ka/ui/RecentsScreens.kt +186 −0
@@ -0,0 +1,186 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// RecentsScreens.kt — Historique consulté (30 derniers, dédupliqués) et
3 +// Recherches sauvegardées & ALERTES honnêtes (+N = delta réel du total API).
4 +package com.groupeka.ka.ui
5 +
6 +import androidx.compose.foundation.background
7 +import androidx.compose.foundation.clickable
8 +import androidx.compose.foundation.layout.Arrangement
9 +import androidx.compose.foundation.layout.Column
10 +import androidx.compose.foundation.layout.PaddingValues
11 +import androidx.compose.foundation.layout.Row
12 +import androidx.compose.foundation.layout.Spacer
13 +import androidx.compose.foundation.layout.fillMaxWidth
14 +import androidx.compose.foundation.layout.padding
15 +import androidx.compose.foundation.layout.width
16 +import androidx.compose.foundation.lazy.LazyColumn
17 +import androidx.compose.foundation.shape.CircleShape
18 +import androidx.compose.material.icons.Icons
19 +import androidx.compose.material.icons.automirrored.filled.ArrowBack
20 +import androidx.compose.material.icons.filled.Delete
21 +import androidx.compose.material.icons.filled.NotificationsOff
22 +import androidx.compose.material.icons.filled.Refresh
23 +import androidx.compose.material.icons.filled.Schedule
24 +import androidx.compose.material3.Icon
25 +import androidx.compose.material3.IconButton
26 +import androidx.compose.material3.MaterialTheme
27 +import androidx.compose.material3.Switch
28 +import androidx.compose.material3.Text
29 +import androidx.compose.material3.TextButton
30 +import androidx.compose.runtime.Composable
31 +import androidx.compose.runtime.collectAsState
32 +import androidx.compose.runtime.getValue
33 +import androidx.compose.runtime.rememberCoroutineScope
34 +import androidx.compose.ui.Alignment
35 +import androidx.compose.ui.Modifier
36 +import androidx.compose.ui.graphics.Color
37 +import androidx.compose.ui.text.font.FontFamily
38 +import androidx.compose.ui.text.font.FontWeight
39 +import androidx.compose.ui.unit.dp
40 +import androidx.compose.ui.unit.sp
41 +import androidx.navigation.NavHostController
42 +import com.groupeka.ka.core.Ecosystem
43 +import com.groupeka.ka.core.RecentsStore
44 +import com.groupeka.ka.encodeNavArg
45 +import com.groupeka.ka.ui.theme.KAChip
46 +import com.groupeka.ka.ui.theme.KAEmptyState
47 +import com.groupeka.ka.ui.theme.KAItemRow
48 +import com.groupeka.ka.ui.theme.KATheme
49 +import com.groupeka.ka.ui.theme.LocalKADark
50 +import com.groupeka.ka.ui.theme.kaCard
51 +import kotlinx.coroutines.launch
52 +
53 +// MARK: - Historique consulté
54 +
55 +@Composable
56 +fun HistoryScreen(nav: NavHostController) {
57 + val dark = LocalKADark.current
58 + val store = RecentsStore.shared ?: return
59 + val viewed by store.viewed.collectAsState()
60 +
61 + LazyColumn(
62 + contentPadding = PaddingValues(14.dp),
63 + verticalArrangement = Arrangement.spacedBy(10.dp),
64 + modifier = Modifier.fillMaxWidth(),
65 + ) {
66 + item {
67 + Row(verticalAlignment = Alignment.CenterVertically) {
68 + IconButton(onClick = { nav.popBackStack() }) {
69 + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Retour", tint = KATheme.ink(dark))
70 + }
71 + Text("Historique", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
72 + Spacer(Modifier.weight(1f))
73 + if (viewed.isNotEmpty()) {
74 + TextButton(onClick = { store.clearHistory() }) {
75 + Text("Effacer", color = KATheme.ink2(dark))
76 + }
77 + }
78 + }
79 + }
80 + if (viewed.isEmpty()) {
81 + item {
82 + KAEmptyState(
83 + Icons.Filled.Schedule, "Rien de consulté encore",
84 + "Les fiches que vous ouvrez apparaîtront ici.",
85 + )
86 + }
87 + }
88 + viewed.forEach { item ->
89 + item(key = item.id) {
90 + KAItemRow(item, Modifier.clickable { nav.navigate("item/${encodeNavArg(item)}") })
91 + }
92 + }
93 + }
94 +}
95 +
96 +// MARK: - Recherches sauvegardées & alertes
97 +
98 +@Composable
99 +fun SavedSearchesScreen(nav: NavHostController) {
100 + val dark = LocalKADark.current
101 + val scope = rememberCoroutineScope()
102 + val store = RecentsStore.shared ?: return
103 + val searches by store.savedSearches.collectAsState()
104 +
105 + LazyColumn(
106 + contentPadding = PaddingValues(14.dp),
107 + verticalArrangement = Arrangement.spacedBy(12.dp),
108 + modifier = Modifier.fillMaxWidth(),
109 + ) {
110 + item {
111 + Row(verticalAlignment = Alignment.CenterVertically) {
112 + IconButton(onClick = { nav.popBackStack() }) {
113 + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Retour", tint = KATheme.ink(dark))
114 + }
115 + Text("Recherches & alertes", fontSize = 22.sp, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
116 + Spacer(Modifier.weight(1f))
117 + IconButton(onClick = { scope.launch { store.refreshAlerts() } }) {
118 + Icon(Icons.Filled.Refresh, "Recompter maintenant", tint = KATheme.green)
119 + }
120 + }
121 + }
122 + if (searches.isEmpty()) {
123 + item {
124 + KAEmptyState(
125 + Icons.Filled.NotificationsOff, "Aucune recherche sauvegardée",
126 + "Dans Recherche, touchez 🔔 pour suivre une recherche : KA recomptera les résultats et affichera les nouveautés.",
127 + )
128 + }
129 + }
130 + searches.forEach { s ->
131 + item(key = s.id) {
132 + Column(
133 + Modifier.fillMaxWidth().kaCard().padding(14.dp),
134 + verticalArrangement = Arrangement.spacedBy(6.dp),
135 + ) {
136 + Row(verticalAlignment = Alignment.CenterVertically) {
137 + Text(s.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark), modifier = Modifier.weight(1f))
138 + if (s.newCount > 0) {
139 + Text(
140 + "+${s.newCount} nouveautés",
141 + fontSize = 10.sp,
142 + fontFamily = FontFamily.Monospace,
143 + fontWeight = FontWeight.Bold,
144 + color = Color.White,
145 + modifier = Modifier
146 + .background(Color(0xFFD32F2F), CircleShape)
147 + .padding(horizontal = 7.dp, vertical = 3.dp),
148 + )
149 + Spacer(Modifier.width(8.dp))
150 + }
151 + Switch(
152 + checked = s.alertsOn,
153 + onCheckedChange = { store.setAlerts(s.id, it) },
154 + )
155 + }
156 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
157 + Ecosystem.universe(s.universeID)?.let { u -> KAChip(u.wordmark, com.groupeka.ka.ui.theme.colorFromHex(u.accentHex)) }
158 + Text("« ${s.query} »", style = MaterialTheme.typography.labelMedium, color = KATheme.ink2(dark), modifier = Modifier.weight(1f))
159 + s.lastTotal?.let { total ->
160 + Text(
161 + "${total + s.newCount} résultats",
162 + fontSize = 10.sp,
163 + fontFamily = FontFamily.Monospace,
164 + color = KATheme.ink2(dark),
165 + )
166 + }
167 + }
168 + Row(verticalAlignment = Alignment.CenterVertically) {
169 + if (s.newCount > 0) {
170 + TextButton(onClick = { store.markSeen(s.id) }) {
171 + Text("Marquer comme vues", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = KATheme.green)
172 + }
173 + }
174 + TextButton(onClick = { nav.navigate("universe/${s.universeID}") }) {
175 + Text("Ouvrir l'univers →", fontSize = 12.sp, color = KATheme.ink2(dark))
176 + }
177 + Spacer(Modifier.weight(1f))
178 + IconButton(onClick = { store.remove(s.id) }) {
179 + Icon(Icons.Filled.Delete, "Supprimer", tint = KATheme.ink2(dark))
180 + }
181 + }
182 + }
183 + }
184 + }
185 + }
186 +}
modified app/src/main/java/com/groupeka/ka/ui/SearchScreen.kt +100 −1
@@ -20,15 +20,21 @@ import androidx.compose.foundation.lazy.LazyRow
20 20 import androidx.compose.foundation.shape.CircleShape
21 21 import androidx.compose.foundation.text.KeyboardActions
22 22 import androidx.compose.foundation.text.KeyboardOptions
23 +import androidx.compose.foundation.layout.size
23 24 import androidx.compose.material.icons.Icons
24 25 import androidx.compose.material.icons.filled.History
26 +import androidx.compose.material.icons.filled.NotificationAdd
25 27 import androidx.compose.material.icons.filled.Search
28 +import androidx.compose.material.icons.filled.Share
29 +import androidx.compose.material3.AlertDialog
26 30 import androidx.compose.material3.CircularProgressIndicator
27 31 import androidx.compose.material3.Icon
28 32 import androidx.compose.material3.MaterialTheme
29 33 import androidx.compose.material3.OutlinedTextField
30 34 import androidx.compose.material3.OutlinedTextFieldDefaults
31 35 import androidx.compose.material3.Text
36 +import androidx.compose.material3.TextButton
37 +import androidx.compose.ui.platform.LocalContext
32 38 import androidx.compose.runtime.Composable
33 39 import androidx.compose.runtime.getValue
34 40 import androidx.compose.runtime.mutableStateOf
@@ -48,6 +54,7 @@ import androidx.navigation.NavHostController
48 54 import com.groupeka.ka.core.Ecosystem
49 55 import com.groupeka.ka.core.KAItem
50 56 import com.groupeka.ka.core.Prefs
57 +import com.groupeka.ka.core.RecentsStore
51 58 import com.groupeka.ka.core.Universe
52 59 import com.groupeka.ka.core.UniverseService
53 60 import com.groupeka.ka.encodeNavArg
@@ -67,16 +74,30 @@ import kotlinx.coroutines.launch
67 74 fun SearchScreen(nav: NavHostController) {
68 75 val dark = LocalKADark.current
69 76 val scope = rememberCoroutineScope()
77 + val context = LocalContext.current
70 78 val keyboard = LocalSoftwareKeyboardController.current
71 79 var query by remember { mutableStateOf("") }
72 80 var submitted by remember { mutableStateOf("") }
73 81 var sections by remember { mutableStateOf<List<Pair<Universe, List<KAItem>>>>(emptyList()) }
74 82 var searching by remember { mutableStateOf(false) }
75 83 var selected by remember { mutableStateOf<Set<String>>(emptySet()) } // filtres univers (vide = tous)
84 + var sort by remember { mutableStateOf("pertinence") } // pertinence / prixAsc / prixDesc
85 + var showSave by remember { mutableStateOf(false) }
86 + var saveName by remember { mutableStateOf("") }
76 87
77 88 val searchables = remember { Ecosystem.all.filter { it.map != null } }
78 89 val history = Prefs.searchHistory.split("\n").filter { it.isNotEmpty() }
79 90
91 + fun price(i: KAItem, asc: Boolean): Double =
92 + i.priceLabel?.filter { it.isDigit() }?.toDoubleOrNull()
93 + ?: if (asc) Double.MAX_VALUE else 0.0
94 +
95 + fun sorted(items: List<KAItem>): List<KAItem> = when (sort) {
96 + "prixAsc" -> items.sortedBy { price(it, true) }
97 + "prixDesc" -> items.sortedByDescending { price(it, false) }
98 + else -> items
99 + }
100 +
80 101 suspend fun doSearch(text: String) {
81 102 val q = text.trim()
82 103 if (q.isEmpty()) return
@@ -127,6 +148,51 @@ fun SearchScreen(nav: NavHostController) {
127 148 modifier = Modifier.fillMaxWidth(),
128 149 )
129 150 }
151 + if (submitted.isNotEmpty() && !searching) {
152 + item {
153 + // tri + sauvegarde/alerte + partage
154 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
155 + listOf("pertinence" to "Pertinence", "prixAsc" to "Prix ↑", "prixDesc" to "Prix ↓").forEach { (v, label) ->
156 + val on = sort == v
157 + Text(
158 + label,
159 + fontSize = 11.sp,
160 + fontWeight = FontWeight.Bold,
161 + fontFamily = FontFamily.Monospace,
162 + color = if (on) Color.White else KATheme.ink2(dark),
163 + modifier = Modifier
164 + .background(if (on) KATheme.green else KATheme.surface(dark), CircleShape)
165 + .border(1.dp, KATheme.ink(dark).copy(alpha = 0.3f), CircleShape)
166 + .clickable { sort = v }
167 + .padding(horizontal = 10.dp, vertical = 7.dp),
168 + )
169 + }
170 + Spacer(Modifier.weight(1f))
171 + Icon(
172 + Icons.Filled.NotificationAdd, "Sauvegarder cette recherche et créer une alerte",
173 + tint = KATheme.green,
174 + modifier = Modifier
175 + .clickable { saveName = submitted; showSave = true }
176 + .padding(4.dp)
177 + .size(22.dp),
178 + )
179 + Icon(
180 + Icons.Filled.Share, "Partager la recherche",
181 + tint = KATheme.ink2(dark),
182 + modifier = Modifier
183 + .clickable {
184 + shareUrl(
185 + context,
186 + "https://www.trouve-ka.com/search?q=" + java.net.URLEncoder.encode(submitted, "UTF-8"),
187 + "Recherche KA : $submitted",
188 + )
189 + }
190 + .padding(4.dp)
191 + .size(20.dp),
192 + )
193 + }
194 + }
195 + }
130 196 item {
131 197 // chips filtres univers
132 198 LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -232,7 +298,7 @@ fun SearchScreen(nav: NavHostController) {
232 298 )
233 299 }
234 300 }
235 items.take(5).forEach { item ->
301 + sorted(items).take(5).forEach { item ->
236 302 item(key = "it-${item.id}") {
237 303 KAItemRow(item, Modifier.clickable { nav.navigate("item/${encodeNavArg(item)}") })
238 304 }
@@ -249,4 +315,37 @@ fun SearchScreen(nav: NavHostController) {
249 315 }
250 316 }
251 317 }
318 +
319 + // — sauvegarde de recherche + alerte honnête
320 + if (showSave) {
321 + AlertDialog(
322 + onDismissRequest = { showSave = false },
323 + title = { Text("Sauvegarder la recherche") },
324 + text = {
325 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
326 + Text("KA recomptera les résultats à chaque ouverture et vous montrera les nouveautés (+N).")
327 + OutlinedTextField(
328 + value = saveName,
329 + onValueChange = { saveName = it },
330 + placeholder = { Text("Nom") },
331 + singleLine = true,
332 + )
333 + }
334 + },
335 + confirmButton = {
336 + TextButton(onClick = {
337 + val uid = if (selected.size == 1) selected.first()
338 + else sections.maxByOrNull { it.second.size }?.first?.id ?: "lou-ka"
339 + val name = saveName
340 + val q = submitted
341 + showSave = false
342 + scope.launch { RecentsStore.shared?.saveSearch(name, uid, q, emptyMap()) }
343 + }) { Text("Sauvegarder + alerte", fontWeight = FontWeight.Bold, color = KATheme.green) }
344 + },
345 + dismissButton = {
346 + TextButton(onClick = { showSave = false }) { Text("Annuler") }
347 + },
348 + containerColor = KATheme.surface(dark),
349 + )
350 + }
252 351 }
modified app/src/main/java/com/groupeka/ka/ui/UniversesScreen.kt +17 −1
@@ -172,6 +172,7 @@ private fun UniverseCard(universe: Universe, total: Int?, modifier: Modifier = M
172 172 fun UniverseHomeScreen(universe: Universe, nav: NavHostController) {
173 173 when {
174 174 universe.id == "vrai-prix" -> VraiPrixScreen(universe, nav)
175 + universe.id == "api-ka" -> ApiKaPlaygroundScreen(universe)
175 176 universe.listPath == null -> WebUniverse(universe)
176 177 else -> UniverseList(universe, nav)
177 178 }
@@ -223,7 +224,15 @@ private fun UniverseList(universe: Universe, nav: NavHostController) {
223 224 var liveTotal by remember { mutableStateOf<Int?>(null) }
224 225 var showSheet by remember { mutableStateOf(false) }
225 226
227 + // Trouve·Ka = « recherche d'abord » : /api/search exige q (422 sinon)
228 + val searchFirst = universe.id == "trouve-ka"
229 +
226 230 suspend fun load() {
231 + if (searchFirst && query.isBlank()) {
232 + items = emptyList()
233 + loading = false
234 + return
235 + }
227 236 loading = items.isEmpty()
228 237 errorText = null
229 238 try {
@@ -321,7 +330,14 @@ private fun UniverseList(universe: Universe, nav: NavHostController) {
321 330 }
322 331 } else if (items.isEmpty()) {
323 332 item {
324 KAEmptyState(Icons.Filled.Inbox, "Aucun résultat", "Essayez d'autres mots-clés.")
333 + if (searchFirst && query.isBlank()) {
334 + KAEmptyState(
335 + Icons.Filled.Search, "Cherchez le web québécois",
336 + "1,2 M de pages d'ici indexées — tapez un mot dans la barre de recherche ci-dessus.",
337 + )
338 + } else {
339 + KAEmptyState(Icons.Filled.Inbox, "Aucun résultat", "Essayez d'autres mots-clés.")
340 + }
325 341 }
326 342 } else {
327 343 items.forEach { it ->
modified app/src/main/res/drawable/splash.xml +2 −1
@@ -1,6 +1,7 @@
1 1 <?xml version="1.0" encoding="utf-8"?>
2 2 <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
3 <item android:drawable="@color/ka_ink" />
3 + <!-- fond blanc, comme l'icône « liquid glass » -->
4 + <item android:drawable="@android:color/white" />
4 5 <item android:gravity="center">
5 6 <bitmap
6 7 android:src="@mipmap/ic_launcher"
modified app/src/main/res/mipmap-hdpi/ic_launcher.png +0 −0

Binary file not shown.

modified app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 −0

Binary file not shown.

modified app/src/main/res/mipmap-mdpi/ic_launcher.png +0 −0

Binary file not shown.

modified app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 −0

Binary file not shown.

modified app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 −0

Binary file not shown.

modified app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 −0

Binary file not shown.

modified app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 −0

Binary file not shown.

modified app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 −0

Binary file not shown.

modified app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 −0

Binary file not shown.

modified app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 −0

Binary file not shown.

modified app/src/main/res/values/themes.xml +3 −3
@@ -8,10 +8,10 @@
8 8 <item name="android:navigationBarColor">@color/ka_paper</item>
9 9 </style>
10 10
11 <!-- Splash simple encre avec l'icône KA lime centrée -->
11 + <!-- Splash : icône « liquid glass » centrée sur fond blanc -->
12 12 <style name="Theme.KA.Splash" parent="Theme.KA">
13 13 <item name="android:windowBackground">@drawable/splash</item>
14 <item name="android:statusBarColor">@color/ka_ink</item>
15 <item name="android:windowLightStatusBar">false</item>
14 + <item name="android:statusBarColor">@android:color/white</item>
15 + <item name="android:windowLightStatusBar">true</item>
16 16 </style>
17 17 </resources>
added app/src/test/java/com/groupeka/ka/RefonteTests.kt +161 −0
@@ -0,0 +1,161 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// RefonteTests.kt — tests de la refonte v2 : clustering par grille (miroir
3 +// iOS), historique consulté (dédupliqué, plafond 30), alertes honnêtes
4 +// (markSeen), galerie multi-photos, garde Trouve-Ka (q obligatoire).
5 +package com.groupeka.ka
6 +
7 +import com.groupeka.ka.core.ClusterEngine
8 +import com.groupeka.ka.core.Ecosystem
9 +import com.groupeka.ka.core.KAItem
10 +import com.groupeka.ka.core.RecentsStore
11 +import com.groupeka.ka.core.SavedSearch
12 +import com.groupeka.ka.core.UniverseService
13 +import kotlinx.coroutines.runBlocking
14 +import kotlinx.serialization.json.Json
15 +import kotlinx.serialization.json.JsonObject
16 +import org.junit.Assert.assertEquals
17 +import org.junit.Assert.assertNull
18 +import org.junit.Assert.assertTrue
19 +import org.junit.Test
20 +import java.io.File
21 +
22 +class RefonteTests {
23 +
24 + private fun item(id: String, lat: Double, lon: Double) =
25 + KAItem(id = id, universeID = "lou-ka", title = id, latitude = lat, longitude = lon)
26 +
27 + // MARK: clustering (miroir des 2 tests iOS)
28 +
29 + @Test
30 + fun clusteringGroupsNearbyAndKeepsSingles() {
31 + val items = listOf(
32 + item("a", 46.805, -71.205),
33 + item("b", 46.806, -71.206), // même cellule que a
34 + item("c", 46.90, -71.40), // loin → seul
35 + )
36 + val clusters = ClusterEngine.clusterize(items, latSpan = 0.3, lonSpan = 0.3)
37 + assertEquals(2, clusters.size)
38 + val group = clusters.first { it.items.size == 2 }
39 + assertTrue(group.items.map { it.id }.containsAll(listOf("a", "b")))
40 + assertTrue(clusters.any { it.isSingle && it.item.id == "c" })
41 + // centroïde du groupe
42 + assertEquals(46.8055, group.latitude, 0.001)
43 + }
44 +
45 + @Test
46 + fun clusteringCapsAt150Renderables() {
47 + val many = (0 until 400).map { i ->
48 + item("i$i", 40.0 + i * 0.5, -70.0 - i * 0.5) // tous éloignés → 400 cellules
49 + }
50 + val clusters = ClusterEngine.clusterize(many, latSpan = 250.0, lonSpan = 250.0)
51 + assertTrue("plafond de rendu 150", clusters.size <= 150)
52 + }
53 +
54 + @Test
55 + fun clusteringIgnoresItemsWithoutCoordinates() {
56 + val items = listOf(
57 + KAItem(id = "x", universeID = "lou-ka", title = "sans coord"),
58 + item("y", 46.8, -71.2),
59 + )
60 + val clusters = ClusterEngine.clusterize(items, latSpan = 0.3, lonSpan = 0.3)
61 + assertEquals(1, clusters.size)
62 + assertEquals("y", clusters[0].item.id)
63 + }
64 +
65 + // MARK: historique consulté
66 +
67 + @Test
68 + fun historyDeduplicatesAndCapsAt30() {
69 + val file = File.createTempFile("ka-recents", ".json").also { it.delete() }
70 + val store = RecentsStore(file)
71 + for (i in 0 until 40) {
72 + store.record(KAItem(id = "it$i", universeID = "lou-ka", title = "It $i"))
73 + }
74 + // re-consulter un ancien → remonte en tête, sans doublon
75 + store.record(KAItem(id = "it35", universeID = "lou-ka", title = "It 35"))
76 + val viewed = store.viewed.value
77 + assertEquals(30, viewed.size)
78 + assertEquals("it35", viewed.first().id)
79 + assertEquals(1, viewed.count { it.id == "it35" })
80 + file.delete()
81 + }
82 +
83 + @Test
84 + fun historyPersistsAcrossInstances() {
85 + val file = File.createTempFile("ka-recents", ".json").also { it.delete() }
86 + RecentsStore(file).record(KAItem(id = "p:1", universeID = "auto-ka", title = "Corolla"))
87 + val reloaded = RecentsStore(file)
88 + assertEquals("p:1", reloaded.viewed.value.first().id)
89 + file.delete()
90 + }
91 +
92 + // MARK: alertes honnêtes
93 +
94 + @Test
95 + fun markSeenFoldsDeltaIntoTotal() {
96 + val file = File.createTempFile("ka-recents", ".json").also { it.delete() }
97 + // injecter une recherche « déjà comptée » via la persistance
98 + file.writeText(
99 + """{"viewed":[],"searches":[{"id":"s1","name":"4½ Québec","universeID":"lou-ka","query":"4½","alertsOn":true,"lastTotal":100,"newCount":12}]}""",
100 + )
101 + val store2 = RecentsStore(file)
102 + assertEquals(12, store2.alertCount)
103 + store2.markSeen("s1")
104 + val s = store2.savedSearches.value.first()
105 + assertEquals(112, s.lastTotal)
106 + assertEquals(0, s.newCount)
107 + assertEquals(0, store2.alertCount)
108 + file.delete()
109 + }
110 +
111 + @Test
112 + fun alertCountSumsOnlyEnabledAlerts() {
113 + val file = File.createTempFile("ka-recents", ".json").also { it.delete() }
114 + file.writeText(
115 + """{"viewed":[],"searches":[
116 + {"id":"a","name":"A","universeID":"lou-ka","query":"a","alertsOn":true,"newCount":5},
117 + {"id":"b","name":"B","universeID":"auto-ka","query":"b","alertsOn":false,"newCount":9}
118 + ]}""",
119 + )
120 + val store = RecentsStore(file)
121 + assertEquals(5, store.alertCount)
122 + file.delete()
123 + }
124 +
125 + // MARK: galerie multi-photos
126 +
127 + @Test
128 + fun galleryKeepsUpTo10HttpImages() {
129 + val u = Ecosystem.universe("auto-ka")!!
130 + val imgs = (1..14).joinToString(",") { "\"https://cdn.x.com/$it.jpg\"" }
131 + val o = Json.parseToJsonElement(
132 + """{"uid":"v:9","title":"Mazda 3","images":[$imgs,"pas-une-url"]}""",
133 + ) as JsonObject
134 + val item = u.map!!(o)!!
135 + assertEquals(10, item.imageURLs.size)
136 + assertEquals("https://cdn.x.com/1.jpg", item.imageURL)
137 + assertEquals(item.imageURL, item.imageURLs.first())
138 + }
139 +
140 + // MARK: Trouve-Ka — recherche d'abord (l'API exige q, 422 sinon)
141 +
142 + @Test
143 + fun trouveKaFetchWithoutQueryReturnsEmptyWithoutNetwork() = runBlocking {
144 + val u = Ecosystem.universe("trouve-ka")!!
145 + // pas de cacheDir ni de réseau nécessaire : la garde répond avant
146 + val items = UniverseService.fetch(u, query = null)
147 + assertTrue(items.isEmpty())
148 + assertNull(UniverseService.total(u, query = ""))
149 + }
150 +
151 + // MARK: éditorial = requêtes réelles vers de vrais univers
152 +
153 + @Test
154 + fun editorialCollectionsPointToRealUniverses() {
155 + val cols = com.groupeka.ka.core.Editorial.collections
156 + assertEquals(4, cols.size)
157 + cols.forEach { c ->
158 + assertTrue("univers inconnu ${c.universeID}", Ecosystem.universe(c.universeID) != null)
159 + }
160 + }
161 +}
added ka-release.keystore +0 −0

Binary file not shown.