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