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
@@ -0,0 +1,596 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// MapScreen.kt — la CARTE LÉGENDAIRE : Mapbox streets-v12 via MapLibre, | |
| 3 | +// bascule 2D/3D (pitch + bâtiments en fill-extrusion comme Ka Maps web), | |
| 4 | +// marqueurs-pilules avec PRIX teintés par univers, clustering par grille | |
| 5 | +// (dégroupage au zoom), « Rechercher dans cette zone » (bbox RÉEL côté API | |
| 6 | +// pour lou-ka/immo-ka/job-ka, filtre client sinon), fiche compacte au tap → | |
| 7 | +// fiche complète (état de carte conservé), bascule liste/carte synchronisée. | |
| 8 | +package com.groupeka.ka.ui | |
| 9 | + | |
| 10 | +import android.content.Context | |
| 11 | +import android.graphics.Bitmap | |
| 12 | +import android.graphics.Canvas | |
| 13 | +import android.graphics.Paint | |
| 14 | +import android.graphics.RectF | |
| 15 | +import android.graphics.Typeface | |
| 16 | +import androidx.compose.foundation.background | |
| 17 | +import androidx.compose.foundation.border | |
| 18 | +import androidx.compose.foundation.clickable | |
| 19 | +import androidx.compose.foundation.layout.Arrangement | |
| 20 | +import androidx.compose.foundation.layout.Box | |
| 21 | +import androidx.compose.foundation.layout.Column | |
| 22 | +import androidx.compose.foundation.layout.PaddingValues | |
| 23 | +import androidx.compose.foundation.layout.Row | |
| 24 | +import androidx.compose.foundation.layout.Spacer | |
| 25 | +import androidx.compose.foundation.layout.fillMaxSize | |
| 26 | +import androidx.compose.foundation.layout.fillMaxWidth | |
| 27 | +import androidx.compose.foundation.layout.padding | |
| 28 | +import androidx.compose.foundation.layout.size | |
| 29 | +import androidx.compose.foundation.layout.width | |
| 30 | +import androidx.compose.foundation.lazy.LazyColumn | |
| 31 | +import androidx.compose.foundation.lazy.LazyRow | |
| 32 | +import androidx.compose.foundation.shape.CircleShape | |
| 33 | +import androidx.compose.foundation.shape.RoundedCornerShape | |
| 34 | +import androidx.compose.material.icons.Icons | |
| 35 | +import androidx.compose.material.icons.automirrored.filled.List | |
| 36 | +import androidx.compose.material.icons.filled.Cancel | |
| 37 | +import androidx.compose.material.icons.filled.Map | |
| 38 | +import androidx.compose.material.icons.filled.Refresh | |
| 39 | +import androidx.compose.material3.Icon | |
| 40 | +import androidx.compose.material3.MaterialTheme | |
| 41 | +import androidx.compose.material3.Text | |
| 42 | +import androidx.compose.runtime.Composable | |
| 43 | +import androidx.compose.runtime.DisposableEffect | |
| 44 | +import androidx.compose.runtime.getValue | |
| 45 | +import androidx.compose.runtime.mutableStateOf | |
| 46 | +import androidx.compose.runtime.remember | |
| 47 | +import androidx.compose.runtime.rememberCoroutineScope | |
| 48 | +import androidx.compose.runtime.setValue | |
| 49 | +import androidx.compose.ui.Alignment | |
| 50 | +import androidx.compose.ui.Modifier | |
| 51 | +import androidx.compose.ui.draw.clip | |
| 52 | +import androidx.compose.ui.graphics.Color | |
| 53 | +import androidx.compose.ui.platform.LocalLifecycleOwner | |
| 54 | +import androidx.compose.ui.text.font.FontFamily | |
| 55 | +import androidx.compose.ui.text.font.FontWeight | |
| 56 | +import androidx.compose.ui.unit.dp | |
| 57 | +import androidx.compose.ui.unit.sp | |
| 58 | +import androidx.compose.ui.viewinterop.AndroidView | |
| 59 | +import androidx.core.graphics.toColorInt | |
| 60 | +import androidx.lifecycle.Lifecycle | |
| 61 | +import androidx.lifecycle.LifecycleEventObserver | |
| 62 | +import androidx.navigation.NavHostController | |
| 63 | +import com.groupeka.ka.core.ClusterEngine | |
| 64 | +import com.groupeka.ka.core.Ecosystem | |
| 65 | +import com.groupeka.ka.core.KAItem | |
| 66 | +import com.groupeka.ka.core.MapCluster | |
| 67 | +import com.groupeka.ka.core.UniverseService | |
| 68 | +import com.groupeka.ka.encodeNavArg | |
| 69 | +import com.groupeka.ka.ui.theme.KAImage | |
| 70 | +import com.groupeka.ka.ui.theme.KATheme | |
| 71 | +import com.groupeka.ka.ui.theme.KAItemRow | |
| 72 | +import com.groupeka.ka.ui.theme.LocalKADark | |
| 73 | +import com.groupeka.ka.ui.theme.accent | |
| 74 | +import com.groupeka.ka.ui.theme.universeIcon | |
| 75 | +import com.mapbox.mapboxsdk.annotations.IconFactory | |
| 76 | +import com.mapbox.mapboxsdk.annotations.Marker | |
| 77 | +import com.mapbox.mapboxsdk.annotations.MarkerOptions | |
| 78 | +import com.mapbox.mapboxsdk.camera.CameraPosition | |
| 79 | +import com.mapbox.mapboxsdk.camera.CameraUpdateFactory | |
| 80 | +import com.mapbox.mapboxsdk.geometry.LatLng | |
| 81 | +import com.mapbox.mapboxsdk.maps.MapView | |
| 82 | +import com.mapbox.mapboxsdk.maps.MapboxMap | |
| 83 | +import com.mapbox.mapboxsdk.maps.Style | |
| 84 | +import com.mapbox.mapboxsdk.style.expressions.Expression | |
| 85 | +import com.mapbox.mapboxsdk.style.layers.FillExtrusionLayer | |
| 86 | +import com.mapbox.mapboxsdk.style.layers.PropertyFactory | |
| 87 | +import kotlinx.coroutines.async | |
| 88 | +import kotlinx.coroutines.awaitAll | |
| 89 | +import kotlinx.coroutines.coroutineScope | |
| 90 | +import kotlinx.coroutines.launch | |
| 91 | + | |
| 92 | +/** Style Mapbox officiel (token public déjà exposé sur les sites Ka Maps). */ | |
| 93 | +const val MAPBOX_TOKEN = | |
| 94 | + "pk.eyJ1Ijoic3Bib3VjaGVyIiwiYSI6ImNtc3Fyb3k4djAwOTgyenB3dWt6NHBjc2kifQ.poqLf0ADy3lIh28O-pFI2Q" | |
| 95 | +const val MAPBOX_STYLE = "mapbox://styles/mapbox/streets-v12" | |
| 96 | + | |
| 97 | +/** État de carte conservé entre navigations (fiche complète → retour). */ | |
| 98 | +object MapStateHolder { | |
| 99 | + var lat = 46.81 | |
| 100 | + var lon = -71.21 | |
| 101 | + var zoom = 10.5 | |
| 102 | + var pitch = 0.0 | |
| 103 | + var enabled: Set<String> = setOf("lou-ka", "immo-ka") | |
| 104 | + var items: List<KAItem> = emptyList() | |
| 105 | +} | |
| 106 | + | |
| 107 | +private val mapUniverseIDs = listOf("lou-ka", "immo-ka", "job-ka", "resto-ka", "sorti-ka") | |
| 108 | + | |
| 109 | +@Composable | |
| 110 | +fun MapScreen(nav: NavHostController) { | |
| 111 | + val dark = LocalKADark.current | |
| 112 | + val scope = rememberCoroutineScope() | |
| 113 | + val lifecycleOwner = LocalLifecycleOwner.current | |
| 114 | + | |
| 115 | + var items by remember { mutableStateOf(MapStateHolder.items) } | |
| 116 | + var enabled by remember { mutableStateOf(MapStateHolder.enabled) } | |
| 117 | + var loading by remember { mutableStateOf(false) } | |
| 118 | + var zoneDirty by remember { mutableStateOf(false) } | |
| 119 | + var selected by remember { mutableStateOf<KAItem?>(null) } | |
| 120 | + var showList by remember { mutableStateOf(false) } | |
| 121 | + var is3D by remember { mutableStateOf(MapStateHolder.pitch > 30) } | |
| 122 | + var resultCount by remember { mutableStateOf(MapStateHolder.items.size) } | |
| 123 | + | |
| 124 | + // références vers la carte (hors état Compose) | |
| 125 | + val mapRef = remember { arrayOfNulls<MapboxMap>(1) } | |
| 126 | + val mapViewRef = remember { arrayOfNulls<MapView>(1) } | |
| 127 | + val markerIndex = remember { mutableMapOf<Long, MapCluster>() } | |
| 128 | + | |
| 129 | + val mapUniverses = remember { mapUniverseIDs.mapNotNull { Ecosystem.universe(it) } } | |
| 130 | + | |
| 131 | + fun visibleItems(): List<KAItem> = items.filter { enabled.contains(it.universeID) } | |
| 132 | + | |
| 133 | + fun renderMarkers(context: Context) { | |
| 134 | + val map = mapRef[0] ?: return | |
| 135 | + val bounds = map.projection.visibleRegion.latLngBounds | |
| 136 | + val clusters = ClusterEngine.clusterize( | |
| 137 | + visibleItems(), | |
| 138 | + latSpan = bounds.latitudeSpan, | |
| 139 | + lonSpan = bounds.longitudeSpan, | |
| 140 | + ) | |
| 141 | + map.clear() | |
| 142 | + markerIndex.clear() | |
| 143 | + val factory = IconFactory.getInstance(context) | |
| 144 | + for (c in clusters) { | |
| 145 | + val bmp = if (c.isSingle) { | |
| 146 | + val u = Ecosystem.universe(c.item.universeID) | |
| 147 | + pillBitmap( | |
| 148 | + context, | |
| 149 | + text = c.item.priceLabel?.split(" ")?.take(2)?.joinToString(" ") ?: (u?.name ?: "•"), | |
| 150 | + colorHex = u?.accentHex ?: "#888888", | |
| 151 | + selected = selected?.id == c.item.id, | |
| 152 | + ) | |
| 153 | + } else { | |
| 154 | + val counts = c.items.groupingBy { it.universeID }.eachCount() | |
| 155 | + val top = counts.maxByOrNull { it.value }?.key | |
| 156 | + clusterBitmap(context, c.items.size, Ecosystem.universe(top ?: "")?.accentHex ?: "#888888") | |
| 157 | + } | |
| 158 | + val marker = map.addMarker( | |
| 159 | + MarkerOptions() | |
| 160 | + .position(LatLng(c.latitude, c.longitude)) | |
| 161 | + .icon(factory.fromBitmap(bmp)), | |
| 162 | + ) | |
| 163 | + markerIndex[marker.id] = c | |
| 164 | + } | |
| 165 | + resultCount = visibleItems().size | |
| 166 | + } | |
| 167 | + | |
| 168 | + suspend fun loadZone(context: Context) { | |
| 169 | + val map = mapRef[0] ?: return | |
| 170 | + loading = true | |
| 171 | + zoneDirty = false | |
| 172 | + selected = null | |
| 173 | + val b = map.projection.visibleRegion.latLngBounds | |
| 174 | + val west = b.longitudeWest | |
| 175 | + val east = b.longitudeEast | |
| 176 | + val south = b.latitudeSouth | |
| 177 | + val north = b.latitudeNorth | |
| 178 | + val all = coroutineScope { | |
| 179 | + mapUniverses.filter { enabled.contains(it.id) }.map { u -> | |
| 180 | + async { UniverseService.mapItems(u, west = west, south = south, east = east, north = north) } | |
| 181 | + }.awaitAll().flatten() | |
| 182 | + } | |
| 183 | + items = all | |
| 184 | + MapStateHolder.items = all | |
| 185 | + loading = false | |
| 186 | + renderMarkers(context) | |
| 187 | + } | |
| 188 | + | |
| 189 | + Box(Modifier.fillMaxSize()) { | |
| 190 | + AndroidView( | |
| 191 | + modifier = Modifier.fillMaxSize(), | |
| 192 | + factory = { context -> | |
| 193 | + com.mapbox.mapboxsdk.Mapbox.getInstance( | |
| 194 | + context.applicationContext, | |
| 195 | + MAPBOX_TOKEN, | |
| 196 | + com.mapbox.mapboxsdk.WellKnownTileServer.Mapbox, | |
| 197 | + ) | |
| 198 | + val mapView = MapView(context) | |
| 199 | + mapViewRef[0] = mapView | |
| 200 | + mapView.onCreate(null) | |
| 201 | + mapView.onStart() | |
| 202 | + mapView.onResume() | |
| 203 | + mapView.getMapAsync { map -> | |
| 204 | + mapRef[0] = map | |
| 205 | + map.uiSettings.isRotateGesturesEnabled = true | |
| 206 | + map.uiSettings.isTiltGesturesEnabled = true | |
| 207 | + map.setStyle(Style.Builder().fromUri(MAPBOX_STYLE)) { style -> | |
| 208 | + if (is3D) add3DBuildings(style) | |
| 209 | + map.cameraPosition = CameraPosition.Builder() | |
| 210 | + .target(LatLng(MapStateHolder.lat, MapStateHolder.lon)) | |
| 211 | + .zoom(MapStateHolder.zoom) | |
| 212 | + .tilt(MapStateHolder.pitch) | |
| 213 | + .build() | |
| 214 | + // premier chargement de zone | |
| 215 | + scope.launch { loadZone(context) } | |
| 216 | + } | |
| 217 | + map.addOnCameraIdleListener { | |
| 218 | + val cam = map.cameraPosition | |
| 219 | + MapStateHolder.lat = cam.target?.latitude ?: MapStateHolder.lat | |
| 220 | + MapStateHolder.lon = cam.target?.longitude ?: MapStateHolder.lon | |
| 221 | + MapStateHolder.zoom = cam.zoom | |
| 222 | + MapStateHolder.pitch = cam.tilt | |
| 223 | + zoneDirty = true | |
| 224 | + renderMarkers(context) | |
| 225 | + } | |
| 226 | + map.setOnMarkerClickListener { marker: Marker -> | |
| 227 | + val c = markerIndex[marker.id] ?: return@setOnMarkerClickListener false | |
| 228 | + if (c.isSingle) { | |
| 229 | + selected = c.item | |
| 230 | + renderMarkers(context) | |
| 231 | + } else { | |
| 232 | + // dégroupage : zoom sur le groupe | |
| 233 | + val cam = map.cameraPosition | |
| 234 | + map.animateCamera( | |
| 235 | + CameraUpdateFactory.newCameraPosition( | |
| 236 | + CameraPosition.Builder() | |
| 237 | + .target(LatLng(c.latitude, c.longitude)) | |
| 238 | + .zoom(cam.zoom + 1.7) | |
| 239 | + .tilt(cam.tilt) | |
| 240 | + .build(), | |
| 241 | + ), | |
| 242 | + 420, | |
| 243 | + ) | |
| 244 | + } | |
| 245 | + true | |
| 246 | + } | |
| 247 | + } | |
| 248 | + mapView | |
| 249 | + }, | |
| 250 | + ) | |
| 251 | + | |
| 252 | + // — cycle de vie MapView (relayé depuis le cycle de vie Compose) | |
| 253 | + val lifecycle = lifecycleOwner.lifecycle | |
| 254 | + DisposableEffect(lifecycle) { | |
| 255 | + val observer = LifecycleEventObserver { _, event -> | |
| 256 | + val mv = mapViewRef[0] ?: return@LifecycleEventObserver | |
| 257 | + when (event) { | |
| 258 | + Lifecycle.Event.ON_START -> mv.onStart() | |
| 259 | + Lifecycle.Event.ON_RESUME -> mv.onResume() | |
| 260 | + Lifecycle.Event.ON_PAUSE -> mv.onPause() | |
| 261 | + Lifecycle.Event.ON_STOP -> mv.onStop() | |
| 262 | + else -> Unit | |
| 263 | + } | |
| 264 | + } | |
| 265 | + lifecycle.addObserver(observer) | |
| 266 | + onDispose { | |
| 267 | + lifecycle.removeObserver(observer) | |
| 268 | + // la composable quitte l'écran : libérer la surface GL proprement | |
| 269 | + mapViewRef[0]?.let { mv -> | |
| 270 | + runCatching { mv.onPause(); mv.onStop(); mv.onDestroy() } | |
| 271 | + } | |
| 272 | + mapViewRef[0] = null | |
| 273 | + mapRef[0] = null | |
| 274 | + } | |
| 275 | + } | |
| 276 | + | |
| 277 | + // — chips univers (haut) | |
| 278 | + LazyRow( | |
| 279 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 280 | + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), | |
| 281 | + modifier = Modifier | |
| 282 | + .align(Alignment.TopStart) | |
| 283 | + .fillMaxWidth() | |
| 284 | + .background(KATheme.paper(dark).copy(alpha = 0.88f)), | |
| 285 | + ) { | |
| 286 | + items(mapUniverses.size) { i -> | |
| 287 | + val u = mapUniverses[i] | |
| 288 | + val on = enabled.contains(u.id) | |
| 289 | + val context = androidx.compose.ui.platform.LocalContext.current | |
| 290 | + Row( | |
| 291 | + Modifier | |
| 292 | + .background(if (on) u.accent else KATheme.surface(dark), CircleShape) | |
| 293 | + .border(1.dp, KATheme.ink(dark).copy(alpha = 0.25f), CircleShape) | |
| 294 | + .clickable { | |
| 295 | + enabled = if (on) enabled - u.id else enabled + u.id | |
| 296 | + MapStateHolder.enabled = enabled | |
| 297 | + selected = null | |
| 298 | + scope.launch { loadZone(context) } | |
| 299 | + } | |
| 300 | + .padding(horizontal = 11.dp, vertical = 8.dp), | |
| 301 | + verticalAlignment = Alignment.CenterVertically, | |
| 302 | + ) { | |
| 303 | + Icon( | |
| 304 | + universeIcon(u.symbol), null, | |
| 305 | + tint = if (on) Color.White else KATheme.ink2(dark), | |
| 306 | + modifier = Modifier.size(13.dp), | |
| 307 | + ) | |
| 308 | + Spacer(Modifier.width(5.dp)) | |
| 309 | + Text( | |
| 310 | + u.wordmark, | |
| 311 | + fontSize = 11.sp, | |
| 312 | + fontWeight = FontWeight.Bold, | |
| 313 | + fontFamily = FontFamily.Monospace, | |
| 314 | + color = if (on) Color.White else KATheme.ink2(dark), | |
| 315 | + ) | |
| 316 | + } | |
| 317 | + } | |
| 318 | + } | |
| 319 | + | |
| 320 | + // — liste synchronisée (recouvrement) | |
| 321 | + if (showList) { | |
| 322 | + LazyColumn( | |
| 323 | + contentPadding = PaddingValues(start = 12.dp, end = 12.dp, top = 64.dp, bottom = 130.dp), | |
| 324 | + verticalArrangement = Arrangement.spacedBy(10.dp), | |
| 325 | + modifier = Modifier | |
| 326 | + .fillMaxSize() | |
| 327 | + .background(KATheme.paper(dark).copy(alpha = 0.97f)), | |
| 328 | + ) { | |
| 329 | + val vis = visibleItems().take(60) | |
| 330 | + items(vis.size) { i -> | |
| 331 | + val it2 = vis[i] | |
| 332 | + val context = androidx.compose.ui.platform.LocalContext.current | |
| 333 | + KAItemRow(it2, Modifier.clickable { | |
| 334 | + showList = false | |
| 335 | + selected = it2 | |
| 336 | + val map = mapRef[0] | |
| 337 | + if (map != null && it2.latitude != null && it2.longitude != null) { | |
| 338 | + map.animateCamera( | |
| 339 | + CameraUpdateFactory.newCameraPosition( | |
| 340 | + CameraPosition.Builder() | |
| 341 | + .target(LatLng(it2.latitude, it2.longitude)) | |
| 342 | + .zoom(14.5).tilt(map.cameraPosition.tilt).build(), | |
| 343 | + ), | |
| 344 | + 420, | |
| 345 | + ) | |
| 346 | + } | |
| 347 | + renderMarkers(context) | |
| 348 | + }) | |
| 349 | + } | |
| 350 | + } | |
| 351 | + } | |
| 352 | + | |
| 353 | + // — pile du bas : fiche compacte + barre d'actions | |
| 354 | + Column( | |
| 355 | + Modifier | |
| 356 | + .align(Alignment.BottomCenter) | |
| 357 | + .fillMaxWidth() | |
| 358 | + .padding(horizontal = 12.dp) | |
| 359 | + .padding(bottom = 10.dp), | |
| 360 | + verticalArrangement = Arrangement.spacedBy(10.dp), | |
| 361 | + ) { | |
| 362 | + val sel = selected | |
| 363 | + if (sel != null) { | |
| 364 | + CompactCard( | |
| 365 | + item = sel, | |
| 366 | + onOpen = { nav.navigate("item/${encodeNavArg(sel)}") }, | |
| 367 | + onClose = { selected = null }, | |
| 368 | + ) | |
| 369 | + } | |
| 370 | + Row(verticalAlignment = Alignment.CenterVertically) { | |
| 371 | + // compteur | |
| 372 | + Text( | |
| 373 | + if (loading) "Chargement…" else "$resultCount résultats", | |
| 374 | + fontSize = 11.sp, | |
| 375 | + fontWeight = FontWeight.Bold, | |
| 376 | + fontFamily = FontFamily.Monospace, | |
| 377 | + color = KATheme.ink(dark), | |
| 378 | + modifier = Modifier | |
| 379 | + .background(KATheme.surface(dark).copy(alpha = 0.92f), CircleShape) | |
| 380 | + .border(1.dp, KATheme.ink(dark).copy(alpha = 0.25f), CircleShape) | |
| 381 | + .padding(horizontal = 11.dp, vertical = 9.dp), | |
| 382 | + ) | |
| 383 | + Spacer(Modifier.width(8.dp)) | |
| 384 | + // 2D/3D | |
| 385 | + val context = androidx.compose.ui.platform.LocalContext.current | |
| 386 | + Text( | |
| 387 | + if (is3D) "2D" else "3D", | |
| 388 | + fontSize = 12.sp, | |
| 389 | + fontWeight = FontWeight.Bold, | |
| 390 | + fontFamily = FontFamily.Monospace, | |
| 391 | + color = if (is3D) KATheme.lime else KATheme.ink(dark), | |
| 392 | + modifier = Modifier | |
| 393 | + .background(if (is3D) KATheme.inkLight else KATheme.surface(dark).copy(alpha = 0.92f), CircleShape) | |
| 394 | + .border(1.dp, KATheme.ink(dark).copy(alpha = 0.25f), CircleShape) | |
| 395 | + .clickable { | |
| 396 | + val map = mapRef[0] ?: return@clickable | |
| 397 | + is3D = !is3D | |
| 398 | + val style = map.style | |
| 399 | + if (is3D) style?.let { add3DBuildings(it) } | |
| 400 | + else style?.removeLayer("ka-3d-buildings") | |
| 401 | + val cam = map.cameraPosition | |
| 402 | + map.animateCamera( | |
| 403 | + CameraUpdateFactory.newCameraPosition( | |
| 404 | + CameraPosition.Builder() | |
| 405 | + .target(cam.target) | |
| 406 | + .zoom(if (is3D) maxOf(cam.zoom, 15.0) else cam.zoom) | |
| 407 | + .tilt(if (is3D) 60.0 else 0.0) | |
| 408 | + .build(), | |
| 409 | + ), | |
| 410 | + 600, | |
| 411 | + ) | |
| 412 | + } | |
| 413 | + .padding(horizontal = 13.dp, vertical = 9.dp), | |
| 414 | + ) | |
| 415 | + Spacer(Modifier.width(8.dp)) | |
| 416 | + // liste/carte | |
| 417 | + Icon( | |
| 418 | + if (showList) Icons.Filled.Map else Icons.AutoMirrored.Filled.List, | |
| 419 | + if (showList) "Voir la carte" else "Voir la liste des résultats", | |
| 420 | + tint = KATheme.ink(dark), | |
| 421 | + modifier = Modifier | |
| 422 | + .background(KATheme.surface(dark).copy(alpha = 0.92f), CircleShape) | |
| 423 | + .border(1.dp, KATheme.ink(dark).copy(alpha = 0.25f), CircleShape) | |
| 424 | + .clickable { showList = !showList } | |
| 425 | + .padding(9.dp) | |
| 426 | + .size(18.dp), | |
| 427 | + ) | |
| 428 | + Spacer(Modifier.weight(1f)) | |
| 429 | + if (zoneDirty && !loading) { | |
| 430 | + Row( | |
| 431 | + Modifier | |
| 432 | + .background(KATheme.inkLight, CircleShape) | |
| 433 | + .clickable { scope.launch { loadZone(context) } } | |
| 434 | + .padding(horizontal = 13.dp, vertical = 10.dp), | |
| 435 | + verticalAlignment = Alignment.CenterVertically, | |
| 436 | + ) { | |
| 437 | + Icon(Icons.Filled.Refresh, null, tint = KATheme.lime, modifier = Modifier.size(14.dp)) | |
| 438 | + Spacer(Modifier.width(6.dp)) | |
| 439 | + Text( | |
| 440 | + "Rechercher dans cette zone", | |
| 441 | + fontSize = 11.sp, | |
| 442 | + fontWeight = FontWeight.Bold, | |
| 443 | + color = KATheme.lime, | |
| 444 | + ) | |
| 445 | + } | |
| 446 | + } | |
| 447 | + } | |
| 448 | + } | |
| 449 | + } | |
| 450 | +} | |
| 451 | + | |
| 452 | +// MARK: - fiche compacte | |
| 453 | + | |
| 454 | +@Composable | |
| 455 | +private fun CompactCard(item: KAItem, onOpen: () -> Unit, onClose: () -> Unit) { | |
| 456 | + val dark = LocalKADark.current | |
| 457 | + val u = Ecosystem.universe(item.universeID) | |
| 458 | + Row( | |
| 459 | + Modifier | |
| 460 | + .fillMaxWidth() | |
| 461 | + .background(KATheme.surface(dark), RoundedCornerShape(14.dp)) | |
| 462 | + .border(1.4.dp, KATheme.ink(dark).copy(alpha = 0.5f), RoundedCornerShape(14.dp)) | |
| 463 | + .clickable { onOpen() } | |
| 464 | + .padding(11.dp), | |
| 465 | + verticalAlignment = Alignment.CenterVertically, | |
| 466 | + ) { | |
| 467 | + if (item.imageURL != null) { | |
| 468 | + KAImage( | |
| 469 | + url = item.imageURL, accent = u?.accent ?: Color.Gray, symbol = u?.symbol ?: "photo", | |
| 470 | + modifier = Modifier.size(58.dp).clip(RoundedCornerShape(9.dp)), | |
| 471 | + ) | |
| 472 | + Spacer(Modifier.width(10.dp)) | |
| 473 | + } | |
| 474 | + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { | |
| 475 | + Text( | |
| 476 | + item.title, | |
| 477 | + style = MaterialTheme.typography.bodyMedium, | |
| 478 | + fontWeight = FontWeight.Bold, | |
| 479 | + maxLines = 1, | |
| 480 | + color = KATheme.ink(dark), | |
| 481 | + ) | |
| 482 | + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 483 | + if (item.priceLabel != null) { | |
| 484 | + Text( | |
| 485 | + item.priceLabel!!, | |
| 486 | + style = MaterialTheme.typography.labelMedium, | |
| 487 | + fontWeight = FontWeight.Bold, | |
| 488 | + color = u?.accent ?: KATheme.ink(dark), | |
| 489 | + ) | |
| 490 | + } | |
| 491 | + if (item.city != null) { | |
| 492 | + Text(item.city, style = MaterialTheme.typography.labelSmall, color = KATheme.ink2(dark)) | |
| 493 | + } | |
| 494 | + } | |
| 495 | + Text( | |
| 496 | + "Toucher pour la fiche complète", | |
| 497 | + fontSize = 9.sp, | |
| 498 | + fontFamily = FontFamily.Monospace, | |
| 499 | + color = KATheme.ink2(dark).copy(alpha = 0.7f), | |
| 500 | + ) | |
| 501 | + } | |
| 502 | + Icon( | |
| 503 | + Icons.Filled.Cancel, "Fermer l'aperçu", | |
| 504 | + tint = KATheme.ink2(dark), | |
| 505 | + modifier = Modifier.clickable { onClose() }.size(22.dp), | |
| 506 | + ) | |
| 507 | + } | |
| 508 | +} | |
| 509 | + | |
| 510 | +// MARK: - couche 3D (bâtiments fill-extrusion, comme Ka Maps web) | |
| 511 | + | |
| 512 | +private fun add3DBuildings(style: Style) { | |
| 513 | + if (style.getLayer("ka-3d-buildings") != null) return | |
| 514 | + val layer = FillExtrusionLayer("ka-3d-buildings", "composite") | |
| 515 | + layer.sourceLayer = "building" | |
| 516 | + layer.setFilter(Expression.eq(Expression.get("extrude"), Expression.literal("true"))) | |
| 517 | + layer.minZoom = 14f | |
| 518 | + layer.setProperties( | |
| 519 | + PropertyFactory.fillExtrusionColor("#cfc9bc"), | |
| 520 | + PropertyFactory.fillExtrusionOpacity(0.78f), | |
| 521 | + PropertyFactory.fillExtrusionHeight( | |
| 522 | + Expression.interpolate( | |
| 523 | + Expression.linear(), Expression.zoom(), | |
| 524 | + Expression.stop(14f, Expression.literal(0f)), | |
| 525 | + Expression.stop(15.05f, Expression.get("height")), | |
| 526 | + ), | |
| 527 | + ), | |
| 528 | + PropertyFactory.fillExtrusionBase(Expression.get("min_height")), | |
| 529 | + ) | |
| 530 | + style.addLayer(layer) | |
| 531 | +} | |
| 532 | + | |
| 533 | +// MARK: - bitmaps de marqueurs (pilule prix / badge de groupe) | |
| 534 | + | |
| 535 | +private val bitmapCache = HashMap<String, Bitmap>() | |
| 536 | + | |
| 537 | +private fun pillBitmap(context: Context, text: String, colorHex: String, selected: Boolean): Bitmap { | |
| 538 | + val key = "p|$text|$colorHex|$selected" | |
| 539 | + bitmapCache[key]?.let { return it } | |
| 540 | + val density = context.resources.displayMetrics.density | |
| 541 | + val textSize = 12f * density | |
| 542 | + val padH = 9f * density | |
| 543 | + val padV = 6f * density | |
| 544 | + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { | |
| 545 | + this.textSize = textSize | |
| 546 | + typeface = Typeface.create(Typeface.DEFAULT_BOLD, Typeface.BOLD) | |
| 547 | + } | |
| 548 | + val tw = paint.measureText(text) | |
| 549 | + val w = (tw + padH * 2).toInt().coerceAtLeast(1) | |
| 550 | + val h = (textSize + padV * 2).toInt() | |
| 551 | + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) | |
| 552 | + val canvas = Canvas(bmp) | |
| 553 | + val bg = Paint(Paint.ANTI_ALIAS_FLAG).apply { | |
| 554 | + color = if (selected) "#141814".toColorInt() else colorHex.toColorInt() | |
| 555 | + } | |
| 556 | + val r = h / 2f | |
| 557 | + canvas.drawRoundRect(RectF(0f, 0f, w.toFloat(), h.toFloat()), r, r, bg) | |
| 558 | + val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { | |
| 559 | + style = Paint.Style.STROKE | |
| 560 | + strokeWidth = 1.6f * density | |
| 561 | + color = "#F2FFFFFF".toColorInt() | |
| 562 | + } | |
| 563 | + canvas.drawRoundRect(RectF(1f, 1f, w - 1f, h - 1f), r, r, stroke) | |
| 564 | + paint.color = if (selected) "#d9f26b".toColorInt() else android.graphics.Color.WHITE | |
| 565 | + val y = h / 2f - (paint.descent() + paint.ascent()) / 2f | |
| 566 | + canvas.drawText(text, padH, y, paint) | |
| 567 | + bitmapCache[key] = bmp | |
| 568 | + return bmp | |
| 569 | +} | |
| 570 | + | |
| 571 | +private fun clusterBitmap(context: Context, count: Int, colorHex: String): Bitmap { | |
| 572 | + val key = "c|$count|$colorHex" | |
| 573 | + bitmapCache[key]?.let { return it } | |
| 574 | + val density = context.resources.displayMetrics.density | |
| 575 | + val d = (38f * density).toInt() | |
| 576 | + val bmp = Bitmap.createBitmap(d, d, Bitmap.Config.ARGB_8888) | |
| 577 | + val canvas = Canvas(bmp) | |
| 578 | + val bg = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = colorHex.toColorInt() } | |
| 579 | + canvas.drawCircle(d / 2f, d / 2f, d / 2f - density, bg) | |
| 580 | + val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { | |
| 581 | + style = Paint.Style.STROKE | |
| 582 | + strokeWidth = 2f * density | |
| 583 | + color = android.graphics.Color.WHITE | |
| 584 | + } | |
| 585 | + canvas.drawCircle(d / 2f, d / 2f, d / 2f - 1.5f * density, stroke) | |
| 586 | + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { | |
| 587 | + textSize = 13f * density | |
| 588 | + typeface = Typeface.create(Typeface.DEFAULT_BOLD, Typeface.BOLD) | |
| 589 | + color = android.graphics.Color.WHITE | |
| 590 | + textAlign = Paint.Align.CENTER | |
| 591 | + } | |
| 592 | + val y = d / 2f - (paint.descent() + paint.ascent()) / 2f | |
| 593 | + canvas.drawText(count.toString(), d / 2f, y, paint) | |
| 594 | + bitmapCache[key] = bmp | |
| 595 | + return bmp | |
| 596 | +} | |
modified
app/src/main/java/com/groupeka/ka/ui/ProfileScreen.kt
+64 −1
@@ -26,8 +26,11 @@ import androidx.compose.material.icons.Icons | ||
| 26 | 26 | import androidx.compose.material.icons.automirrored.filled.Logout |
| 27 | 27 | import androidx.compose.material.icons.automirrored.filled.OpenInNew |
| 28 | 28 | import androidx.compose.material.icons.filled.AccountCircle |
| 29 | +import androidx.compose.material.icons.filled.Favorite | |
| 30 | +import androidx.compose.material.icons.filled.History | |
| 29 | 31 | import androidx.compose.material.icons.filled.Language |
| 30 | 32 | import androidx.compose.material.icons.filled.MonitorHeart |
| 33 | +import androidx.compose.material.icons.filled.NotificationsActive | |
| 31 | 34 | import androidx.compose.material.icons.filled.Star |
| 32 | 35 | import androidx.compose.material.icons.filled.StarBorder |
| 33 | 36 | import androidx.compose.material3.CircularProgressIndicator |
@@ -50,10 +53,13 @@ import androidx.compose.ui.text.font.FontWeight | ||
| 50 | 53 | import androidx.compose.ui.unit.dp |
| 51 | 54 | import androidx.compose.ui.unit.sp |
| 52 | 55 | import coil.compose.SubcomposeAsyncImage |
| 56 | +import androidx.navigation.NavHostController | |
| 53 | 57 | import com.groupeka.ka.auth.KAIDManager |
| 54 | 58 | import com.groupeka.ka.auth.KAIDProfile |
| 55 | 59 | import com.groupeka.ka.core.Ecosystem |
| 60 | +import com.groupeka.ka.core.FavoritesStore | |
| 56 | 61 | import com.groupeka.ka.core.Prefs |
| 62 | +import com.groupeka.ka.core.RecentsStore | |
| 57 | 63 | import com.groupeka.ka.ui.theme.KATheme |
| 58 | 64 | import com.groupeka.ka.ui.theme.LocalKADark |
| 59 | 65 | import com.groupeka.ka.ui.theme.accent |
@@ -62,13 +68,19 @@ import com.groupeka.ka.ui.theme.kaCard | ||
| 62 | 68 | import com.groupeka.ka.ui.theme.universeIcon |
| 63 | 69 | |
| 64 | 70 | @Composable |
| 65 | −fun ProfileScreen() { | |
| 71 | +fun ProfileScreen(nav: NavHostController) { | |
| 66 | 72 | val dark = LocalKADark.current |
| 67 | 73 | val context = LocalContext.current |
| 68 | 74 | val profile by KAIDManager.profile.collectAsState() |
| 69 | 75 | val busy by KAIDManager.busy.collectAsState() |
| 70 | 76 | val lastError by KAIDManager.lastError.collectAsState() |
| 71 | 77 | val favIDs = Prefs.favUniverses.split(",").filter { it.isNotEmpty() }.toSet() |
| 78 | + val favCollections = FavoritesStore.shared?.collections?.collectAsState() | |
| 79 | + val favCount = favCollections?.value?.sumOf { it.items.size } ?: 0 | |
| 80 | + val recents = RecentsStore.shared | |
| 81 | + val viewed = recents?.viewed?.collectAsState() | |
| 82 | + val savedSearches = recents?.savedSearches?.collectAsState() | |
| 83 | + val alertCount = savedSearches?.value?.filter { it.alertsOn }?.sumOf { it.newCount } ?: 0 | |
| 72 | 84 | |
| 73 | 85 | LazyColumn( |
| 74 | 86 | contentPadding = PaddingValues(16.dp), |
@@ -138,6 +150,25 @@ fun ProfileScreen() { | ||
| 138 | 150 | } |
| 139 | 151 | } |
| 140 | 152 | |
| 153 | + // — Mes contenus : hub Favoris / Historique / Recherches & alertes | |
| 154 | + item { | |
| 155 | + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 156 | + Text("Mes contenus", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark)) | |
| 157 | + Column(Modifier.fillMaxWidth().kaCard()) { | |
| 158 | + HubRow(Icons.Filled.Favorite, "Favoris & collections", "$favCount") { nav.navigate("favorites") } | |
| 159 | + HorizontalDivider(color = KATheme.ink(dark).copy(alpha = 0.08f)) | |
| 160 | + HubRow(Icons.Filled.History, "Historique consulté", "${viewed?.value?.size ?: 0}") { nav.navigate("history") } | |
| 161 | + HorizontalDivider(color = KATheme.ink(dark).copy(alpha = 0.08f)) | |
| 162 | + HubRow( | |
| 163 | + Icons.Filled.NotificationsActive, | |
| 164 | + "Recherches sauvegardées & alertes", | |
| 165 | + if (alertCount > 0) "+$alertCount nouv." else "${savedSearches?.value?.size ?: 0}", | |
| 166 | + badgeAccent = alertCount > 0, | |
| 167 | + ) { nav.navigate("alerts") } | |
| 168 | + } | |
| 169 | + } | |
| 170 | + } | |
| 171 | + | |
| 141 | 172 | // — Univers favoris |
| 142 | 173 | item { |
| 143 | 174 | Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { |
@@ -261,6 +292,38 @@ fun ProfileScreen() { | ||
| 261 | 292 | } |
| 262 | 293 | } |
| 263 | 294 | |
| 295 | +@Composable | |
| 296 | +private fun HubRow( | |
| 297 | + icon: androidx.compose.ui.graphics.vector.ImageVector, | |
| 298 | + label: String, | |
| 299 | + badge: String, | |
| 300 | + badgeAccent: Boolean = false, | |
| 301 | + onClick: () -> Unit, | |
| 302 | +) { | |
| 303 | + val dark = LocalKADark.current | |
| 304 | + Row( | |
| 305 | + Modifier | |
| 306 | + .fillMaxWidth() | |
| 307 | + .clickable { onClick() } | |
| 308 | + .padding(vertical = 12.dp, horizontal = 14.dp), | |
| 309 | + verticalAlignment = Alignment.CenterVertically, | |
| 310 | + ) { | |
| 311 | + Icon(icon, null, tint = KATheme.green, modifier = Modifier.size(20.dp)) | |
| 312 | + Spacer(Modifier.width(10.dp)) | |
| 313 | + Text(label, style = MaterialTheme.typography.bodyMedium, color = KATheme.ink(dark), modifier = Modifier.weight(1f)) | |
| 314 | + Text( | |
| 315 | + badge, | |
| 316 | + fontSize = 10.sp, | |
| 317 | + fontFamily = FontFamily.Monospace, | |
| 318 | + fontWeight = FontWeight.Bold, | |
| 319 | + color = if (badgeAccent) Color.White else KATheme.ink2(dark), | |
| 320 | + modifier = if (badgeAccent) { | |
| 321 | + Modifier.background(Color(0xFFD32F2F), CircleShape).padding(horizontal = 7.dp, vertical = 2.dp) | |
| 322 | + } else Modifier, | |
| 323 | + ) | |
| 324 | + } | |
| 325 | +} | |
| 326 | + | |
| 264 | 327 | @Composable |
| 265 | 328 | private fun ProfileLinkRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, tint: Color? = null, onClick: () -> Unit) { |
| 266 | 329 | val dark = LocalKADark.current |
added
app/src/main/java/com/groupeka/ka/ui/RecentsScreens.kt
+186 −0
@@ -0,0 +1,186 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// RecentsScreens.kt — Historique consulté (30 derniers, dédupliqués) et | |
| 3 | +// Recherches sauvegardées & ALERTES honnêtes (+N = delta réel du total API). | |
| 4 | +package com.groupeka.ka.ui | |
| 5 | + | |
| 6 | +import androidx.compose.foundation.background | |
| 7 | +import androidx.compose.foundation.clickable | |
| 8 | +import androidx.compose.foundation.layout.Arrangement | |
| 9 | +import androidx.compose.foundation.layout.Column | |
| 10 | +import androidx.compose.foundation.layout.PaddingValues | |
| 11 | +import androidx.compose.foundation.layout.Row | |
| 12 | +import androidx.compose.foundation.layout.Spacer | |
| 13 | +import androidx.compose.foundation.layout.fillMaxWidth | |
| 14 | +import androidx.compose.foundation.layout.padding | |
| 15 | +import androidx.compose.foundation.layout.width | |
| 16 | +import androidx.compose.foundation.lazy.LazyColumn | |
| 17 | +import androidx.compose.foundation.shape.CircleShape | |
| 18 | +import androidx.compose.material.icons.Icons | |
| 19 | +import androidx.compose.material.icons.automirrored.filled.ArrowBack | |
| 20 | +import androidx.compose.material.icons.filled.Delete | |
| 21 | +import androidx.compose.material.icons.filled.NotificationsOff | |
| 22 | +import androidx.compose.material.icons.filled.Refresh | |
| 23 | +import androidx.compose.material.icons.filled.Schedule | |
| 24 | +import androidx.compose.material3.Icon | |
| 25 | +import androidx.compose.material3.IconButton | |
| 26 | +import androidx.compose.material3.MaterialTheme | |
| 27 | +import androidx.compose.material3.Switch | |
| 28 | +import androidx.compose.material3.Text | |
| 29 | +import androidx.compose.material3.TextButton | |
| 30 | +import androidx.compose.runtime.Composable | |
| 31 | +import androidx.compose.runtime.collectAsState | |
| 32 | +import androidx.compose.runtime.getValue | |
| 33 | +import androidx.compose.runtime.rememberCoroutineScope | |
| 34 | +import androidx.compose.ui.Alignment | |
| 35 | +import androidx.compose.ui.Modifier | |
| 36 | +import androidx.compose.ui.graphics.Color | |
| 37 | +import androidx.compose.ui.text.font.FontFamily | |
| 38 | +import androidx.compose.ui.text.font.FontWeight | |
| 39 | +import androidx.compose.ui.unit.dp | |
| 40 | +import androidx.compose.ui.unit.sp | |
| 41 | +import androidx.navigation.NavHostController | |
| 42 | +import com.groupeka.ka.core.Ecosystem | |
| 43 | +import com.groupeka.ka.core.RecentsStore | |
| 44 | +import com.groupeka.ka.encodeNavArg | |
| 45 | +import com.groupeka.ka.ui.theme.KAChip | |
| 46 | +import com.groupeka.ka.ui.theme.KAEmptyState | |
| 47 | +import com.groupeka.ka.ui.theme.KAItemRow | |
| 48 | +import com.groupeka.ka.ui.theme.KATheme | |
| 49 | +import com.groupeka.ka.ui.theme.LocalKADark | |
| 50 | +import com.groupeka.ka.ui.theme.kaCard | |
| 51 | +import kotlinx.coroutines.launch | |
| 52 | + | |
| 53 | +// MARK: - Historique consulté | |
| 54 | + | |
| 55 | +@Composable | |
| 56 | +fun HistoryScreen(nav: NavHostController) { | |
| 57 | + val dark = LocalKADark.current | |
| 58 | + val store = RecentsStore.shared ?: return | |
| 59 | + val viewed by store.viewed.collectAsState() | |
| 60 | + | |
| 61 | + LazyColumn( | |
| 62 | + contentPadding = PaddingValues(14.dp), | |
| 63 | + verticalArrangement = Arrangement.spacedBy(10.dp), | |
| 64 | + modifier = Modifier.fillMaxWidth(), | |
| 65 | + ) { | |
| 66 | + item { | |
| 67 | + Row(verticalAlignment = Alignment.CenterVertically) { | |
| 68 | + IconButton(onClick = { nav.popBackStack() }) { | |
| 69 | + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Retour", tint = KATheme.ink(dark)) | |
| 70 | + } | |
| 71 | + Text("Historique", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = KATheme.ink(dark)) | |
| 72 | + Spacer(Modifier.weight(1f)) | |
| 73 | + if (viewed.isNotEmpty()) { | |
| 74 | + TextButton(onClick = { store.clearHistory() }) { | |
| 75 | + Text("Effacer", color = KATheme.ink2(dark)) | |
| 76 | + } | |
| 77 | + } | |
| 78 | + } | |
| 79 | + } | |
| 80 | + if (viewed.isEmpty()) { | |
| 81 | + item { | |
| 82 | + KAEmptyState( | |
| 83 | + Icons.Filled.Schedule, "Rien de consulté encore", | |
| 84 | + "Les fiches que vous ouvrez apparaîtront ici.", | |
| 85 | + ) | |
| 86 | + } | |
| 87 | + } | |
| 88 | + viewed.forEach { item -> | |
| 89 | + item(key = item.id) { | |
| 90 | + KAItemRow(item, Modifier.clickable { nav.navigate("item/${encodeNavArg(item)}") }) | |
| 91 | + } | |
| 92 | + } | |
| 93 | + } | |
| 94 | +} | |
| 95 | + | |
| 96 | +// MARK: - Recherches sauvegardées & alertes | |
| 97 | + | |
| 98 | +@Composable | |
| 99 | +fun SavedSearchesScreen(nav: NavHostController) { | |
| 100 | + val dark = LocalKADark.current | |
| 101 | + val scope = rememberCoroutineScope() | |
| 102 | + val store = RecentsStore.shared ?: return | |
| 103 | + val searches by store.savedSearches.collectAsState() | |
| 104 | + | |
| 105 | + LazyColumn( | |
| 106 | + contentPadding = PaddingValues(14.dp), | |
| 107 | + verticalArrangement = Arrangement.spacedBy(12.dp), | |
| 108 | + modifier = Modifier.fillMaxWidth(), | |
| 109 | + ) { | |
| 110 | + item { | |
| 111 | + Row(verticalAlignment = Alignment.CenterVertically) { | |
| 112 | + IconButton(onClick = { nav.popBackStack() }) { | |
| 113 | + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Retour", tint = KATheme.ink(dark)) | |
| 114 | + } | |
| 115 | + Text("Recherches & alertes", fontSize = 22.sp, fontWeight = FontWeight.Bold, color = KATheme.ink(dark)) | |
| 116 | + Spacer(Modifier.weight(1f)) | |
| 117 | + IconButton(onClick = { scope.launch { store.refreshAlerts() } }) { | |
| 118 | + Icon(Icons.Filled.Refresh, "Recompter maintenant", tint = KATheme.green) | |
| 119 | + } | |
| 120 | + } | |
| 121 | + } | |
| 122 | + if (searches.isEmpty()) { | |
| 123 | + item { | |
| 124 | + KAEmptyState( | |
| 125 | + Icons.Filled.NotificationsOff, "Aucune recherche sauvegardée", | |
| 126 | + "Dans Recherche, touchez 🔔 pour suivre une recherche : KA recomptera les résultats et affichera les nouveautés.", | |
| 127 | + ) | |
| 128 | + } | |
| 129 | + } | |
| 130 | + searches.forEach { s -> | |
| 131 | + item(key = s.id) { | |
| 132 | + Column( | |
| 133 | + Modifier.fillMaxWidth().kaCard().padding(14.dp), | |
| 134 | + verticalArrangement = Arrangement.spacedBy(6.dp), | |
| 135 | + ) { | |
| 136 | + Row(verticalAlignment = Alignment.CenterVertically) { | |
| 137 | + Text(s.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark), modifier = Modifier.weight(1f)) | |
| 138 | + if (s.newCount > 0) { | |
| 139 | + Text( | |
| 140 | + "+${s.newCount} nouveautés", | |
| 141 | + fontSize = 10.sp, | |
| 142 | + fontFamily = FontFamily.Monospace, | |
| 143 | + fontWeight = FontWeight.Bold, | |
| 144 | + color = Color.White, | |
| 145 | + modifier = Modifier | |
| 146 | + .background(Color(0xFFD32F2F), CircleShape) | |
| 147 | + .padding(horizontal = 7.dp, vertical = 3.dp), | |
| 148 | + ) | |
| 149 | + Spacer(Modifier.width(8.dp)) | |
| 150 | + } | |
| 151 | + Switch( | |
| 152 | + checked = s.alertsOn, | |
| 153 | + onCheckedChange = { store.setAlerts(s.id, it) }, | |
| 154 | + ) | |
| 155 | + } | |
| 156 | + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 157 | + Ecosystem.universe(s.universeID)?.let { u -> KAChip(u.wordmark, com.groupeka.ka.ui.theme.colorFromHex(u.accentHex)) } | |
| 158 | + Text("« ${s.query} »", style = MaterialTheme.typography.labelMedium, color = KATheme.ink2(dark), modifier = Modifier.weight(1f)) | |
| 159 | + s.lastTotal?.let { total -> | |
| 160 | + Text( | |
| 161 | + "${total + s.newCount} résultats", | |
| 162 | + fontSize = 10.sp, | |
| 163 | + fontFamily = FontFamily.Monospace, | |
| 164 | + color = KATheme.ink2(dark), | |
| 165 | + ) | |
| 166 | + } | |
| 167 | + } | |
| 168 | + Row(verticalAlignment = Alignment.CenterVertically) { | |
| 169 | + if (s.newCount > 0) { | |
| 170 | + TextButton(onClick = { store.markSeen(s.id) }) { | |
| 171 | + Text("Marquer comme vues", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = KATheme.green) | |
| 172 | + } | |
| 173 | + } | |
| 174 | + TextButton(onClick = { nav.navigate("universe/${s.universeID}") }) { | |
| 175 | + Text("Ouvrir l'univers →", fontSize = 12.sp, color = KATheme.ink2(dark)) | |
| 176 | + } | |
| 177 | + Spacer(Modifier.weight(1f)) | |
| 178 | + IconButton(onClick = { store.remove(s.id) }) { | |
| 179 | + Icon(Icons.Filled.Delete, "Supprimer", tint = KATheme.ink2(dark)) | |
| 180 | + } | |
| 181 | + } | |
| 182 | + } | |
| 183 | + } | |
| 184 | + } | |
| 185 | + } | |
| 186 | +} | |
modified
app/src/main/java/com/groupeka/ka/ui/SearchScreen.kt
+100 −1
@@ -20,15 +20,21 @@ import androidx.compose.foundation.lazy.LazyRow | ||
| 20 | 20 | import androidx.compose.foundation.shape.CircleShape |
| 21 | 21 | import androidx.compose.foundation.text.KeyboardActions |
| 22 | 22 | import androidx.compose.foundation.text.KeyboardOptions |
| 23 | +import androidx.compose.foundation.layout.size | |
| 23 | 24 | import androidx.compose.material.icons.Icons |
| 24 | 25 | import androidx.compose.material.icons.filled.History |
| 26 | +import androidx.compose.material.icons.filled.NotificationAdd | |
| 25 | 27 | import androidx.compose.material.icons.filled.Search |
| 28 | +import androidx.compose.material.icons.filled.Share | |
| 29 | +import androidx.compose.material3.AlertDialog | |
| 26 | 30 | import androidx.compose.material3.CircularProgressIndicator |
| 27 | 31 | import androidx.compose.material3.Icon |
| 28 | 32 | import androidx.compose.material3.MaterialTheme |
| 29 | 33 | import androidx.compose.material3.OutlinedTextField |
| 30 | 34 | import androidx.compose.material3.OutlinedTextFieldDefaults |
| 31 | 35 | import androidx.compose.material3.Text |
| 36 | +import androidx.compose.material3.TextButton | |
| 37 | +import androidx.compose.ui.platform.LocalContext | |
| 32 | 38 | import androidx.compose.runtime.Composable |
| 33 | 39 | import androidx.compose.runtime.getValue |
| 34 | 40 | import androidx.compose.runtime.mutableStateOf |
@@ -48,6 +54,7 @@ import androidx.navigation.NavHostController | ||
| 48 | 54 | import com.groupeka.ka.core.Ecosystem |
| 49 | 55 | import com.groupeka.ka.core.KAItem |
| 50 | 56 | import com.groupeka.ka.core.Prefs |
| 57 | +import com.groupeka.ka.core.RecentsStore | |
| 51 | 58 | import com.groupeka.ka.core.Universe |
| 52 | 59 | import com.groupeka.ka.core.UniverseService |
| 53 | 60 | import com.groupeka.ka.encodeNavArg |
@@ -67,16 +74,30 @@ import kotlinx.coroutines.launch | ||
| 67 | 74 | fun SearchScreen(nav: NavHostController) { |
| 68 | 75 | val dark = LocalKADark.current |
| 69 | 76 | val scope = rememberCoroutineScope() |
| 77 | + val context = LocalContext.current | |
| 70 | 78 | val keyboard = LocalSoftwareKeyboardController.current |
| 71 | 79 | var query by remember { mutableStateOf("") } |
| 72 | 80 | var submitted by remember { mutableStateOf("") } |
| 73 | 81 | var sections by remember { mutableStateOf<List<Pair<Universe, List<KAItem>>>>(emptyList()) } |
| 74 | 82 | var searching by remember { mutableStateOf(false) } |
| 75 | 83 | var selected by remember { mutableStateOf<Set<String>>(emptySet()) } // filtres univers (vide = tous) |
| 84 | + var sort by remember { mutableStateOf("pertinence") } // pertinence / prixAsc / prixDesc | |
| 85 | + var showSave by remember { mutableStateOf(false) } | |
| 86 | + var saveName by remember { mutableStateOf("") } | |
| 76 | 87 | |
| 77 | 88 | val searchables = remember { Ecosystem.all.filter { it.map != null } } |
| 78 | 89 | val history = Prefs.searchHistory.split("\n").filter { it.isNotEmpty() } |
| 79 | 90 | |
| 91 | + fun price(i: KAItem, asc: Boolean): Double = | |
| 92 | + i.priceLabel?.filter { it.isDigit() }?.toDoubleOrNull() | |
| 93 | + ?: if (asc) Double.MAX_VALUE else 0.0 | |
| 94 | + | |
| 95 | + fun sorted(items: List<KAItem>): List<KAItem> = when (sort) { | |
| 96 | + "prixAsc" -> items.sortedBy { price(it, true) } | |
| 97 | + "prixDesc" -> items.sortedByDescending { price(it, false) } | |
| 98 | + else -> items | |
| 99 | + } | |
| 100 | + | |
| 80 | 101 | suspend fun doSearch(text: String) { |
| 81 | 102 | val q = text.trim() |
| 82 | 103 | if (q.isEmpty()) return |
@@ -127,6 +148,51 @@ fun SearchScreen(nav: NavHostController) { | ||
| 127 | 148 | modifier = Modifier.fillMaxWidth(), |
| 128 | 149 | ) |
| 129 | 150 | } |
| 151 | + if (submitted.isNotEmpty() && !searching) { | |
| 152 | + item { | |
| 153 | + // tri + sauvegarde/alerte + partage | |
| 154 | + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 155 | + listOf("pertinence" to "Pertinence", "prixAsc" to "Prix ↑", "prixDesc" to "Prix ↓").forEach { (v, label) -> | |
| 156 | + val on = sort == v | |
| 157 | + Text( | |
| 158 | + label, | |
| 159 | + fontSize = 11.sp, | |
| 160 | + fontWeight = FontWeight.Bold, | |
| 161 | + fontFamily = FontFamily.Monospace, | |
| 162 | + color = if (on) Color.White else KATheme.ink2(dark), | |
| 163 | + modifier = Modifier | |
| 164 | + .background(if (on) KATheme.green else KATheme.surface(dark), CircleShape) | |
| 165 | + .border(1.dp, KATheme.ink(dark).copy(alpha = 0.3f), CircleShape) | |
| 166 | + .clickable { sort = v } | |
| 167 | + .padding(horizontal = 10.dp, vertical = 7.dp), | |
| 168 | + ) | |
| 169 | + } | |
| 170 | + Spacer(Modifier.weight(1f)) | |
| 171 | + Icon( | |
| 172 | + Icons.Filled.NotificationAdd, "Sauvegarder cette recherche et créer une alerte", | |
| 173 | + tint = KATheme.green, | |
| 174 | + modifier = Modifier | |
| 175 | + .clickable { saveName = submitted; showSave = true } | |
| 176 | + .padding(4.dp) | |
| 177 | + .size(22.dp), | |
| 178 | + ) | |
| 179 | + Icon( | |
| 180 | + Icons.Filled.Share, "Partager la recherche", | |
| 181 | + tint = KATheme.ink2(dark), | |
| 182 | + modifier = Modifier | |
| 183 | + .clickable { | |
| 184 | + shareUrl( | |
| 185 | + context, | |
| 186 | + "https://www.trouve-ka.com/search?q=" + java.net.URLEncoder.encode(submitted, "UTF-8"), | |
| 187 | + "Recherche KA : $submitted", | |
| 188 | + ) | |
| 189 | + } | |
| 190 | + .padding(4.dp) | |
| 191 | + .size(20.dp), | |
| 192 | + ) | |
| 193 | + } | |
| 194 | + } | |
| 195 | + } | |
| 130 | 196 | item { |
| 131 | 197 | // chips filtres univers |
| 132 | 198 | LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
@@ -232,7 +298,7 @@ fun SearchScreen(nav: NavHostController) { | ||
| 232 | 298 | ) |
| 233 | 299 | } |
| 234 | 300 | } |
| 235 | − items.take(5).forEach { item -> | |
| 301 | + sorted(items).take(5).forEach { item -> | |
| 236 | 302 | item(key = "it-${item.id}") { |
| 237 | 303 | KAItemRow(item, Modifier.clickable { nav.navigate("item/${encodeNavArg(item)}") }) |
| 238 | 304 | } |
@@ -249,4 +315,37 @@ fun SearchScreen(nav: NavHostController) { | ||
| 249 | 315 | } |
| 250 | 316 | } |
| 251 | 317 | } |
| 318 | + | |
| 319 | + // — sauvegarde de recherche + alerte honnête | |
| 320 | + if (showSave) { | |
| 321 | + AlertDialog( | |
| 322 | + onDismissRequest = { showSave = false }, | |
| 323 | + title = { Text("Sauvegarder la recherche") }, | |
| 324 | + text = { | |
| 325 | + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 326 | + Text("KA recomptera les résultats à chaque ouverture et vous montrera les nouveautés (+N).") | |
| 327 | + OutlinedTextField( | |
| 328 | + value = saveName, | |
| 329 | + onValueChange = { saveName = it }, | |
| 330 | + placeholder = { Text("Nom") }, | |
| 331 | + singleLine = true, | |
| 332 | + ) | |
| 333 | + } | |
| 334 | + }, | |
| 335 | + confirmButton = { | |
| 336 | + TextButton(onClick = { | |
| 337 | + val uid = if (selected.size == 1) selected.first() | |
| 338 | + else sections.maxByOrNull { it.second.size }?.first?.id ?: "lou-ka" | |
| 339 | + val name = saveName | |
| 340 | + val q = submitted | |
| 341 | + showSave = false | |
| 342 | + scope.launch { RecentsStore.shared?.saveSearch(name, uid, q, emptyMap()) } | |
| 343 | + }) { Text("Sauvegarder + alerte", fontWeight = FontWeight.Bold, color = KATheme.green) } | |
| 344 | + }, | |
| 345 | + dismissButton = { | |
| 346 | + TextButton(onClick = { showSave = false }) { Text("Annuler") } | |
| 347 | + }, | |
| 348 | + containerColor = KATheme.surface(dark), | |
| 349 | + ) | |
| 350 | + } | |
| 252 | 351 | } |
modified
app/src/main/java/com/groupeka/ka/ui/UniversesScreen.kt
+17 −1
@@ -172,6 +172,7 @@ private fun UniverseCard(universe: Universe, total: Int?, modifier: Modifier = M | ||
| 172 | 172 | fun UniverseHomeScreen(universe: Universe, nav: NavHostController) { |
| 173 | 173 | when { |
| 174 | 174 | universe.id == "vrai-prix" -> VraiPrixScreen(universe, nav) |
| 175 | + universe.id == "api-ka" -> ApiKaPlaygroundScreen(universe) | |
| 175 | 176 | universe.listPath == null -> WebUniverse(universe) |
| 176 | 177 | else -> UniverseList(universe, nav) |
| 177 | 178 | } |
@@ -223,7 +224,15 @@ private fun UniverseList(universe: Universe, nav: NavHostController) { | ||
| 223 | 224 | var liveTotal by remember { mutableStateOf<Int?>(null) } |
| 224 | 225 | var showSheet by remember { mutableStateOf(false) } |
| 225 | 226 | |
| 227 | + // Trouve·Ka = « recherche d'abord » : /api/search exige q (422 sinon) | |
| 228 | + val searchFirst = universe.id == "trouve-ka" | |
| 229 | + | |
| 226 | 230 | suspend fun load() { |
| 231 | + if (searchFirst && query.isBlank()) { | |
| 232 | + items = emptyList() | |
| 233 | + loading = false | |
| 234 | + return | |
| 235 | + } | |
| 227 | 236 | loading = items.isEmpty() |
| 228 | 237 | errorText = null |
| 229 | 238 | try { |
@@ -321,7 +330,14 @@ private fun UniverseList(universe: Universe, nav: NavHostController) { | ||
| 321 | 330 | } |
| 322 | 331 | } else if (items.isEmpty()) { |
| 323 | 332 | item { |
| 324 | − KAEmptyState(Icons.Filled.Inbox, "Aucun résultat", "Essayez d'autres mots-clés.") | |
| 333 | + if (searchFirst && query.isBlank()) { | |
| 334 | + KAEmptyState( | |
| 335 | + Icons.Filled.Search, "Cherchez le web québécois", | |
| 336 | + "1,2 M de pages d'ici indexées — tapez un mot dans la barre de recherche ci-dessus.", | |
| 337 | + ) | |
| 338 | + } else { | |
| 339 | + KAEmptyState(Icons.Filled.Inbox, "Aucun résultat", "Essayez d'autres mots-clés.") | |
| 340 | + } | |
| 325 | 341 | } |
| 326 | 342 | } else { |
| 327 | 343 | items.forEach { it -> |
modified
app/src/main/res/drawable/splash.xml
+2 −1
@@ -1,6 +1,7 @@ | ||
| 1 | 1 | <?xml version="1.0" encoding="utf-8"?> |
| 2 | 2 | <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> |
| 3 | − <item android:drawable="@color/ka_ink" /> | |
| 3 | + <!-- fond blanc, comme l'icône « liquid glass » --> | |
| 4 | + <item android:drawable="@android:color/white" /> | |
| 4 | 5 | <item android:gravity="center"> |
| 5 | 6 | <bitmap |
| 6 | 7 | android:src="@mipmap/ic_launcher" |
modified
app/src/main/res/mipmap-hdpi/ic_launcher.png
+0 −0
Binary file not shown.
modified
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
+0 −0
Binary file not shown.
modified
app/src/main/res/mipmap-mdpi/ic_launcher.png
+0 −0
Binary file not shown.
modified
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
+0 −0
Binary file not shown.
modified
app/src/main/res/mipmap-xhdpi/ic_launcher.png
+0 −0
Binary file not shown.
modified
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
+0 −0
Binary file not shown.
modified
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
+0 −0
Binary file not shown.
modified
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
+0 −0
Binary file not shown.
modified
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
+0 −0
Binary file not shown.
modified
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
+0 −0
Binary file not shown.
modified
app/src/main/res/values/themes.xml
+3 −3
@@ -8,10 +8,10 @@ | ||
| 8 | 8 | <item name="android:navigationBarColor">@color/ka_paper</item> |
| 9 | 9 | </style> |
| 10 | 10 | |
| 11 | − <!-- Splash simple encre avec l'icône KA lime centrée --> | |
| 11 | + <!-- Splash : icône « liquid glass » centrée sur fond blanc --> | |
| 12 | 12 | <style name="Theme.KA.Splash" parent="Theme.KA"> |
| 13 | 13 | <item name="android:windowBackground">@drawable/splash</item> |
| 14 | − <item name="android:statusBarColor">@color/ka_ink</item> | |
| 15 | − <item name="android:windowLightStatusBar">false</item> | |
| 14 | + <item name="android:statusBarColor">@android:color/white</item> | |
| 15 | + <item name="android:windowLightStatusBar">true</item> | |
| 16 | 16 | </style> |
| 17 | 17 | </resources> |
added
app/src/test/java/com/groupeka/ka/RefonteTests.kt
+161 −0
@@ -0,0 +1,161 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// RefonteTests.kt — tests de la refonte v2 : clustering par grille (miroir | |
| 3 | +// iOS), historique consulté (dédupliqué, plafond 30), alertes honnêtes | |
| 4 | +// (markSeen), galerie multi-photos, garde Trouve-Ka (q obligatoire). | |
| 5 | +package com.groupeka.ka | |
| 6 | + | |
| 7 | +import com.groupeka.ka.core.ClusterEngine | |
| 8 | +import com.groupeka.ka.core.Ecosystem | |
| 9 | +import com.groupeka.ka.core.KAItem | |
| 10 | +import com.groupeka.ka.core.RecentsStore | |
| 11 | +import com.groupeka.ka.core.SavedSearch | |
| 12 | +import com.groupeka.ka.core.UniverseService | |
| 13 | +import kotlinx.coroutines.runBlocking | |
| 14 | +import kotlinx.serialization.json.Json | |
| 15 | +import kotlinx.serialization.json.JsonObject | |
| 16 | +import org.junit.Assert.assertEquals | |
| 17 | +import org.junit.Assert.assertNull | |
| 18 | +import org.junit.Assert.assertTrue | |
| 19 | +import org.junit.Test | |
| 20 | +import java.io.File | |
| 21 | + | |
| 22 | +class RefonteTests { | |
| 23 | + | |
| 24 | + private fun item(id: String, lat: Double, lon: Double) = | |
| 25 | + KAItem(id = id, universeID = "lou-ka", title = id, latitude = lat, longitude = lon) | |
| 26 | + | |
| 27 | + // MARK: clustering (miroir des 2 tests iOS) | |
| 28 | + | |
| 29 | + @Test | |
| 30 | + fun clusteringGroupsNearbyAndKeepsSingles() { | |
| 31 | + val items = listOf( | |
| 32 | + item("a", 46.805, -71.205), | |
| 33 | + item("b", 46.806, -71.206), // même cellule que a | |
| 34 | + item("c", 46.90, -71.40), // loin → seul | |
| 35 | + ) | |
| 36 | + val clusters = ClusterEngine.clusterize(items, latSpan = 0.3, lonSpan = 0.3) | |
| 37 | + assertEquals(2, clusters.size) | |
| 38 | + val group = clusters.first { it.items.size == 2 } | |
| 39 | + assertTrue(group.items.map { it.id }.containsAll(listOf("a", "b"))) | |
| 40 | + assertTrue(clusters.any { it.isSingle && it.item.id == "c" }) | |
| 41 | + // centroïde du groupe | |
| 42 | + assertEquals(46.8055, group.latitude, 0.001) | |
| 43 | + } | |
| 44 | + | |
| 45 | + @Test | |
| 46 | + fun clusteringCapsAt150Renderables() { | |
| 47 | + val many = (0 until 400).map { i -> | |
| 48 | + item("i$i", 40.0 + i * 0.5, -70.0 - i * 0.5) // tous éloignés → 400 cellules | |
| 49 | + } | |
| 50 | + val clusters = ClusterEngine.clusterize(many, latSpan = 250.0, lonSpan = 250.0) | |
| 51 | + assertTrue("plafond de rendu 150", clusters.size <= 150) | |
| 52 | + } | |
| 53 | + | |
| 54 | + @Test | |
| 55 | + fun clusteringIgnoresItemsWithoutCoordinates() { | |
| 56 | + val items = listOf( | |
| 57 | + KAItem(id = "x", universeID = "lou-ka", title = "sans coord"), | |
| 58 | + item("y", 46.8, -71.2), | |
| 59 | + ) | |
| 60 | + val clusters = ClusterEngine.clusterize(items, latSpan = 0.3, lonSpan = 0.3) | |
| 61 | + assertEquals(1, clusters.size) | |
| 62 | + assertEquals("y", clusters[0].item.id) | |
| 63 | + } | |
| 64 | + | |
| 65 | + // MARK: historique consulté | |
| 66 | + | |
| 67 | + @Test | |
| 68 | + fun historyDeduplicatesAndCapsAt30() { | |
| 69 | + val file = File.createTempFile("ka-recents", ".json").also { it.delete() } | |
| 70 | + val store = RecentsStore(file) | |
| 71 | + for (i in 0 until 40) { | |
| 72 | + store.record(KAItem(id = "it$i", universeID = "lou-ka", title = "It $i")) | |
| 73 | + } | |
| 74 | + // re-consulter un ancien → remonte en tête, sans doublon | |
| 75 | + store.record(KAItem(id = "it35", universeID = "lou-ka", title = "It 35")) | |
| 76 | + val viewed = store.viewed.value | |
| 77 | + assertEquals(30, viewed.size) | |
| 78 | + assertEquals("it35", viewed.first().id) | |
| 79 | + assertEquals(1, viewed.count { it.id == "it35" }) | |
| 80 | + file.delete() | |
| 81 | + } | |
| 82 | + | |
| 83 | + @Test | |
| 84 | + fun historyPersistsAcrossInstances() { | |
| 85 | + val file = File.createTempFile("ka-recents", ".json").also { it.delete() } | |
| 86 | + RecentsStore(file).record(KAItem(id = "p:1", universeID = "auto-ka", title = "Corolla")) | |
| 87 | + val reloaded = RecentsStore(file) | |
| 88 | + assertEquals("p:1", reloaded.viewed.value.first().id) | |
| 89 | + file.delete() | |
| 90 | + } | |
| 91 | + | |
| 92 | + // MARK: alertes honnêtes | |
| 93 | + | |
| 94 | + @Test | |
| 95 | + fun markSeenFoldsDeltaIntoTotal() { | |
| 96 | + val file = File.createTempFile("ka-recents", ".json").also { it.delete() } | |
| 97 | + // injecter une recherche « déjà comptée » via la persistance | |
| 98 | + file.writeText( | |
| 99 | + """{"viewed":[],"searches":[{"id":"s1","name":"4½ Québec","universeID":"lou-ka","query":"4½","alertsOn":true,"lastTotal":100,"newCount":12}]}""", | |
| 100 | + ) | |
| 101 | + val store2 = RecentsStore(file) | |
| 102 | + assertEquals(12, store2.alertCount) | |
| 103 | + store2.markSeen("s1") | |
| 104 | + val s = store2.savedSearches.value.first() | |
| 105 | + assertEquals(112, s.lastTotal) | |
| 106 | + assertEquals(0, s.newCount) | |
| 107 | + assertEquals(0, store2.alertCount) | |
| 108 | + file.delete() | |
| 109 | + } | |
| 110 | + | |
| 111 | + @Test | |
| 112 | + fun alertCountSumsOnlyEnabledAlerts() { | |
| 113 | + val file = File.createTempFile("ka-recents", ".json").also { it.delete() } | |
| 114 | + file.writeText( | |
| 115 | + """{"viewed":[],"searches":[ | |
| 116 | + {"id":"a","name":"A","universeID":"lou-ka","query":"a","alertsOn":true,"newCount":5}, | |
| 117 | + {"id":"b","name":"B","universeID":"auto-ka","query":"b","alertsOn":false,"newCount":9} | |
| 118 | + ]}""", | |
| 119 | + ) | |
| 120 | + val store = RecentsStore(file) | |
| 121 | + assertEquals(5, store.alertCount) | |
| 122 | + file.delete() | |
| 123 | + } | |
| 124 | + | |
| 125 | + // MARK: galerie multi-photos | |
| 126 | + | |
| 127 | + @Test | |
| 128 | + fun galleryKeepsUpTo10HttpImages() { | |
| 129 | + val u = Ecosystem.universe("auto-ka")!! | |
| 130 | + val imgs = (1..14).joinToString(",") { "\"https://cdn.x.com/$it.jpg\"" } | |
| 131 | + val o = Json.parseToJsonElement( | |
| 132 | + """{"uid":"v:9","title":"Mazda 3","images":[$imgs,"pas-une-url"]}""", | |
| 133 | + ) as JsonObject | |
| 134 | + val item = u.map!!(o)!! | |
| 135 | + assertEquals(10, item.imageURLs.size) | |
| 136 | + assertEquals("https://cdn.x.com/1.jpg", item.imageURL) | |
| 137 | + assertEquals(item.imageURL, item.imageURLs.first()) | |
| 138 | + } | |
| 139 | + | |
| 140 | + // MARK: Trouve-Ka — recherche d'abord (l'API exige q, 422 sinon) | |
| 141 | + | |
| 142 | + @Test | |
| 143 | + fun trouveKaFetchWithoutQueryReturnsEmptyWithoutNetwork() = runBlocking { | |
| 144 | + val u = Ecosystem.universe("trouve-ka")!! | |
| 145 | + // pas de cacheDir ni de réseau nécessaire : la garde répond avant | |
| 146 | + val items = UniverseService.fetch(u, query = null) | |
| 147 | + assertTrue(items.isEmpty()) | |
| 148 | + assertNull(UniverseService.total(u, query = "")) | |
| 149 | + } | |
| 150 | + | |
| 151 | + // MARK: éditorial = requêtes réelles vers de vrais univers | |
| 152 | + | |
| 153 | + @Test | |
| 154 | + fun editorialCollectionsPointToRealUniverses() { | |
| 155 | + val cols = com.groupeka.ka.core.Editorial.collections | |
| 156 | + assertEquals(4, cols.size) | |
| 157 | + cols.forEach { c -> | |
| 158 | + assertTrue("univers inconnu ${c.universeID}", Ecosystem.universe(c.universeID) != null) | |
| 159 | + } | |
| 160 | + } | |
| 161 | +} | |
added
ka-release.keystore
+0 −0
Binary file not shown.