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