SPB Git

spb/ka-android

Public

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 (Aug 18, 2026) parent 03c470c Browse files

31 changed files +2,392 −104

31 changed files
Unified Split
modified README.md +116 −60
@@ -2,7 +2,10 @@
2 2
3 Portage Android natif complet de l'app iOS « KA » : tout l'écosystème Groupe-KA 3 Portage Android natif complet de l'app iOS « KA » : tout l'écosystème Groupe-KA
4 (12 univers ·Ka) dans une seule app **Kotlin + Jetpack Compose**, compilée en 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 ## Build 10 ## Build
8 11
@@ -10,72 +13,125 @@ ligne de commande (aucun Android Studio requis).
10 export JAVA_HOME=$(brew --prefix openjdk@21) 13 export JAVA_HOME=$(brew --prefix openjdk@21)
11 # local.properties doit pointer vers le SDK : 14 # local.properties doit pointer vers le SDK :
12 # sdk.dir=/opt/homebrew/share/android-commandlinetools 15 # sdk.dir=/opt/homebrew/share/android-commandlinetools
13−./gradlew assembleDebug # → app/build/outputs/apk/debug/app-debug.apk 16+./gradlew test # 22 tests JVM (adaptateurs + clustering + stores)
14−./gradlew test # tests JVM des adaptateurs d'univers (12 tests) 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 Trio de versions éprouvé : **Gradle 8.13 + AGP 8.9.2 + Kotlin 2.1.20** 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, 22+(plugin Compose intégré), Compose BOM 2024.09.03, **MapLibre GL 10.3.1**,
19−target/compileSdk 34. Portrait-first (téléphones). 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 | iOS (SwiftUI) | Android (Compose) | 98 | iOS (SwiftUI) | Android (Compose) |
24 |-----------------------------|-----------------------------------------------------| 99 |-----------------------------|-----------------------------------------------------|
25−| `Core/Ecosystem.swift` | `core/Ecosystem.kt` — les 12 univers config-driven | 100+| `Core/Ecosystem.swift` | `core/Ecosystem.kt` — 12 univers config-driven, galeries `imageURLs` |
26−| `Core/Services.swift` | `core/Services.kt` — client JSON + cache disque, fetch univers, liveTotal, **AgentService SSE**, menus resto, Vrai-Prix | 101+| `Core/Services.swift` | `core/Services.kt` — cache disque hors ligne, fetch, `total()`, `mapItems()` bbox geojson, liveTotal, AgentService SSE |
27−| `Core/KAFilters.swift` | `core/Filters.kt` — catalogue de filtres métier | 102+| `Core/KAFilters.swift` | `core/Filters.kt` |
28−| `Core/FavoritesStore.swift` | `core/FavoritesStore.kt` — collections multi-univers persistées (JSON) | 103+| `Core/FavoritesStore.swift` | `core/FavoritesStore.kt` |
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) | 104+| `Core/RecentsStore.swift` | `core/RecentsStore.kt` + `Editorial` |
30−| `Design/Theme.swift` | `ui/theme/Theme.kt` — papier/encre, carte à ombre décalée dure, wordmarks « Lou[Ka] » (-2°), chips mono | 105+| `Features/MapView.swift` | `ui/MapScreen.kt` + `core/ClusterEngine.kt` |
31−| `Features/*` | `ui/*Screen.kt` — Accueil vivant, Recherche fan-out, grille Univers + explorateur + fiche, Vrai-Prix natif, menus resto, Favoris, Profil, chat KA Agent | 106+| `Core/KAID.swift` | `auth/KaId.kt` — SSO Custom Tabs, client `ka-android` |
32−| `App/KAApp.swift` | `MainActivity.kt` + `KAApp.kt` — 5 onglets + bulle Agent flottante, onboarding | 107+| `Design/Theme.swift` | `ui/theme/Theme.kt` — éditorial sharp, clair par défaut |
33− 108+| `Features/*` | `ui/*Screen.kt` |
34−Le **thème clair est le défaut** (papier `#f5f3ee` / encre `#141814`) ; sombre 109+
35−et automatique dans Profil → Apparence. Tous les textes sont en français. 110+Icône : « liquid glass » (KA verre lime sur fond blanc, perles des univers) —
36− 111+mipmaps générés depuis `docs/icon-glass-1024.png` d'iOS.
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`).
52 112
53 ## Ajouter un univers 113 ## Ajouter un univers
54 114
55−Ajouter une entrée dans `Ecosystem.all` (`core/Ecosystem.kt`) : identité, 115+Une entrée dans `Ecosystem.all` (`core/Ecosystem.kt`) : identité, config
56−config d'API et lambda de mapping. Rien d'autre — la grille, la recherche 116+d'API, lambda de mapping. La grille, la recherche, l'accueil, les compteurs,
57−universelle, l'accueil, les compteurs et la fiche le prennent en charge 117+la fiche — et la carte si l'API expose lat/lng — suivent automatiquement.
58−automatiquement. (Ajouter des filtres métier dans `core/Filters.kt` et une 118+
59−icône dans `universeIcon()` si souhaité.) 119+## Écarts vs iOS (v2)
60− 120+
61−## Écarts volontaires vs iOS (v1) 121+- Localisation « ma position » non incluse (aucune permission de localisation
62− 122+ demandée) — la carte démarre sur Québec.
63−- **Carte unifiée absente** : la MapView iOS (MapKit) n'est pas portée — 123+- Widgets iOS sans équivalent.
64− prévue pour une version ultérieure (Google Maps/MapLibre à trancher). 124+- SF Symbols → Material Icons ; haptique simplifiée.
65− Les latitudes/longitudes sont déjà mappées dans `KAItem`. 125+- MapLibre 10.3.1 (packages `com.mapbox.mapboxsdk.*`) : dernière lignée qui
66−- **Widgets** : pas d'équivalent des widgets iOS (KAWidgets) en v1. 126+ résout nativement les URL `mapbox://` du style officiel via
67−- Le SSO utilise Chrome Custom Tabs (au lieu d'`ASWebAuthenticationSession`) ; 127+ `WellKnownTileServer.Mapbox` — MapLibre 11 les a retirées.
68− le retour se fait par `ka-android://auth/callback` (intent-filter, 128+
69− `launchMode=singleTask`), puis échange `{ka_token}` sur 129+## Tests (22, tous verts)
70− `https://www.api-ka.com/api/ios/auth/exchange` (accepte l'audience 130+
71− `ka-android`). 131+- `AdapterTests` (12) : mappings d'univers (mêmes échantillons que
72−- Haptique légère (API Compose) plutôt que le moteur riche d'iOS. 132+ `KATests.swift`), URLs de liste, favoris/collections + persistance.
73−- Icônes Material en remplacement des SF Symbols (mapping dans `universeIcon`). 133+- `RefonteTests` (10) : clustering (regroupement/centroïde, plafond 150,
74− 134+ items sans coordonnées), historique (déduplication, plafond 30,
75−## Tests 135+ persistance), alertes (markSeen replie le delta, somme des alertes
76− 136+ actives seulement), galerie ≤ 10 images http, garde Trouve-Ka sans
77−`app/src/test/java/com/groupeka/ka/AdapterTests.kt` — mêmes échantillons JSON 137+ réseau, collections éditoriales pointant vers de vrais univers.
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.
modified app/build.gradle.kts +13 −2
@@ -14,13 +14,23 @@ android {
14 applicationId = "com.groupeka.ka" 14 applicationId = "com.groupeka.ka"
15 minSdk = 26 15 minSdk = 26
16 targetSdk = 34 16 targetSdk = 34
17− versionCode = 1 17+ versionCode = 2
18− versionName = "1.0.0" 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 buildTypes { 30 buildTypes {
22 release { 31 release {
23 isMinifyEnabled = false 32 isMinifyEnabled = false
33+ signingConfig = signingConfigs.getByName("release")
24 } 34 }
25 } 35 }
26 compileOptions { 36 compileOptions {
@@ -50,6 +60,7 @@ dependencies {
50 implementation("com.squareup.okhttp3:okhttp:4.12.0") 60 implementation("com.squareup.okhttp3:okhttp:4.12.0")
51 implementation("androidx.browser:browser:1.8.0") 61 implementation("androidx.browser:browser:1.8.0")
52 implementation("androidx.core:core-ktx:1.13.1") 62 implementation("androidx.core:core-ktx:1.13.1")
63+ implementation("org.maplibre.gl:android-sdk:10.3.1")
53 64
54 testImplementation("junit:junit:4.13.2") 65 testImplementation("junit:junit:4.13.2")
55 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") 66 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
modified app/src/main/AndroidManifest.xml +1 −0
@@ -2,6 +2,7 @@
2 <manifest xmlns:android="http://schemas.android.com/apk/res/android"> 2 <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3 3
4 <uses-permission android:name="android.permission.INTERNET" /> 4 <uses-permission android:name="android.permission.INTERNET" />
5+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
5 6
6 <application 7 <application
7 android:name=".KAApp" 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 import com.groupeka.ka.core.ApiClient 8 import com.groupeka.ka.core.ApiClient
9 import com.groupeka.ka.core.FavoritesStore 9 import com.groupeka.ka.core.FavoritesStore
10 import com.groupeka.ka.core.Prefs 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 import java.io.File 15 import java.io.File
12 16
13 class KAApp : Application() { 17 class KAApp : Application() {
@@ -16,6 +20,11 @@ class KAApp : Application() {
16 ApiClient.cacheDir = File(cacheDir, "ka-json") 20 ApiClient.cacheDir = File(cacheDir, "ka-json")
17 Prefs.init(this) 21 Prefs.init(this)
18 FavoritesStore.shared = FavoritesStore(File(filesDir, "ka-favoris.json")) 22 FavoritesStore.shared = FavoritesStore(File(filesDir, "ka-favoris.json"))
23+ RecentsStore.shared = RecentsStore(File(filesDir, "ka-recents.json"))
19 KAIDManager.restore(this) 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 import androidx.compose.foundation.layout.size 17 import androidx.compose.foundation.layout.size
18 import androidx.compose.foundation.shape.CircleShape 18 import androidx.compose.foundation.shape.CircleShape
19 import androidx.compose.material.icons.Icons 19 import androidx.compose.material.icons.Icons
20−import androidx.compose.material.icons.filled.Favorite
21 import androidx.compose.material.icons.filled.Home 20 import androidx.compose.material.icons.filled.Home
21+import androidx.compose.material.icons.filled.Map
22 import androidx.compose.material.icons.filled.Person 22 import androidx.compose.material.icons.filled.Person
23 import androidx.compose.material.icons.filled.Search 23 import androidx.compose.material.icons.filled.Search
24 import androidx.compose.material.icons.filled.Workspaces 24 import androidx.compose.material.icons.filled.Workspaces
@@ -55,10 +55,13 @@ import com.groupeka.ka.core.VPResult
55 import com.groupeka.ka.ui.AgentChatScreen 55 import com.groupeka.ka.ui.AgentChatScreen
56 import com.groupeka.ka.ui.EstimateScreen 56 import com.groupeka.ka.ui.EstimateScreen
57 import com.groupeka.ka.ui.FavoritesScreen 57 import com.groupeka.ka.ui.FavoritesScreen
58+import com.groupeka.ka.ui.HistoryScreen
58 import com.groupeka.ka.ui.HomeScreen 59 import com.groupeka.ka.ui.HomeScreen
59 import com.groupeka.ka.ui.ItemDetailScreen 60 import com.groupeka.ka.ui.ItemDetailScreen
61+import com.groupeka.ka.ui.MapScreen
60 import com.groupeka.ka.ui.OnboardingScreen 62 import com.groupeka.ka.ui.OnboardingScreen
61 import com.groupeka.ka.ui.ProfileScreen 63 import com.groupeka.ka.ui.ProfileScreen
64+import com.groupeka.ka.ui.SavedSearchesScreen
62 import com.groupeka.ka.ui.SearchScreen 65 import com.groupeka.ka.ui.SearchScreen
63 import com.groupeka.ka.ui.UniverseHomeScreen 66 import com.groupeka.ka.ui.UniverseHomeScreen
64 import com.groupeka.ka.ui.UniversesScreen 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 private val tabs = listOf( 130 private val tabs = listOf(
128 Tab("home", "Accueil", Icons.Filled.Home), 131 Tab("home", "Accueil", Icons.Filled.Home),
129 Tab("search", "Recherche", Icons.Filled.Search), 132 Tab("search", "Recherche", Icons.Filled.Search),
130− Tab("universes", "Univers", Icons.Filled.Workspaces), 133+ Tab("universes", "Explorer", Icons.Filled.Workspaces),
131− Tab("favorites", "Favoris", Icons.Filled.Favorite), 134+ Tab("map", "Carte", Icons.Filled.Map),
132 Tab("profile", "Profil", Icons.Filled.Person), 135 Tab("profile", "Profil", Icons.Filled.Person),
133 ) 136 )
134 137
@@ -172,8 +175,11 @@ fun RootView() {
172 composable("home") { HomeScreen(nav) } 175 composable("home") { HomeScreen(nav) }
173 composable("search") { SearchScreen(nav) } 176 composable("search") { SearchScreen(nav) }
174 composable("universes") { UniversesScreen(nav) } 177 composable("universes") { UniversesScreen(nav) }
178+ composable("map") { MapScreen(nav) }
175 composable("favorites") { FavoritesScreen(nav) } 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 composable("universe/{id}") { entry -> 183 composable("universe/{id}") { entry ->
178 val id = entry.arguments?.getString("id") 184 val id = entry.arguments?.getString("id")
179 Ecosystem.universe(id ?: "")?.let { UniverseHomeScreen(it, nav) } 185 Ecosystem.universe(id ?: "")?.let { UniverseHomeScreen(it, nav) }
@@ -189,8 +195,9 @@ fun RootView() {
189 composable("agent") { AgentChatScreen(onClose = { nav.popBackStack() }) } 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 198+ // Bulle KA Agent — flottante au-dessus de la tab bar, partout sauf
193− if (route != "agent") { 199+ // dans l'agent et sur la carte (contrôles de zone au même endroit)
200+ if (route != "agent" && route != "map") {
194 Box( 201 Box(
195 Modifier 202 Modifier
196 .align(Alignment.BottomEnd) 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 val city: String? = null, 23 val city: String? = null,
24 var url: String? = null, 24 var url: String? = null,
25 val imageURL: String? = null, 25 val imageURL: String? = null,
26+ /** Galerie complète (les sites en ont souvent plusieurs) */
27+ val imageURLs: List<String> = emptyList(),
26 val latitude: Double? = null, 28 val latitude: Double? = null,
27 val longitude: Double? = null, 29 val longitude: Double? = null,
28 /** Description longue (fiche) */ 30 /** Description longue (fiche) */
@@ -79,18 +81,22 @@ object Ecosystem {
79 // Aides de mapping communes 81 // Aides de mapping communes
80 private fun base(o: JsonObject, universe: String, idKeys: List<String> = listOf("uid")): KAItem { 82 private fun base(o: JsonObject, universe: String, idKeys: List<String> = listOf("uid")): KAItem {
81 val id: String? = idKeys.firstNotNullOfOrNull { k -> o[k].text } 83 val id: String? = idKeys.firstNotNullOfOrNull { k -> o[k].text }
82− // première image utilisable : champ `images` (liste) ou `image` 84+ // galerie : champ `images` (liste) ou `image` — jusqu'à 10, http seulement
83− var image: String? = o["images"].arrayOrNull 85+ var gallery: List<String> = (o["images"].arrayOrNull ?: emptyList())
84− ?.mapNotNull { it.text } 86+ .mapNotNull { it.text }
85− ?.firstOrNull { it.startsWith("http") } 87+ .filter { it.startsWith("http") }
86− if (image == null) image = o.str("image", "image_url", "photo", "thumbnail") 88+ .take(10)
89+ if (gallery.isEmpty()) {
90+ o.str("image", "image_url", "photo", "thumbnail")?.let { gallery = listOf(it) }
91+ }
87 return KAItem( 92 return KAItem(
88 id = "$universe:${id ?: UUID.randomUUID()}", 93 id = "$universe:${id ?: UUID.randomUUID()}",
89 universeID = universe, 94 universeID = universe,
90 title = o.str("title", "name", "display_name") ?: "Sans titre", 95 title = o.str("title", "name", "display_name") ?: "Sans titre",
91 city = o.str("city"), 96 city = o.str("city"),
92 url = o.str("url"), 97 url = o.str("url"),
93− imageURL = image, 98+ imageURL = gallery.firstOrNull(),
99+ imageURLs = gallery,
94 latitude = o.num("lat", "latitude"), 100 latitude = o.num("lat", "latitude"),
95 longitude = o.num("lng", "lon", "longitude"), 101 longitude = o.num("lng", "lon", "longitude"),
96 detail = o.str("description", "menu_summary", "bio")?.strippingHTML()?.trim(), 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 limit: Int = 30, 116 limit: Int = 30,
117 params: Map<String, String> = emptyMap(), 117 params: Map<String, String> = emptyMap(),
118 ): List<KAItem> { 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 val url = listURL(u, query, city, limit, params) ?: return emptyList() 121 val url = listURL(u, query, city, limit, params) ?: return emptyList()
120 val map = u.map ?: return emptyList() 122 val map = u.map ?: return emptyList()
121 val root = ApiClient.fetchJson(url) 123 val root = ApiClient.fetchJson(url)
@@ -126,6 +128,58 @@ object UniverseService {
126 return raw.mapNotNull { el -> el.objectOrNull?.let(map) } 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 /** Total « en direct » depuis /api/stats (clé selon l'univers, chemins a.b acceptés). */ 183 /** Total « en direct » depuis /api/stats (clé selon l'univers, chemins a.b acceptés). */
130 suspend fun liveTotal(u: Universe): Int? { 184 suspend fun liveTotal(u: Universe): Int? {
131 val path = if (u.id == "trouve-ka") "/api/status" else "/api/stats" 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 // du VRAI contenu en direct, et le pouls de l'écosystème (compteurs live). 4 // du VRAI contenu en direct, et le pouls de l'écosystème (compteurs live).
5 package com.groupeka.ka.ui 5 package com.groupeka.ka.ui
6 6
7+import androidx.compose.foundation.background
7 import androidx.compose.foundation.clickable 8 import androidx.compose.foundation.clickable
8 import androidx.compose.foundation.layout.Arrangement 9 import androidx.compose.foundation.layout.Arrangement
9 import androidx.compose.foundation.layout.Box 10 import androidx.compose.foundation.layout.Box
@@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
15 import androidx.compose.foundation.layout.height 16 import androidx.compose.foundation.layout.height
16 import androidx.compose.foundation.layout.padding 17 import androidx.compose.foundation.layout.padding
17 import androidx.compose.foundation.layout.width 18 import androidx.compose.foundation.layout.width
19+import androidx.compose.foundation.layout.widthIn
18 import androidx.compose.foundation.lazy.LazyColumn 20 import androidx.compose.foundation.lazy.LazyColumn
19 import androidx.compose.foundation.lazy.LazyRow 21 import androidx.compose.foundation.lazy.LazyRow
20 import androidx.compose.material3.CircularProgressIndicator 22 import androidx.compose.material3.CircularProgressIndicator
@@ -34,14 +36,25 @@ import androidx.compose.ui.text.style.TextOverflow
34 import androidx.compose.ui.unit.dp 36 import androidx.compose.ui.unit.dp
35 import androidx.compose.ui.unit.sp 37 import androidx.compose.ui.unit.sp
36 import androidx.compose.foundation.shape.RoundedCornerShape 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 import androidx.navigation.NavHostController 47 import androidx.navigation.NavHostController
38 import com.groupeka.ka.core.Ecosystem 48 import com.groupeka.ka.core.Ecosystem
49+import com.groupeka.ka.core.Editorial
39 import com.groupeka.ka.core.KAItem 50 import com.groupeka.ka.core.KAItem
40 import com.groupeka.ka.core.Prefs 51 import com.groupeka.ka.core.Prefs
52+import com.groupeka.ka.core.RecentsStore
41 import com.groupeka.ka.core.Universe 53 import com.groupeka.ka.core.Universe
42 import com.groupeka.ka.core.UniverseService 54 import com.groupeka.ka.core.UniverseService
43 import com.groupeka.ka.core.frFormatted 55 import com.groupeka.ka.core.frFormatted
44 import com.groupeka.ka.encodeNavArg 56 import com.groupeka.ka.encodeNavArg
57+import com.groupeka.ka.ui.theme.universeIcon
45 import com.groupeka.ka.ui.theme.GroupeKAMark 58 import com.groupeka.ka.ui.theme.GroupeKAMark
46 import com.groupeka.ka.ui.theme.KAImage 59 import com.groupeka.ka.ui.theme.KAImage
47 import com.groupeka.ka.ui.theme.KATheme 60 import com.groupeka.ka.ui.theme.KATheme
@@ -90,7 +103,11 @@ fun HomeScreen(nav: NavHostController) {
90 val dark = LocalKADark.current 103 val dark = LocalKADark.current
91 var featured by remember { mutableStateOf<List<Pair<Universe, List<KAItem>>>>(emptyList()) } 104 var featured by remember { mutableStateOf<List<Pair<Universe, List<KAItem>>>>(emptyList()) }
92 var pulse by remember { mutableStateOf<List<Pair<Universe, Int>>>(emptyList()) } 105 var pulse by remember { mutableStateOf<List<Pair<Universe, Int>>>(emptyList()) }
106+ var editorialItems by remember { mutableStateOf<Map<String, List<KAItem>>>(emptyMap()) }
93 var loading by remember { mutableStateOf(true) } 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 LaunchedEffect(Unit) { 112 LaunchedEffect(Unit) {
96 loading = true 113 loading = true
@@ -107,6 +124,18 @@ fun HomeScreen(nav: NavHostController) {
107 val counts = Ecosystem.all.map { u -> async { u to UniverseService.liveTotal(u) } }.awaitAll() 124 val counts = Ecosystem.all.map { u -> async { u to UniverseService.liveTotal(u) } }.awaitAll()
108 pulse = counts.mapNotNull { (u, n) -> n?.let { u to it } }.sortedByDescending { it.second } 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 val (title, sub) = greeting() 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 featured.forEach { (u, items) -> 261 featured.forEach { (u, items) ->
161 item(key = u.id) { 262 item(key = u.id) {
162 FeaturedSection(u, items, nav) 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 if (loading && featured.isEmpty()) { 329 if (loading && featured.isEmpty()) {
166 item { 330 item {
167 Row( 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 import androidx.compose.ui.unit.dp 55 import androidx.compose.ui.unit.dp
56 import androidx.compose.ui.unit.sp 56 import androidx.compose.ui.unit.sp
57 import androidx.navigation.NavHostController 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 import com.groupeka.ka.core.Ecosystem 71 import com.groupeka.ka.core.Ecosystem
59 import com.groupeka.ka.core.FavoritesStore 72 import com.groupeka.ka.core.FavoritesStore
60 import com.groupeka.ka.core.KAItem 73 import com.groupeka.ka.core.KAItem
74+import com.groupeka.ka.core.RecentsStore
61 import com.groupeka.ka.core.RestoMenuLoader 75 import com.groupeka.ka.core.RestoMenuLoader
62 import com.groupeka.ka.core.RestoMenuSection 76 import com.groupeka.ka.core.RestoMenuSection
77+import com.groupeka.ka.core.UniverseService
78+import com.groupeka.ka.encodeNavArg
63 import com.groupeka.ka.ui.theme.KAChip 79 import com.groupeka.ka.ui.theme.KAChip
64 import com.groupeka.ka.ui.theme.KAImage 80 import com.groupeka.ka.ui.theme.KAImage
65 import com.groupeka.ka.ui.theme.KATheme 81 import com.groupeka.ka.ui.theme.KATheme
@@ -93,11 +109,20 @@ fun ItemDetailScreen(item: KAItem, nav: NavHostController) {
93 var showCollections by remember { mutableStateOf(false) } 109 var showCollections by remember { mutableStateOf(false) }
94 var menu by remember { mutableStateOf<List<RestoMenuSection>>(emptyList()) } 110 var menu by remember { mutableStateOf<List<RestoMenuSection>>(emptyList()) }
95 var expandedSections by remember { mutableStateOf<Set<String>>(emptySet()) } 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 LaunchedEffect(item.id) { 115 LaunchedEffect(item.id) {
116+ RecentsStore.shared?.record(item)
98 if (item.universeID == "resto-ka") { 117 if (item.universeID == "resto-ka") {
99 menu = RestoMenuLoader.load(uid = item.id.removePrefix("resto-ka:")) 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 LazyColumn( 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 item { 160 item {
135− KAImage( 161+ // galerie multi-photos : pager + badge compteur + plein écran au tap
136− url = item.imageURL, accent = accent, symbol = universe?.symbol ?: "photo", 162+ val pagerState = rememberPagerState(pageCount = { gallery.size })
137− modifier = Modifier 163+ Box {
138− .fillMaxWidth() 164+ HorizontalPager(
139− .height(230.dp) 165+ state = pagerState,
140− .clip(RoundedCornerShape(16.dp)) 166+ modifier = Modifier
141− .border(1.2.dp, KATheme.ink(dark).copy(alpha = 0.3f), RoundedCornerShape(16.dp)), 167+ .fillMaxWidth()
142− ) 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 item { 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 item { 324 item {
274 Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { 325 Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
275− Box( 326+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
276− Modifier 327+ if (item.url != null) {
277− .fillMaxWidth() 328+ Box(
278− .background(accent, RoundedCornerShape(12.dp)) 329+ Modifier
279− .clickable { openUrl(context, item.url!!) } 330+ .weight(1f)
280− .padding(vertical = 14.dp), 331+ .background(accent, RoundedCornerShape(12.dp))
281− contentAlignment = Alignment.Center, 332+ .clickable { openUrl(context, item.url!!) }
282− ) { 333+ .padding(vertical = 14.dp),
283− Row(verticalAlignment = Alignment.CenterVertically) { 334+ contentAlignment = Alignment.Center,
284− Icon(Icons.AutoMirrored.Filled.OpenInNew, null, tint = Color.White, modifier = Modifier.size(18.dp)) 335+ ) {
285− Spacer(Modifier.width(8.dp)) 336+ Row(verticalAlignment = Alignment.CenterVertically) {
286− Text("Voir à la source", fontWeight = FontWeight.Bold, color = Color.White) 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 Text( 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 style = MaterialTheme.typography.labelSmall, 369 style = MaterialTheme.typography.labelSmall,
292 color = KATheme.ink2(dark).copy(alpha = 0.7f), 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 if (showCollections) { 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 import androidx.compose.material.icons.automirrored.filled.Logout 26 import androidx.compose.material.icons.automirrored.filled.Logout
27 import androidx.compose.material.icons.automirrored.filled.OpenInNew 27 import androidx.compose.material.icons.automirrored.filled.OpenInNew
28 import androidx.compose.material.icons.filled.AccountCircle 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 import androidx.compose.material.icons.filled.Language 31 import androidx.compose.material.icons.filled.Language
30 import androidx.compose.material.icons.filled.MonitorHeart 32 import androidx.compose.material.icons.filled.MonitorHeart
33+import androidx.compose.material.icons.filled.NotificationsActive
31 import androidx.compose.material.icons.filled.Star 34 import androidx.compose.material.icons.filled.Star
32 import androidx.compose.material.icons.filled.StarBorder 35 import androidx.compose.material.icons.filled.StarBorder
33 import androidx.compose.material3.CircularProgressIndicator 36 import androidx.compose.material3.CircularProgressIndicator
@@ -50,10 +53,13 @@ import androidx.compose.ui.text.font.FontWeight
50 import androidx.compose.ui.unit.dp 53 import androidx.compose.ui.unit.dp
51 import androidx.compose.ui.unit.sp 54 import androidx.compose.ui.unit.sp
52 import coil.compose.SubcomposeAsyncImage 55 import coil.compose.SubcomposeAsyncImage
56+import androidx.navigation.NavHostController
53 import com.groupeka.ka.auth.KAIDManager 57 import com.groupeka.ka.auth.KAIDManager
54 import com.groupeka.ka.auth.KAIDProfile 58 import com.groupeka.ka.auth.KAIDProfile
55 import com.groupeka.ka.core.Ecosystem 59 import com.groupeka.ka.core.Ecosystem
60+import com.groupeka.ka.core.FavoritesStore
56 import com.groupeka.ka.core.Prefs 61 import com.groupeka.ka.core.Prefs
62+import com.groupeka.ka.core.RecentsStore
57 import com.groupeka.ka.ui.theme.KATheme 63 import com.groupeka.ka.ui.theme.KATheme
58 import com.groupeka.ka.ui.theme.LocalKADark 64 import com.groupeka.ka.ui.theme.LocalKADark
59 import com.groupeka.ka.ui.theme.accent 65 import com.groupeka.ka.ui.theme.accent
@@ -62,13 +68,19 @@ import com.groupeka.ka.ui.theme.kaCard
62 import com.groupeka.ka.ui.theme.universeIcon 68 import com.groupeka.ka.ui.theme.universeIcon
63 69
64 @Composable 70 @Composable
65−fun ProfileScreen() { 71+fun ProfileScreen(nav: NavHostController) {
66 val dark = LocalKADark.current 72 val dark = LocalKADark.current
67 val context = LocalContext.current 73 val context = LocalContext.current
68 val profile by KAIDManager.profile.collectAsState() 74 val profile by KAIDManager.profile.collectAsState()
69 val busy by KAIDManager.busy.collectAsState() 75 val busy by KAIDManager.busy.collectAsState()
70 val lastError by KAIDManager.lastError.collectAsState() 76 val lastError by KAIDManager.lastError.collectAsState()
71 val favIDs = Prefs.favUniverses.split(",").filter { it.isNotEmpty() }.toSet() 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 LazyColumn( 85 LazyColumn(
74 contentPadding = PaddingValues(16.dp), 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 // — Univers favoris 172 // — Univers favoris
142 item { 173 item {
143 Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { 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 @Composable 327 @Composable
265 private fun ProfileLinkRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, tint: Color? = null, onClick: () -> Unit) { 328 private fun ProfileLinkRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, tint: Color? = null, onClick: () -> Unit) {
266 val dark = LocalKADark.current 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 import androidx.compose.foundation.shape.CircleShape 20 import androidx.compose.foundation.shape.CircleShape
21 import androidx.compose.foundation.text.KeyboardActions 21 import androidx.compose.foundation.text.KeyboardActions
22 import androidx.compose.foundation.text.KeyboardOptions 22 import androidx.compose.foundation.text.KeyboardOptions
23+import androidx.compose.foundation.layout.size
23 import androidx.compose.material.icons.Icons 24 import androidx.compose.material.icons.Icons
24 import androidx.compose.material.icons.filled.History 25 import androidx.compose.material.icons.filled.History
26+import androidx.compose.material.icons.filled.NotificationAdd
25 import androidx.compose.material.icons.filled.Search 27 import androidx.compose.material.icons.filled.Search
28+import androidx.compose.material.icons.filled.Share
29+import androidx.compose.material3.AlertDialog
26 import androidx.compose.material3.CircularProgressIndicator 30 import androidx.compose.material3.CircularProgressIndicator
27 import androidx.compose.material3.Icon 31 import androidx.compose.material3.Icon
28 import androidx.compose.material3.MaterialTheme 32 import androidx.compose.material3.MaterialTheme
29 import androidx.compose.material3.OutlinedTextField 33 import androidx.compose.material3.OutlinedTextField
30 import androidx.compose.material3.OutlinedTextFieldDefaults 34 import androidx.compose.material3.OutlinedTextFieldDefaults
31 import androidx.compose.material3.Text 35 import androidx.compose.material3.Text
36+import androidx.compose.material3.TextButton
37+import androidx.compose.ui.platform.LocalContext
32 import androidx.compose.runtime.Composable 38 import androidx.compose.runtime.Composable
33 import androidx.compose.runtime.getValue 39 import androidx.compose.runtime.getValue
34 import androidx.compose.runtime.mutableStateOf 40 import androidx.compose.runtime.mutableStateOf
@@ -48,6 +54,7 @@ import androidx.navigation.NavHostController
48 import com.groupeka.ka.core.Ecosystem 54 import com.groupeka.ka.core.Ecosystem
49 import com.groupeka.ka.core.KAItem 55 import com.groupeka.ka.core.KAItem
50 import com.groupeka.ka.core.Prefs 56 import com.groupeka.ka.core.Prefs
57+import com.groupeka.ka.core.RecentsStore
51 import com.groupeka.ka.core.Universe 58 import com.groupeka.ka.core.Universe
52 import com.groupeka.ka.core.UniverseService 59 import com.groupeka.ka.core.UniverseService
53 import com.groupeka.ka.encodeNavArg 60 import com.groupeka.ka.encodeNavArg
@@ -67,16 +74,30 @@ import kotlinx.coroutines.launch
67 fun SearchScreen(nav: NavHostController) { 74 fun SearchScreen(nav: NavHostController) {
68 val dark = LocalKADark.current 75 val dark = LocalKADark.current
69 val scope = rememberCoroutineScope() 76 val scope = rememberCoroutineScope()
77+ val context = LocalContext.current
70 val keyboard = LocalSoftwareKeyboardController.current 78 val keyboard = LocalSoftwareKeyboardController.current
71 var query by remember { mutableStateOf("") } 79 var query by remember { mutableStateOf("") }
72 var submitted by remember { mutableStateOf("") } 80 var submitted by remember { mutableStateOf("") }
73 var sections by remember { mutableStateOf<List<Pair<Universe, List<KAItem>>>>(emptyList()) } 81 var sections by remember { mutableStateOf<List<Pair<Universe, List<KAItem>>>>(emptyList()) }
74 var searching by remember { mutableStateOf(false) } 82 var searching by remember { mutableStateOf(false) }
75 var selected by remember { mutableStateOf<Set<String>>(emptySet()) } // filtres univers (vide = tous) 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 val searchables = remember { Ecosystem.all.filter { it.map != null } } 88 val searchables = remember { Ecosystem.all.filter { it.map != null } }
78 val history = Prefs.searchHistory.split("\n").filter { it.isNotEmpty() } 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 suspend fun doSearch(text: String) { 101 suspend fun doSearch(text: String) {
81 val q = text.trim() 102 val q = text.trim()
82 if (q.isEmpty()) return 103 if (q.isEmpty()) return
@@ -127,6 +148,51 @@ fun SearchScreen(nav: NavHostController) {
127 modifier = Modifier.fillMaxWidth(), 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 item { 196 item {
131 // chips filtres univers 197 // chips filtres univers
132 LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { 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 item(key = "it-${item.id}") { 302 item(key = "it-${item.id}") {
237 KAItemRow(item, Modifier.clickable { nav.navigate("item/${encodeNavArg(item)}") }) 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 fun UniverseHomeScreen(universe: Universe, nav: NavHostController) { 172 fun UniverseHomeScreen(universe: Universe, nav: NavHostController) {
173 when { 173 when {
174 universe.id == "vrai-prix" -> VraiPrixScreen(universe, nav) 174 universe.id == "vrai-prix" -> VraiPrixScreen(universe, nav)
175+ universe.id == "api-ka" -> ApiKaPlaygroundScreen(universe)
175 universe.listPath == null -> WebUniverse(universe) 176 universe.listPath == null -> WebUniverse(universe)
176 else -> UniverseList(universe, nav) 177 else -> UniverseList(universe, nav)
177 } 178 }
@@ -223,7 +224,15 @@ private fun UniverseList(universe: Universe, nav: NavHostController) {
223 var liveTotal by remember { mutableStateOf<Int?>(null) } 224 var liveTotal by remember { mutableStateOf<Int?>(null) }
224 var showSheet by remember { mutableStateOf(false) } 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 suspend fun load() { 230 suspend fun load() {
231+ if (searchFirst && query.isBlank()) {
232+ items = emptyList()
233+ loading = false
234+ return
235+ }
227 loading = items.isEmpty() 236 loading = items.isEmpty()
228 errorText = null 237 errorText = null
229 try { 238 try {
@@ -321,7 +330,14 @@ private fun UniverseList(universe: Universe, nav: NavHostController) {
321 } 330 }
322 } else if (items.isEmpty()) { 331 } else if (items.isEmpty()) {
323 item { 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 } else { 342 } else {
327 items.forEach { it -> 343 items.forEach { it ->
modified app/src/main/res/drawable/splash.xml +2 −1
@@ -1,6 +1,7 @@
1 <?xml version="1.0" encoding="utf-8"?> 1 <?xml version="1.0" encoding="utf-8"?>
2 <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> 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 <item android:gravity="center"> 5 <item android:gravity="center">
5 <bitmap 6 <bitmap
6 android:src="@mipmap/ic_launcher" 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 <item name="android:navigationBarColor">@color/ka_paper</item> 8 <item name="android:navigationBarColor">@color/ka_paper</item>
9 </style> 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 <style name="Theme.KA.Splash" parent="Theme.KA"> 12 <style name="Theme.KA.Splash" parent="Theme.KA">
13 <item name="android:windowBackground">@drawable/splash</item> 13 <item name="android:windowBackground">@drawable/splash</item>
14− <item name="android:statusBarColor">@color/ka_ink</item> 14+ <item name="android:statusBarColor">@android:color/white</item>
15− <item name="android:windowLightStatusBar">false</item> 15+ <item name="android:windowLightStatusBar">true</item>
16 </style> 16 </style>
17 </resources> 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.

Menu

Theme