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>
31 changed files +2,392 −104
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.