SPB Git

spb/ka-android

Public

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>
Simon-Pierre Boucher committed (Aug 18, 2026) Browse files

45 changed files +5,131 −0

45 changed files
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("&amp;", "&")
345+ .replace("&nbsp;", " ")
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
Menu

Theme