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