KA Android — portage natif complet de la super-app iOS Groupe-KA
Kotlin + Jetpack Compose (Gradle 8.13 / AGP 8.9.2 / Kotlin 2.1.20), minSdk 26. - 12 univers config-driven (miroir d'Ecosystem.swift, mêmes seuils et mappings) - Client JSON + cache disque hors ligne, stats live, KA Agent SSE - Accueil vivant, recherche universelle fan-out, explorateur + fiche, Vrai-Prix natif, menus resto, favoris/collections, profil + KA ID (Custom Tabs, client ka-android), onboarding animé - Design éditorial sharp : papier/encre, ombre décalée dure, wordmarks -2°, thème clair par défaut ; 12 tests JVM verts ; APK debug vérifié Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
45 changed files +5,131 −0
added
.gitignore
+10 −0
@@ -0,0 +1,10 @@ | |||||
| 1 | +.gradle/ | ||||
| 2 | +build/ | ||||
| 3 | +app/build/ | ||||
| 4 | +local.properties | ||||
| 5 | +.DS_Store | ||||
| 6 | +*.iml | ||||
| 7 | +.idea/ | ||||
| 8 | +captures/ | ||||
| 9 | +.externalNativeBuild/ | ||||
| 10 | +.cxx/ | ||||
added
README.md
+81 −0
@@ -0,0 +1,81 @@ | |||||
| 1 | +# KA Android — la super-app Groupe-KA | ||||
| 2 | + | ||||
| 3 | +Portage Android natif complet de l'app iOS « KA » : tout l'écosystème Groupe-KA | ||||
| 4 | +(12 univers ·Ka) dans une seule app **Kotlin + Jetpack Compose**, compilée en | ||||
| 5 | +ligne de commande (aucun Android Studio requis). | ||||
| 6 | + | ||||
| 7 | +## Build | ||||
| 8 | + | ||||
| 9 | +```bash | ||||
| 10 | +export JAVA_HOME=$(brew --prefix openjdk@21) | ||||
| 11 | +# local.properties doit pointer vers le SDK : | ||||
| 12 | +# 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) | ||||
| 15 | +``` | ||||
| 16 | + | ||||
| 17 | +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). | ||||
| 20 | + | ||||
| 21 | +## Architecture — miroir exact de l'app iOS (`~/Desktop/KA`) | ||||
| 22 | + | ||||
| 23 | +| iOS (SwiftUI) | Android (Compose) | | ||||
| 24 | +|-----------------------------|-----------------------------------------------------| | ||||
| 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`). | ||||
| 52 | + | ||||
| 53 | +## Ajouter un univers | ||||
| 54 | + | ||||
| 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. | ||||
added
app/build.gradle.kts
+56 −0
@@ -0,0 +1,56 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +plugins { | ||||
| 3 | + id("com.android.application") | ||||
| 4 | + id("org.jetbrains.kotlin.android") | ||||
| 5 | + id("org.jetbrains.kotlin.plugin.compose") | ||||
| 6 | + id("org.jetbrains.kotlin.plugin.serialization") | ||||
| 7 | +} | ||||
| 8 | + | ||||
| 9 | +android { | ||||
| 10 | + namespace = "com.groupeka.ka" | ||||
| 11 | + compileSdk = 34 | ||||
| 12 | + | ||||
| 13 | + defaultConfig { | ||||
| 14 | + applicationId = "com.groupeka.ka" | ||||
| 15 | + minSdk = 26 | ||||
| 16 | + targetSdk = 34 | ||||
| 17 | + versionCode = 1 | ||||
| 18 | + versionName = "1.0.0" | ||||
| 19 | + } | ||||
| 20 | + | ||||
| 21 | + buildTypes { | ||||
| 22 | + release { | ||||
| 23 | + isMinifyEnabled = false | ||||
| 24 | + } | ||||
| 25 | + } | ||||
| 26 | + compileOptions { | ||||
| 27 | + sourceCompatibility = JavaVersion.VERSION_17 | ||||
| 28 | + targetCompatibility = JavaVersion.VERSION_17 | ||||
| 29 | + } | ||||
| 30 | + kotlinOptions { | ||||
| 31 | + jvmTarget = "17" | ||||
| 32 | + } | ||||
| 33 | + buildFeatures { | ||||
| 34 | + compose = true | ||||
| 35 | + } | ||||
| 36 | +} | ||||
| 37 | + | ||||
| 38 | +dependencies { | ||||
| 39 | + val composeBom = platform("androidx.compose:compose-bom:2024.09.03") | ||||
| 40 | + implementation(composeBom) | ||||
| 41 | + implementation("androidx.compose.ui:ui") | ||||
| 42 | + implementation("androidx.compose.material3:material3") | ||||
| 43 | + implementation("androidx.compose.material:material-icons-extended") | ||||
| 44 | + implementation("androidx.activity:activity-compose:1.9.2") | ||||
| 45 | + implementation("androidx.navigation:navigation-compose:2.8.1") | ||||
| 46 | + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6") | ||||
| 47 | + implementation("io.coil-kt:coil-compose:2.7.0") | ||||
| 48 | + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") | ||||
| 49 | + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") | ||||
| 50 | + implementation("com.squareup.okhttp3:okhttp:4.12.0") | ||||
| 51 | + implementation("androidx.browser:browser:1.8.0") | ||||
| 52 | + implementation("androidx.core:core-ktx:1.13.1") | ||||
| 53 | + | ||||
| 54 | + testImplementation("junit:junit:4.13.2") | ||||
| 55 | + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") | ||||
| 56 | +} | ||||
added
app/src/main/AndroidManifest.xml
+36 −0
@@ -0,0 +1,36 @@ | |||||
| 1 | +<?xml version="1.0" encoding="utf-8"?> | ||||
| 2 | +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> | ||||
| 3 | + | ||||
| 4 | + <uses-permission android:name="android.permission.INTERNET" /> | ||||
| 5 | + | ||||
| 6 | + <application | ||||
| 7 | + android:name=".KAApp" | ||||
| 8 | + android:allowBackup="true" | ||||
| 9 | + android:icon="@mipmap/ic_launcher" | ||||
| 10 | + android:roundIcon="@mipmap/ic_launcher_round" | ||||
| 11 | + android:label="@string/app_name" | ||||
| 12 | + android:supportsRtl="true" | ||||
| 13 | + android:theme="@style/Theme.KA"> | ||||
| 14 | + | ||||
| 15 | + <activity | ||||
| 16 | + android:name=".MainActivity" | ||||
| 17 | + android:exported="true" | ||||
| 18 | + android:launchMode="singleTask" | ||||
| 19 | + android:screenOrientation="portrait" | ||||
| 20 | + android:configChanges="orientation|screenSize|keyboardHidden|uiMode" | ||||
| 21 | + android:windowSoftInputMode="adjustResize" | ||||
| 22 | + android:theme="@style/Theme.KA.Splash"> | ||||
| 23 | + <intent-filter> | ||||
| 24 | + <action android:name="android.intent.action.MAIN" /> | ||||
| 25 | + <category android:name="android.intent.category.LAUNCHER" /> | ||||
| 26 | + </intent-filter> | ||||
| 27 | + <!-- Retour du SSO KA ID : ka-android://auth/callback --> | ||||
| 28 | + <intent-filter> | ||||
| 29 | + <action android:name="android.intent.action.VIEW" /> | ||||
| 30 | + <category android:name="android.intent.category.DEFAULT" /> | ||||
| 31 | + <category android:name="android.intent.category.BROWSABLE" /> | ||||
| 32 | + <data android:scheme="ka-android" android:host="auth" /> | ||||
| 33 | + </intent-filter> | ||||
| 34 | + </activity> | ||||
| 35 | + </application> | ||||
| 36 | +</manifest> | ||||
added
app/src/main/java/com/groupeka/ka/KAApp.kt
+21 −0
@@ -0,0 +1,21 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// KAApp.kt — Application : initialise le cache JSON disque, les préférences, | ||||
| 3 | +// les favoris et le profil KA ID persistant. | ||||
| 4 | +package com.groupeka.ka | ||||
| 5 | + | ||||
| 6 | +import android.app.Application | ||||
| 7 | +import com.groupeka.ka.auth.KAIDManager | ||||
| 8 | +import com.groupeka.ka.core.ApiClient | ||||
| 9 | +import com.groupeka.ka.core.FavoritesStore | ||||
| 10 | +import com.groupeka.ka.core.Prefs | ||||
| 11 | +import java.io.File | ||||
| 12 | + | ||||
| 13 | +class KAApp : Application() { | ||||
| 14 | + override fun onCreate() { | ||||
| 15 | + super.onCreate() | ||||
| 16 | + ApiClient.cacheDir = File(cacheDir, "ka-json") | ||||
| 17 | + Prefs.init(this) | ||||
| 18 | + FavoritesStore.shared = FavoritesStore(File(filesDir, "ka-favoris.json")) | ||||
| 19 | + KAIDManager.restore(this) | ||||
| 20 | + } | ||||
| 21 | +} | ||||
added
app/src/main/java/com/groupeka/ka/MainActivity.kt
+215 −0
@@ -0,0 +1,215 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// MainActivity.kt — point d'entrée : onboarding au premier lancement, puis la | ||||
| 3 | +// super-app (5 onglets + bulle KA Agent flottante partout). Reçoit aussi le | ||||
| 4 | +// retour SSO ka-android://auth/callback. | ||||
| 5 | +package com.groupeka.ka | ||||
| 6 | + | ||||
| 7 | +import android.content.Intent | ||||
| 8 | +import android.net.Uri | ||||
| 9 | +import android.os.Bundle | ||||
| 10 | +import androidx.activity.ComponentActivity | ||||
| 11 | +import androidx.activity.compose.setContent | ||||
| 12 | +import androidx.compose.foundation.background | ||||
| 13 | +import androidx.compose.foundation.border | ||||
| 14 | +import androidx.compose.foundation.layout.Box | ||||
| 15 | +import androidx.compose.foundation.layout.fillMaxSize | ||||
| 16 | +import androidx.compose.foundation.layout.padding | ||||
| 17 | +import androidx.compose.foundation.layout.size | ||||
| 18 | +import androidx.compose.foundation.shape.CircleShape | ||||
| 19 | +import androidx.compose.material.icons.Icons | ||||
| 20 | +import androidx.compose.material.icons.filled.Favorite | ||||
| 21 | +import androidx.compose.material.icons.filled.Home | ||||
| 22 | +import androidx.compose.material.icons.filled.Person | ||||
| 23 | +import androidx.compose.material.icons.filled.Search | ||||
| 24 | +import androidx.compose.material.icons.filled.Workspaces | ||||
| 25 | +import androidx.compose.material3.Icon | ||||
| 26 | +import androidx.compose.material3.MaterialTheme | ||||
| 27 | +import androidx.compose.material3.NavigationBar | ||||
| 28 | +import androidx.compose.material3.NavigationBarItem | ||||
| 29 | +import androidx.compose.material3.NavigationBarItemDefaults | ||||
| 30 | +import androidx.compose.material3.Scaffold | ||||
| 31 | +import androidx.compose.material3.Surface | ||||
| 32 | +import androidx.compose.material3.Text | ||||
| 33 | +import androidx.compose.runtime.Composable | ||||
| 34 | +import androidx.compose.runtime.getValue | ||||
| 35 | +import androidx.compose.ui.Alignment | ||||
| 36 | +import androidx.compose.ui.Modifier | ||||
| 37 | +import androidx.compose.ui.draw.shadow | ||||
| 38 | +import androidx.compose.ui.graphics.vector.ImageVector | ||||
| 39 | +import androidx.compose.ui.text.font.FontWeight | ||||
| 40 | +import androidx.compose.ui.unit.dp | ||||
| 41 | +import androidx.compose.ui.unit.sp | ||||
| 42 | +import androidx.compose.foundation.clickable | ||||
| 43 | +import androidx.lifecycle.lifecycleScope | ||||
| 44 | +import androidx.navigation.NavHostController | ||||
| 45 | +import androidx.navigation.compose.NavHost | ||||
| 46 | +import androidx.navigation.compose.composable | ||||
| 47 | +import androidx.navigation.compose.currentBackStackEntryAsState | ||||
| 48 | +import androidx.navigation.compose.rememberNavController | ||||
| 49 | +import com.groupeka.ka.auth.KAIDManager | ||||
| 50 | +import com.groupeka.ka.core.ApiClient | ||||
| 51 | +import com.groupeka.ka.core.Ecosystem | ||||
| 52 | +import com.groupeka.ka.core.KAItem | ||||
| 53 | +import com.groupeka.ka.core.Prefs | ||||
| 54 | +import com.groupeka.ka.core.VPResult | ||||
| 55 | +import com.groupeka.ka.ui.AgentChatScreen | ||||
| 56 | +import com.groupeka.ka.ui.EstimateScreen | ||||
| 57 | +import com.groupeka.ka.ui.FavoritesScreen | ||||
| 58 | +import com.groupeka.ka.ui.HomeScreen | ||||
| 59 | +import com.groupeka.ka.ui.ItemDetailScreen | ||||
| 60 | +import com.groupeka.ka.ui.OnboardingScreen | ||||
| 61 | +import com.groupeka.ka.ui.ProfileScreen | ||||
| 62 | +import com.groupeka.ka.ui.SearchScreen | ||||
| 63 | +import com.groupeka.ka.ui.UniverseHomeScreen | ||||
| 64 | +import com.groupeka.ka.ui.UniversesScreen | ||||
| 65 | +import com.groupeka.ka.ui.theme.KAAppTheme | ||||
| 66 | +import com.groupeka.ka.ui.theme.KATheme | ||||
| 67 | +import com.groupeka.ka.ui.theme.LocalKADark | ||||
| 68 | +import kotlinx.coroutines.launch | ||||
| 69 | +import kotlinx.serialization.encodeToString | ||||
| 70 | +import kotlinx.serialization.serializer | ||||
| 71 | +import java.nio.charset.StandardCharsets | ||||
| 72 | +import java.util.Base64 | ||||
| 73 | + | ||||
| 74 | +// MARK: - Encodage d'objets dans les routes de navigation | ||||
| 75 | + | ||||
| 76 | +inline fun <reified T> encodeNavArg(value: T): String = | ||||
| 77 | + Base64.getUrlEncoder().withoutPadding() | ||||
| 78 | + .encodeToString(ApiClient.json.encodeToString(serializer<T>(), value).toByteArray(StandardCharsets.UTF_8)) | ||||
| 79 | + | ||||
| 80 | +inline fun <reified T> decodeNavArg(raw: String): T? = runCatching { | ||||
| 81 | + ApiClient.json.decodeFromString( | ||||
| 82 | + serializer<T>(), | ||||
| 83 | + String(Base64.getUrlDecoder().decode(raw), StandardCharsets.UTF_8), | ||||
| 84 | + ) | ||||
| 85 | +}.getOrNull() | ||||
| 86 | + | ||||
| 87 | +class MainActivity : ComponentActivity() { | ||||
| 88 | + | ||||
| 89 | + override fun onCreate(savedInstanceState: Bundle?) { | ||||
| 90 | + super.onCreate(savedInstanceState) | ||||
| 91 | + handleAuthIntent(intent) | ||||
| 92 | + setContent { | ||||
| 93 | + KAAppTheme { | ||||
| 94 | + Surface(Modifier.fillMaxSize(), color = KATheme.paper(LocalKADark.current)) { | ||||
| 95 | + if (Prefs.onboarded) RootView() else OnboardingScreen() | ||||
| 96 | + } | ||||
| 97 | + } | ||||
| 98 | + } | ||||
| 99 | + } | ||||
| 100 | + | ||||
| 101 | + override fun onNewIntent(intent: Intent) { | ||||
| 102 | + super.onNewIntent(intent) | ||||
| 103 | + handleAuthIntent(intent) | ||||
| 104 | + } | ||||
| 105 | + | ||||
| 106 | + override fun onResume() { | ||||
| 107 | + super.onResume() | ||||
| 108 | + // retour du Custom Tab sans callback : libérer l'état « occupé » | ||||
| 109 | + KAIDManager.cancelIfPending() | ||||
| 110 | + } | ||||
| 111 | + | ||||
| 112 | + private fun handleAuthIntent(intent: Intent?) { | ||||
| 113 | + val uri: Uri = intent?.data ?: return | ||||
| 114 | + if (uri.scheme == "ka-android" && uri.host == "auth") { | ||||
| 115 | + lifecycleScope.launch { | ||||
| 116 | + KAIDManager.handleCallback(uri) | ||||
| 117 | + KAIDManager.persist(this@MainActivity) | ||||
| 118 | + } | ||||
| 119 | + } | ||||
| 120 | + } | ||||
| 121 | +} | ||||
| 122 | + | ||||
| 123 | +// MARK: - Racine : 5 onglets + bulle Agent | ||||
| 124 | + | ||||
| 125 | +private data class Tab(val route: String, val label: String, val icon: ImageVector) | ||||
| 126 | + | ||||
| 127 | +private val tabs = listOf( | ||||
| 128 | + Tab("home", "Accueil", Icons.Filled.Home), | ||||
| 129 | + Tab("search", "Recherche", Icons.Filled.Search), | ||||
| 130 | + Tab("universes", "Univers", Icons.Filled.Workspaces), | ||||
| 131 | + Tab("favorites", "Favoris", Icons.Filled.Favorite), | ||||
| 132 | + Tab("profile", "Profil", Icons.Filled.Person), | ||||
| 133 | +) | ||||
| 134 | + | ||||
| 135 | +@Composable | ||||
| 136 | +fun RootView() { | ||||
| 137 | + val nav: NavHostController = rememberNavController() | ||||
| 138 | + val backStack by nav.currentBackStackEntryAsState() | ||||
| 139 | + val route = backStack?.destination?.route | ||||
| 140 | + val onTab = tabs.any { it.route == route } | ||||
| 141 | + | ||||
| 142 | + Scaffold( | ||||
| 143 | + containerColor = KATheme.paper(LocalKADark.current), | ||||
| 144 | + bottomBar = { | ||||
| 145 | + if (onTab) { | ||||
| 146 | + NavigationBar(containerColor = KATheme.surface(LocalKADark.current)) { | ||||
| 147 | + tabs.forEach { tab -> | ||||
| 148 | + NavigationBarItem( | ||||
| 149 | + selected = route == tab.route, | ||||
| 150 | + onClick = { | ||||
| 151 | + nav.navigate(tab.route) { | ||||
| 152 | + popUpTo(nav.graph.startDestinationId) { saveState = true } | ||||
| 153 | + launchSingleTop = true | ||||
| 154 | + restoreState = true | ||||
| 155 | + } | ||||
| 156 | + }, | ||||
| 157 | + icon = { Icon(tab.icon, null) }, | ||||
| 158 | + label = { Text(tab.label, fontSize = 10.sp) }, | ||||
| 159 | + colors = NavigationBarItemDefaults.colors( | ||||
| 160 | + selectedIconColor = KATheme.green, | ||||
| 161 | + selectedTextColor = KATheme.green, | ||||
| 162 | + indicatorColor = KATheme.green.copy(alpha = 0.14f), | ||||
| 163 | + ), | ||||
| 164 | + ) | ||||
| 165 | + } | ||||
| 166 | + } | ||||
| 167 | + } | ||||
| 168 | + }, | ||||
| 169 | + ) { padding -> | ||||
| 170 | + Box(Modifier.fillMaxSize().padding(padding)) { | ||||
| 171 | + NavHost(navController = nav, startDestination = "home") { | ||||
| 172 | + composable("home") { HomeScreen(nav) } | ||||
| 173 | + composable("search") { SearchScreen(nav) } | ||||
| 174 | + composable("universes") { UniversesScreen(nav) } | ||||
| 175 | + composable("favorites") { FavoritesScreen(nav) } | ||||
| 176 | + composable("profile") { ProfileScreen() } | ||||
| 177 | + composable("universe/{id}") { entry -> | ||||
| 178 | + val id = entry.arguments?.getString("id") | ||||
| 179 | + Ecosystem.universe(id ?: "")?.let { UniverseHomeScreen(it, nav) } | ||||
| 180 | + } | ||||
| 181 | + composable("item/{data}") { entry -> | ||||
| 182 | + val item = entry.arguments?.getString("data")?.let { decodeNavArg<KAItem>(it) } | ||||
| 183 | + if (item != null) ItemDetailScreen(item, nav) | ||||
| 184 | + } | ||||
| 185 | + composable("estimate/{data}") { entry -> | ||||
| 186 | + val res = entry.arguments?.getString("data")?.let { decodeNavArg<VPResult>(it) } | ||||
| 187 | + if (res != null) EstimateScreen(res) | ||||
| 188 | + } | ||||
| 189 | + composable("agent") { AgentChatScreen(onClose = { nav.popBackStack() }) } | ||||
| 190 | + } | ||||
| 191 | + | ||||
| 192 | + // Bulle KA Agent — flottante au-dessus de la tab bar, partout sauf dans l'agent | ||||
| 193 | + if (route != "agent") { | ||||
| 194 | + Box( | ||||
| 195 | + Modifier | ||||
| 196 | + .align(Alignment.BottomEnd) | ||||
| 197 | + .padding(end = 16.dp, bottom = 18.dp) | ||||
| 198 | + .size(54.dp) | ||||
| 199 | + .shadow(3.dp, CircleShape) | ||||
| 200 | + .background(KATheme.lime, CircleShape) | ||||
| 201 | + .border(2.dp, KATheme.inkLight, CircleShape) | ||||
| 202 | + .clickable { nav.navigate("agent") { launchSingleTop = true } }, | ||||
| 203 | + contentAlignment = Alignment.Center, | ||||
| 204 | + ) { | ||||
| 205 | + Text( | ||||
| 206 | + "Ka", | ||||
| 207 | + fontSize = 19.sp, | ||||
| 208 | + fontWeight = FontWeight.Bold, | ||||
| 209 | + color = KATheme.inkLight, | ||||
| 210 | + ) | ||||
| 211 | + } | ||||
| 212 | + } | ||||
| 213 | + } | ||||
| 214 | + } | ||||
| 215 | +} | ||||
added
app/src/main/java/com/groupeka/ka/auth/KaId.kt
+140 −0
@@ -0,0 +1,140 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// KaId.kt — connexion KA ID NATIVE : Chrome Custom Tabs vers le hub | ||||
| 3 | +// groupe-ka.com (SSO officiel de l'écosystème, client « ka-android »), retour | ||||
| 4 | +// par le scheme ka-android://auth/callback, puis ÉCHANGE VÉRIFIÉ côté serveur | ||||
| 5 | +// (api-ka valide la signature du jeton — aucun secret dans l'app) qui renvoie | ||||
| 6 | +// le profil. | ||||
| 7 | +package com.groupeka.ka.auth | ||||
| 8 | + | ||||
| 9 | +import android.content.Context | ||||
| 10 | +import android.net.Uri | ||||
| 11 | +import androidx.browser.customtabs.CustomTabsIntent | ||||
| 12 | +import com.groupeka.ka.core.ApiClient | ||||
| 13 | +import com.groupeka.ka.core.objectOrNull | ||||
| 14 | +import com.groupeka.ka.core.str | ||||
| 15 | +import kotlinx.coroutines.Dispatchers | ||||
| 16 | +import kotlinx.coroutines.flow.MutableStateFlow | ||||
| 17 | +import kotlinx.coroutines.flow.StateFlow | ||||
| 18 | +import kotlinx.coroutines.withContext | ||||
| 19 | +import kotlinx.serialization.Serializable | ||||
| 20 | +import kotlinx.serialization.encodeToString | ||||
| 21 | +import kotlinx.serialization.json.buildJsonObject | ||||
| 22 | +import kotlinx.serialization.json.put | ||||
| 23 | +import okhttp3.MediaType.Companion.toMediaType | ||||
| 24 | +import okhttp3.Request | ||||
| 25 | +import okhttp3.RequestBody.Companion.toRequestBody | ||||
| 26 | +import java.io.IOException | ||||
| 27 | +import java.util.UUID | ||||
| 28 | + | ||||
| 29 | +@Serializable | ||||
| 30 | +data class KAIDProfile( | ||||
| 31 | + val kaID: String, | ||||
| 32 | + val name: String? = null, | ||||
| 33 | + val email: String? = null, | ||||
| 34 | + val picture: String? = null, | ||||
| 35 | + val roleLabel: String? = null, | ||||
| 36 | + val city: String? = null, | ||||
| 37 | + val bio: String? = null, | ||||
| 38 | +) | ||||
| 39 | + | ||||
| 40 | +object KAIDManager { | ||||
| 41 | + private const val STORE_KEY = "ka.id.profile" | ||||
| 42 | + private const val PREFS = "ka_prefs" | ||||
| 43 | + | ||||
| 44 | + val profile = MutableStateFlow<KAIDProfile?>(null) | ||||
| 45 | + val busy = MutableStateFlow(false) | ||||
| 46 | + val lastError = MutableStateFlow<String?>(null) | ||||
| 47 | + | ||||
| 48 | + private var pendingState: String? = null | ||||
| 49 | + | ||||
| 50 | + fun restore(context: Context) { | ||||
| 51 | + val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) | ||||
| 52 | + .getString(STORE_KEY, null) ?: return | ||||
| 53 | + profile.value = runCatching { | ||||
| 54 | + ApiClient.json.decodeFromString<KAIDProfile>(raw) | ||||
| 55 | + }.getOrNull() | ||||
| 56 | + } | ||||
| 57 | + | ||||
| 58 | + /** Ouvre le hub SSO dans un Chrome Custom Tab (client déjà enregistré). */ | ||||
| 59 | + fun login(context: Context) { | ||||
| 60 | + if (busy.value) return | ||||
| 61 | + busy.value = true | ||||
| 62 | + lastError.value = null | ||||
| 63 | + val state = UUID.randomUUID().toString() | ||||
| 64 | + pendingState = state | ||||
| 65 | + val uri = Uri.parse("https://www.groupe-ka.com/sso/authorize").buildUpon() | ||||
| 66 | + .appendQueryParameter("client_id", "ka-android") | ||||
| 67 | + .appendQueryParameter("redirect_uri", "ka-android://auth/callback") | ||||
| 68 | + .appendQueryParameter("state", state) | ||||
| 69 | + .build() | ||||
| 70 | + try { | ||||
| 71 | + CustomTabsIntent.Builder().setShowTitle(true).build().launchUrl(context, uri) | ||||
| 72 | + } catch (e: Exception) { | ||||
| 73 | + busy.value = false | ||||
| 74 | + lastError.value = "Aucun navigateur disponible pour la connexion." | ||||
| 75 | + } | ||||
| 76 | + } | ||||
| 77 | + | ||||
| 78 | + /** L'utilisateur est revenu du Custom Tab sans jeton (retour dans l'app). */ | ||||
| 79 | + fun cancelIfPending() { | ||||
| 80 | + if (profile.value == null) busy.value = false | ||||
| 81 | + } | ||||
| 82 | + | ||||
| 83 | + /** Appelé par MainActivity quand ka-android://auth/callback arrive. */ | ||||
| 84 | + suspend fun handleCallback(uri: Uri) { | ||||
| 85 | + val state = uri.getQueryParameter("state") | ||||
| 86 | + val token = uri.getQueryParameter("ka_token") | ||||
| 87 | + if (state != pendingState || token == null) { | ||||
| 88 | + lastError.value = "Réponse du hub invalide." | ||||
| 89 | + busy.value = false | ||||
| 90 | + return | ||||
| 91 | + } | ||||
| 92 | + exchange(token) | ||||
| 93 | + } | ||||
| 94 | + | ||||
| 95 | + private suspend fun exchange(token: String) = withContext(Dispatchers.IO) { | ||||
| 96 | + try { | ||||
| 97 | + val payload = buildJsonObject { put("ka_token", token) }.toString() | ||||
| 98 | + val req = Request.Builder() | ||||
| 99 | + .url("https://www.api-ka.com/api/ios/auth/exchange") | ||||
| 100 | + .post(payload.toRequestBody("application/json".toMediaType())) | ||||
| 101 | + .build() | ||||
| 102 | + ApiClient.http.newCall(req).execute().use { resp -> | ||||
| 103 | + if (!resp.isSuccessful) throw IOException("HTTP ${resp.code}") | ||||
| 104 | + val maybeObj = ApiClient.json.parseToJsonElement(resp.body?.string() ?: "{}").objectOrNull | ||||
| 105 | + val kaID = maybeObj?.str("ka_id") | ||||
| 106 | + if (maybeObj == null || kaID == null) { | ||||
| 107 | + lastError.value = "Vérification du compte impossible." | ||||
| 108 | + return@use | ||||
| 109 | + } | ||||
| 110 | + val obj = maybeObj | ||||
| 111 | + val hub = obj["profile"].objectOrNull | ||||
| 112 | + profile.value = KAIDProfile( | ||||
| 113 | + kaID = kaID, | ||||
| 114 | + name = obj.str("name") ?: hub?.str("name"), | ||||
| 115 | + email = obj.str("email"), | ||||
| 116 | + picture = obj.str("picture") ?: hub?.str("picture"), | ||||
| 117 | + roleLabel = hub?.str("role_label"), | ||||
| 118 | + city = hub?.str("city"), | ||||
| 119 | + bio = hub?.str("bio"), | ||||
| 120 | + ) | ||||
| 121 | + } | ||||
| 122 | + } catch (e: Exception) { | ||||
| 123 | + lastError.value = "Réseau indisponible — réessayez." | ||||
| 124 | + } finally { | ||||
| 125 | + busy.value = false | ||||
| 126 | + } | ||||
| 127 | + } | ||||
| 128 | + | ||||
| 129 | + fun persist(context: Context) { | ||||
| 130 | + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) | ||||
| 131 | + val p = profile.value | ||||
| 132 | + if (p == null) prefs.edit().remove(STORE_KEY).apply() | ||||
| 133 | + else prefs.edit().putString(STORE_KEY, ApiClient.json.encodeToString(p)).apply() | ||||
| 134 | + } | ||||
| 135 | + | ||||
| 136 | + fun logout(context: Context) { | ||||
| 137 | + profile.value = null | ||||
| 138 | + persist(context) | ||||
| 139 | + } | ||||
| 140 | +} | ||||
added
app/src/main/java/com/groupeka/ka/core/Ecosystem.kt
+345 −0
@@ -0,0 +1,345 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// Ecosystem.kt — la source de vérité des 13 univers Groupe-KA dans l'app : | ||||
| 3 | +// identité (nom, accent, symbole, tagline) + configuration d'API (endpoint de | ||||
| 4 | +// liste, clé des items, mapping JSON → KAItem). Ajouter un univers = ajouter | ||||
| 5 | +// une entrée ici, rien d'autre. Miroir exact de KA/Core/Ecosystem.swift (iOS). | ||||
| 6 | +package com.groupeka.ka.core | ||||
| 7 | + | ||||
| 8 | +import kotlinx.serialization.Serializable | ||||
| 9 | +import kotlinx.serialization.json.JsonObject | ||||
| 10 | +import java.text.NumberFormat | ||||
| 11 | +import java.util.Locale | ||||
| 12 | +import java.util.UUID | ||||
| 13 | + | ||||
| 14 | +// MARK: - L'élément universel | ||||
| 15 | + | ||||
| 16 | +@Serializable | ||||
| 17 | +data class KAItem( | ||||
| 18 | + val id: String, | ||||
| 19 | + val universeID: String, | ||||
| 20 | + var title: String, | ||||
| 21 | + var subtitle: String? = null, | ||||
| 22 | + var priceLabel: String? = null, | ||||
| 23 | + val city: String? = null, | ||||
| 24 | + var url: String? = null, | ||||
| 25 | + val imageURL: String? = null, | ||||
| 26 | + val latitude: Double? = null, | ||||
| 27 | + val longitude: Double? = null, | ||||
| 28 | + /** Description longue (fiche) */ | ||||
| 29 | + val detail: String? = null, | ||||
| 30 | + /** Paires libres affichées sur la fiche (« Année : 2021 », « Salaire : 65 000 $ »…) */ | ||||
| 31 | + var facts: List<Fact> = emptyList(), | ||||
| 32 | + /** Liens riches (boutons) — ex. les comptes d'un créateur */ | ||||
| 33 | + var links: List<Fact> = emptyList(), | ||||
| 34 | +) { | ||||
| 35 | + @Serializable | ||||
| 36 | + data class Fact(val label: String, val value: String) | ||||
| 37 | +} | ||||
| 38 | + | ||||
| 39 | +// MARK: - Univers | ||||
| 40 | + | ||||
| 41 | +data class Universe( | ||||
| 42 | + val id: String, | ||||
| 43 | + val wordmark: String, // « Lou·Ka » | ||||
| 44 | + val name: String, // « Lou-KA » | ||||
| 45 | + val tagline: String, | ||||
| 46 | + val accentHex: String, | ||||
| 47 | + val symbol: String, // clé d'icône (mappée en Material côté UI) | ||||
| 48 | + val domain: String, | ||||
| 49 | + val unit: String, // « logements », « offres »… | ||||
| 50 | + /** Chemin de la liste (null = univers sans liste native, ex. api-ka) */ | ||||
| 51 | + val listPath: String?, | ||||
| 52 | + val itemsKey: String, | ||||
| 53 | + val searchParam: String, | ||||
| 54 | + val statTotalKeys: List<String>, // clés du total dans /api/stats | ||||
| 55 | + val map: ((JsonObject) -> KAItem?)?, | ||||
| 56 | +) { | ||||
| 57 | + val baseURL: String get() = "https://$domain" | ||||
| 58 | +} | ||||
| 59 | + | ||||
| 60 | +object Ecosystem { | ||||
| 61 | + const val hubURL = "https://www.groupe-ka.com" | ||||
| 62 | + const val signupURL = "https://www.groupe-ka.com/connexion" | ||||
| 63 | + const val statusURL = "https://www.groupe-ka.com/status" | ||||
| 64 | + val contacts: List<Pair<String, String>> = listOf( | ||||
| 65 | + "contact@groupe-ka.com" to "Projets, partenariats & données", | ||||
| 66 | + "info@groupe-ka.com" to "Médias & questions générales", | ||||
| 67 | + "admin@groupe-ka.com" to "Légal, vie privée & Loi 25", | ||||
| 68 | + ) | ||||
| 69 | + val legal: List<Pair<String, String>> = listOf( | ||||
| 70 | + "Conditions d'utilisation" to "/conditions", | ||||
| 71 | + "Politique de confidentialité" to "/confidentialite", | ||||
| 72 | + "Renseignements personnels (Loi 25)" to "/loi-25", | ||||
| 73 | + "Transparence des robots" to "/bots", | ||||
| 74 | + ) | ||||
| 75 | + const val disclaimer = "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction. Données lues à la source — rien d'inventé, tout est traçable." | ||||
| 76 | + | ||||
| 77 | + fun universe(id: String): Universe? = all.firstOrNull { it.id == id } | ||||
| 78 | + | ||||
| 79 | + // Aides de mapping communes | ||||
| 80 | + private fun base(o: JsonObject, universe: String, idKeys: List<String> = listOf("uid")): KAItem { | ||||
| 81 | + 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") | ||||
| 87 | + return KAItem( | ||||
| 88 | + id = "$universe:${id ?: UUID.randomUUID()}", | ||||
| 89 | + universeID = universe, | ||||
| 90 | + title = o.str("title", "name", "display_name") ?: "Sans titre", | ||||
| 91 | + city = o.str("city"), | ||||
| 92 | + url = o.str("url"), | ||||
| 93 | + imageURL = image, | ||||
| 94 | + latitude = o.num("lat", "latitude"), | ||||
| 95 | + longitude = o.num("lng", "lon", "longitude"), | ||||
| 96 | + detail = o.str("description", "menu_summary", "bio")?.strippingHTML()?.trim(), | ||||
| 97 | + ) | ||||
| 98 | + } | ||||
| 99 | + | ||||
| 100 | + // MARK: les 12 univers (+ le hub, sans liste) | ||||
| 101 | + val all: List<Universe> = listOf( | ||||
| 102 | + Universe( | ||||
| 103 | + id = "lou-ka", wordmark = "Lou·Ka", name = "Lou-KA", | ||||
| 104 | + tagline = "Tous les logements à louer", accentHex = "#ff6a00", | ||||
| 105 | + symbol = "key", domain = "www.lou-ka.com", unit = "logements", | ||||
| 106 | + listPath = "/api/listings", itemsKey = "listings", searchParam = "q", | ||||
| 107 | + statTotalKeys = listOf("total"), | ||||
| 108 | + map = { o -> | ||||
| 109 | + val it = base(o, "lou-ka") | ||||
| 110 | + it.subtitle = listOfNotNull(o.str("unit_type"), o.str("address")).joinToString(" · ") | ||||
| 111 | + o.num("price")?.let { p -> if (p > 50) it.priceLabel = p.money0 + "/mois" } | ||||
| 112 | + it.facts = listOfNotNull( | ||||
| 113 | + o.str("unit_type")?.let { v -> KAItem.Fact("Taille", v) }, | ||||
| 114 | + o.str("address")?.let { v -> KAItem.Fact("Adresse", v) }, | ||||
| 115 | + o.str("available_by")?.let { v -> KAItem.Fact("Disponible", v) }, | ||||
| 116 | + o.num("area_sqft")?.takeIf { v -> v > 50 }?.let { v -> KAItem.Fact("Superficie", "${v.toInt()} pi²") }, | ||||
| 117 | + o.str("source")?.let { v -> KAItem.Fact("Gestionnaire", v) }, | ||||
| 118 | + ) | ||||
| 119 | + it | ||||
| 120 | + }), | ||||
| 121 | + Universe( | ||||
| 122 | + id = "immo-ka", wordmark = "Immo·Ka", name = "Immo-KA", | ||||
| 123 | + tagline = "Toutes les propriétés à vendre", accentHex = "#e23744", | ||||
| 124 | + symbol = "house", domain = "www.immo-ka.com", unit = "propriétés", | ||||
| 125 | + listPath = "/api/listings", itemsKey = "listings", searchParam = "q", | ||||
| 126 | + statTotalKeys = listOf("total"), | ||||
| 127 | + map = { o -> | ||||
| 128 | + val it = base(o, "immo-ka") | ||||
| 129 | + it.subtitle = listOfNotNull(o.str("property_type"), o.str("region")).joinToString(" · ") | ||||
| 130 | + o.num("price")?.let { p -> if (p > 1000) it.priceLabel = p.money0 } | ||||
| 131 | + it.facts = listOfNotNull( | ||||
| 132 | + o.num("bedrooms")?.let { v -> KAItem.Fact("Chambres", v.toInt().toString()) }, | ||||
| 133 | + o.num("bathrooms")?.let { v -> KAItem.Fact("Salles de bain", v.toInt().toString()) }, | ||||
| 134 | + o.num("area_sqft")?.takeIf { v -> v > 50 }?.let { v -> KAItem.Fact("Superficie", "${v.toInt()} pi²") }, | ||||
| 135 | + o.str("address")?.let { v -> KAItem.Fact("Adresse", v) }, | ||||
| 136 | + o.str("source")?.let { v -> KAItem.Fact("Source", v) }, | ||||
| 137 | + ) | ||||
| 138 | + it | ||||
| 139 | + }), | ||||
| 140 | + Universe( | ||||
| 141 | + id = "vrai-prix", wordmark = "Vrai-Prix", name = "Vrai-Prix", | ||||
| 142 | + tagline = "La valeur réelle de chaque propriété", accentHex = "#ff5148", | ||||
| 143 | + symbol = "chart", domain = "www.vrai-prix.com", unit = "propriétés estimées", | ||||
| 144 | + listPath = null, itemsKey = "", searchParam = "q", | ||||
| 145 | + statTotalKeys = listOf("units_total"), map = null), | ||||
| 146 | + Universe( | ||||
| 147 | + id = "auto-ka", wordmark = "Auto·Ka", name = "Auto-KA", | ||||
| 148 | + tagline = "Les voitures usagées du Québec", accentHex = "#ff5a2a", | ||||
| 149 | + symbol = "car", domain = "www.auto-ka.com", unit = "véhicules", | ||||
| 150 | + listPath = "/api/vehicles", itemsKey = "vehicles", searchParam = "q", | ||||
| 151 | + statTotalKeys = listOf("total"), | ||||
| 152 | + map = { o -> | ||||
| 153 | + val it = base(o, "auto-ka") | ||||
| 154 | + it.subtitle = listOfNotNull(o.num("year")?.let { v -> v.toInt().toString() }, o.str("mileage_label")) | ||||
| 155 | + .joinToString(" · ") | ||||
| 156 | + it.priceLabel = o.str("price_label") ?: o.num("price")?.money0 | ||||
| 157 | + it.facts = listOfNotNull( | ||||
| 158 | + o.str("make")?.let { v -> KAItem.Fact("Marque", v) }, | ||||
| 159 | + o.str("model")?.let { v -> KAItem.Fact("Modèle", v) }, | ||||
| 160 | + o.str("transmission")?.let { v -> KAItem.Fact("Boîte", v) }, | ||||
| 161 | + o.str("fuel")?.let { v -> KAItem.Fact("Carburant", v) }, | ||||
| 162 | + o.str("drivetrain")?.let { v -> KAItem.Fact("Rouage", v) }, | ||||
| 163 | + o.str("body_type")?.let { v -> KAItem.Fact("Carrosserie", v) }, | ||||
| 164 | + o.str("dealer_name")?.let { v -> KAItem.Fact("Concessionnaire", v) }, | ||||
| 165 | + o.str("mileage_label")?.let { v -> KAItem.Fact("Kilométrage", v) }, | ||||
| 166 | + ) | ||||
| 167 | + it | ||||
| 168 | + }), | ||||
| 169 | + Universe( | ||||
| 170 | + id = "fabri-ka", wordmark = "Fabri·Ka", name = "Fabri-KA", | ||||
| 171 | + tagline = "Les produits fabriqués au Québec", accentHex = "#c4532e", | ||||
| 172 | + symbol = "box", domain = "www.fabri-ka.com", unit = "produits", | ||||
| 173 | + listPath = "/api/products", itemsKey = "items", searchParam = "q", | ||||
| 174 | + statTotalKeys = listOf("totals.products", "total"), | ||||
| 175 | + map = { o -> | ||||
| 176 | + val it = base(o, "fabri-ka") | ||||
| 177 | + it.subtitle = o.str("store_id", "store") | ||||
| 178 | + it.priceLabel = o.str("price_label") ?: o.num("price")?.money2 | ||||
| 179 | + it.facts = listOfNotNull(o.str("category")?.let { v -> KAItem.Fact("Catégorie", v) }) | ||||
| 180 | + it | ||||
| 181 | + }), | ||||
| 182 | + Universe( | ||||
| 183 | + id = "food-ka", wordmark = "Food·Ka", name = "Food-KA", | ||||
| 184 | + tagline = "Les prix d'épicerie, suivis à la source", accentHex = "#1f9d55", | ||||
| 185 | + symbol = "cart", domain = "www.food-ka.com", unit = "produits", | ||||
| 186 | + listPath = "/api/products", itemsKey = "products", searchParam = "q", | ||||
| 187 | + statTotalKeys = listOf("total"), | ||||
| 188 | + map = { o -> | ||||
| 189 | + val it = base(o, "food-ka") | ||||
| 190 | + it.subtitle = listOfNotNull(o.str("brand"), o.str("size_label")).joinToString(" · ") | ||||
| 191 | + val onSale = o["on_sale"].boolOrNull == true | ||||
| 192 | + o.num("price")?.let { p -> | ||||
| 193 | + if (p > 0.2) { | ||||
| 194 | + val reg = o.num("regular_price") | ||||
| 195 | + it.priceLabel = if (onSale && reg != null && reg > p) | ||||
| 196 | + "${p.money2} 🏷️ (rég. ${reg.money2})" else p.money2 | ||||
| 197 | + } | ||||
| 198 | + } | ||||
| 199 | + it.facts = listOfNotNull( | ||||
| 200 | + o.str("category")?.let { v -> KAItem.Fact("Catégorie", v) }, | ||||
| 201 | + o.num("unit_price")?.takeIf { v -> v > 0.001 }?.let { v -> KAItem.Fact("Prix unitaire", v.money2) }, | ||||
| 202 | + if (onSale) KAItem.Fact("Solde", "Oui 🏷️") else null, | ||||
| 203 | + ) | ||||
| 204 | + it | ||||
| 205 | + }), | ||||
| 206 | + Universe( | ||||
| 207 | + id = "resto-ka", wordmark = "Resto·Ka", name = "Resto-KA", | ||||
| 208 | + tagline = "Chaque resto, chaque plat, chaque prix", accentHex = "#f08c00", | ||||
| 209 | + symbol = "restaurant", domain = "www.resto-ka.com", unit = "restaurants", | ||||
| 210 | + listPath = "/api/restaurants", itemsKey = "restaurants", searchParam = "q", | ||||
| 211 | + statTotalKeys = listOf("restaurants", "total"), | ||||
| 212 | + map = { o -> | ||||
| 213 | + val it = base(o, "resto-ka") | ||||
| 214 | + val cuisines = o["cuisines"].text | ||||
| 215 | + it.subtitle = listOfNotNull(cuisines, o.str("price_range")).joinToString(" · ") | ||||
| 216 | + it.facts = listOfNotNull( | ||||
| 217 | + o.str("address")?.let { v -> KAItem.Fact("Adresse", v) }, | ||||
| 218 | + o.str("chain")?.let { v -> KAItem.Fact("Chaîne", v) }, | ||||
| 219 | + ) | ||||
| 220 | + it | ||||
| 221 | + }), | ||||
| 222 | + Universe( | ||||
| 223 | + id = "sorti-ka", wordmark = "Sorti·Ka", name = "Sorti-KA", | ||||
| 224 | + tagline = "Toutes les sorties, dans les 17 régions", accentHex = "#d6336c", | ||||
| 225 | + symbol = "ticket", domain = "www.sorti-ka.com", unit = "événements", | ||||
| 226 | + listPath = "/api/events?upcoming=true", itemsKey = "events", searchParam = "q", | ||||
| 227 | + statTotalKeys = listOf("total_active", "total"), | ||||
| 228 | + map = { o -> | ||||
| 229 | + val it = base(o, "sorti-ka") | ||||
| 230 | + it.subtitle = listOfNotNull(o.str("start_date"), o.str("venue")).joinToString(" · ") | ||||
| 231 | + it.facts = listOfNotNull( | ||||
| 232 | + o.str("start_date")?.let { v -> KAItem.Fact("Début", v) }, | ||||
| 233 | + o.str("end_date")?.let { v -> KAItem.Fact("Fin", v) }, | ||||
| 234 | + o.str("venue")?.let { v -> KAItem.Fact("Lieu", v) }, | ||||
| 235 | + o.str("region")?.let { v -> KAItem.Fact("Région", v) }, | ||||
| 236 | + if (o["is_free"].boolOrNull == true) KAItem.Fact("Entrée", "Gratuite 🎉") else null, | ||||
| 237 | + o.num("price_min")?.takeIf { v -> v > 1 }?.let { v -> KAItem.Fact("Billets dès", v.money0) }, | ||||
| 238 | + ) | ||||
| 239 | + it | ||||
| 240 | + }), | ||||
| 241 | + Universe( | ||||
| 242 | + id = "crea-ka", wordmark = "Créa·Ka", name = "Créa-KA", | ||||
| 243 | + tagline = "Les créateurs d'ici, tous leurs liens", accentHex = "#7048e8", | ||||
| 244 | + symbol = "sparkles", domain = "www.crea-ka.com", unit = "créateurs", | ||||
| 245 | + listPath = "/api/creators", itemsKey = "items", searchParam = "q", | ||||
| 246 | + statTotalKeys = listOf("creators", "total"), | ||||
| 247 | + map = { o -> | ||||
| 248 | + val it = base(o, "crea-ka", idKeys = listOf("cid", "uid", "display_name")) | ||||
| 249 | + val plats = o["platforms"].arrayOrNull?.mapNotNull { p -> p.objectOrNull } ?: emptyList() | ||||
| 250 | + val totalFollowers = plats.mapNotNull { p -> p.num("followers") }.sum() | ||||
| 251 | + it.subtitle = listOfNotNull( | ||||
| 252 | + o["niches"].text, | ||||
| 253 | + if (totalFollowers > 0) totalFollowers.compact + " abonnés" else null, | ||||
| 254 | + ).joinToString(" · ") | ||||
| 255 | + if (it.url == null) it.url = plats.firstOrNull()?.str("url") | ||||
| 256 | + it.facts = plats.take(6).mapNotNull { pl -> | ||||
| 257 | + val name = pl.str("platform") ?: return@mapNotNull null | ||||
| 258 | + val f = pl.num("followers")?.compact ?: "" | ||||
| 259 | + KAItem.Fact( | ||||
| 260 | + name.replaceFirstChar { c -> c.uppercase() }, | ||||
| 261 | + if (f.isEmpty()) "@" + (pl.str("handle") ?: "") else "$f abonnés", | ||||
| 262 | + ) | ||||
| 263 | + } | ||||
| 264 | + it.links = plats.take(6).mapNotNull { pl -> | ||||
| 265 | + val name = pl.str("platform") ?: return@mapNotNull null | ||||
| 266 | + val url = pl.str("url") ?: return@mapNotNull null | ||||
| 267 | + KAItem.Fact(name.replaceFirstChar { c -> c.uppercase() }, url) | ||||
| 268 | + } | ||||
| 269 | + it | ||||
| 270 | + }), | ||||
| 271 | + Universe( | ||||
| 272 | + id = "job-ka", wordmark = "Job·Ka", name = "Job-KA", | ||||
| 273 | + tagline = "Tous les emplois des employeurs québécois", accentHex = "#0c8599", | ||||
| 274 | + symbol = "briefcase", domain = "www.job-ka.com", unit = "offres d'emploi", | ||||
| 275 | + listPath = "/api/jobs", itemsKey = "jobs", searchParam = "q", | ||||
| 276 | + statTotalKeys = listOf("total"), | ||||
| 277 | + map = { o -> | ||||
| 278 | + val it = base(o, "job-ka") | ||||
| 279 | + it.subtitle = listOfNotNull(o.str("employer"), o.str("city")).joinToString(" · ") | ||||
| 280 | + val sMin = o.num("salary_year_min") | ||||
| 281 | + val sMax = o.num("salary_year_max") | ||||
| 282 | + if (sMin != null && sMin > 20000) { | ||||
| 283 | + it.priceLabel = if (sMax != null && sMax > sMin) | ||||
| 284 | + "${sMin.money0}–${sMax.money0}/an" else sMin.money0 + "/an" | ||||
| 285 | + } else { | ||||
| 286 | + o.num("salary_hour_min")?.let { h -> if (h > 10) it.priceLabel = h.money2 + "/h" } | ||||
| 287 | + } | ||||
| 288 | + it.facts = listOfNotNull( | ||||
| 289 | + o.str("employer")?.let { v -> KAItem.Fact("Employeur", v) }, | ||||
| 290 | + o.str("work_mode")?.let { v -> KAItem.Fact("Mode de travail", v) }, | ||||
| 291 | + o.str("employment_type")?.let { v -> KAItem.Fact("Type", v) }, | ||||
| 292 | + o.str("ats")?.let { v -> KAItem.Fact("Plateforme carrière", v) }, | ||||
| 293 | + o.str("date_posted")?.let { v -> KAItem.Fact("Publiée le", v.take(10)) }, | ||||
| 294 | + ) | ||||
| 295 | + it | ||||
| 296 | + }), | ||||
| 297 | + Universe( | ||||
| 298 | + id = "trouve-ka", wordmark = "Trouve·Ka", name = "Trouve-KA", | ||||
| 299 | + tagline = "Le moteur de recherche du web québécois", accentHex = "#1c7ed6", | ||||
| 300 | + symbol = "search", domain = "www.trouve-ka.com", unit = "pages indexées", | ||||
| 301 | + listPath = "/api/search", itemsKey = "results", searchParam = "q", | ||||
| 302 | + statTotalKeys = listOf("pages_indexed"), | ||||
| 303 | + map = { o -> | ||||
| 304 | + val it = base(o, "trouve-ka", idKeys = listOf("url")) | ||||
| 305 | + it.title = (o.str("title") ?: "Page web").strippingHTML() | ||||
| 306 | + it.subtitle = o.str("snippet")?.strippingHTML() | ||||
| 307 | + it.facts = listOfNotNull(o.str("domain")?.let { v -> KAItem.Fact("Domaine", v) }) | ||||
| 308 | + it | ||||
| 309 | + }), | ||||
| 310 | + Universe( | ||||
| 311 | + id = "api-ka", wordmark = "API·Ka", name = "API-KA", | ||||
| 312 | + tagline = "La donnée de l'écosystème, par API", accentHex = "#3b5bdb", | ||||
| 313 | + symbol = "terminal", domain = "www.api-ka.com", unit = "appels API", | ||||
| 314 | + listPath = null, itemsKey = "", searchParam = "q", | ||||
| 315 | + statTotalKeys = listOf("total"), map = null), | ||||
| 316 | + ) | ||||
| 317 | +} | ||||
| 318 | + | ||||
| 319 | +// MARK: - petites extensions | ||||
| 320 | + | ||||
| 321 | +/** 17 100 000 → « 17,1 M », 5 200 → « 5,2 k » */ | ||||
| 322 | +val Double.compact: String | ||||
| 323 | + get() = when { | ||||
| 324 | + this >= 1_000_000 -> String.format(Locale.US, "%.1f M", this / 1_000_000).replace(".", ",") | ||||
| 325 | + this >= 10_000 -> String.format(Locale.US, "%.0f k", this / 1_000) | ||||
| 326 | + this >= 1_000 -> String.format(Locale.US, "%.1f k", this / 1_000).replace(".", ",") | ||||
| 327 | + else -> this.toInt().toString() | ||||
| 328 | + } | ||||
| 329 | + | ||||
| 330 | +private val frCA: Locale = Locale.CANADA_FRENCH | ||||
| 331 | + | ||||
| 332 | +val Double.money0: String | ||||
| 333 | + get() = NumberFormat.getCurrencyInstance(frCA).apply { maximumFractionDigits = 0 }.format(this) | ||||
| 334 | + | ||||
| 335 | +val Double.money2: String | ||||
| 336 | + get() = NumberFormat.getCurrencyInstance(frCA).apply { maximumFractionDigits = 2 }.format(this) | ||||
| 337 | + | ||||
| 338 | +/** Nombre entier localisé fr-CA (« 47 840 »). */ | ||||
| 339 | +val Int.frFormatted: String | ||||
| 340 | + get() = NumberFormat.getIntegerInstance(frCA).format(this) | ||||
| 341 | + | ||||
| 342 | +fun String.strippingHTML(): String = | ||||
| 343 | + replace(Regex("<[^>]+>"), "") | ||||
| 344 | + .replace("&", "&") | ||||
| 345 | + .replace(" ", " ") | ||||
added
app/src/main/java/com/groupeka/ka/core/FavoritesStore.kt
+89 −0
@@ -0,0 +1,89 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// FavoritesStore.kt — favoris et collections UNIFIÉS (tous univers dans une | ||||
| 3 | +// même collection : « Déménagement à Val-d'Or » peut contenir un 4½, une auto | ||||
| 4 | +// et un resto). Persistance JSON locale (consultation hors ligne). | ||||
| 5 | +package com.groupeka.ka.core | ||||
| 6 | + | ||||
| 7 | +import kotlinx.coroutines.flow.MutableStateFlow | ||||
| 8 | +import kotlinx.coroutines.flow.StateFlow | ||||
| 9 | +import kotlinx.serialization.Serializable | ||||
| 10 | +import kotlinx.serialization.encodeToString | ||||
| 11 | +import kotlinx.serialization.json.Json | ||||
| 12 | +import java.io.File | ||||
| 13 | +import java.util.UUID | ||||
| 14 | + | ||||
| 15 | +@Serializable | ||||
| 16 | +data class FavCollection( | ||||
| 17 | + val id: String = UUID.randomUUID().toString(), | ||||
| 18 | + val name: String, | ||||
| 19 | + val items: List<KAItem> = emptyList(), | ||||
| 20 | +) | ||||
| 21 | + | ||||
| 22 | +class FavoritesStore(private val file: File) { | ||||
| 23 | + | ||||
| 24 | + companion object { | ||||
| 25 | + /** Singleton initialisé au démarrage de l'app (filesDir). */ | ||||
| 26 | + @Volatile | ||||
| 27 | + var shared: FavoritesStore? = null | ||||
| 28 | + } | ||||
| 29 | + | ||||
| 30 | + private val json = Json { ignoreUnknownKeys = true } | ||||
| 31 | + private val _collections = MutableStateFlow<List<FavCollection>>(emptyList()) | ||||
| 32 | + val collections: StateFlow<List<FavCollection>> = _collections | ||||
| 33 | + | ||||
| 34 | + init { | ||||
| 35 | + val loaded = runCatching { | ||||
| 36 | + json.decodeFromString<List<FavCollection>>(file.readText()) | ||||
| 37 | + }.getOrNull() ?: emptyList() | ||||
| 38 | + _collections.value = loaded.ifEmpty { listOf(FavCollection(name = "Mes favoris")) } | ||||
| 39 | + } | ||||
| 40 | + | ||||
| 41 | + private fun update(block: (List<FavCollection>) -> List<FavCollection>) { | ||||
| 42 | + _collections.value = block(_collections.value) | ||||
| 43 | + runCatching { | ||||
| 44 | + file.parentFile?.mkdirs() | ||||
| 45 | + file.writeText(json.encodeToString(_collections.value)) | ||||
| 46 | + } | ||||
| 47 | + } | ||||
| 48 | + | ||||
| 49 | + // MARK: API | ||||
| 50 | + | ||||
| 51 | + val allItems: List<KAItem> get() = _collections.value.flatMap { it.items } | ||||
| 52 | + | ||||
| 53 | + fun isFavorite(item: KAItem): Boolean = | ||||
| 54 | + _collections.value.any { c -> c.items.any { it.id == item.id } } | ||||
| 55 | + | ||||
| 56 | + fun toggle(item: KAItem, collectionID: String? = null) { | ||||
| 57 | + if (isFavorite(item)) { | ||||
| 58 | + update { cols -> cols.map { c -> c.copy(items = c.items.filterNot { it.id == item.id }) } } | ||||
| 59 | + } else { | ||||
| 60 | + update { cols -> | ||||
| 61 | + val idx = cols.indexOfFirst { it.id == collectionID }.let { if (it >= 0) it else 0 } | ||||
| 62 | + cols.mapIndexed { i, c -> | ||||
| 63 | + if (i == idx) c.copy(items = listOf(item) + c.items) else c | ||||
| 64 | + } | ||||
| 65 | + } | ||||
| 66 | + } | ||||
| 67 | + } | ||||
| 68 | + | ||||
| 69 | + fun addCollection(name: String) { | ||||
| 70 | + val trimmed = name.trim() | ||||
| 71 | + if (trimmed.isEmpty()) return | ||||
| 72 | + update { it + FavCollection(name = trimmed) } | ||||
| 73 | + } | ||||
| 74 | + | ||||
| 75 | + fun removeCollection(id: String) { | ||||
| 76 | + if (_collections.value.size <= 1) return | ||||
| 77 | + update { cols -> cols.filterNot { it.id == id } } | ||||
| 78 | + } | ||||
| 79 | + | ||||
| 80 | + fun move(item: KAItem, collectionID: String) { | ||||
| 81 | + update { cols -> | ||||
| 82 | + cols.map { c -> | ||||
| 83 | + val without = c.items.filterNot { it.id == item.id } | ||||
| 84 | + if (c.id == collectionID) c.copy(items = listOf(item) + without) | ||||
| 85 | + else c.copy(items = without) | ||||
| 86 | + } | ||||
| 87 | + } | ||||
| 88 | + } | ||||
| 89 | +} | ||||
added
app/src/main/java/com/groupeka/ka/core/Filters.kt
+60 −0
@@ -0,0 +1,60 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// Filters.kt — les FILTRES MÉTIER de chaque univers, comme sur les sites web : | ||||
| 3 | +// ville/taille/prix (Lou·Ka), marque/année/km (Auto·Ka), gratuit (Sorti·Ka), | ||||
| 4 | +// en solde (Food·Ka), cuisine (Resto·Ka)… Chaque filtre mappe directement un | ||||
| 5 | +// paramètre de l'API du site. Miroir de KA/Core/KAFilters.swift. | ||||
| 6 | +package com.groupeka.ka.core | ||||
| 7 | + | ||||
| 8 | +data class KAFilter( | ||||
| 9 | + val id: String, // nom du paramètre API (ou préfixe pour minMax) | ||||
| 10 | + val label: String, | ||||
| 11 | + val kind: Kind, | ||||
| 12 | +) { | ||||
| 13 | + sealed class Kind { | ||||
| 14 | + data class Text(val placeholder: String) : Kind() // champ libre → param | ||||
| 15 | + data class Options(val options: List<String>) : Kind() // choix unique → param | ||||
| 16 | + data class MinMax( | ||||
| 17 | + val minParam: String, val maxParam: String, | ||||
| 18 | + val unit: String, val step: Double, | ||||
| 19 | + val min: Double, val max: Double, | ||||
| 20 | + ) : Kind() | ||||
| 21 | + data class Toggle(val value: String) : Kind() // interrupteur → param=value | ||||
| 22 | + } | ||||
| 23 | +} | ||||
| 24 | + | ||||
| 25 | +object FilterCatalog { | ||||
| 26 | + fun filters(universeID: String): List<KAFilter> = when (universeID) { | ||||
| 27 | + "lou-ka" -> listOf( | ||||
| 28 | + KAFilter("city", "Ville", KAFilter.Kind.Text("Québec, Montréal…")), | ||||
| 29 | + KAFilter("unit_type", "Taille", KAFilter.Kind.Options(listOf("Studio", "1½", "2½", "3½", "4½", "5½", "6½"))), | ||||
| 30 | + KAFilter("loyer", "Loyer", KAFilter.Kind.MinMax("price_min", "price_max", "$", 50.0, 300.0, 4000.0)), | ||||
| 31 | + ) | ||||
| 32 | + "immo-ka" -> listOf( | ||||
| 33 | + KAFilter("city", "Ville", KAFilter.Kind.Text("Lévis, Gatineau…")), | ||||
| 34 | + KAFilter("prix", "Prix", KAFilter.Kind.MinMax("price_min", "price_max", "$", 25000.0, 50000.0, 2000000.0)), | ||||
| 35 | + ) | ||||
| 36 | + "auto-ka" -> listOf( | ||||
| 37 | + KAFilter("make", "Marque", KAFilter.Kind.Text("Toyota, Kia…")), | ||||
| 38 | + KAFilter("model", "Modèle", KAFilter.Kind.Text("Corolla…")), | ||||
| 39 | + KAFilter("annee", "Année", KAFilter.Kind.MinMax("year_min", "year_max", "", 1.0, 2000.0, 2026.0)), | ||||
| 40 | + KAFilter("prix", "Prix", KAFilter.Kind.MinMax("price_min", "price_max", "$", 1000.0, 1000.0, 120000.0)), | ||||
| 41 | + KAFilter("km_max", "Km max", KAFilter.Kind.Options(listOf("50000", "100000", "150000", "200000"))), | ||||
| 42 | + ) | ||||
| 43 | + "job-ka" -> listOf( | ||||
| 44 | + KAFilter("city", "Ville", KAFilter.Kind.Text("Montréal, Québec…")), | ||||
| 45 | + ) | ||||
| 46 | + "food-ka" -> listOf( | ||||
| 47 | + KAFilter("on_sale", "En solde 🏷️", KAFilter.Kind.Toggle("true")), | ||||
| 48 | + ) | ||||
| 49 | + "resto-ka" -> listOf( | ||||
| 50 | + KAFilter("city", "Ville", KAFilter.Kind.Text("Montréal…")), | ||||
| 51 | + KAFilter("cuisine", "Cuisine", KAFilter.Kind.Options(listOf("italien", "quebecois", "sushi", "bbq-grillades", "mexicain", "indien", "vegetarien", "dejeuner"))), | ||||
| 52 | + ) | ||||
| 53 | + "sorti-ka" -> listOf( | ||||
| 54 | + KAFilter("city", "Ville", KAFilter.Kind.Text("Sherbrooke…")), | ||||
| 55 | + KAFilter("region", "Région", KAFilter.Kind.Options(listOf("Montréal", "Capitale-Nationale", "Montérégie", "Estrie", "Laurentides", "Outaouais", "Mauricie"))), | ||||
| 56 | + KAFilter("free", "Gratuit 🎉", KAFilter.Kind.Toggle("true")), | ||||
| 57 | + ) | ||||
| 58 | + else -> emptyList() | ||||
| 59 | + } | ||||
| 60 | +} | ||||
added
app/src/main/java/com/groupeka/ka/core/Json.kt
+52 −0
@@ -0,0 +1,52 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// Json.kt — helpers de lecture « au mieux » sur JsonElement (les API des | ||||
| 3 | +// univers ont chacune leur forme) — miroir de JSONValue côté iOS. | ||||
| 4 | +package com.groupeka.ka.core | ||||
| 5 | + | ||||
| 6 | +import kotlinx.serialization.json.JsonArray | ||||
| 7 | +import kotlinx.serialization.json.JsonElement | ||||
| 8 | +import kotlinx.serialization.json.JsonNull | ||||
| 9 | +import kotlinx.serialization.json.JsonObject | ||||
| 10 | +import kotlinx.serialization.json.JsonPrimitive | ||||
| 11 | + | ||||
| 12 | +/** Texte « au mieux » : string non vide, nombre formaté, liste jointe. */ | ||||
| 13 | +val JsonElement?.text: String? | ||||
| 14 | + get() = when (this) { | ||||
| 15 | + null, is JsonNull -> null | ||||
| 16 | + is JsonPrimitive -> when { | ||||
| 17 | + isString -> content.ifEmpty { null } | ||||
| 18 | + content == "true" || content == "false" -> null // bool ≠ texte (miroir iOS) | ||||
| 19 | + else -> content.toDoubleOrNull()?.let { n -> | ||||
| 20 | + if (n == Math.rint(n) && !n.isInfinite()) n.toLong().toString() else n.toString() | ||||
| 21 | + } | ||||
| 22 | + } | ||||
| 23 | + is JsonArray -> mapNotNull { it.text }.ifEmpty { null }?.joinToString(", ") | ||||
| 24 | + else -> null | ||||
| 25 | + } | ||||
| 26 | + | ||||
| 27 | +val JsonElement?.numberOrNull: Double? | ||||
| 28 | + get() = (this as? JsonPrimitive)?.takeIf { !it.isString }?.content?.toDoubleOrNull() | ||||
| 29 | + | ||||
| 30 | +val JsonElement?.stringOrNull: String? | ||||
| 31 | + get() = (this as? JsonPrimitive)?.takeIf { it.isString }?.content | ||||
| 32 | + | ||||
| 33 | +val JsonElement?.arrayOrNull: JsonArray? | ||||
| 34 | + get() = this as? JsonArray | ||||
| 35 | + | ||||
| 36 | +val JsonElement?.objectOrNull: JsonObject? | ||||
| 37 | + get() = this as? JsonObject | ||||
| 38 | + | ||||
| 39 | +val JsonElement?.boolOrNull: Boolean? | ||||
| 40 | + get() = (this as? JsonPrimitive)?.takeIf { !it.isString }?.content?.toBooleanStrictOrNull() | ||||
| 41 | + | ||||
| 42 | +/** Première valeur textuelle trouvée parmi les clés. */ | ||||
| 43 | +fun JsonObject.str(vararg keys: String): String? { | ||||
| 44 | + for (k in keys) this[k].text?.let { return it } | ||||
| 45 | + return null | ||||
| 46 | +} | ||||
| 47 | + | ||||
| 48 | +/** Premier nombre trouvé parmi les clés. */ | ||||
| 49 | +fun JsonObject.num(vararg keys: String): Double? { | ||||
| 50 | + for (k in keys) this[k].numberOrNull?.let { return it } | ||||
| 51 | + return null | ||||
| 52 | +} | ||||
added
app/src/main/java/com/groupeka/ka/core/Prefs.kt
+39 −0
@@ -0,0 +1,39 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// Prefs.kt — préférences locales (onboarding, apparence, univers favoris, | ||||
| 3 | +// historique de recherche) observables par Compose. | ||||
| 4 | +package com.groupeka.ka.core | ||||
| 5 | + | ||||
| 6 | +import android.content.Context | ||||
| 7 | +import android.content.SharedPreferences | ||||
| 8 | +import androidx.compose.runtime.getValue | ||||
| 9 | +import androidx.compose.runtime.mutableStateOf | ||||
| 10 | +import androidx.compose.runtime.setValue | ||||
| 11 | + | ||||
| 12 | +object Prefs { | ||||
| 13 | + private lateinit var sp: SharedPreferences | ||||
| 14 | + | ||||
| 15 | + var onboarded by mutableStateOf(false) | ||||
| 16 | + private set | ||||
| 17 | + /** « clair » (défaut), « sombre », « auto » */ | ||||
| 18 | + var appearance by mutableStateOf("clair") | ||||
| 19 | + private set | ||||
| 20 | + /** ids d'univers favoris, séparés par des virgules */ | ||||
| 21 | + var favUniverses by mutableStateOf("") | ||||
| 22 | + private set | ||||
| 23 | + /** historique de recherche, séparé par des sauts de ligne */ | ||||
| 24 | + var searchHistory by mutableStateOf("") | ||||
| 25 | + private set | ||||
| 26 | + | ||||
| 27 | + fun init(context: Context) { | ||||
| 28 | + sp = context.getSharedPreferences("ka_prefs", Context.MODE_PRIVATE) | ||||
| 29 | + onboarded = sp.getBoolean("ka.onboarded", false) | ||||
| 30 | + appearance = sp.getString("ka.appearance", "clair") ?: "clair" | ||||
| 31 | + favUniverses = sp.getString("ka.favUniverses", "") ?: "" | ||||
| 32 | + searchHistory = sp.getString("ka.search.history", "") ?: "" | ||||
| 33 | + } | ||||
| 34 | + | ||||
| 35 | + fun setOnboardedDone() { onboarded = true; sp.edit().putBoolean("ka.onboarded", true).apply() } | ||||
| 36 | + fun setAppearanceValue(v: String) { appearance = v; sp.edit().putString("ka.appearance", v).apply() } | ||||
| 37 | + fun setFavUniversesValue(v: String) { favUniverses = v; sp.edit().putString("ka.favUniverses", v).apply() } | ||||
| 38 | + fun setSearchHistoryValue(v: String) { searchHistory = v; sp.edit().putString("ka.search.history", v).apply() } | ||||
| 39 | +} | ||||
added
app/src/main/java/com/groupeka/ka/core/Services.kt
+262 −0
@@ -0,0 +1,262 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// Services.kt — réseau : client JSON avec cache disque (mode hors ligne de | ||||
| 3 | +// consultation), service d'univers (listes/recherche), stats live, KA Agent | ||||
| 4 | +// (chat SSE vers l'API centrale api-ka) et menus Resto·Ka. | ||||
| 5 | +package com.groupeka.ka.core | ||||
| 6 | + | ||||
| 7 | +import kotlinx.coroutines.Dispatchers | ||||
| 8 | +import kotlinx.coroutines.channels.awaitClose | ||||
| 9 | +import kotlinx.coroutines.flow.Flow | ||||
| 10 | +import kotlinx.coroutines.flow.callbackFlow | ||||
| 11 | +import kotlinx.coroutines.flow.flowOn | ||||
| 12 | +import kotlinx.coroutines.withContext | ||||
| 13 | +import kotlinx.serialization.json.Json | ||||
| 14 | +import kotlinx.serialization.json.JsonElement | ||||
| 15 | +import kotlinx.serialization.json.JsonObject | ||||
| 16 | +import kotlinx.serialization.json.buildJsonArray | ||||
| 17 | +import kotlinx.serialization.json.buildJsonObject | ||||
| 18 | +import kotlinx.serialization.json.put | ||||
| 19 | +import okhttp3.MediaType.Companion.toMediaType | ||||
| 20 | +import okhttp3.OkHttpClient | ||||
| 21 | +import okhttp3.Request | ||||
| 22 | +import okhttp3.RequestBody.Companion.toRequestBody | ||||
| 23 | +import java.io.File | ||||
| 24 | +import java.io.IOException | ||||
| 25 | +import java.net.URLEncoder | ||||
| 26 | +import java.util.Base64 | ||||
| 27 | +import java.util.concurrent.TimeUnit | ||||
| 28 | + | ||||
| 29 | +// MARK: - Client HTTP + cache disque | ||||
| 30 | + | ||||
| 31 | +object ApiClient { | ||||
| 32 | + val json = Json { ignoreUnknownKeys = true; isLenient = true } | ||||
| 33 | + | ||||
| 34 | + val http: OkHttpClient by lazy { | ||||
| 35 | + OkHttpClient.Builder() | ||||
| 36 | + .connectTimeout(15, TimeUnit.SECONDS) | ||||
| 37 | + .readTimeout(15, TimeUnit.SECONDS) | ||||
| 38 | + .build() | ||||
| 39 | + } | ||||
| 40 | + | ||||
| 41 | + /** Dossier du cache JSON — injecté au démarrage (cacheDir Android, temp en test). */ | ||||
| 42 | + @Volatile | ||||
| 43 | + var cacheDir: File? = null | ||||
| 44 | + set(value) { | ||||
| 45 | + field = value?.also { it.mkdirs() } | ||||
| 46 | + } | ||||
| 47 | + | ||||
| 48 | + private fun cacheFile(url: String): File? { | ||||
| 49 | + val dir = cacheDir ?: return null | ||||
| 50 | + val name = Base64.getEncoder().encodeToString(url.toByteArray()).replace("/", "_") | ||||
| 51 | + return File(dir, name.takeLast(120) + ".json") | ||||
| 52 | + } | ||||
| 53 | + | ||||
| 54 | + /** JSON brut ; en cas d'échec réseau, sert la dernière copie disque (hors ligne). */ | ||||
| 55 | + suspend fun fetchJson(url: String, ttlSeconds: Long = 120): JsonElement = withContext(Dispatchers.IO) { | ||||
| 56 | + val file = cacheFile(url) | ||||
| 57 | + if (file != null && file.exists() && System.currentTimeMillis() - file.lastModified() < ttlSeconds * 1000) { | ||||
| 58 | + runCatching { json.parseToJsonElement(file.readText()) }.getOrNull()?.let { return@withContext it } | ||||
| 59 | + } | ||||
| 60 | + try { | ||||
| 61 | + val req = Request.Builder().url(url) | ||||
| 62 | + .header("User-Agent", "KA-Android/1.0 (+https://www.groupe-ka.com)") | ||||
| 63 | + .build() | ||||
| 64 | + http.newCall(req).execute().use { resp -> | ||||
| 65 | + if (!resp.isSuccessful) throw IOException("HTTP ${resp.code}") | ||||
| 66 | + val body = resp.body?.string() ?: throw IOException("corps vide") | ||||
| 67 | + val value = json.parseToJsonElement(body) | ||||
| 68 | + runCatching { file?.writeText(body) } | ||||
| 69 | + value | ||||
| 70 | + } | ||||
| 71 | + } catch (e: Exception) { | ||||
| 72 | + // hors ligne : dernier contenu connu | ||||
| 73 | + if (file != null && file.exists()) { | ||||
| 74 | + runCatching { json.parseToJsonElement(file.readText()) }.getOrNull()?.let { return@withContext it } | ||||
| 75 | + } | ||||
| 76 | + throw e | ||||
| 77 | + } | ||||
| 78 | + } | ||||
| 79 | +} | ||||
| 80 | + | ||||
| 81 | +// MARK: - Univers : listes, recherche, stats | ||||
| 82 | + | ||||
| 83 | +object UniverseService { | ||||
| 84 | + | ||||
| 85 | + fun listURL( | ||||
| 86 | + u: Universe, | ||||
| 87 | + query: String? = null, | ||||
| 88 | + city: String? = null, | ||||
| 89 | + limit: Int = 30, | ||||
| 90 | + params: Map<String, String> = emptyMap(), | ||||
| 91 | + ): String? { | ||||
| 92 | + val path = u.listPath ?: return null | ||||
| 93 | + // listPath peut contenir déjà une query (ex. events?upcoming=true) | ||||
| 94 | + val split = path.split("?", limit = 2) | ||||
| 95 | + val items = mutableListOf<Pair<String, String?>>() | ||||
| 96 | + if (split.size > 1) { | ||||
| 97 | + split[1].split("&").forEach { part -> | ||||
| 98 | + val kv = part.split("=", limit = 2) | ||||
| 99 | + items.add(kv[0] to kv.getOrNull(1)) | ||||
| 100 | + } | ||||
| 101 | + } | ||||
| 102 | + if (!query.isNullOrEmpty()) items.add(u.searchParam to query) | ||||
| 103 | + if (!city.isNullOrEmpty()) items.add("city" to city) | ||||
| 104 | + params.entries.sortedBy { it.key }.forEach { (k, v) -> if (v.isNotEmpty()) items.add(k to v) } | ||||
| 105 | + items.add("limit" to limit.toString()) | ||||
| 106 | + val qs = items.joinToString("&") { (k, v) -> | ||||
| 107 | + if (v == null) k else "$k=${URLEncoder.encode(v, "UTF-8").replace("+", "%20")}" | ||||
| 108 | + } | ||||
| 109 | + return "${u.baseURL}${split[0]}?$qs" | ||||
| 110 | + } | ||||
| 111 | + | ||||
| 112 | + suspend fun fetch( | ||||
| 113 | + u: Universe, | ||||
| 114 | + query: String? = null, | ||||
| 115 | + city: String? = null, | ||||
| 116 | + limit: Int = 30, | ||||
| 117 | + params: Map<String, String> = emptyMap(), | ||||
| 118 | + ): List<KAItem> { | ||||
| 119 | + val url = listURL(u, query, city, limit, params) ?: return emptyList() | ||||
| 120 | + val map = u.map ?: return emptyList() | ||||
| 121 | + val root = ApiClient.fetchJson(url) | ||||
| 122 | + val obj = root.objectOrNull ?: JsonObject(emptyMap()) | ||||
| 123 | + val raw = obj[u.itemsKey].arrayOrNull | ||||
| 124 | + ?: obj["items"].arrayOrNull ?: obj["results"].arrayOrNull ?: obj["hits"].arrayOrNull | ||||
| 125 | + ?: root.arrayOrNull ?: emptyList() | ||||
| 126 | + return raw.mapNotNull { el -> el.objectOrNull?.let(map) } | ||||
| 127 | + } | ||||
| 128 | + | ||||
| 129 | + /** Total « en direct » depuis /api/stats (clé selon l'univers, chemins a.b acceptés). */ | ||||
| 130 | + suspend fun liveTotal(u: Universe): Int? { | ||||
| 131 | + val path = if (u.id == "trouve-ka") "/api/status" else "/api/stats" | ||||
| 132 | + val root = runCatching { ApiClient.fetchJson("https://${u.domain}$path", ttlSeconds = 300) } | ||||
| 133 | + .getOrNull() ?: return null | ||||
| 134 | + for (key in u.statTotalKeys) { | ||||
| 135 | + var cur: JsonElement? = root | ||||
| 136 | + for (part in key.split(".")) cur = cur.objectOrNull?.get(part) | ||||
| 137 | + cur.numberOrNull?.let { return it.toInt() } | ||||
| 138 | + } | ||||
| 139 | + return null | ||||
| 140 | + } | ||||
| 141 | +} | ||||
| 142 | + | ||||
| 143 | +// MARK: - KA Agent (SSE) | ||||
| 144 | + | ||||
| 145 | +sealed class AgentEvent { | ||||
| 146 | + data class Delta(val text: String) : AgentEvent() | ||||
| 147 | + data class Tool(val name: String) : AgentEvent() | ||||
| 148 | + data object Done : AgentEvent() | ||||
| 149 | + data class Error(val message: String) : AgentEvent() | ||||
| 150 | +} | ||||
| 151 | + | ||||
| 152 | +object AgentService { | ||||
| 153 | + const val endpoint = "https://www.api-ka.com/api/agent/chat" | ||||
| 154 | + | ||||
| 155 | + fun stream(site: String, messages: List<Map<String, String>>): Flow<AgentEvent> = callbackFlow { | ||||
| 156 | + val payload = buildJsonObject { | ||||
| 157 | + put("site", site) | ||||
| 158 | + put("messages", buildJsonArray { | ||||
| 159 | + messages.forEach { m -> | ||||
| 160 | + add(buildJsonObject { | ||||
| 161 | + put("role", m["role"] ?: "user") | ||||
| 162 | + put("content", m["content"] ?: "") | ||||
| 163 | + }) | ||||
| 164 | + } | ||||
| 165 | + }) | ||||
| 166 | + } | ||||
| 167 | + val client = ApiClient.http.newBuilder().readTimeout(90, TimeUnit.SECONDS).build() | ||||
| 168 | + val req = Request.Builder().url(endpoint) | ||||
| 169 | + .post(payload.toString().toRequestBody("application/json".toMediaType())) | ||||
| 170 | + .build() | ||||
| 171 | + val call = client.newCall(req) | ||||
| 172 | + try { | ||||
| 173 | + call.execute().use { resp -> | ||||
| 174 | + if (!resp.isSuccessful) throw IOException("HTTP ${resp.code}") | ||||
| 175 | + val source = resp.body?.source() ?: throw IOException("corps vide") | ||||
| 176 | + var event = "" | ||||
| 177 | + while (true) { | ||||
| 178 | + val line = source.readUtf8Line() ?: break | ||||
| 179 | + when { | ||||
| 180 | + line.startsWith("event: ") -> event = line.removePrefix("event: ") | ||||
| 181 | + line.startsWith("data: ") -> { | ||||
| 182 | + val obj = runCatching { | ||||
| 183 | + ApiClient.json.parseToJsonElement(line.removePrefix("data: ")).objectOrNull | ||||
| 184 | + }.getOrNull() ?: JsonObject(emptyMap()) | ||||
| 185 | + when (event) { | ||||
| 186 | + "delta" -> obj["text"].stringOrNull?.let { trySend(AgentEvent.Delta(it)) } | ||||
| 187 | + "tool" -> trySend(AgentEvent.Tool(obj["name"].stringOrNull ?: "recherche")) | ||||
| 188 | + "done" -> { trySend(AgentEvent.Done); close(); return@use } | ||||
| 189 | + "error" -> trySend(AgentEvent.Error(obj["message"].stringOrNull ?: "erreur")) | ||||
| 190 | + } | ||||
| 191 | + } | ||||
| 192 | + } | ||||
| 193 | + } | ||||
| 194 | + } | ||||
| 195 | + close() | ||||
| 196 | + } catch (e: Exception) { | ||||
| 197 | + close(e) | ||||
| 198 | + } | ||||
| 199 | + awaitClose { call.cancel() } | ||||
| 200 | + }.flowOn(Dispatchers.IO) | ||||
| 201 | +} | ||||
| 202 | + | ||||
| 203 | +// MARK: - Menu de resto (Resto·Ka) | ||||
| 204 | + | ||||
| 205 | +data class RestoMenuSection(val name: String, val items: List<Pair<String, String?>>) | ||||
| 206 | + | ||||
| 207 | +object RestoMenuLoader { | ||||
| 208 | + suspend fun load(uid: String): List<RestoMenuSection> { | ||||
| 209 | + val root = runCatching { | ||||
| 210 | + ApiClient.fetchJson("https://www.resto-ka.com/api/restaurants/$uid", ttlSeconds = 600) | ||||
| 211 | + }.getOrNull() ?: return emptyList() | ||||
| 212 | + val menus = root.objectOrNull?.get("menus").arrayOrNull ?: return emptyList() | ||||
| 213 | + val sections = mutableListOf<RestoMenuSection>() | ||||
| 214 | + for (menu in menus.take(1)) { | ||||
| 215 | + for (s in menu.objectOrNull?.get("sections").arrayOrNull ?: emptyList()) { | ||||
| 216 | + val so = s.objectOrNull ?: continue | ||||
| 217 | + val name = so.str("name") ?: continue | ||||
| 218 | + val items = (so["items"].arrayOrNull ?: emptyList()).take(30).mapNotNull { i -> | ||||
| 219 | + val io = i.objectOrNull ?: return@mapNotNull null | ||||
| 220 | + val n = io.str("name") ?: return@mapNotNull null | ||||
| 221 | + n to io.num("price")?.takeIf { it > 0 }?.money2 | ||||
| 222 | + } | ||||
| 223 | + if (items.isNotEmpty()) sections.add(RestoMenuSection(name, items)) | ||||
| 224 | + } | ||||
| 225 | + } | ||||
| 226 | + return sections | ||||
| 227 | + } | ||||
| 228 | +} | ||||
| 229 | + | ||||
| 230 | +// MARK: - Vrai-Prix (recherche + estimation) | ||||
| 231 | + | ||||
| 232 | +@kotlinx.serialization.Serializable | ||||
| 233 | +data class VPResult(val id: String, val adresse: String, val municipalite: String, val type: String?) | ||||
| 234 | + | ||||
| 235 | +object VraiPrixService { | ||||
| 236 | + suspend fun search(q: String): List<VPResult> { | ||||
| 237 | + val url = "https://www.vrai-prix.com/api/search?q=${URLEncoder.encode(q, "UTF-8").replace("+", "%20")}" | ||||
| 238 | + val root = runCatching { ApiClient.fetchJson(url) }.getOrNull() ?: return emptyList() | ||||
| 239 | + val arr = root.objectOrNull?.get("results").arrayOrNull ?: return emptyList() | ||||
| 240 | + return arr.mapNotNull { v -> | ||||
| 241 | + val o = v.objectOrNull ?: return@mapNotNull null | ||||
| 242 | + val id = o.str("id") ?: return@mapNotNull null | ||||
| 243 | + val ad = o.str("adresse") ?: return@mapNotNull null | ||||
| 244 | + val apt = o.str("apt") | ||||
| 245 | + VPResult( | ||||
| 246 | + id = id, | ||||
| 247 | + adresse = if (!apt.isNullOrEmpty()) "$ad app. $apt" else ad, | ||||
| 248 | + municipalite = o.str("municipalite") ?: "", | ||||
| 249 | + type = o.str("typeProp"), | ||||
| 250 | + ) | ||||
| 251 | + } | ||||
| 252 | + } | ||||
| 253 | + | ||||
| 254 | + /** Renvoie (unit, result) de /api/estimate?id= */ | ||||
| 255 | + suspend fun estimate(id: String): Pair<JsonObject?, JsonObject?>? { | ||||
| 256 | + val root = runCatching { | ||||
| 257 | + ApiClient.fetchJson("https://www.vrai-prix.com/api/estimate?id=$id", ttlSeconds = 3600) | ||||
| 258 | + }.getOrNull() ?: return null | ||||
| 259 | + val obj = root.objectOrNull ?: return null | ||||
| 260 | + return (obj["unit"].objectOrNull) to (obj["result"].objectOrNull) | ||||
| 261 | + } | ||||
| 262 | +} | ||||
added
app/src/main/java/com/groupeka/ka/ui/AgentChatScreen.kt
+273 −0
@@ -0,0 +1,273 @@ | |||||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | +// AgentChatScreen.kt — KA Agent natif : le même assistant IA que sur les sites | ||||
| 3 | +// (via l'API centrale api-ka), en chat plein écran, flux token par token + | ||||
| 4 | +// puces d'outils. | ||||
| 5 | +package com.groupeka.ka.ui | ||||
| 6 | + | ||||
| 7 | +import androidx.compose.foundation.background | ||||
| 8 | +import androidx.compose.foundation.border | ||||
| 9 | +import androidx.compose.foundation.clickable | ||||
| 10 | +import androidx.compose.foundation.layout.Arrangement | ||||
| 11 | +import androidx.compose.foundation.layout.Box | ||||
| 12 | +import androidx.compose.foundation.layout.Column | ||||
| 13 | +import androidx.compose.foundation.layout.PaddingValues | ||||
| 14 | +import androidx.compose.foundation.layout.Row | ||||
| 15 | +import androidx.compose.foundation.layout.Spacer | ||||
| 16 | +import androidx.compose.foundation.layout.fillMaxSize | ||||
| 17 | +import androidx.compose.foundation.layout.fillMaxWidth | ||||
| 18 | +import androidx.compose.foundation.layout.imePadding | ||||
| 19 | +import androidx.compose.foundation.layout.padding | ||||
| 20 | +import androidx.compose.foundation.layout.size | ||||
| 21 | +import androidx.compose.foundation.layout.width | ||||
| 22 | +import androidx.compose.foundation.lazy.LazyColumn | ||||
| 23 | +import androidx.compose.foundation.lazy.rememberLazyListState | ||||
| 24 | +import androidx.compose.foundation.shape.CircleShape | ||||
| 25 | +import androidx.compose.foundation.shape.RoundedCornerShape | ||||
| 26 | +import androidx.compose.material.icons.Icons | ||||
| 27 | +import androidx.compose.material.icons.automirrored.filled.Send | ||||
| 28 | +import androidx.compose.material.icons.filled.Cancel | ||||
| 29 | +import androidx.compose.material.icons.filled.Search | ||||
| 30 | +import androidx.compose.material3.CircularProgressIndicator | ||||
| 31 | +import androidx.compose.material3.Icon | ||||
| 32 | +import androidx.compose.material3.IconButton | ||||
| 33 | +import androidx.compose.material3.MaterialTheme | ||||
| 34 | +import androidx.compose.material3.OutlinedTextField | ||||
| 35 | +import androidx.compose.material3.OutlinedTextFieldDefaults | ||||
| 36 | +import androidx.compose.material3.Text | ||||
| 37 | +import androidx.compose.runtime.Composable | ||||
| 38 | +import androidx.compose.runtime.LaunchedEffect | ||||
| 39 | +import androidx.compose.runtime.getValue | ||||
| 40 | +import androidx.compose.runtime.mutableStateListOf | ||||
| 41 | +import androidx.compose.runtime.mutableStateOf | ||||
| 42 | +import androidx.compose.runtime.remember | ||||
| 43 | +import androidx.compose.runtime.rememberCoroutineScope | ||||
| 44 | +import androidx.compose.runtime.setValue | ||||
| 45 | +import androidx.compose.ui.Alignment | ||||
| 46 | +import androidx.compose.ui.Modifier | ||||
| 47 | +import androidx.compose.ui.draw.rotate | ||||
| 48 | +import androidx.compose.ui.graphics.PathEffect | ||||
| 49 | +import androidx.compose.ui.text.font.FontFamily | ||||
| 50 | +import androidx.compose.ui.text.font.FontWeight | ||||
| 51 | +import androidx.compose.ui.unit.dp | ||||
| 52 | +import androidx.compose.ui.unit.sp | ||||
| 53 | +import com.groupeka.ka.core.AgentEvent | ||||
| 54 | +import com.groupeka.ka.core.AgentService | ||||
| 55 | +import com.groupeka.ka.ui.theme.KATheme | ||||
| 56 | +import com.groupeka.ka.ui.theme.LocalKADark | ||||
| 57 | +import com.groupeka.ka.ui.theme.kaCard | ||||
| 58 | +import kotlinx.coroutines.launch | ||||
| 59 | +import java.util.UUID | ||||
| 60 | + | ||||
| 61 | +data class AgentMessage( | ||||
| 62 | + val id: String = UUID.randomUUID().toString(), | ||||
| 63 | + val role: String, // user / assistant / tool | ||||
| 64 | + var content: String, | ||||
| 65 | +) | ||||
| 66 | + | ||||
| 67 | +@Composable | ||||
| 68 | +fun AgentChatScreen(onClose: () -> Unit) { | ||||
| 69 | + val dark = LocalKADark.current | ||||
| 70 | + val scope = rememberCoroutineScope() | ||||
| 71 | + val messages = remember { mutableStateListOf<AgentMessage>() } | ||||
| 72 | + var input by remember { mutableStateOf("") } | ||||
| 73 | + var busy by remember { mutableStateOf(false) } | ||||
| 74 | + val listState = rememberLazyListState() | ||||
| 75 | + | ||||
| 76 | + fun send(text: String) { | ||||
| 77 | + val q = text.trim() | ||||
| 78 | + if (q.isEmpty() || busy) return | ||||
| 79 | + messages.add(AgentMessage(role = "user", content = q)) | ||||
| 80 | + messages.add(AgentMessage(role = "assistant", content = "")) | ||||
| 81 | + busy = true | ||||
| 82 | + val history = messages | ||||
| 83 | + .filter { it.role == "user" || (it.role == "assistant" && it.content.isNotEmpty()) } | ||||
| 84 | + .takeLast(16) | ||||
| 85 | + .map { mapOf("role" to it.role, "content" to it.content) } | ||||
| 86 | + | ||||
| 87 | + scope.launch { | ||||
| 88 | + try { | ||||
| 89 | + AgentService.stream(site = "groupe-ka", messages = history).collect { event -> | ||||
| 90 | + when (event) { | ||||
| 91 | + is AgentEvent.Delta -> { | ||||
| 92 | + val i = messages.indexOfLast { it.role == "assistant" } | ||||
| 93 | + if (i >= 0) messages[i] = messages[i].copy(content = messages[i].content + event.text) | ||||
| 94 | + } | ||||
| 95 | + is AgentEvent.Tool -> { | ||||
| 96 | + // insérer la puce outil AVANT la bulle assistante en cours | ||||
| 97 | + val i = messages.indexOfLast { it.role == "assistant" } | ||||
| 98 | + if (i >= 0) messages.add(i, AgentMessage(role = "tool", content = event.name.replace("_", " "))) | ||||
| 99 | + } | ||||
| 100 | + is AgentEvent.Error -> { | ||||
| 101 | + val i = messages.indexOfLast { it.role == "assistant" } | ||||
| 102 | + if (i >= 0 && messages[i].content.isEmpty()) { | ||||
| 103 | + messages[i] = messages[i].copy(content = "Désolé, une erreur est survenue (${event.message}). Réessayez.") | ||||
| 104 | + } | ||||
| 105 | + } | ||||
| 106 | + is AgentEvent.Done -> Unit | ||||
| 107 | + } | ||||
| 108 | + } | ||||
| 109 | + } catch (e: Exception) { | ||||
| 110 | + val i = messages.indexOfLast { it.role == "assistant" } | ||||
| 111 | + if (i >= 0 && messages[i].content.isEmpty()) { | ||||
| 112 | + messages[i] = messages[i].copy(content = "Impossible de joindre KA Agent — vérifiez votre connexion.") | ||||
| 113 | + } | ||||
| 114 | + } | ||||
| 115 | + busy = false | ||||
| 116 | + } | ||||
| 117 | + } | ||||
| 118 | + | ||||
| 119 | + LaunchedEffect(messages.size, messages.lastOrNull()?.content) { | ||||
| 120 | + if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1) | ||||
| 121 | + } | ||||
| 122 | + | ||||
| 123 | + Column(Modifier.fillMaxSize().imePadding()) { | ||||
| 124 | + // — en-tête | ||||
| 125 | + Row( | ||||
| 126 | + Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), | ||||
| 127 | + verticalAlignment = Alignment.CenterVertically, | ||||
| 128 | + ) { | ||||
| 129 | + Text("KA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark)) | ||||
| 130 | + Spacer(Modifier.width(6.dp)) | ||||
| 131 | + Text( | ||||
| 132 | + "Agent", | ||||
| 133 | + fontSize = 13.sp, | ||||
| 134 | + fontWeight = FontWeight.Bold, | ||||
| 135 | + color = KATheme.lime, | ||||
| 136 | + modifier = Modifier | ||||
| 137 | + .rotate(-2f) | ||||
| 138 | + .background(KATheme.inkLight, RoundedCornerShape(6.dp)) | ||||
| 139 | + .padding(horizontal = 7.dp, vertical = 2.dp), | ||||
| 140 | + ) | ||||
| 141 | + Spacer(Modifier.width(6.dp)) | ||||
| 142 | + Text("· IA de l'écosystème", style = MaterialTheme.typography.labelSmall, color = KATheme.ink2(dark)) | ||||
| 143 | + Spacer(Modifier.weight(1f)) | ||||
| 144 | + IconButton(onClick = onClose) { | ||||
| 145 | + Icon(Icons.Filled.Cancel, "Fermer KA Agent", tint = KATheme.ink2(dark)) | ||||
| 146 | + } | ||||
| 147 | + } | ||||
| 148 | + | ||||
| 149 | + LazyColumn( | ||||
| 150 | + state = listState, | ||||
| 151 | + contentPadding = PaddingValues(14.dp), | ||||
| 152 | + verticalArrangement = Arrangement.spacedBy(10.dp), | ||||
| 153 | + modifier = Modifier.weight(1f), | ||||
| 154 | + ) { | ||||
| 155 | + if (messages.isEmpty()) { | ||||
| 156 | + item { | ||||
| 157 | + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(4.dp)) { | ||||
| 158 | + Text("👋 Je suis KA Agent.", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark)) | ||||
| 159 | + Text( | ||||
| 160 | + "Posez-moi n'importe quelle question sur l'écosystème Groupe KA et ses données : logements, propriétés, autos, emplois, prix d'épicerie, restos, sorties, créateurs, statistiques…", | ||||
| 161 | + style = MaterialTheme.typography.bodyMedium, | ||||
| 162 | + color = KATheme.ink2(dark), | ||||
| 163 | + ) | ||||
| 164 | + listOf( | ||||
| 165 | + "Combien de logements à louer ?", | ||||
| 166 | + "Un resto italien à Montréal", | ||||
| 167 | + "Les sorties gratuites ce week-end", | ||||
| 168 | + "C'est quoi le Groupe KA ?", | ||||
| 169 | + ).forEach { s -> | ||||
| 170 | + Text( | ||||
| 171 | + s, | ||||
| 172 | + style = MaterialTheme.typography.labelMedium, | ||||
| 173 | + fontWeight = FontWeight.SemiBold, | ||||
| 174 | + color = KATheme.ink(dark), | ||||
| 175 | + modifier = Modifier | ||||
| 176 | + .background(KATheme.ink(dark).copy(alpha = 0.08f), CircleShape) | ||||
| 177 | + .clickable { send(s) } | ||||
| 178 | + .padding(horizontal = 11.dp, vertical = 7.dp), | ||||
| 179 | + ) | ||||
| 180 | + } | ||||
| 181 | + } | ||||
| 182 | + } | ||||
| 183 | + } | ||||
| 184 | + messages.forEach { m -> | ||||
| 185 | + item(key = m.id) { Bubble(m) } | ||||
| 186 | + } | ||||
| 187 | + } | ||||
| 188 | + | ||||
| 189 | + // — barre de saisie | ||||
| 190 | + Row( | ||||
| 191 | + Modifier | ||||
| 192 | + .fillMaxWidth() | ||||
| 193 | + .background(KATheme.surface(dark)) | ||||
| 194 | + .padding(12.dp), | ||||
| 195 | + verticalAlignment = Alignment.Bottom, | ||||
| 196 | + ) { | ||||
| 197 | + OutlinedTextField( | ||||
| 198 | + value = input, | ||||
| 199 | + onValueChange = { input = it }, | ||||
| 200 | + placeholder = { Text("Posez votre question…", color = KATheme.ink2(dark)) }, | ||||
| 201 | + maxLines = 4, | ||||
| 202 | + colors = OutlinedTextFieldDefaults.colors( | ||||
| 203 | + focusedBorderColor = KATheme.ink(dark), | ||||
| 204 | + unfocusedBorderColor = KATheme.ink(dark).copy(alpha = 0.35f), | ||||
| 205 | + ), | ||||
| 206 | + modifier = Modifier.weight(1f), | ||||
| 207 | + ) | ||||
| 208 | + Spacer(Modifier.width(8.dp)) | ||||
| 209 | + val canSend = !busy && input.trim().isNotEmpty() | ||||
| 210 | + Box( | ||||
| 211 | + Modifier | ||||
| 212 | + .size(48.dp) | ||||
| 213 | + .background(if (canSend) KATheme.inkLight else KATheme.inkLight.copy(alpha = 0.4f), CircleShape) | ||||
| 214 | + .clickable(enabled = canSend) { send(input); input = "" }, | ||||
| 215 | + contentAlignment = Alignment.Center, | ||||
| 216 | + ) { | ||||
| 217 | + Icon(Icons.AutoMirrored.Filled.Send, "Envoyer", tint = KATheme.lime, modifier = Modifier.size(20.dp)) | ||||
| 218 | + } | ||||
| 219 | + } | ||||
| 220 | + } | ||||
| 221 | +} | ||||
| 222 | + | ||||
| 223 | +@Composable | ||||
| 224 | +private fun Bubble(m: AgentMessage) { | ||||
| 225 | + val dark = LocalKADark.current | ||||
| 226 | + when (m.role) { | ||||
| 227 | + "user" -> Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { | ||||
| 228 | + Text( | ||||
| 229 | + m.content, | ||||
| 230 | + color = KATheme.inkLight, | ||||
| 231 | + modifier = Modifier | ||||
| 232 | + .background(KATheme.lime, RoundedCornerShape(13.dp)) | ||||
| 233 | + .padding(horizontal = 13.dp, vertical = 9.dp), | ||||
| 234 | + ) | ||||
| 235 | + } | ||||
| 236 | + "tool" -> Row(verticalAlignment = Alignment.CenterVertically) { | ||||
| 237 | + Icon(Icons.Filled.Search, null, tint = KATheme.ink2(dark), modifier = Modifier.size(12.dp)) | ||||
| 238 | + Spacer(Modifier.width(5.dp)) | ||||
| 239 | + Text( | ||||
| 240 | + m.content.uppercase(), | ||||
| 241 | + fontSize = 10.sp, | ||||
| 242 | + fontFamily = FontFamily.Monospace, | ||||
| 243 | + fontWeight = FontWeight.Bold, | ||||
| 244 | + color = KATheme.ink2(dark), | ||||
| 245 | + modifier = Modifier | ||||
| 246 | + .dashedBorder() | ||||
| 247 | + .padding(horizontal = 10.dp, vertical = 5.dp), | ||||
| 248 | + ) | ||||
| 249 | + } | ||||
| 250 | + else -> Row(Modifier.fillMaxWidth()) { | ||||
| 251 | + if (m.content.isEmpty()) { | ||||
| 252 | + CircularProgressIndicator(Modifier.size(20.dp).padding(2.dp), color = KATheme.green, strokeWidth = 2.dp) | ||||
| 253 | + } else { | ||||
| 254 | + Text( | ||||
| 255 | + m.content, | ||||
| 256 | + color = KATheme.ink(dark), | ||||
| 257 | + modifier = Modifier | ||||
| 258 | + .kaCard() | ||||
| 259 | + .padding(horizontal = 13.dp, vertical = 9.dp), | ||||
| 260 | + ) | ||||
| 261 | + } | ||||
| 262 | + } | ||||
| 263 | + } | ||||
| 264 | +} | ||||
| 265 | + | ||||
| 266 | +/** bordure pointillée façon puce d'outil */ | ||||
| 267 | +private fun Modifier.dashedBorder(): Modifier = this.then( | ||||
| 268 | + Modifier.border( | ||||
| 269 | + width = 1.dp, | ||||
| 270 | + color = androidx.compose.ui.graphics.Color.Gray, | ||||
| 271 | + shape = CircleShape, | ||||
| 272 | + ), | ||||
| 273 | +) | ||||
added
app/src/main/java/com/groupeka/ka/ui/FavoritesScreen.kt
+184 −0
Large diff (184 lines) is not loaded by default to keep the page fast.Load diff
added
app/src/main/java/com/groupeka/ka/ui/HomeScreen.kt
+256 −0
Large diff (256 lines) is not loaded by default to keep the page fast.Load diff
added
app/src/main/java/com/groupeka/ka/ui/ItemDetailScreen.kt
+319 −0
Large diff (319 lines) is not loaded by default to keep the page fast.Load diff
added
app/src/main/java/com/groupeka/ka/ui/OnboardingScreen.kt
+204 −0
Large diff (204 lines) is not loaded by default to keep the page fast.Load diff
added
app/src/main/java/com/groupeka/ka/ui/ProfileScreen.kt
+349 −0
Large diff (349 lines) is not loaded by default to keep the page fast.Load diff
added
app/src/main/java/com/groupeka/ka/ui/SearchScreen.kt
+252 −0
Large diff (252 lines) is not loaded by default to keep the page fast.Load diff
added
app/src/main/java/com/groupeka/ka/ui/UniversesScreen.kt
+560 −0
Large diff (560 lines) is not loaded by default to keep the page fast.Load diff
added
app/src/main/java/com/groupeka/ka/ui/VraiPrixScreen.kt
+369 −0
Large diff (369 lines) is not loaded by default to keep the page fast.Load diff
added
app/src/main/java/com/groupeka/ka/ui/theme/Theme.kt
+388 −0
Large diff (388 lines) is not loaded by default to keep the page fast.Load diff
added
app/src/main/res/drawable/splash.xml
+9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added
app/src/main/res/mipmap-hdpi/ic_launcher.png
+0 −0
Binary file not shown.
added
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
+0 −0
Binary file not shown.
added
app/src/main/res/mipmap-mdpi/ic_launcher.png
+0 −0
Binary file not shown.
added
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
+0 −0
Binary file not shown.
added
app/src/main/res/mipmap-xhdpi/ic_launcher.png
+0 −0
Binary file not shown.
added
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
+0 −0
Binary file not shown.
added
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
+0 −0
Binary file not shown.
added
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
+0 −0
Binary file not shown.
added
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
+0 −0
Binary file not shown.
added
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
+0 −0
Binary file not shown.
added
app/src/main/res/values/colors.xml
+6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added
app/src/main/res/values/strings.xml
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
app/src/main/res/values/themes.xml
+17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added
app/src/test/java/com/groupeka/ka/AdapterTests.kt
+147 −0
Large diff (147 lines) is not loaded by default to keep the page fast.Load diff
added
build.gradle.kts
+8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added
gradle.properties
+5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added
gradle/wrapper/gradle-wrapper.jar
+0 −0
Binary file not shown.
added
gradle/wrapper/gradle-wrapper.properties
+9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added
gradlew
+248 −0
Large diff (248 lines) is not loaded by default to keep the page fast.Load diff
added
gradlew.bat
+82 −0
Large diff (82 lines) is not loaded by default to keep the page fast.Load diff
added
settings.gradle.kts
+17 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff