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
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
Large diff (596 lines) is not loaded by default to keep the page fast.Load diff
modified app/src/main/java/com/groupeka/ka/ui/ProfileScreen.kt +64 −1
Large diff (107 lines) is not loaded by default to keep the page fast.Load diff
added app/src/main/java/com/groupeka/ka/ui/RecentsScreens.kt +186 −0
Large diff (186 lines) is not loaded by default to keep the page fast.Load diff
modified app/src/main/java/com/groupeka/ka/ui/SearchScreen.kt +100 −1
Large diff (154 lines) is not loaded by default to keep the page fast.Load diff
modified app/src/main/java/com/groupeka/ka/ui/UniversesScreen.kt +17 −1
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
modified app/src/main/res/drawable/splash.xml +2 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
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
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added app/src/test/java/com/groupeka/ka/RefonteTests.kt +161 −0
Large diff (161 lines) is not loaded by default to keep the page fast.Load diff
added ka-release.keystore +0 −0

Binary file not shown.

Menu

Theme