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