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
Unified Split
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
@@ -0,0 +1,184 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// FavoritesScreen.kt — favoris & collections unifiés (multi-univers dans une
3+// même collection), consultables hors ligne, partage natif.
4+package com.groupeka.ka.ui
5+
6+import androidx.compose.foundation.clickable
7+import androidx.compose.foundation.combinedClickable
8+import androidx.compose.foundation.ExperimentalFoundationApi
9+import androidx.compose.foundation.layout.Arrangement
10+import androidx.compose.foundation.layout.Box
11+import androidx.compose.foundation.layout.Column
12+import androidx.compose.foundation.layout.PaddingValues
13+import androidx.compose.foundation.layout.Row
14+import androidx.compose.foundation.layout.Spacer
15+import androidx.compose.foundation.layout.fillMaxWidth
16+import androidx.compose.foundation.layout.padding
17+import androidx.compose.foundation.layout.width
18+import androidx.compose.foundation.lazy.LazyColumn
19+import androidx.compose.material.icons.Icons
20+import androidx.compose.material.icons.filled.CreateNewFolder
21+import androidx.compose.material.icons.filled.Delete
22+import androidx.compose.material.icons.filled.Folder
23+import androidx.compose.material.icons.filled.FavoriteBorder
24+import androidx.compose.material.icons.filled.MoreVert
25+import androidx.compose.material3.AlertDialog
26+import androidx.compose.material3.DropdownMenu
27+import androidx.compose.material3.DropdownMenuItem
28+import androidx.compose.material3.Icon
29+import androidx.compose.material3.IconButton
30+import androidx.compose.material3.MaterialTheme
31+import androidx.compose.material3.OutlinedTextField
32+import androidx.compose.material3.Text
33+import androidx.compose.material3.TextButton
34+import androidx.compose.runtime.Composable
35+import androidx.compose.runtime.collectAsState
36+import androidx.compose.runtime.getValue
37+import androidx.compose.runtime.mutableStateOf
38+import androidx.compose.runtime.remember
39+import androidx.compose.runtime.setValue
40+import androidx.compose.ui.Alignment
41+import androidx.compose.ui.Modifier
42+import androidx.compose.ui.platform.LocalContext
43+import androidx.compose.ui.text.font.FontFamily
44+import androidx.compose.ui.text.font.FontWeight
45+import androidx.compose.ui.unit.dp
46+import androidx.compose.ui.unit.sp
47+import androidx.navigation.NavHostController
48+import com.groupeka.ka.core.FavoritesStore
49+import com.groupeka.ka.encodeNavArg
50+import com.groupeka.ka.ui.theme.KAEmptyState
51+import com.groupeka.ka.ui.theme.KAItemRow
52+import com.groupeka.ka.ui.theme.KATheme
53+import com.groupeka.ka.ui.theme.LocalKADark
54+
55+@OptIn(ExperimentalFoundationApi::class)
56+@Composable
57+fun FavoritesScreen(nav: NavHostController) {
58+ val dark = LocalKADark.current
59+ val context = LocalContext.current
60+ val store = FavoritesStore.shared ?: return
61+ val collections by store.collections.collectAsState()
62+ var newName by remember { mutableStateOf("") }
63+ var showNew by remember { mutableStateOf(false) }
64+ var menuFor by remember { mutableStateOf<String?>(null) } // id d'item avec menu contextuel ouvert
65+
66+ LazyColumn(
67+ contentPadding = PaddingValues(16.dp),
68+ verticalArrangement = Arrangement.spacedBy(12.dp),
69+ modifier = Modifier.fillMaxWidth(),
70+ ) {
71+ item {
72+ Row(verticalAlignment = Alignment.CenterVertically) {
73+ Text("Favoris", fontSize = 28.sp, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
74+ Spacer(Modifier.weight(1f))
75+ IconButton(onClick = { showNew = true }) {
76+ Icon(Icons.Filled.CreateNewFolder, "Nouvelle collection", tint = KATheme.green)
77+ }
78+ }
79+ }
80+ if (collections.all { it.items.isEmpty() }) {
81+ item {
82+ KAEmptyState(
83+ Icons.Filled.FavoriteBorder,
84+ "Aucun favori pour l'instant",
85+ "Touchez ♥ sur un logement, une auto, un resto ou une sortie — tout se retrouve ici, même hors ligne.",
86+ )
87+ }
88+ }
89+ collections.forEach { c ->
90+ item(key = "col-${c.id}") {
91+ Row(verticalAlignment = Alignment.CenterVertically) {
92+ Icon(Icons.Filled.Folder, null, tint = KATheme.green)
93+ Spacer(Modifier.width(8.dp))
94+ Text(c.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
95+ Spacer(Modifier.weight(1f))
96+ Text(
97+ "${c.items.size}",
98+ fontFamily = FontFamily.Monospace,
99+ fontWeight = FontWeight.Bold,
100+ fontSize = 12.sp,
101+ color = KATheme.ink2(dark),
102+ )
103+ if (collections.size > 1) {
104+ IconButton(onClick = { store.removeCollection(c.id) }) {
105+ Icon(Icons.Filled.Delete, "Supprimer la collection", tint = KATheme.ink2(dark))
106+ }
107+ }
108+ }
109+ }
110+ if (c.items.isEmpty()) {
111+ item(key = "empty-${c.id}") {
112+ Text(
113+ "Vide — ajoutez-y des trouvailles depuis n'importe quel univers.",
114+ style = MaterialTheme.typography.labelMedium,
115+ color = KATheme.ink2(dark).copy(alpha = 0.8f),
116+ )
117+ }
118+ }
119+ c.items.forEach { fav ->
120+ item(key = "fav-${c.id}-${fav.id}") {
121+ Box {
122+ KAItemRow(
123+ fav,
124+ Modifier.combinedClickable(
125+ onClick = { nav.navigate("item/${encodeNavArg(fav)}") },
126+ onLongClick = { menuFor = "${c.id}-${fav.id}" },
127+ ),
128+ )
129+ DropdownMenu(
130+ expanded = menuFor == "${c.id}-${fav.id}",
131+ onDismissRequest = { menuFor = null },
132+ ) {
133+ collections.filter { it.id != c.id }.forEach { other ->
134+ DropdownMenuItem(
135+ text = { Text("Déplacer vers ${other.name}") },
136+ onClick = { store.move(fav, other.id); menuFor = null },
137+ )
138+ }
139+ if (fav.url != null) {
140+ DropdownMenuItem(
141+ text = { Text("Partager") },
142+ onClick = { shareUrl(context, fav.url!!, fav.title); menuFor = null },
143+ )
144+ }
145+ DropdownMenuItem(
146+ text = { Text("Retirer") },
147+ onClick = { store.toggle(fav); menuFor = null },
148+ )
149+ }
150+ }
151+ }
152+ }
153+ }
154+ }
155+
156+ if (showNew) {
157+ AlertDialog(
158+ onDismissRequest = { showNew = false; newName = "" },
159+ title = { Text("Nouvelle collection") },
160+ text = {
161+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
162+ Text("Une collection peut mélanger tous les univers : un 4½, une auto et un resto ensemble.")
163+ OutlinedTextField(
164+ value = newName,
165+ onValueChange = { newName = it },
166+ placeholder = { Text("Ex. Déménagement à Val-d'Or") },
167+ singleLine = true,
168+ )
169+ }
170+ },
171+ confirmButton = {
172+ TextButton(onClick = {
173+ store.addCollection(newName)
174+ newName = ""
175+ showNew = false
176+ }) { Text("Créer", fontWeight = FontWeight.Bold, color = KATheme.green) }
177+ },
178+ dismissButton = {
179+ TextButton(onClick = { showNew = false; newName = "" }) { Text("Annuler") }
180+ },
181+ containerColor = KATheme.surface(dark),
182+ )
183+ }
184+}
added app/src/main/java/com/groupeka/ka/ui/HomeScreen.kt +256 −0
@@ -0,0 +1,256 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// HomeScreen.kt — l'accueil VIVANT : salutation selon l'heure, suggestions
3+// contextuelles (restos le soir, sorties le week-end, emplois le matin…) avec
4+// du VRAI contenu en direct, et le pouls de l'écosystème (compteurs live).
5+package com.groupeka.ka.ui
6+
7+import androidx.compose.foundation.clickable
8+import androidx.compose.foundation.layout.Arrangement
9+import androidx.compose.foundation.layout.Box
10+import androidx.compose.foundation.layout.Column
11+import androidx.compose.foundation.layout.PaddingValues
12+import androidx.compose.foundation.layout.Row
13+import androidx.compose.foundation.layout.Spacer
14+import androidx.compose.foundation.layout.fillMaxWidth
15+import androidx.compose.foundation.layout.height
16+import androidx.compose.foundation.layout.padding
17+import androidx.compose.foundation.layout.width
18+import androidx.compose.foundation.lazy.LazyColumn
19+import androidx.compose.foundation.lazy.LazyRow
20+import androidx.compose.material3.CircularProgressIndicator
21+import androidx.compose.material3.MaterialTheme
22+import androidx.compose.material3.Text
23+import androidx.compose.runtime.Composable
24+import androidx.compose.runtime.LaunchedEffect
25+import androidx.compose.runtime.getValue
26+import androidx.compose.runtime.mutableStateOf
27+import androidx.compose.runtime.remember
28+import androidx.compose.runtime.setValue
29+import androidx.compose.ui.Alignment
30+import androidx.compose.ui.Modifier
31+import androidx.compose.ui.draw.clip
32+import androidx.compose.ui.text.font.FontWeight
33+import androidx.compose.ui.text.style.TextOverflow
34+import androidx.compose.ui.unit.dp
35+import androidx.compose.ui.unit.sp
36+import androidx.compose.foundation.shape.RoundedCornerShape
37+import androidx.navigation.NavHostController
38+import com.groupeka.ka.core.Ecosystem
39+import com.groupeka.ka.core.KAItem
40+import com.groupeka.ka.core.Prefs
41+import com.groupeka.ka.core.Universe
42+import com.groupeka.ka.core.UniverseService
43+import com.groupeka.ka.core.frFormatted
44+import com.groupeka.ka.encodeNavArg
45+import com.groupeka.ka.ui.theme.GroupeKAMark
46+import com.groupeka.ka.ui.theme.KAImage
47+import com.groupeka.ka.ui.theme.KATheme
48+import com.groupeka.ka.ui.theme.KAWordmark
49+import com.groupeka.ka.ui.theme.LocalKADark
50+import com.groupeka.ka.ui.theme.accent
51+import com.groupeka.ka.ui.theme.kaCard
52+import kotlinx.coroutines.async
53+import kotlinx.coroutines.awaitAll
54+import kotlinx.coroutines.coroutineScope
55+import java.util.Calendar
56+
57+private fun greeting(): Pair<String, String> {
58+ val cal = Calendar.getInstance()
59+ val h = cal.get(Calendar.HOUR_OF_DAY)
60+ val dow = cal.get(Calendar.DAY_OF_WEEK)
61+ val weekend = dow == Calendar.SATURDAY || dow == Calendar.SUNDAY
62+ return when (h) {
63+ in 5..11 -> "Bon matin ☀️" to if (weekend) "On planifie la fin de semaine ?" else "L'écosystème a travaillé toute la nuit."
64+ in 12..16 -> "Bon après-midi 👋" to "Tout le Québec, dans votre poche."
65+ in 17..21 -> "Bonsoir 🌆" to if (weekend) "Resto ? Sortie ? KA a des idées." else "On soupe où ce soir ?"
66+ else -> "Bonne nuit 🌙" to "Les connecteurs veillent — revenez demain matin."
67+ }
68+}
69+
70+/** Univers mis en avant selon l'heure + les préférences d'onboarding. */
71+private fun contextualUniverses(): List<Universe> {
72+ val cal = Calendar.getInstance()
73+ val h = cal.get(Calendar.HOUR_OF_DAY)
74+ val dow = cal.get(Calendar.DAY_OF_WEEK)
75+ val weekend = dow == Calendar.SATURDAY || dow == Calendar.SUNDAY
76+ var ids: MutableList<String> = when (h) {
77+ in 5..10 -> mutableListOf("job-ka", "food-ka", "immo-ka")
78+ in 11..13 -> mutableListOf("resto-ka", "food-ka", "sorti-ka")
79+ in 14..16 -> if (weekend) mutableListOf("sorti-ka", "resto-ka", "auto-ka") else mutableListOf("immo-ka", "auto-ka", "lou-ka")
80+ in 17..22 -> mutableListOf("resto-ka", "sorti-ka", "crea-ka")
81+ else -> mutableListOf("sorti-ka", "crea-ka", "lou-ka")
82+ }
83+ val favs = Prefs.favUniverses.split(",").filter { it.isNotEmpty() }
84+ for (f in favs.reversed()) if (!ids.contains(f)) ids.add(0, f)
85+ return ids.take(4).mapNotNull { Ecosystem.universe(it) }.filter { it.map != null }
86+}
87+
88+@Composable
89+fun HomeScreen(nav: NavHostController) {
90+ val dark = LocalKADark.current
91+ var featured by remember { mutableStateOf<List<Pair<Universe, List<KAItem>>>>(emptyList()) }
92+ var pulse by remember { mutableStateOf<List<Pair<Universe, Int>>>(emptyList()) }
93+ var loading by remember { mutableStateOf(true) }
94+
95+ LaunchedEffect(Unit) {
96+ loading = true
97+ val universes = contextualUniverses()
98+ coroutineScope {
99+ val jobs = universes.map { u ->
100+ async { u to (runCatching { UniverseService.fetch(u, limit = 8) }.getOrNull() ?: emptyList()) }
101+ }
102+ featured = jobs.awaitAll().filter { it.second.isNotEmpty() }
103+ }
104+ loading = false
105+ // pouls (compteurs live) — après le contenu pour ne pas retarder l'accueil
106+ coroutineScope {
107+ val counts = Ecosystem.all.map { u -> async { u to UniverseService.liveTotal(u) } }.awaitAll()
108+ pulse = counts.mapNotNull { (u, n) -> n?.let { u to it } }.sortedByDescending { it.second }
109+ }
110+ }
111+
112+ val (title, sub) = greeting()
113+
114+ LazyColumn(
115+ contentPadding = PaddingValues(16.dp),
116+ verticalArrangement = Arrangement.spacedBy(22.dp),
117+ modifier = Modifier.fillMaxWidth(),
118+ ) {
119+ item {
120+ Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
121+ GroupeKAMark(size = 16f)
122+ Spacer(Modifier.height(10.dp))
123+ Text(title, fontSize = 30.sp, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
124+ Text(sub, style = MaterialTheme.typography.bodyMedium, color = KATheme.ink2(dark))
125+ }
126+ }
127+ item {
128+ // Le pouls de l'écosystème en direct
129+ LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
130+ if (pulse.isEmpty()) {
131+ item {
132+ Text(
133+ "Le pouls de l'écosystème…",
134+ style = MaterialTheme.typography.labelMedium,
135+ color = KATheme.ink2(dark),
136+ modifier = Modifier.padding(10.dp),
137+ )
138+ }
139+ }
140+ items(pulse.size) { i ->
141+ val (u, n) = pulse[i]
142+ Column(
143+ Modifier
144+ .kaCard(u.accent)
145+ .clickable { nav.navigate("universe/${u.id}") }
146+ .padding(horizontal = 13.dp, vertical = 9.dp),
147+ verticalArrangement = Arrangement.spacedBy(3.dp),
148+ ) {
149+ Text(
150+ n.frFormatted,
151+ style = MaterialTheme.typography.titleMedium,
152+ fontWeight = FontWeight.Bold,
153+ color = u.accent,
154+ )
155+ Text(u.unit, style = MaterialTheme.typography.labelSmall, color = KATheme.ink2(dark))
156+ }
157+ }
158+ }
159+ }
160+ featured.forEach { (u, items) ->
161+ item(key = u.id) {
162+ FeaturedSection(u, items, nav)
163+ }
164+ }
165+ if (loading && featured.isEmpty()) {
166+ item {
167+ Row(
168+ Modifier.fillMaxWidth().padding(40.dp),
169+ horizontalArrangement = Arrangement.Center,
170+ verticalAlignment = Alignment.CenterVertically,
171+ ) {
172+ CircularProgressIndicator(color = KATheme.green)
173+ Spacer(Modifier.width(12.dp))
174+ Text("KA prépare votre accueil…", color = KATheme.ink2(dark))
175+ }
176+ }
177+ }
178+ item {
179+ Text(
180+ Ecosystem.disclaimer,
181+ style = MaterialTheme.typography.labelSmall,
182+ color = KATheme.ink2(dark).copy(alpha = 0.7f),
183+ modifier = Modifier.padding(top = 8.dp),
184+ )
185+ }
186+ }
187+}
188+
189+@Composable
190+private fun FeaturedSection(u: Universe, items: List<KAItem>, nav: NavHostController) {
191+ val dark = LocalKADark.current
192+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
193+ Row(verticalAlignment = Alignment.CenterVertically) {
194+ KAWordmark(u, size = 17f)
195+ Spacer(Modifier.weight(1f))
196+ Text(
197+ "Tout voir",
198+ style = MaterialTheme.typography.labelMedium,
199+ fontWeight = FontWeight.SemiBold,
200+ color = u.accent,
201+ modifier = Modifier.clickable { nav.navigate("universe/${u.id}") },
202+ )
203+ }
204+ LazyRow(
205+ horizontalArrangement = Arrangement.spacedBy(12.dp),
206+ contentPadding = PaddingValues(vertical = 4.dp),
207+ ) {
208+ items(items.take(8).size) { i ->
209+ val item = items[i]
210+ Column(
211+ Modifier
212+ .width(216.dp)
213+ .kaCard(u.accent)
214+ .clickable { nav.navigate("item/${encodeNavArg(item)}") },
215+ ) {
216+ KAImage(
217+ url = item.imageURL, accent = u.accent, symbol = u.symbol,
218+ modifier = Modifier
219+ .fillMaxWidth()
220+ .height(118.dp)
221+ .clip(RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)),
222+ )
223+ Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
224+ Text(
225+ item.title,
226+ style = MaterialTheme.typography.bodyMedium,
227+ fontWeight = FontWeight.SemiBold,
228+ maxLines = 2, minLines = 2, overflow = TextOverflow.Ellipsis,
229+ color = KATheme.ink(dark),
230+ )
231+ Row(verticalAlignment = Alignment.CenterVertically) {
232+ if (item.priceLabel != null) {
233+ Text(
234+ item.priceLabel!!,
235+ style = MaterialTheme.typography.labelMedium,
236+ fontWeight = FontWeight.Bold,
237+ color = u.accent,
238+ maxLines = 1,
239+ )
240+ }
241+ Spacer(Modifier.weight(1f))
242+ if (item.city != null) {
243+ Text(
244+ item.city,
245+ style = MaterialTheme.typography.labelSmall,
246+ color = KATheme.ink2(dark),
247+ maxLines = 1, overflow = TextOverflow.Ellipsis,
248+ )
249+ }
250+ }
251+ }
252+ }
253+ }
254+ }
255+ }
256+}
added app/src/main/java/com/groupeka/ka/ui/ItemDetailScreen.kt +319 −0
@@ -0,0 +1,319 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// ItemDetailScreen.kt — la fiche universelle : image héros, À propos, faits,
3+// liens riches, menus resto natifs (Resto·Ka), favori (+ choix de collection),
4+// partage natif et « Voir à la source ».
5+package com.groupeka.ka.ui
6+
7+import android.content.Context
8+import android.content.Intent
9+import android.net.Uri
10+import androidx.compose.foundation.background
11+import androidx.compose.foundation.border
12+import androidx.compose.foundation.clickable
13+import androidx.compose.foundation.layout.Arrangement
14+import androidx.compose.foundation.layout.Box
15+import androidx.compose.foundation.layout.Column
16+import androidx.compose.foundation.layout.PaddingValues
17+import androidx.compose.foundation.layout.Row
18+import androidx.compose.foundation.layout.Spacer
19+import androidx.compose.foundation.layout.fillMaxWidth
20+import androidx.compose.foundation.layout.height
21+import androidx.compose.foundation.layout.padding
22+import androidx.compose.foundation.layout.size
23+import androidx.compose.foundation.layout.width
24+import androidx.compose.foundation.lazy.LazyColumn
25+import androidx.compose.foundation.shape.RoundedCornerShape
26+import androidx.compose.material.icons.Icons
27+import androidx.compose.material.icons.automirrored.filled.ArrowBack
28+import androidx.compose.material.icons.automirrored.filled.OpenInNew
29+import androidx.compose.material.icons.filled.ExpandLess
30+import androidx.compose.material.icons.filled.ExpandMore
31+import androidx.compose.material.icons.filled.Favorite
32+import androidx.compose.material.icons.filled.FavoriteBorder
33+import androidx.compose.material.icons.filled.Link
34+import androidx.compose.material.icons.filled.Share
35+import androidx.compose.material3.AlertDialog
36+import androidx.compose.material3.HorizontalDivider
37+import androidx.compose.material3.Icon
38+import androidx.compose.material3.IconButton
39+import androidx.compose.material3.MaterialTheme
40+import androidx.compose.material3.Text
41+import androidx.compose.material3.TextButton
42+import androidx.compose.runtime.Composable
43+import androidx.compose.runtime.LaunchedEffect
44+import androidx.compose.runtime.collectAsState
45+import androidx.compose.runtime.getValue
46+import androidx.compose.runtime.mutableStateOf
47+import androidx.compose.runtime.remember
48+import androidx.compose.runtime.setValue
49+import androidx.compose.ui.Alignment
50+import androidx.compose.ui.Modifier
51+import androidx.compose.ui.draw.clip
52+import androidx.compose.ui.graphics.Color
53+import androidx.compose.ui.platform.LocalContext
54+import androidx.compose.ui.text.font.FontWeight
55+import androidx.compose.ui.unit.dp
56+import androidx.compose.ui.unit.sp
57+import androidx.navigation.NavHostController
58+import com.groupeka.ka.core.Ecosystem
59+import com.groupeka.ka.core.FavoritesStore
60+import com.groupeka.ka.core.KAItem
61+import com.groupeka.ka.core.RestoMenuLoader
62+import com.groupeka.ka.core.RestoMenuSection
63+import com.groupeka.ka.ui.theme.KAChip
64+import com.groupeka.ka.ui.theme.KAImage
65+import com.groupeka.ka.ui.theme.KATheme
66+import com.groupeka.ka.ui.theme.LocalKADark
67+import com.groupeka.ka.ui.theme.accent
68+import com.groupeka.ka.ui.theme.kaCard
69+
70+/** Ouvre une URL dans le navigateur. */
71+fun openUrl(context: Context, url: String) {
72+ runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
73+}
74+
75+fun shareUrl(context: Context, url: String, subject: String) {
76+ val intent = Intent(Intent.ACTION_SEND).apply {
77+ type = "text/plain"
78+ putExtra(Intent.EXTRA_SUBJECT, subject)
79+ putExtra(Intent.EXTRA_TEXT, url)
80+ }
81+ runCatching { context.startActivity(Intent.createChooser(intent, subject)) }
82+}
83+
84+@Composable
85+fun ItemDetailScreen(item: KAItem, nav: NavHostController) {
86+ val dark = LocalKADark.current
87+ val context = LocalContext.current
88+ val universe = Ecosystem.universe(item.universeID)
89+ val accent = universe?.accent ?: Color.Gray
90+ val favorites = FavoritesStore.shared
91+ val collections by favorites!!.collections.collectAsState()
92+ val isFav = collections.any { c -> c.items.any { it.id == item.id } }
93+ var showCollections by remember { mutableStateOf(false) }
94+ var menu by remember { mutableStateOf<List<RestoMenuSection>>(emptyList()) }
95+ var expandedSections by remember { mutableStateOf<Set<String>>(emptySet()) }
96+
97+ LaunchedEffect(item.id) {
98+ if (item.universeID == "resto-ka") {
99+ menu = RestoMenuLoader.load(uid = item.id.removePrefix("resto-ka:"))
100+ }
101+ }
102+
103+ LazyColumn(
104+ contentPadding = PaddingValues(16.dp),
105+ verticalArrangement = Arrangement.spacedBy(16.dp),
106+ modifier = Modifier.fillMaxWidth(),
107+ ) {
108+ item {
109+ // barre d'actions : retour, favori, partage
110+ Row(verticalAlignment = Alignment.CenterVertically) {
111+ IconButton(onClick = { nav.popBackStack() }) {
112+ Icon(Icons.AutoMirrored.Filled.ArrowBack, "Retour", tint = KATheme.ink(dark))
113+ }
114+ Spacer(Modifier.weight(1f))
115+ IconButton(onClick = {
116+ if (isFav) favorites.toggle(item)
117+ else if (collections.size > 1) showCollections = true
118+ else favorites.toggle(item)
119+ }) {
120+ Icon(
121+ if (isFav) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
122+ if (isFav) "Retirer des favoris" else "Ajouter aux favoris",
123+ tint = if (isFav) Color.Red else KATheme.ink(dark),
124+ )
125+ }
126+ if (item.url != null) {
127+ IconButton(onClick = { shareUrl(context, item.url!!, item.title) }) {
128+ Icon(Icons.Filled.Share, "Partager", tint = KATheme.ink(dark))
129+ }
130+ }
131+ }
132+ }
133+ if (item.imageURL != null) {
134+ item {
135+ KAImage(
136+ url = item.imageURL, accent = accent, symbol = universe?.symbol ?: "photo",
137+ modifier = Modifier
138+ .fillMaxWidth()
139+ .height(230.dp)
140+ .clip(RoundedCornerShape(16.dp))
141+ .border(1.2.dp, KATheme.ink(dark).copy(alpha = 0.3f), RoundedCornerShape(16.dp)),
142+ )
143+ }
144+ }
145+ item {
146+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
147+ if (universe != null) Row { KAChip(universe.wordmark, universe.accent) }
148+ Text(item.title, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
149+ if (item.subtitle != null) {
150+ Text(item.subtitle!!, style = MaterialTheme.typography.bodyLarge, color = KATheme.ink2(dark))
151+ }
152+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
153+ if (item.priceLabel != null) {
154+ Text(item.priceLabel!!, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = accent)
155+ }
156+ if (item.city != null) KAChip(item.city)
157+ }
158+ }
159+ }
160+
161+ if (!item.detail.isNullOrEmpty()) {
162+ item {
163+ Column(
164+ Modifier.fillMaxWidth().kaCard().padding(14.dp),
165+ verticalArrangement = Arrangement.spacedBy(6.dp),
166+ ) {
167+ Text("À propos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
168+ Text(
169+ item.detail,
170+ style = MaterialTheme.typography.bodyMedium,
171+ color = KATheme.ink2(dark),
172+ maxLines = 12,
173+ )
174+ }
175+ }
176+ }
177+
178+ if (item.facts.isNotEmpty()) {
179+ item {
180+ Column(Modifier.fillMaxWidth().kaCard()) {
181+ item.facts.forEachIndexed { i, f ->
182+ Row(Modifier.padding(vertical = 10.dp, horizontal = 14.dp)) {
183+ Text(f.label, style = MaterialTheme.typography.bodyMedium, color = KATheme.ink2(dark))
184+ Spacer(Modifier.weight(1f))
185+ Text(
186+ f.value,
187+ style = MaterialTheme.typography.bodyMedium,
188+ fontWeight = FontWeight.SemiBold,
189+ color = KATheme.ink(dark),
190+ textAlign = androidx.compose.ui.text.style.TextAlign.End,
191+ modifier = Modifier.weight(1.4f),
192+ )
193+ }
194+ if (i != item.facts.lastIndex) HorizontalDivider(color = KATheme.ink(dark).copy(alpha = 0.1f))
195+ }
196+ }
197+ }
198+ }
199+
200+ if (item.links.isNotEmpty()) {
201+ item {
202+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
203+ Text("Comptes & liens", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
204+ item.links.forEach { l ->
205+ Row(
206+ Modifier
207+ .fillMaxWidth()
208+ .background(accent.copy(alpha = 0.1f), RoundedCornerShape(10.dp))
209+ .clickable { openUrl(context, l.value) }
210+ .padding(11.dp),
211+ verticalAlignment = Alignment.CenterVertically,
212+ ) {
213+ Icon(Icons.Filled.Link, null, tint = accent, modifier = Modifier.size(18.dp))
214+ Spacer(Modifier.width(8.dp))
215+ Text(l.label, fontWeight = FontWeight.SemiBold, color = accent)
216+ Spacer(Modifier.weight(1f))
217+ Icon(Icons.AutoMirrored.Filled.OpenInNew, null, tint = accent, modifier = Modifier.size(14.dp))
218+ }
219+ }
220+ }
221+ }
222+ }
223+
224+ if (menu.isNotEmpty()) {
225+ item {
226+ Row(verticalAlignment = Alignment.CenterVertically) {
227+ Text("Menu & prix réels", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
228+ Spacer(Modifier.weight(1f))
229+ KAChip("${menu.sumOf { it.items.size }} plats", accent)
230+ }
231+ }
232+ menu.take(6).forEach { section ->
233+ item(key = "menu-${section.name}") {
234+ val expanded = expandedSections.contains(section.name)
235+ Column(
236+ Modifier
237+ .fillMaxWidth()
238+ .kaCard()
239+ .clickable {
240+ expandedSections =
241+ if (expanded) expandedSections - section.name else expandedSections + section.name
242+ }
243+ .padding(horizontal = 14.dp, vertical = 8.dp),
244+ ) {
245+ Row(verticalAlignment = Alignment.CenterVertically) {
246+ Text(section.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
247+ Spacer(Modifier.weight(1f))
248+ Icon(
249+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
250+ null, tint = KATheme.ink2(dark),
251+ )
252+ }
253+ if (expanded) {
254+ Spacer(Modifier.height(4.dp))
255+ section.items.forEachIndexed { i, (name, price) ->
256+ Row(Modifier.padding(vertical = 7.dp)) {
257+ Text(name, style = MaterialTheme.typography.bodyMedium, color = KATheme.ink(dark), modifier = Modifier.weight(1f))
258+ if (price != null) {
259+ Text(price, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = accent)
260+ }
261+ }
262+ if (i != section.items.lastIndex) {
263+ HorizontalDivider(color = KATheme.ink(dark).copy(alpha = 0.08f))
264+ }
265+ }
266+ }
267+ }
268+ }
269+ }
270+ }
271+
272+ if (item.url != null) {
273+ item {
274+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
275+ Box(
276+ Modifier
277+ .fillMaxWidth()
278+ .background(accent, RoundedCornerShape(12.dp))
279+ .clickable { openUrl(context, item.url!!) }
280+ .padding(vertical = 14.dp),
281+ contentAlignment = Alignment.Center,
282+ ) {
283+ Row(verticalAlignment = Alignment.CenterVertically) {
284+ Icon(Icons.AutoMirrored.Filled.OpenInNew, null, tint = Color.White, modifier = Modifier.size(18.dp))
285+ Spacer(Modifier.width(8.dp))
286+ Text("Voir à la source", fontWeight = FontWeight.Bold, color = Color.White)
287+ }
288+ }
289+ Text(
290+ "Groupe KA est un agrégateur : la transaction se fait chez la source originale.",
291+ style = MaterialTheme.typography.labelSmall,
292+ color = KATheme.ink2(dark).copy(alpha = 0.7f),
293+ )
294+ }
295+ }
296+ }
297+ }
298+
299+ if (showCollections) {
300+ AlertDialog(
301+ onDismissRequest = { showCollections = false },
302+ title = { Text("Ajouter à…") },
303+ text = {
304+ Column {
305+ collections.forEach { c ->
306+ TextButton(onClick = {
307+ favorites.toggle(item, collectionID = c.id)
308+ showCollections = false
309+ }) { Text(c.name, color = KATheme.green) }
310+ }
311+ }
312+ },
313+ confirmButton = {
314+ TextButton(onClick = { showCollections = false }) { Text("Annuler") }
315+ },
316+ containerColor = KATheme.surface(dark),
317+ )
318+ }
319+}
added app/src/main/java/com/groupeka/ka/ui/OnboardingScreen.kt +204 −0
@@ -0,0 +1,204 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// OnboardingScreen.kt — le « waouh » de la première ouverture : animation du
3+// logo KA (boîte encre qui se redresse, lime qui s'allume), pitch en 3 lignes,
4+// choix des univers favoris, puis c'est parti.
5+package com.groupeka.ka.ui
6+
7+import androidx.compose.animation.core.Spring
8+import androidx.compose.animation.core.animateFloatAsState
9+import androidx.compose.animation.core.spring
10+import androidx.compose.animation.core.tween
11+import androidx.compose.foundation.background
12+import androidx.compose.foundation.border
13+import androidx.compose.foundation.clickable
14+import androidx.compose.foundation.layout.Arrangement
15+import androidx.compose.foundation.layout.Box
16+import androidx.compose.foundation.layout.Column
17+import androidx.compose.foundation.layout.Row
18+import androidx.compose.foundation.layout.Spacer
19+import androidx.compose.foundation.layout.fillMaxSize
20+import androidx.compose.foundation.layout.fillMaxWidth
21+import androidx.compose.foundation.layout.padding
22+import androidx.compose.foundation.layout.size
23+import androidx.compose.foundation.layout.width
24+import androidx.compose.foundation.lazy.grid.GridCells
25+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
26+import androidx.compose.foundation.rememberScrollState
27+import androidx.compose.foundation.shape.RoundedCornerShape
28+import androidx.compose.foundation.verticalScroll
29+import androidx.compose.material.icons.Icons
30+import androidx.compose.material.icons.filled.AutoAwesome
31+import androidx.compose.material.icons.filled.Favorite
32+import androidx.compose.material.icons.filled.Search
33+import androidx.compose.material3.Icon
34+import androidx.compose.material3.MaterialTheme
35+import androidx.compose.material3.Text
36+import androidx.compose.runtime.Composable
37+import androidx.compose.runtime.LaunchedEffect
38+import androidx.compose.runtime.getValue
39+import androidx.compose.runtime.mutableStateOf
40+import androidx.compose.runtime.remember
41+import androidx.compose.runtime.setValue
42+import androidx.compose.ui.Alignment
43+import androidx.compose.ui.Modifier
44+import androidx.compose.ui.draw.rotate
45+import androidx.compose.ui.draw.scale
46+import androidx.compose.ui.draw.shadow
47+import androidx.compose.ui.graphics.Color
48+import androidx.compose.ui.graphics.vector.ImageVector
49+import androidx.compose.ui.text.font.FontWeight
50+import androidx.compose.ui.text.style.TextAlign
51+import androidx.compose.ui.text.style.TextOverflow
52+import androidx.compose.ui.unit.dp
53+import androidx.compose.ui.unit.sp
54+import com.groupeka.ka.core.Ecosystem
55+import com.groupeka.ka.core.Prefs
56+import com.groupeka.ka.ui.theme.KATheme
57+import com.groupeka.ka.ui.theme.LocalKADark
58+import com.groupeka.ka.ui.theme.accent
59+import com.groupeka.ka.ui.theme.universeIcon
60+
61+@Composable
62+fun OnboardingScreen() {
63+ val dark = LocalKADark.current
64+ var step by remember { mutableStateOf(0) }
65+ var started by remember { mutableStateOf(false) }
66+ var picked by remember { mutableStateOf<Set<String>>(emptySet()) }
67+
68+ val rotation by animateFloatAsState(
69+ targetValue = if (started) -4f else -14f,
70+ animationSpec = spring(dampingRatio = 0.55f, stiffness = Spring.StiffnessLow),
71+ label = "rotation",
72+ )
73+ val scale by animateFloatAsState(
74+ targetValue = if (started) 1f else 0.6f,
75+ animationSpec = spring(dampingRatio = 0.55f, stiffness = Spring.StiffnessLow),
76+ label = "scale",
77+ )
78+ val glow by animateFloatAsState(
79+ targetValue = if (started) 1f else 0f,
80+ animationSpec = tween(1100, delayMillis = 500),
81+ label = "glow",
82+ )
83+
84+ LaunchedEffect(Unit) { started = true }
85+
86+ Column(
87+ Modifier
88+ .fillMaxSize()
89+ .verticalScroll(rememberScrollState())
90+ .padding(24.dp),
91+ horizontalAlignment = Alignment.CenterHorizontally,
92+ ) {
93+ Spacer(Modifier.weight(1f))
94+ // — logo animé
95+ Box(
96+ Modifier
97+ .size(128.dp)
98+ .scale(scale)
99+ .rotate(rotation)
100+ .shadow((glow * 24).dp, RoundedCornerShape(30.dp), ambientColor = KATheme.lime, spotColor = KATheme.lime)
101+ .background(KATheme.inkLight, RoundedCornerShape(30.dp)),
102+ contentAlignment = Alignment.Center,
103+ ) {
104+ Text("KA", fontSize = 56.sp, fontWeight = FontWeight.Bold, color = KATheme.lime)
105+ }
106+ Spacer(Modifier.size(18.dp))
107+ Text(
108+ "Mille sites. Un seul KA.",
109+ fontSize = 22.sp,
110+ fontWeight = FontWeight.Bold,
111+ color = KATheme.ink(dark),
112+ textAlign = TextAlign.Center,
113+ )
114+ Spacer(Modifier.weight(1f))
115+
116+ if (step == 0) {
117+ Column(verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
118+ PitchRow(Icons.Filled.Search, "Une recherche, tout le Québec", "Logements, propriétés, autos, emplois, restos, sorties — interrogés d'un coup.")
119+ PitchRow(Icons.Filled.Favorite, "Des favoris qui traversent les univers", "Un 4½, une auto et un resto dans une même collection.")
120+ PitchRow(Icons.Filled.AutoAwesome, "KA Agent, votre IA d'ici", "Posez une question, il fouille les vraies données de l'écosystème.")
121+ }
122+ } else {
123+ Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
124+ Text("Vos univers du quotidien ?", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
125+ Text(
126+ "Ils remonteront en tête de votre accueil (modifiable dans Profil).",
127+ style = MaterialTheme.typography.labelMedium,
128+ color = KATheme.ink2(dark),
129+ )
130+ Ecosystem.all.chunked(3).forEach { rowUniverses ->
131+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
132+ rowUniverses.forEach { u ->
133+ val on = picked.contains(u.id)
134+ Column(
135+ Modifier
136+ .weight(1f)
137+ .background(if (on) u.accent else KATheme.surface(dark), RoundedCornerShape(11.dp))
138+ .border(1.dp, KATheme.ink(dark).copy(alpha = 0.2f), RoundedCornerShape(11.dp))
139+ .clickable { picked = if (on) picked - u.id else picked + u.id }
140+ .padding(vertical = 10.dp),
141+ horizontalAlignment = Alignment.CenterHorizontally,
142+ verticalArrangement = Arrangement.spacedBy(4.dp),
143+ ) {
144+ Icon(universeIcon(u.symbol), null, tint = if (on) Color.White else KATheme.ink(dark), modifier = Modifier.size(18.dp))
145+ Text(
146+ u.name,
147+ fontSize = 10.sp,
148+ fontWeight = FontWeight.Bold,
149+ maxLines = 1,
150+ overflow = TextOverflow.Ellipsis,
151+ color = if (on) Color.White else KATheme.ink(dark),
152+ )
153+ }
154+ }
155+ repeat(3 - rowUniverses.size) { Spacer(Modifier.weight(1f)) }
156+ }
157+ }
158+ }
159+ }
160+
161+ Spacer(Modifier.size(16.dp))
162+ Box(
163+ Modifier
164+ .fillMaxWidth()
165+ .background(KATheme.inkLight, RoundedCornerShape(14.dp))
166+ .clickable {
167+ if (step == 0) step = 1
168+ else {
169+ Prefs.setFavUniversesValue(picked.sorted().joinToString(","))
170+ Prefs.setOnboardedDone()
171+ }
172+ }
173+ .padding(vertical = 15.dp),
174+ contentAlignment = Alignment.Center,
175+ ) {
176+ Text(
177+ if (step == 0) "Continuer" else if (picked.isEmpty()) "Explorer sans choisir" else "C'est parti 🚀",
178+ fontWeight = FontWeight.Bold,
179+ color = KATheme.lime,
180+ )
181+ }
182+ Spacer(Modifier.size(4.dp))
183+ }
184+}
185+
186+@Composable
187+private fun PitchRow(icon: ImageVector, title: String, sub: String) {
188+ val dark = LocalKADark.current
189+ Row {
190+ Box(
191+ Modifier
192+ .size(34.dp)
193+ .background(KATheme.green.copy(alpha = 0.12f), RoundedCornerShape(9.dp)),
194+ contentAlignment = Alignment.Center,
195+ ) {
196+ Icon(icon, null, tint = KATheme.green, modifier = Modifier.size(18.dp))
197+ }
198+ Spacer(Modifier.width(12.dp))
199+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
200+ Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
201+ Text(sub, style = MaterialTheme.typography.labelMedium, color = KATheme.ink2(dark))
202+ }
203+ }
204+}
added app/src/main/java/com/groupeka/ka/ui/ProfileScreen.kt +349 −0
@@ -0,0 +1,349 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// ProfileScreen.kt — profil : KA ID (compte unique de l'écosystème, carte de
3+// membre), univers favoris, apparence, contacts complets du Groupe KA, pages
4+// légales, à propos.
5+package com.groupeka.ka.ui
6+
7+import android.content.Intent
8+import android.net.Uri
9+import androidx.compose.foundation.background
10+import androidx.compose.foundation.border
11+import androidx.compose.foundation.clickable
12+import androidx.compose.foundation.layout.Arrangement
13+import androidx.compose.foundation.layout.Box
14+import androidx.compose.foundation.layout.Column
15+import androidx.compose.foundation.layout.PaddingValues
16+import androidx.compose.foundation.layout.Row
17+import androidx.compose.foundation.layout.Spacer
18+import androidx.compose.foundation.layout.fillMaxWidth
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.shape.CircleShape
24+import androidx.compose.foundation.shape.RoundedCornerShape
25+import androidx.compose.material.icons.Icons
26+import androidx.compose.material.icons.automirrored.filled.Logout
27+import androidx.compose.material.icons.automirrored.filled.OpenInNew
28+import androidx.compose.material.icons.filled.AccountCircle
29+import androidx.compose.material.icons.filled.Language
30+import androidx.compose.material.icons.filled.MonitorHeart
31+import androidx.compose.material.icons.filled.Star
32+import androidx.compose.material.icons.filled.StarBorder
33+import androidx.compose.material3.CircularProgressIndicator
34+import androidx.compose.material3.HorizontalDivider
35+import androidx.compose.material3.Icon
36+import androidx.compose.material3.MaterialTheme
37+import androidx.compose.material3.Text
38+import androidx.compose.runtime.Composable
39+import androidx.compose.runtime.collectAsState
40+import androidx.compose.runtime.getValue
41+import androidx.compose.ui.Alignment
42+import androidx.compose.ui.Modifier
43+import androidx.compose.ui.draw.clip
44+import androidx.compose.ui.draw.rotate
45+import androidx.compose.ui.graphics.Color
46+import androidx.compose.ui.layout.ContentScale
47+import androidx.compose.ui.platform.LocalContext
48+import androidx.compose.ui.text.font.FontFamily
49+import androidx.compose.ui.text.font.FontWeight
50+import androidx.compose.ui.unit.dp
51+import androidx.compose.ui.unit.sp
52+import coil.compose.SubcomposeAsyncImage
53+import com.groupeka.ka.auth.KAIDManager
54+import com.groupeka.ka.auth.KAIDProfile
55+import com.groupeka.ka.core.Ecosystem
56+import com.groupeka.ka.core.Prefs
57+import com.groupeka.ka.ui.theme.KATheme
58+import com.groupeka.ka.ui.theme.LocalKADark
59+import com.groupeka.ka.ui.theme.accent
60+import com.groupeka.ka.ui.theme.colorFromHex
61+import com.groupeka.ka.ui.theme.kaCard
62+import com.groupeka.ka.ui.theme.universeIcon
63+
64+@Composable
65+fun ProfileScreen() {
66+ val dark = LocalKADark.current
67+ val context = LocalContext.current
68+ val profile by KAIDManager.profile.collectAsState()
69+ val busy by KAIDManager.busy.collectAsState()
70+ val lastError by KAIDManager.lastError.collectAsState()
71+ val favIDs = Prefs.favUniverses.split(",").filter { it.isNotEmpty() }.toSet()
72+
73+ LazyColumn(
74+ contentPadding = PaddingValues(16.dp),
75+ verticalArrangement = Arrangement.spacedBy(14.dp),
76+ modifier = Modifier.fillMaxWidth(),
77+ ) {
78+ item { Text("Profil", fontSize = 28.sp, fontWeight = FontWeight.Bold, color = KATheme.ink(dark)) }
79+
80+ // — KA ID
81+ item {
82+ val p = profile
83+ if (p != null) {
84+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
85+ KAIDCard(p)
86+ ProfileLinkRow(Icons.Filled.AccountCircle, "Gérer mon profil sur groupe-ka.com") {
87+ openUrl(context, "${Ecosystem.hubURL}/compte")
88+ }
89+ ProfileLinkRow(Icons.AutoMirrored.Filled.Logout, "Se déconnecter", tint = Color(0xFFC0392B)) {
90+ KAIDManager.logout(context)
91+ }
92+ }
93+ } else {
94+ Column(Modifier.fillMaxWidth().kaCard().padding(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
95+ Row(verticalAlignment = Alignment.CenterVertically) {
96+ Box(
97+ Modifier.size(56.dp).background(KATheme.inkLight, RoundedCornerShape(14.dp)),
98+ contentAlignment = Alignment.Center,
99+ ) {
100+ Text("KA", fontSize = 22.sp, fontWeight = FontWeight.Bold, color = KATheme.lime, modifier = Modifier.rotate(-4f))
101+ }
102+ Spacer(Modifier.width(14.dp))
103+ Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
104+ Text("Votre KA ID", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
105+ Text(
106+ "Un seul compte pour les 13 plateformes de l'écosystème.",
107+ style = MaterialTheme.typography.labelMedium,
108+ color = KATheme.ink2(dark),
109+ )
110+ }
111+ }
112+ Row(
113+ Modifier
114+ .fillMaxWidth()
115+ .background(KATheme.green, RoundedCornerShape(10.dp))
116+ .clickable(enabled = !busy) { KAIDManager.login(context) }
117+ .padding(vertical = 12.dp),
118+ horizontalArrangement = Arrangement.Center,
119+ verticalAlignment = Alignment.CenterVertically,
120+ ) {
121+ if (busy) {
122+ CircularProgressIndicator(Modifier.size(18.dp), color = Color.White)
123+ Spacer(Modifier.width(8.dp))
124+ }
125+ Text("Se connecter avec KA ID", fontWeight = FontWeight.Bold, color = Color.White)
126+ }
127+ Text(
128+ "Créer un compte sur groupe-ka.com →",
129+ style = MaterialTheme.typography.labelMedium,
130+ color = KATheme.green,
131+ fontWeight = FontWeight.SemiBold,
132+ modifier = Modifier.clickable { openUrl(context, Ecosystem.signupURL) },
133+ )
134+ if (lastError != null) {
135+ Text(lastError!!, style = MaterialTheme.typography.labelMedium, color = Color(0xFFC0392B))
136+ }
137+ }
138+ }
139+ }
140+
141+ // — Univers favoris
142+ item {
143+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
144+ Text("Mes univers favoris", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
145+ Text("Vos univers favoris remontent en tête de l'accueil.", style = MaterialTheme.typography.labelMedium, color = KATheme.ink2(dark))
146+ }
147+ }
148+ item {
149+ Column(Modifier.fillMaxWidth().kaCard()) {
150+ Ecosystem.all.forEachIndexed { i, u ->
151+ Row(
152+ Modifier
153+ .fillMaxWidth()
154+ .clickable {
155+ val ids = if (favIDs.contains(u.id)) favIDs - u.id else favIDs + u.id
156+ Prefs.setFavUniversesValue(ids.sorted().joinToString(","))
157+ }
158+ .padding(vertical = 10.dp, horizontal = 14.dp),
159+ verticalAlignment = Alignment.CenterVertically,
160+ ) {
161+ Icon(universeIcon(u.symbol), null, tint = u.accent, modifier = Modifier.size(22.dp))
162+ Spacer(Modifier.width(12.dp))
163+ Text(u.name, color = KATheme.ink(dark))
164+ Spacer(Modifier.weight(1f))
165+ Icon(
166+ if (favIDs.contains(u.id)) Icons.Filled.Star else Icons.Filled.StarBorder,
167+ null,
168+ tint = if (favIDs.contains(u.id)) Color(0xFFF4C20D) else KATheme.ink2(dark).copy(alpha = 0.5f),
169+ modifier = Modifier.size(20.dp),
170+ )
171+ }
172+ if (i != Ecosystem.all.lastIndex) HorizontalDivider(color = KATheme.ink(dark).copy(alpha = 0.08f))
173+ }
174+ }
175+ }
176+
177+ // — Apparence
178+ item {
179+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
180+ Text("Apparence", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
181+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
182+ listOf("auto" to "Automatique", "clair" to "Clair", "sombre" to "Sombre").forEach { (v, label) ->
183+ val on = Prefs.appearance == v
184+ Text(
185+ label,
186+ fontSize = 12.sp,
187+ fontWeight = FontWeight.Bold,
188+ color = if (on) Color.White else KATheme.ink(dark),
189+ modifier = Modifier
190+ .background(if (on) KATheme.green else KATheme.surface(dark), CircleShape)
191+ .border(1.dp, KATheme.ink(dark).copy(alpha = 0.3f), CircleShape)
192+ .clickable { Prefs.setAppearanceValue(v) }
193+ .padding(horizontal = 14.dp, vertical = 8.dp),
194+ )
195+ }
196+ }
197+ }
198+ }
199+
200+ // — Contacts
201+ item {
202+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
203+ Text("Joindre le Groupe KA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
204+ Column(Modifier.fillMaxWidth().kaCard()) {
205+ Ecosystem.contacts.forEachIndexed { i, (email, role) ->
206+ Column(
207+ Modifier
208+ .fillMaxWidth()
209+ .clickable {
210+ runCatching {
211+ context.startActivity(Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$email")))
212+ }
213+ }
214+ .padding(vertical = 10.dp, horizontal = 14.dp),
215+ verticalArrangement = Arrangement.spacedBy(2.dp),
216+ ) {
217+ Text(email, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = KATheme.ink(dark))
218+ Text(role, style = MaterialTheme.typography.labelMedium, color = KATheme.ink2(dark))
219+ }
220+ HorizontalDivider(color = KATheme.ink(dark).copy(alpha = 0.08f))
221+ }
222+ ProfileLinkRow(Icons.Filled.Language, "groupe-ka.com — le portail") { openUrl(context, Ecosystem.hubURL) }
223+ HorizontalDivider(color = KATheme.ink(dark).copy(alpha = 0.08f))
224+ ProfileLinkRow(Icons.Filled.MonitorHeart, "État des services en direct") { openUrl(context, Ecosystem.statusURL) }
225+ }
226+ }
227+ }
228+
229+ // — Légal
230+ item {
231+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
232+ Text("Légal", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
233+ Column(Modifier.fillMaxWidth().kaCard()) {
234+ Ecosystem.legal.forEachIndexed { i, (label, path) ->
235+ Row(
236+ Modifier
237+ .fillMaxWidth()
238+ .clickable { openUrl(context, Ecosystem.hubURL + path) }
239+ .padding(vertical = 12.dp, horizontal = 14.dp),
240+ verticalAlignment = Alignment.CenterVertically,
241+ ) {
242+ Text(label, style = MaterialTheme.typography.bodyMedium, color = KATheme.ink(dark))
243+ Spacer(Modifier.weight(1f))
244+ Icon(Icons.AutoMirrored.Filled.OpenInNew, null, tint = KATheme.ink2(dark), modifier = Modifier.size(14.dp))
245+ }
246+ if (i != Ecosystem.legal.lastIndex) HorizontalDivider(color = KATheme.ink(dark).copy(alpha = 0.08f))
247+ }
248+ }
249+ Text(Ecosystem.disclaimer, style = MaterialTheme.typography.labelSmall, color = KATheme.ink2(dark))
250+ }
251+ }
252+
253+ // — À propos
254+ item {
255+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
256+ Text("À propos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
257+ Text("Version 1.0.0 · Écosystème : ${Ecosystem.all.size} univers", style = MaterialTheme.typography.labelMedium, color = KATheme.ink2(dark))
258+ Text("KA — tout l'écosystème Groupe-KA dans votre poche. Fait au Québec. 💚", style = MaterialTheme.typography.labelMedium, color = KATheme.ink2(dark))
259+ }
260+ }
261+ }
262+}
263+
264+@Composable
265+private fun ProfileLinkRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, tint: Color? = null, onClick: () -> Unit) {
266+ val dark = LocalKADark.current
267+ Row(
268+ Modifier
269+ .fillMaxWidth()
270+ .clickable { onClick() }
271+ .padding(vertical = 12.dp, horizontal = 14.dp),
272+ verticalAlignment = Alignment.CenterVertically,
273+ ) {
274+ Icon(icon, null, tint = tint ?: KATheme.green, modifier = Modifier.size(20.dp))
275+ Spacer(Modifier.width(10.dp))
276+ Text(label, style = MaterialTheme.typography.bodyMedium, color = tint ?: KATheme.ink(dark))
277+ }
278+}
279+
280+// MARK: - Carte de membre Groupe KA
281+
282+@Composable
283+fun KAIDCard(profile: KAIDProfile) {
284+ val paper = colorFromHex("#f5f3ee")
285+ Column(
286+ Modifier
287+ .fillMaxWidth()
288+ .background(KATheme.inkLight, RoundedCornerShape(16.dp))
289+ .border(1.2.dp, KATheme.lime.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
290+ .padding(16.dp),
291+ verticalArrangement = Arrangement.spacedBy(10.dp),
292+ ) {
293+ Row(verticalAlignment = Alignment.CenterVertically) {
294+ Text("Groupe", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = paper)
295+ Spacer(Modifier.width(4.dp))
296+ Text(
297+ "KA",
298+ fontSize = 11.sp,
299+ fontWeight = FontWeight.Bold,
300+ color = KATheme.lime,
301+ modifier = Modifier
302+ .background(paper.copy(alpha = 0.14f), RoundedCornerShape(6.dp))
303+ .padding(horizontal = 6.dp, vertical = 2.dp),
304+ )
305+ Spacer(Modifier.weight(1f))
306+ Text("MEMBRE", fontSize = 9.sp, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, color = KATheme.lime)
307+ }
308+ Row(verticalAlignment = Alignment.CenterVertically) {
309+ SubcomposeAsyncImage(
310+ model = profile.picture,
311+ contentDescription = null,
312+ contentScale = ContentScale.Crop,
313+ loading = { AvatarFallback(profile) },
314+ error = { AvatarFallback(profile) },
315+ modifier = Modifier
316+ .size(54.dp)
317+ .clip(CircleShape)
318+ .border(1.6.dp, KATheme.lime, CircleShape),
319+ )
320+ Spacer(Modifier.width(12.dp))
321+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
322+ Text(profile.name ?: "Membre KA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = paper)
323+ Text(profile.kaID, fontSize = 11.sp, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, color = KATheme.lime)
324+ val r = profile.roleLabel ?: profile.city
325+ if (r != null) Text(r, style = MaterialTheme.typography.labelSmall, color = paper.copy(alpha = 0.6f))
326+ }
327+ }
328+ Text(
329+ "Un seul compte pour les 13 plateformes de l'écosystème.",
330+ style = MaterialTheme.typography.labelSmall,
331+ color = paper.copy(alpha = 0.55f),
332+ )
333+ }
334+}
335+
336+@Composable
337+private fun AvatarFallback(profile: KAIDProfile) {
338+ Box(
339+ Modifier.background(KATheme.lime.copy(alpha = 0.25f)),
340+ contentAlignment = Alignment.Center,
341+ ) {
342+ Text(
343+ (profile.name?.firstOrNull() ?: 'K').toString(),
344+ fontSize = 20.sp,
345+ fontWeight = FontWeight.Bold,
346+ color = KATheme.lime,
347+ )
348+ }
349+}
added app/src/main/java/com/groupeka/ka/ui/SearchScreen.kt +252 −0
@@ -0,0 +1,252 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// SearchScreen.kt — la recherche UNIVERSELLE : une seule barre qui interroge
3+// tous les univers en parallèle (fan-out sur leurs API + web québécois via
4+// Trouve·Ka), résultats groupés par univers, filtres par univers, historique.
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.Column
12+import androidx.compose.foundation.layout.PaddingValues
13+import androidx.compose.foundation.layout.Row
14+import androidx.compose.foundation.layout.Spacer
15+import androidx.compose.foundation.layout.fillMaxWidth
16+import androidx.compose.foundation.layout.padding
17+import androidx.compose.foundation.layout.width
18+import androidx.compose.foundation.lazy.LazyColumn
19+import androidx.compose.foundation.lazy.LazyRow
20+import androidx.compose.foundation.shape.CircleShape
21+import androidx.compose.foundation.text.KeyboardActions
22+import androidx.compose.foundation.text.KeyboardOptions
23+import androidx.compose.material.icons.Icons
24+import androidx.compose.material.icons.filled.History
25+import androidx.compose.material.icons.filled.Search
26+import androidx.compose.material3.CircularProgressIndicator
27+import androidx.compose.material3.Icon
28+import androidx.compose.material3.MaterialTheme
29+import androidx.compose.material3.OutlinedTextField
30+import androidx.compose.material3.OutlinedTextFieldDefaults
31+import androidx.compose.material3.Text
32+import androidx.compose.runtime.Composable
33+import androidx.compose.runtime.getValue
34+import androidx.compose.runtime.mutableStateOf
35+import androidx.compose.runtime.remember
36+import androidx.compose.runtime.rememberCoroutineScope
37+import androidx.compose.runtime.setValue
38+import androidx.compose.ui.Alignment
39+import androidx.compose.ui.Modifier
40+import androidx.compose.ui.graphics.Color
41+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
42+import androidx.compose.ui.text.font.FontFamily
43+import androidx.compose.ui.text.font.FontWeight
44+import androidx.compose.ui.text.input.ImeAction
45+import androidx.compose.ui.unit.dp
46+import androidx.compose.ui.unit.sp
47+import androidx.navigation.NavHostController
48+import com.groupeka.ka.core.Ecosystem
49+import com.groupeka.ka.core.KAItem
50+import com.groupeka.ka.core.Prefs
51+import com.groupeka.ka.core.Universe
52+import com.groupeka.ka.core.UniverseService
53+import com.groupeka.ka.encodeNavArg
54+import com.groupeka.ka.ui.theme.KAEmptyState
55+import com.groupeka.ka.ui.theme.KAItemRow
56+import com.groupeka.ka.ui.theme.KATheme
57+import com.groupeka.ka.ui.theme.KAWordmark
58+import com.groupeka.ka.ui.theme.LocalKADark
59+import com.groupeka.ka.ui.theme.accent
60+import com.groupeka.ka.ui.theme.kaCard
61+import kotlinx.coroutines.async
62+import kotlinx.coroutines.awaitAll
63+import kotlinx.coroutines.coroutineScope
64+import kotlinx.coroutines.launch
65+
66+@Composable
67+fun SearchScreen(nav: NavHostController) {
68+ val dark = LocalKADark.current
69+ val scope = rememberCoroutineScope()
70+ val keyboard = LocalSoftwareKeyboardController.current
71+ var query by remember { mutableStateOf("") }
72+ var submitted by remember { mutableStateOf("") }
73+ var sections by remember { mutableStateOf<List<Pair<Universe, List<KAItem>>>>(emptyList()) }
74+ var searching by remember { mutableStateOf(false) }
75+ var selected by remember { mutableStateOf<Set<String>>(emptySet()) } // filtres univers (vide = tous)
76+
77+ val searchables = remember { Ecosystem.all.filter { it.map != null } }
78+ val history = Prefs.searchHistory.split("\n").filter { it.isNotEmpty() }
79+
80+ suspend fun doSearch(text: String) {
81+ val q = text.trim()
82+ if (q.isEmpty()) return
83+ submitted = q
84+ searching = true
85+ val hist = listOf(q) + history.filter { it != q }
86+ Prefs.setSearchHistoryValue(hist.take(10).joinToString("\n"))
87+ val targets = searchables.filter { selected.isEmpty() || selected.contains(it.id) }
88+ coroutineScope {
89+ val found = targets.map { u ->
90+ async { u to (runCatching { UniverseService.fetch(u, query = q, limit = 8) }.getOrNull() ?: emptyList()) }
91+ }.awaitAll()
92+ // ordre : univers avec le plus de résultats d'abord, web québécois à la fin
93+ sections = found.sortedWith(
94+ compareBy<Pair<Universe, List<KAItem>>> { it.first.id == "trouve-ka" }
95+ .thenByDescending { it.second.size }
96+ )
97+ }
98+ searching = false
99+ }
100+
101+ LazyColumn(
102+ contentPadding = PaddingValues(16.dp),
103+ verticalArrangement = Arrangement.spacedBy(16.dp),
104+ modifier = Modifier.fillMaxWidth(),
105+ ) {
106+ item {
107+ Text("Recherche", fontSize = 28.sp, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
108+ }
109+ item {
110+ OutlinedTextField(
111+ value = query,
112+ onValueChange = { query = it },
113+ placeholder = { Text("appartement Rouyn, resto italien, Corolla…", color = KATheme.ink2(dark)) },
114+ leadingIcon = { Icon(Icons.Filled.Search, null, tint = KATheme.ink2(dark)) },
115+ singleLine = true,
116+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
117+ keyboardActions = KeyboardActions(onSearch = {
118+ keyboard?.hide()
119+ scope.launch { doSearch(query) }
120+ }),
121+ colors = OutlinedTextFieldDefaults.colors(
122+ focusedBorderColor = KATheme.ink(dark),
123+ unfocusedBorderColor = KATheme.ink(dark).copy(alpha = 0.4f),
124+ focusedContainerColor = KATheme.surface(dark),
125+ unfocusedContainerColor = KATheme.surface(dark),
126+ ),
127+ modifier = Modifier.fillMaxWidth(),
128+ )
129+ }
130+ item {
131+ // chips filtres univers
132+ LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
133+ items(searchables.size) { i ->
134+ val u = searchables[i]
135+ val on = selected.isEmpty() || selected.contains(u.id)
136+ Text(
137+ u.wordmark,
138+ fontSize = 11.sp,
139+ fontWeight = FontWeight.Bold,
140+ fontFamily = FontFamily.Monospace,
141+ color = if (on) Color.White else KATheme.ink2(dark),
142+ modifier = Modifier
143+ .background(
144+ if (on) u.accent.copy(alpha = 0.9f) else KATheme.surface(dark),
145+ CircleShape,
146+ )
147+ .border(1.dp, KATheme.ink(dark).copy(alpha = 0.3f), CircleShape)
148+ .clickable {
149+ selected = if (selected.contains(u.id)) selected - u.id else selected + u.id
150+ if (selected.size == searchables.size) selected = emptySet()
151+ if (submitted.isNotEmpty()) scope.launch { doSearch(submitted) }
152+ }
153+ .padding(horizontal = 11.dp, vertical = 7.dp),
154+ )
155+ }
156+ }
157+ }
158+
159+ if (searching) {
160+ item {
161+ Row(
162+ Modifier.fillMaxWidth().padding(24.dp),
163+ horizontalArrangement = Arrangement.Center,
164+ verticalAlignment = Alignment.CenterVertically,
165+ ) {
166+ CircularProgressIndicator(color = KATheme.green)
167+ Spacer(Modifier.width(10.dp))
168+ Text(
169+ "KA interroge ${if (selected.isEmpty()) searchables.size else selected.size} univers…",
170+ color = KATheme.ink2(dark),
171+ )
172+ }
173+ }
174+ } else if (submitted.isEmpty()) {
175+ // suggestions + historique
176+ if (history.isNotEmpty()) {
177+ item { Text("Récemment cherché", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark)) }
178+ history.take(6).forEach { h ->
179+ item(key = "h-$h") {
180+ Row(
181+ Modifier
182+ .fillMaxWidth()
183+ .kaCard()
184+ .clickable { query = h; scope.launch { doSearch(h) } }
185+ .padding(12.dp),
186+ verticalAlignment = Alignment.CenterVertically,
187+ ) {
188+ Icon(Icons.Filled.History, null, tint = KATheme.ink2(dark))
189+ Spacer(Modifier.width(10.dp))
190+ Text(h, color = KATheme.ink(dark))
191+ }
192+ }
193+ }
194+ }
195+ item { Text("Idées", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark)) }
196+ listOf("4½ à Québec", "resto italien Montréal", "Toyota Corolla", "emploi infirmière", "spectacle ce week-end").forEach { s ->
197+ item(key = "s-$s") {
198+ Row(
199+ Modifier
200+ .fillMaxWidth()
201+ .kaCard()
202+ .clickable { query = s; scope.launch { doSearch(s) } }
203+ .padding(12.dp),
204+ verticalAlignment = Alignment.CenterVertically,
205+ ) {
206+ Icon(Icons.Filled.Search, null, tint = KATheme.green)
207+ Spacer(Modifier.width(10.dp))
208+ Text(s, color = KATheme.ink(dark))
209+ }
210+ }
211+ }
212+ } else if (sections.all { it.second.isEmpty() }) {
213+ item {
214+ KAEmptyState(
215+ Icons.Filled.Search,
216+ "Rien trouvé pour « $submitted »",
217+ "Essayez un autre mot, ou élargissez les univers filtrés.",
218+ )
219+ }
220+ } else {
221+ sections.filter { it.second.isNotEmpty() }.forEach { (u, items) ->
222+ item(key = "sec-${u.id}") {
223+ Row(verticalAlignment = Alignment.CenterVertically) {
224+ KAWordmark(u, size = 16f)
225+ Spacer(Modifier.weight(1f))
226+ Text(
227+ "${items.size}",
228+ fontFamily = FontFamily.Monospace,
229+ fontWeight = FontWeight.Bold,
230+ fontSize = 12.sp,
231+ color = u.accent,
232+ )
233+ }
234+ }
235+ items.take(5).forEach { item ->
236+ item(key = "it-${item.id}") {
237+ KAItemRow(item, Modifier.clickable { nav.navigate("item/${encodeNavArg(item)}") })
238+ }
239+ }
240+ item(key = "more-${u.id}") {
241+ Text(
242+ "Tout voir dans ${u.name} →",
243+ style = MaterialTheme.typography.bodyMedium,
244+ fontWeight = FontWeight.SemiBold,
245+ color = u.accent,
246+ modifier = Modifier.clickable { nav.navigate("universe/${u.id}") },
247+ )
248+ }
249+ }
250+ }
251+ }
252+}
added app/src/main/java/com/groupeka/ka/ui/UniversesScreen.kt +560 −0
@@ -0,0 +1,560 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// UniversesScreen.kt — la grille des 12 univers (cartes accent + compteur en
3+// direct) et l'explorateur générique d'un univers (héros tagline+compteur,
4+// FilterBar métier + feuille, liste avec vignettes, squelettes).
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.padding
19+import androidx.compose.foundation.layout.size
20+import androidx.compose.foundation.layout.width
21+import androidx.compose.foundation.lazy.LazyColumn
22+import androidx.compose.foundation.lazy.LazyRow
23+import androidx.compose.foundation.lazy.grid.GridCells
24+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
25+import androidx.compose.foundation.rememberScrollState
26+import androidx.compose.foundation.shape.CircleShape
27+import androidx.compose.foundation.shape.RoundedCornerShape
28+import androidx.compose.foundation.text.KeyboardActions
29+import androidx.compose.foundation.text.KeyboardOptions
30+import androidx.compose.foundation.verticalScroll
31+import androidx.compose.material.icons.Icons
32+import androidx.compose.material.icons.filled.Add
33+import androidx.compose.material.icons.filled.ChevronRight
34+import androidx.compose.material.icons.filled.FilterList
35+import androidx.compose.material.icons.filled.Inbox
36+import androidx.compose.material.icons.filled.OpenInNew
37+import androidx.compose.material.icons.filled.Remove
38+import androidx.compose.material.icons.filled.Search
39+import androidx.compose.material.icons.filled.SignalWifiOff
40+import androidx.compose.material3.DropdownMenu
41+import androidx.compose.material3.DropdownMenuItem
42+import androidx.compose.material3.ExperimentalMaterial3Api
43+import androidx.compose.material3.Icon
44+import androidx.compose.material3.IconButton
45+import androidx.compose.material3.MaterialTheme
46+import androidx.compose.material3.ModalBottomSheet
47+import androidx.compose.material3.OutlinedTextField
48+import androidx.compose.material3.OutlinedTextFieldDefaults
49+import androidx.compose.material3.Switch
50+import androidx.compose.material3.Text
51+import androidx.compose.material3.TextButton
52+import androidx.compose.runtime.Composable
53+import androidx.compose.runtime.LaunchedEffect
54+import androidx.compose.runtime.getValue
55+import androidx.compose.runtime.mutableStateOf
56+import androidx.compose.runtime.remember
57+import androidx.compose.runtime.rememberCoroutineScope
58+import androidx.compose.runtime.setValue
59+import androidx.compose.ui.Alignment
60+import androidx.compose.ui.Modifier
61+import androidx.compose.ui.graphics.Color
62+import androidx.compose.ui.platform.LocalContext
63+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
64+import androidx.compose.ui.text.font.FontFamily
65+import androidx.compose.ui.text.font.FontWeight
66+import androidx.compose.ui.text.input.ImeAction
67+import androidx.compose.ui.text.style.TextOverflow
68+import androidx.compose.ui.unit.dp
69+import androidx.compose.ui.unit.sp
70+import androidx.navigation.NavHostController
71+import com.groupeka.ka.core.Ecosystem
72+import com.groupeka.ka.core.FilterCatalog
73+import com.groupeka.ka.core.KAFilter
74+import com.groupeka.ka.core.KAItem
75+import com.groupeka.ka.core.Universe
76+import com.groupeka.ka.core.UniverseService
77+import com.groupeka.ka.core.frFormatted
78+import com.groupeka.ka.core.money0
79+import com.groupeka.ka.encodeNavArg
80+import com.groupeka.ka.ui.theme.KAEmptyState
81+import com.groupeka.ka.ui.theme.KAItemRow
82+import com.groupeka.ka.ui.theme.KASkeletonRow
83+import com.groupeka.ka.ui.theme.KATheme
84+import com.groupeka.ka.ui.theme.KAWordmark
85+import com.groupeka.ka.ui.theme.LocalKADark
86+import com.groupeka.ka.ui.theme.accent
87+import com.groupeka.ka.ui.theme.kaCard
88+import com.groupeka.ka.ui.theme.universeIcon
89+import com.groupeka.ka.ui.openUrl
90+import kotlinx.coroutines.async
91+import kotlinx.coroutines.awaitAll
92+import kotlinx.coroutines.coroutineScope
93+import kotlinx.coroutines.launch
94+import java.io.IOException
95+
96+// MARK: - Grille des univers
97+
98+@Composable
99+fun UniversesScreen(nav: NavHostController) {
100+ val dark = LocalKADark.current
101+ var totals by remember { mutableStateOf<Map<String, Int>>(emptyMap()) }
102+
103+ LaunchedEffect(Unit) {
104+ coroutineScope {
105+ val results = Ecosystem.all.map { u -> async { u.id to UniverseService.liveTotal(u) } }.awaitAll()
106+ totals = results.mapNotNull { (id, n) -> n?.let { id to it } }.toMap()
107+ }
108+ }
109+
110+ LazyVerticalGrid(
111+ columns = GridCells.Adaptive(160.dp),
112+ contentPadding = PaddingValues(16.dp),
113+ horizontalArrangement = Arrangement.spacedBy(14.dp),
114+ verticalArrangement = Arrangement.spacedBy(14.dp),
115+ modifier = Modifier.fillMaxSize(),
116+ ) {
117+ item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
118+ Text("Univers", fontSize = 28.sp, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
119+ }
120+ items(Ecosystem.all.size) { i ->
121+ val u = Ecosystem.all[i]
122+ UniverseCard(u, totals[u.id], Modifier.clickable { nav.navigate("universe/${u.id}") })
123+ }
124+ }
125+}
126+
127+@Composable
128+private fun UniverseCard(universe: Universe, total: Int?, modifier: Modifier = Modifier) {
129+ val dark = LocalKADark.current
130+ Column(
131+ modifier
132+ .kaCard(universe.accent)
133+ .padding(14.dp),
134+ verticalArrangement = Arrangement.spacedBy(10.dp),
135+ ) {
136+ Row(verticalAlignment = Alignment.CenterVertically) {
137+ Box(
138+ Modifier
139+ .size(40.dp)
140+ .background(universe.accent.copy(alpha = 0.14f), RoundedCornerShape(11.dp)),
141+ contentAlignment = Alignment.Center,
142+ ) {
143+ Icon(universeIcon(universe.symbol), null, tint = universe.accent)
144+ }
145+ Spacer(Modifier.weight(1f))
146+ Icon(Icons.Filled.ChevronRight, null, tint = KATheme.ink2(dark), modifier = Modifier.size(16.dp))
147+ }
148+ KAWordmark(universe, size = 17f)
149+ Text(
150+ universe.tagline,
151+ style = MaterialTheme.typography.labelMedium,
152+ color = KATheme.ink2(dark),
153+ maxLines = 2, minLines = 2, overflow = TextOverflow.Ellipsis,
154+ )
155+ if (total != null) {
156+ Text(
157+ "${total.frFormatted} ${universe.unit}",
158+ fontFamily = FontFamily.Monospace,
159+ fontSize = 10.sp,
160+ fontWeight = FontWeight.Bold,
161+ color = universe.accent,
162+ )
163+ } else {
164+ Text("—", fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = KATheme.ink2(dark))
165+ }
166+ }
167+}
168+
169+// MARK: - Explorateur d'un univers
170+
171+@Composable
172+fun UniverseHomeScreen(universe: Universe, nav: NavHostController) {
173+ when {
174+ universe.id == "vrai-prix" -> VraiPrixScreen(universe, nav)
175+ universe.listPath == null -> WebUniverse(universe)
176+ else -> UniverseList(universe, nav)
177+ }
178+}
179+
180+@Composable
181+private fun WebUniverse(universe: Universe) {
182+ val context = LocalContext.current
183+ val dark = LocalKADark.current
184+ Column(
185+ Modifier.fillMaxSize().padding(30.dp),
186+ horizontalAlignment = Alignment.CenterHorizontally,
187+ verticalArrangement = Arrangement.spacedBy(18.dp, Alignment.CenterVertically),
188+ ) {
189+ Icon(universeIcon(universe.symbol), null, tint = universe.accent, modifier = Modifier.size(52.dp))
190+ KAWordmark(universe, size = 30f)
191+ Text(
192+ universe.tagline,
193+ style = MaterialTheme.typography.bodyMedium,
194+ color = KATheme.ink2(dark),
195+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
196+ )
197+ Row(
198+ Modifier
199+ .background(universe.accent, CircleShape)
200+ .clickable { openUrl(context, universe.baseURL) }
201+ .padding(horizontal = 22.dp, vertical = 13.dp),
202+ verticalAlignment = Alignment.CenterVertically,
203+ ) {
204+ Icon(Icons.Filled.OpenInNew, null, tint = Color.White, modifier = Modifier.size(18.dp))
205+ Spacer(Modifier.width(8.dp))
206+ Text("Ouvrir ${universe.name}", fontWeight = FontWeight.Bold, color = Color.White)
207+ }
208+ }
209+}
210+
211+@OptIn(ExperimentalMaterial3Api::class)
212+@Composable
213+private fun UniverseList(universe: Universe, nav: NavHostController) {
214+ val dark = LocalKADark.current
215+ val context = LocalContext.current
216+ val scope = rememberCoroutineScope()
217+ val keyboard = LocalSoftwareKeyboardController.current
218+ var items by remember { mutableStateOf<List<KAItem>>(emptyList()) }
219+ var query by remember { mutableStateOf("") }
220+ var loading by remember { mutableStateOf(true) }
221+ var errorText by remember { mutableStateOf<String?>(null) }
222+ var filterParams by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
223+ var liveTotal by remember { mutableStateOf<Int?>(null) }
224+ var showSheet by remember { mutableStateOf(false) }
225+
226+ suspend fun load() {
227+ loading = items.isEmpty()
228+ errorText = null
229+ try {
230+ items = UniverseService.fetch(
231+ universe,
232+ query = query.ifEmpty { null },
233+ limit = 40,
234+ params = filterParams,
235+ )
236+ } catch (e: Exception) {
237+ errorText = if (e is IOException) "Vous êtes hors ligne." else "Le service ne répond pas."
238+ }
239+ loading = false
240+ }
241+
242+ LaunchedEffect(universe.id) {
243+ load()
244+ liveTotal = UniverseService.liveTotal(universe)
245+ }
246+
247+ LazyColumn(
248+ contentPadding = PaddingValues(16.dp),
249+ verticalArrangement = Arrangement.spacedBy(12.dp),
250+ modifier = Modifier.fillMaxSize(),
251+ ) {
252+ item {
253+ // barre titre + lien vers le site
254+ Row(verticalAlignment = Alignment.CenterVertically) {
255+ KAWordmark(universe, size = 22f)
256+ Spacer(Modifier.weight(1f))
257+ IconButton(onClick = { openUrl(context, universe.baseURL) }) {
258+ Icon(Icons.Filled.OpenInNew, "Ouvrir le site ${universe.name}", tint = universe.accent)
259+ }
260+ }
261+ }
262+ item {
263+ // héros : tagline + compteur live
264+ Row(verticalAlignment = Alignment.CenterVertically) {
265+ Text(
266+ universe.tagline,
267+ style = MaterialTheme.typography.bodyMedium,
268+ fontWeight = FontWeight.Bold,
269+ color = KATheme.ink(dark),
270+ modifier = Modifier.weight(1f),
271+ )
272+ if (liveTotal != null) {
273+ Text(
274+ "${liveTotal!!.frFormatted} ${universe.unit}",
275+ fontFamily = FontFamily.Monospace,
276+ fontSize = 10.sp,
277+ fontWeight = FontWeight.Bold,
278+ color = universe.accent,
279+ modifier = Modifier
280+ .background(universe.accent.copy(alpha = 0.15f), CircleShape)
281+ .padding(horizontal = 9.dp, vertical = 4.dp),
282+ )
283+ }
284+ }
285+ }
286+ item {
287+ OutlinedTextField(
288+ value = query,
289+ onValueChange = { query = it },
290+ placeholder = { Text("Chercher dans ${universe.name}…", color = KATheme.ink2(dark)) },
291+ leadingIcon = { Icon(Icons.Filled.Search, null, tint = KATheme.ink2(dark)) },
292+ singleLine = true,
293+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
294+ keyboardActions = KeyboardActions(onSearch = {
295+ keyboard?.hide()
296+ scope.launch { load() }
297+ }),
298+ colors = OutlinedTextFieldDefaults.colors(
299+ focusedBorderColor = universe.accent,
300+ unfocusedBorderColor = KATheme.ink(dark).copy(alpha = 0.4f),
301+ focusedContainerColor = KATheme.surface(dark),
302+ unfocusedContainerColor = KATheme.surface(dark),
303+ ),
304+ modifier = Modifier.fillMaxWidth(),
305+ )
306+ }
307+ item {
308+ FilterBar(
309+ universe = universe,
310+ params = filterParams,
311+ onParams = { filterParams = it; scope.launch { load() } },
312+ onOpenSheet = { showSheet = true },
313+ )
314+ }
315+
316+ if (loading) {
317+ items(6) { KASkeletonRow() }
318+ } else if (errorText != null && items.isEmpty()) {
319+ item {
320+ KAEmptyState(Icons.Filled.SignalWifiOff, "Impossible de charger", errorText + "\nRéessayez plus tard.")
321+ }
322+ } else if (items.isEmpty()) {
323+ item {
324+ KAEmptyState(Icons.Filled.Inbox, "Aucun résultat", "Essayez d'autres mots-clés.")
325+ }
326+ } else {
327+ items.forEach { it ->
328+ item(key = it.id) {
329+ KAItemRow(it, Modifier.clickable { nav.navigate("item/${encodeNavArg(it)}") })
330+ }
331+ }
332+ }
333+ }
334+
335+ if (showSheet) {
336+ ModalBottomSheet(
337+ onDismissRequest = { showSheet = false },
338+ containerColor = KATheme.paper(dark),
339+ ) {
340+ FilterSheet(
341+ universe = universe,
342+ params = filterParams,
343+ onApply = { p ->
344+ filterParams = p
345+ showSheet = false
346+ scope.launch { load() }
347+ },
348+ )
349+ }
350+ }
351+}
352+
353+// MARK: - Barre + feuille de filtres métier
354+
355+@Composable
356+fun FilterBar(
357+ universe: Universe,
358+ params: Map<String, String>,
359+ onParams: (Map<String, String>) -> Unit,
360+ onOpenSheet: () -> Unit,
361+) {
362+ val dark = LocalKADark.current
363+ val filters = remember(universe.id) { FilterCatalog.filters(universe.id) }
364+ if (filters.isEmpty()) return
365+ val activeCount = params.size
366+
367+ LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
368+ item {
369+ Row(
370+ Modifier
371+ .background(if (activeCount > 0) universe.accent else KATheme.surface(dark), CircleShape)
372+ .border(1.dp, KATheme.ink(dark).copy(alpha = 0.3f), CircleShape)
373+ .clickable { onOpenSheet() }
374+ .padding(horizontal = 12.dp, vertical = 9.dp),
375+ verticalAlignment = Alignment.CenterVertically,
376+ ) {
377+ Icon(
378+ Icons.Filled.FilterList, null,
379+ tint = if (activeCount > 0) Color.White else KATheme.ink(dark),
380+ modifier = Modifier.size(14.dp),
381+ )
382+ Spacer(Modifier.width(5.dp))
383+ Text(
384+ if (activeCount > 0) "Filtres · $activeCount" else "Filtres",
385+ fontSize = 11.sp,
386+ fontWeight = FontWeight.Bold,
387+ fontFamily = FontFamily.Monospace,
388+ color = if (activeCount > 0) Color.White else KATheme.ink(dark),
389+ )
390+ }
391+ }
392+ // interrupteurs directement dans la barre
393+ filters.filter { it.kind is KAFilter.Kind.Toggle }.forEach { f ->
394+ val value = (f.kind as KAFilter.Kind.Toggle).value
395+ item(key = f.id) {
396+ val on = params[f.id] == value
397+ Text(
398+ f.label,
399+ fontSize = 11.sp,
400+ fontWeight = FontWeight.Bold,
401+ fontFamily = FontFamily.Monospace,
402+ color = if (on) Color.White else KATheme.ink(dark),
403+ modifier = Modifier
404+ .background(if (on) universe.accent else KATheme.surface(dark), CircleShape)
405+ .border(1.dp, KATheme.ink(dark).copy(alpha = 0.3f), CircleShape)
406+ .clickable {
407+ onParams(if (on) params - f.id else params + (f.id to value))
408+ }
409+ .padding(horizontal = 12.dp, vertical = 9.dp),
410+ )
411+ }
412+ }
413+ if (activeCount > 0) {
414+ item {
415+ Text(
416+ "✕ Effacer",
417+ fontSize = 10.sp,
418+ fontWeight = FontWeight.Bold,
419+ fontFamily = FontFamily.Monospace,
420+ color = KATheme.ink2(dark),
421+ modifier = Modifier
422+ .clickable { onParams(emptyMap()) }
423+ .padding(horizontal = 10.dp, vertical = 9.dp),
424+ )
425+ }
426+ }
427+ }
428+}
429+
430+@Composable
431+private fun FilterSheet(
432+ universe: Universe,
433+ params: Map<String, String>,
434+ onApply: (Map<String, String>) -> Unit,
435+) {
436+ val dark = LocalKADark.current
437+ var local by remember { mutableStateOf(params) }
438+ val filters = remember(universe.id) { FilterCatalog.filters(universe.id) }
439+
440+ Column(
441+ Modifier
442+ .fillMaxWidth()
443+ .verticalScroll(rememberScrollState())
444+ .padding(horizontal = 20.dp)
445+ .padding(bottom = 30.dp),
446+ verticalArrangement = Arrangement.spacedBy(14.dp),
447+ ) {
448+ Row(verticalAlignment = Alignment.CenterVertically) {
449+ TextButton(onClick = { local = emptyMap() }, enabled = local.isNotEmpty()) {
450+ Text("Effacer", color = KATheme.ink2(dark))
451+ }
452+ Spacer(Modifier.weight(1f))
453+ Text(
454+ "Filtres ${universe.name}",
455+ style = MaterialTheme.typography.titleMedium,
456+ fontWeight = FontWeight.Bold,
457+ color = KATheme.ink(dark),
458+ )
459+ Spacer(Modifier.weight(1f))
460+ TextButton(onClick = { onApply(local) }) {
461+ Text("Appliquer", fontWeight = FontWeight.Bold, color = universe.accent)
462+ }
463+ }
464+
465+ filters.forEach { f ->
466+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
467+ Text(f.label, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
468+ when (val kind = f.kind) {
469+ is KAFilter.Kind.Text -> {
470+ OutlinedTextField(
471+ value = local[f.id] ?: "",
472+ onValueChange = { v ->
473+ local = if (v.isEmpty()) local - f.id else local + (f.id to v)
474+ },
475+ placeholder = { Text(kind.placeholder, color = KATheme.ink2(dark)) },
476+ singleLine = true,
477+ colors = OutlinedTextFieldDefaults.colors(
478+ focusedBorderColor = universe.accent,
479+ unfocusedBorderColor = KATheme.ink(dark).copy(alpha = 0.4f),
480+ ),
481+ modifier = Modifier.fillMaxWidth(),
482+ )
483+ }
484+ is KAFilter.Kind.Options -> {
485+ var expanded by remember { mutableStateOf(false) }
486+ Box {
487+ Text(
488+ local[f.id] ?: "Tous",
489+ modifier = Modifier
490+ .fillMaxWidth()
491+ .border(1.dp, KATheme.ink(dark).copy(alpha = 0.4f), RoundedCornerShape(8.dp))
492+ .clickable { expanded = true }
493+ .padding(12.dp),
494+ color = KATheme.ink(dark),
495+ )
496+ DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
497+ DropdownMenuItem(text = { Text("Tous") }, onClick = {
498+ local = local - f.id; expanded = false
499+ })
500+ kind.options.forEach { opt ->
501+ DropdownMenuItem(text = { Text(opt) }, onClick = {
502+ local = local + (f.id to opt); expanded = false
503+ })
504+ }
505+ }
506+ }
507+ }
508+ is KAFilter.Kind.MinMax -> {
509+ MinMaxRow("Min", kind.minParam, kind, local) { local = it }
510+ MinMaxRow("Max", kind.maxParam, kind, local) { local = it }
511+ if (local.containsKey(kind.minParam) || local.containsKey(kind.maxParam)) {
512+ TextButton(onClick = { local = local - kind.minParam - kind.maxParam }) {
513+ Text("Réinitialiser ${f.label.lowercase()}", fontSize = 12.sp, color = KATheme.ink2(dark))
514+ }
515+ }
516+ }
517+ is KAFilter.Kind.Toggle -> {
518+ Row(verticalAlignment = Alignment.CenterVertically) {
519+ Text(f.label, modifier = Modifier.weight(1f), color = KATheme.ink(dark))
520+ Switch(
521+ checked = local[f.id] == kind.value,
522+ onCheckedChange = { on ->
523+ local = if (on) local + (f.id to kind.value) else local - f.id
524+ },
525+ )
526+ }
527+ }
528+ }
529+ }
530+ }
531+ }
532+}
533+
534+@Composable
535+private fun MinMaxRow(
536+ label: String,
537+ param: String,
538+ kind: KAFilter.Kind.MinMax,
539+ local: Map<String, String>,
540+ onChange: (Map<String, String>) -> Unit,
541+) {
542+ val dark = LocalKADark.current
543+ val default = if (label == "Min") kind.min else kind.max
544+ val current = local[param]?.toDoubleOrNull()
545+ val display = current?.let { v ->
546+ if (kind.unit == "$") v.money0 else "${v.toInt()}${if (kind.unit.isEmpty()) "" else " ${kind.unit}"}"
547+ } ?: "—"
548+ Row(verticalAlignment = Alignment.CenterVertically) {
549+ Text(label, modifier = Modifier.width(48.dp), color = KATheme.ink2(dark))
550+ Text(display, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), color = KATheme.ink(dark))
551+ IconButton(onClick = {
552+ val v = ((current ?: default) - kind.step).coerceIn(kind.min, kind.max)
553+ onChange(local + (param to v.toInt().toString()))
554+ }) { Icon(Icons.Filled.Remove, "Moins", tint = KATheme.ink(dark)) }
555+ IconButton(onClick = {
556+ val v = ((current ?: default) + kind.step).coerceIn(kind.min, kind.max)
557+ onChange(local + (param to v.toInt().toString()))
558+ }) { Icon(Icons.Filled.Add, "Plus", tint = KATheme.ink(dark)) }
559+ }
560+}
added app/src/main/java/com/groupeka/ka/ui/VraiPrixScreen.kt +369 −0
@@ -0,0 +1,369 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// VraiPrixScreen.kt — l'expérience Vrai-Prix NATIVE, fidèle au site :
3+// recherche d'adresse (FTS 3,7 M unités) → fiche d'estimation complète
4+// (estimation, fourchette basse-haute, indice de confiance A-D, valeur au
5+// rôle, historique 2021-2026 en barres, portrait de la propriété).
6+package com.groupeka.ka.ui
7+
8+import androidx.compose.foundation.background
9+import androidx.compose.foundation.border
10+import androidx.compose.foundation.clickable
11+import androidx.compose.foundation.layout.Arrangement
12+import androidx.compose.foundation.layout.Box
13+import androidx.compose.foundation.layout.Column
14+import androidx.compose.foundation.layout.PaddingValues
15+import androidx.compose.foundation.layout.Row
16+import androidx.compose.foundation.layout.Spacer
17+import androidx.compose.foundation.layout.fillMaxWidth
18+import androidx.compose.foundation.layout.height
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.shape.CircleShape
24+import androidx.compose.foundation.shape.RoundedCornerShape
25+import androidx.compose.foundation.text.KeyboardActions
26+import androidx.compose.foundation.text.KeyboardOptions
27+import androidx.compose.material.icons.Icons
28+import androidx.compose.material.icons.filled.Description
29+import androidx.compose.material.icons.filled.HomeWork
30+import androidx.compose.material.icons.filled.Search
31+import androidx.compose.material.icons.filled.SignalWifiOff
32+import androidx.compose.material3.CircularProgressIndicator
33+import androidx.compose.material3.HorizontalDivider
34+import androidx.compose.material3.Icon
35+import androidx.compose.material3.MaterialTheme
36+import androidx.compose.material3.OutlinedTextField
37+import androidx.compose.material3.OutlinedTextFieldDefaults
38+import androidx.compose.material3.Text
39+import androidx.compose.runtime.Composable
40+import androidx.compose.runtime.LaunchedEffect
41+import androidx.compose.runtime.getValue
42+import androidx.compose.runtime.mutableStateOf
43+import androidx.compose.runtime.remember
44+import androidx.compose.runtime.rememberCoroutineScope
45+import androidx.compose.runtime.setValue
46+import androidx.compose.ui.Alignment
47+import androidx.compose.ui.Modifier
48+import androidx.compose.ui.graphics.Color
49+import androidx.compose.ui.platform.LocalContext
50+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
51+import androidx.compose.ui.text.font.FontFamily
52+import androidx.compose.ui.text.font.FontWeight
53+import androidx.compose.ui.text.input.ImeAction
54+import androidx.compose.ui.unit.dp
55+import androidx.compose.ui.unit.sp
56+import androidx.navigation.NavHostController
57+import com.groupeka.ka.core.Universe
58+import com.groupeka.ka.core.VPResult
59+import com.groupeka.ka.core.VraiPrixService
60+import com.groupeka.ka.core.compact
61+import com.groupeka.ka.core.money0
62+import com.groupeka.ka.core.num
63+import com.groupeka.ka.core.str
64+import com.groupeka.ka.encodeNavArg
65+import com.groupeka.ka.ui.theme.KAChip
66+import com.groupeka.ka.ui.theme.KAEmptyState
67+import com.groupeka.ka.ui.theme.KATheme
68+import com.groupeka.ka.ui.theme.LocalKADark
69+import com.groupeka.ka.ui.theme.accent
70+import com.groupeka.ka.ui.theme.colorFromHex
71+import com.groupeka.ka.ui.theme.kaCard
72+import kotlinx.coroutines.launch
73+import kotlinx.serialization.json.JsonObject
74+
75+@Composable
76+fun VraiPrixScreen(universe: Universe, nav: NavHostController) {
77+ val dark = LocalKADark.current
78+ val scope = rememberCoroutineScope()
79+ val keyboard = LocalSoftwareKeyboardController.current
80+ var query by remember { mutableStateOf("") }
81+ var results by remember { mutableStateOf<List<VPResult>>(emptyList()) }
82+ var searching by remember { mutableStateOf(false) }
83+
84+ suspend fun search() {
85+ val q = query.trim()
86+ if (q.length <= 2) return
87+ searching = true
88+ results = VraiPrixService.search(q)
89+ searching = false
90+ }
91+
92+ LazyColumn(
93+ contentPadding = PaddingValues(16.dp),
94+ verticalArrangement = Arrangement.spacedBy(16.dp),
95+ modifier = Modifier.fillMaxWidth(),
96+ ) {
97+ item {
98+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
99+ Text(
100+ "LA VALEUR RÉELLE · 3 747 008 PROPRIÉTÉS",
101+ fontSize = 10.sp,
102+ fontFamily = FontFamily.Monospace,
103+ fontWeight = FontWeight.Bold,
104+ color = universe.accent,
105+ )
106+ Text(
107+ "Estimez n'importe quelle adresse du Québec",
108+ style = MaterialTheme.typography.titleLarge,
109+ fontWeight = FontWeight.Bold,
110+ color = KATheme.ink(dark),
111+ )
112+ }
113+ }
114+ item {
115+ OutlinedTextField(
116+ value = query,
117+ onValueChange = { query = it },
118+ placeholder = { Text("1305 Chemin Sainte-Foy, Québec…", color = KATheme.ink2(dark)) },
119+ leadingIcon = { Icon(Icons.Filled.Search, null, tint = KATheme.ink2(dark)) },
120+ trailingIcon = { if (searching) CircularProgressIndicator(Modifier.size(20.dp), color = universe.accent) },
121+ singleLine = true,
122+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
123+ keyboardActions = KeyboardActions(onSearch = {
124+ keyboard?.hide()
125+ scope.launch { search() }
126+ }),
127+ colors = OutlinedTextFieldDefaults.colors(
128+ focusedBorderColor = universe.accent,
129+ unfocusedBorderColor = KATheme.ink(dark).copy(alpha = 0.4f),
130+ focusedContainerColor = KATheme.surface(dark),
131+ unfocusedContainerColor = KATheme.surface(dark),
132+ ),
133+ modifier = Modifier.fillMaxWidth(),
134+ )
135+ }
136+ if (results.isEmpty() && query.isNotEmpty() && !searching) {
137+ item {
138+ KAEmptyState(Icons.Filled.HomeWork, "Adresse introuvable", "Essayez numéro + rue + ville.")
139+ }
140+ }
141+ results.forEach { r ->
142+ item(key = r.id) {
143+ Column(
144+ Modifier
145+ .fillMaxWidth()
146+ .kaCard(universe.accent)
147+ .clickable { nav.navigate("estimate/${encodeNavArg(r)}") }
148+ .padding(12.dp),
149+ verticalArrangement = Arrangement.spacedBy(3.dp),
150+ ) {
151+ Text(r.adresse, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = KATheme.ink(dark))
152+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
153+ Text(r.municipalite, style = MaterialTheme.typography.labelMedium, color = KATheme.ink2(dark))
154+ if (r.type != null) KAChip(r.type, universe.accent)
155+ }
156+ }
157+ }
158+ }
159+ }
160+}
161+
162+// MARK: - Fiche d'estimation
163+
164+@Composable
165+fun EstimateScreen(result: VPResult) {
166+ val dark = LocalKADark.current
167+ val context = LocalContext.current
168+ val universe = com.groupeka.ka.core.Ecosystem.universe("vrai-prix")!!
169+ var unit by remember { mutableStateOf<JsonObject?>(null) }
170+ var res by remember { mutableStateOf<JsonObject?>(null) }
171+ var failed by remember { mutableStateOf(false) }
172+
173+ LaunchedEffect(result.id) {
174+ val pair = VraiPrixService.estimate(result.id)
175+ if (pair == null || pair.second == null) failed = true
176+ else {
177+ unit = pair.first
178+ res = pair.second
179+ }
180+ }
181+
182+ LazyColumn(
183+ contentPadding = PaddingValues(16.dp),
184+ verticalArrangement = Arrangement.spacedBy(16.dp),
185+ modifier = Modifier.fillMaxWidth(),
186+ ) {
187+ item {
188+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
189+ Text(result.adresse, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
190+ Text(result.municipalite, style = MaterialTheme.typography.bodyMedium, color = KATheme.ink2(dark))
191+ }
192+ }
193+ val r = res
194+ if (r != null) {
195+ item { EstimateCard(r, unit, universe) }
196+ val u = unit
197+ if (u != null) {
198+ item { HistoryCard(u, universe) }
199+ item { PortraitCard(u) }
200+ }
201+ item {
202+ Box(
203+ Modifier
204+ .fillMaxWidth()
205+ .background(universe.accent, RoundedCornerShape(12.dp))
206+ .clickable { openUrl(context, "https://www.vrai-prix.com/estimation/${result.id}") }
207+ .padding(vertical = 14.dp),
208+ contentAlignment = Alignment.Center,
209+ ) {
210+ Row(verticalAlignment = Alignment.CenterVertically) {
211+ Icon(Icons.Filled.Description, null, tint = Color.White, modifier = Modifier.size(18.dp))
212+ Spacer(Modifier.width(8.dp))
213+ Text("Fiche complète + rapport PDF sur Vrai-Prix", fontWeight = FontWeight.Bold, color = Color.White)
214+ }
215+ }
216+ }
217+ } else if (failed) {
218+ item {
219+ KAEmptyState(Icons.Filled.SignalWifiOff, "Estimation indisponible", "Le service ne répond pas — réessayez.")
220+ }
221+ } else {
222+ item {
223+ Row(
224+ Modifier.fillMaxWidth().padding(40.dp),
225+ horizontalArrangement = Arrangement.Center,
226+ verticalAlignment = Alignment.CenterVertically,
227+ ) {
228+ CircularProgressIndicator(color = universe.accent)
229+ Spacer(Modifier.width(12.dp))
230+ Text("Le moteur estime…", color = KATheme.ink2(dark))
231+ }
232+ }
233+ }
234+ }
235+}
236+
237+@Composable
238+private fun EstimateCard(r: JsonObject, unit: JsonObject?, universe: Universe) {
239+ val paper = colorFromHex("#f5f3ee")
240+ Column(
241+ Modifier
242+ .fillMaxWidth()
243+ .background(KATheme.inkLight, RoundedCornerShape(16.dp))
244+ .padding(18.dp),
245+ verticalArrangement = Arrangement.spacedBy(8.dp),
246+ ) {
247+ Text(
248+ "ESTIMATION VRAI-PRIX",
249+ fontSize = 10.sp,
250+ fontFamily = FontFamily.Monospace,
251+ fontWeight = FontWeight.Bold,
252+ color = paper.copy(alpha = 0.6f),
253+ )
254+ Text(
255+ (r.num("estimate") ?: 0.0).money0,
256+ fontSize = 38.sp,
257+ fontWeight = FontWeight.Bold,
258+ color = universe.accent,
259+ maxLines = 1,
260+ )
261+ val lo = r.num("low")
262+ val hi = r.num("high")
263+ if (lo != null && hi != null) {
264+ Text(
265+ "Fourchette ${lo.money0} – ${hi.money0}",
266+ style = MaterialTheme.typography.bodyMedium,
267+ color = paper.copy(alpha = 0.85f),
268+ )
269+ }
270+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
271+ val level = r.str("confidenceLevel")
272+ if (level != null) {
273+ Text(
274+ "Confiance $level",
275+ fontSize = 11.sp,
276+ fontFamily = FontFamily.Monospace,
277+ fontWeight = FontWeight.Bold,
278+ color = Color.Black,
279+ modifier = Modifier
280+ .background(confColor(level), CircleShape)
281+ .padding(horizontal = 10.dp, vertical = 4.dp),
282+ )
283+ }
284+ r.num("confidencePct")?.let { pct ->
285+ Text("${pct.toInt()} %", style = MaterialTheme.typography.labelMedium, color = paper.copy(alpha = 0.7f))
286+ }
287+ Spacer(Modifier.weight(1f))
288+ unit?.num("valeurRole")?.let { role ->
289+ Column(horizontalAlignment = Alignment.End) {
290+ Text("Rôle 2026", fontSize = 9.sp, fontFamily = FontFamily.Monospace, color = paper.copy(alpha = 0.55f))
291+ Text(role.money0, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = paper)
292+ }
293+ }
294+ }
295+ }
296+}
297+
298+@Composable
299+private fun HistoryCard(u: JsonObject, universe: Universe) {
300+ val dark = LocalKADark.current
301+ val hist = u["history"]?.let { it as? kotlinx.serialization.json.JsonArray } ?: return
302+ if (hist.size <= 1) return
303+ val pts: List<Pair<String, Double>> = hist.mapNotNull { h ->
304+ val o = h as? JsonObject ?: return@mapNotNull null
305+ val y = o.num("year") ?: return@mapNotNull null
306+ val v = o.num("value") ?: return@mapNotNull null
307+ y.toInt().toString() to v
308+ }
309+ if (pts.size <= 1) return
310+ val maxV = pts.maxOf { it.second }.takeIf { it > 0 } ?: 1.0
311+ Column(
312+ Modifier.fillMaxWidth().kaCard(universe.accent).padding(14.dp),
313+ verticalArrangement = Arrangement.spacedBy(10.dp),
314+ ) {
315+ Text("Valeur au rôle, 2021 → 2026", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
316+ Row(
317+ verticalAlignment = Alignment.Bottom,
318+ horizontalArrangement = Arrangement.spacedBy(10.dp),
319+ modifier = Modifier.fillMaxWidth(),
320+ ) {
321+ pts.forEach { (year, v) ->
322+ Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
323+ Text(v.compact, fontSize = 8.sp, fontFamily = FontFamily.Monospace, color = KATheme.ink2(dark))
324+ Box(
325+ Modifier
326+ .fillMaxWidth()
327+ .height(maxOf((v / maxV * 90).toFloat(), 6f).dp)
328+ .background(
329+ universe.accent.copy(alpha = if (year == pts.last().first) 1f else 0.45f),
330+ RoundedCornerShape(3.dp),
331+ ),
332+ )
333+ Text(year, fontSize = 9.sp, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
334+ }
335+ }
336+ }
337+ }
338+}
339+
340+@Composable
341+private fun PortraitCard(u: JsonObject) {
342+ val dark = LocalKADark.current
343+ val rows: List<Pair<String, String?>> = listOf(
344+ "Type" to (u.str("cubfLibelle") ?: u.str("typeProp")),
345+ "Année de construction" to u.num("anneeConstruction")?.let { it.toInt().toString() },
346+ "Aire des étages" to u.num("aireEtagesM2")?.let { "${it.toInt()} m²" },
347+ "Terrain" to u.num("superficieTerrainM2")?.let { "${it.toInt()} m²" },
348+ "Logements" to u.num("nbLogements")?.let { it.toInt().toString() },
349+ )
350+ val filtered = rows.filter { it.second != null }
351+ if (filtered.isEmpty()) return
352+ Column(Modifier.fillMaxWidth().kaCard()) {
353+ filtered.forEachIndexed { i, (label, value) ->
354+ Row(Modifier.padding(vertical = 10.dp, horizontal = 14.dp)) {
355+ Text(label, style = MaterialTheme.typography.bodyMedium, color = KATheme.ink2(dark))
356+ Spacer(Modifier.weight(1f))
357+ Text(value ?: "", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = KATheme.ink(dark))
358+ }
359+ if (i != filtered.lastIndex) HorizontalDivider(color = KATheme.ink(dark).copy(alpha = 0.1f))
360+ }
361+ }
362+}
363+
364+private fun confColor(level: String): Color = when (level) {
365+ "A" -> colorFromHex("#d9f26b")
366+ "B" -> colorFromHex("#a8e063")
367+ "C" -> colorFromHex("#e8a33d")
368+ else -> colorFromHex("#ff8a80")
369+}
added app/src/main/java/com/groupeka/ka/ui/theme/Theme.kt +388 −0
@@ -0,0 +1,388 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// Theme.kt — design system Groupe-KA « éditorial sharp » : papier/encre en
3+// clair, encre/papier en sombre, accents par univers, composants signature
4+// (carte à ombre décalée dure, puce mono, wordmark boîte encre) + haptique.
5+package com.groupeka.ka.ui.theme
6+
7+import androidx.compose.animation.core.RepeatMode
8+import androidx.compose.animation.core.animateFloat
9+import androidx.compose.animation.core.infiniteRepeatable
10+import androidx.compose.animation.core.rememberInfiniteTransition
11+import androidx.compose.animation.core.tween
12+import androidx.compose.foundation.background
13+import androidx.compose.foundation.border
14+import androidx.compose.foundation.layout.Arrangement
15+import androidx.compose.foundation.layout.Box
16+import androidx.compose.foundation.layout.Column
17+import androidx.compose.foundation.layout.Row
18+import androidx.compose.foundation.layout.Spacer
19+import androidx.compose.foundation.layout.fillMaxWidth
20+import androidx.compose.foundation.layout.height
21+import androidx.compose.foundation.layout.padding
22+import androidx.compose.foundation.layout.size
23+import androidx.compose.foundation.layout.width
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.filled.AutoAwesome
28+import androidx.compose.material.icons.filled.ConfirmationNumber
29+import androidx.compose.material.icons.filled.DirectionsCar
30+import androidx.compose.material.icons.filled.Favorite
31+import androidx.compose.material.icons.filled.Home
32+import androidx.compose.material.icons.filled.Inventory2
33+import androidx.compose.material.icons.filled.Key
34+import androidx.compose.material.icons.filled.Photo
35+import androidx.compose.material.icons.filled.Restaurant
36+import androidx.compose.material.icons.filled.Search
37+import androidx.compose.material.icons.filled.ShoppingCart
38+import androidx.compose.material.icons.filled.Terminal
39+import androidx.compose.material.icons.filled.TrendingUp
40+import androidx.compose.material.icons.filled.Work
41+import androidx.compose.material3.Icon
42+import androidx.compose.material3.MaterialTheme
43+import androidx.compose.material3.Text
44+import androidx.compose.material3.darkColorScheme
45+import androidx.compose.material3.lightColorScheme
46+import androidx.compose.runtime.Composable
47+import androidx.compose.runtime.CompositionLocalProvider
48+import androidx.compose.runtime.getValue
49+import androidx.compose.runtime.staticCompositionLocalOf
50+import androidx.compose.ui.Alignment
51+import androidx.compose.ui.Modifier
52+import androidx.compose.runtime.collectAsState
53+import androidx.compose.ui.draw.alpha
54+import androidx.compose.ui.draw.clip
55+import androidx.compose.ui.draw.drawBehind
56+import androidx.compose.ui.draw.rotate
57+import androidx.compose.ui.geometry.CornerRadius
58+import androidx.compose.ui.geometry.Offset
59+import androidx.compose.ui.graphics.Color
60+import androidx.compose.ui.graphics.drawscope.Stroke
61+import androidx.compose.ui.graphics.vector.ImageVector
62+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
63+import androidx.compose.ui.layout.ContentScale
64+import androidx.compose.ui.platform.LocalHapticFeedback
65+import androidx.compose.ui.text.font.FontFamily
66+import androidx.compose.ui.text.font.FontWeight
67+import androidx.compose.ui.text.style.TextOverflow
68+import androidx.compose.ui.unit.Dp
69+import androidx.compose.ui.unit.dp
70+import androidx.compose.ui.unit.sp
71+import androidx.compose.foundation.isSystemInDarkTheme
72+import androidx.compose.ui.composed
73+import coil.compose.SubcomposeAsyncImage
74+import com.groupeka.ka.core.Ecosystem
75+import com.groupeka.ka.core.FavoritesStore
76+import com.groupeka.ka.core.KAItem
77+import com.groupeka.ka.core.Prefs
78+import com.groupeka.ka.core.Universe
79+
80+// MARK: - Palette
81+
82+fun colorFromHex(hex: String): Color {
83+ var h = hex.trim().removePrefix("#")
84+ if (h.length == 3) h = h.map { "$it$it" }.joinToString("")
85+ val v = h.toLongOrNull(16) ?: 0L
86+ return Color(
87+ red = ((v shr 16) and 0xFF) / 255f,
88+ green = ((v shr 8) and 0xFF) / 255f,
89+ blue = (v and 0xFF) / 255f,
90+ )
91+}
92+
93+val Universe.accent: Color get() = colorFromHex(accentHex)
94+
95+object KATheme {
96+ val paperLight = colorFromHex("#f5f3ee")
97+ val paperDark = colorFromHex("#101410")
98+ val inkLight = colorFromHex("#141814")
99+ val inkDark = colorFromHex("#f0efe8")
100+ val lime = colorFromHex("#d9f26b")
101+ val green = colorFromHex("#1c5c41")
102+
103+ fun paper(dark: Boolean) = if (dark) paperDark else paperLight
104+ fun ink(dark: Boolean) = if (dark) inkDark else inkLight
105+ fun surface(dark: Boolean) = if (dark) colorFromHex("#1a1f1a") else Color.White
106+ fun ink2(dark: Boolean) = if (dark) colorFromHex("#a9b0a9") else colorFromHex("#4d5551")
107+}
108+
109+/** true = mode sombre effectif (préférence « sombre », ou « auto » + système sombre). */
110+val LocalKADark = staticCompositionLocalOf { false }
111+
112+@Composable
113+fun KAAppTheme(content: @Composable () -> Unit) {
114+ val dark = when (Prefs.appearance) {
115+ "sombre" -> true
116+ "clair" -> false
117+ else -> isSystemInDarkTheme()
118+ }
119+ val scheme = if (dark) darkColorScheme(
120+ primary = KATheme.green,
121+ background = KATheme.paperDark,
122+ surface = KATheme.paperDark,
123+ onBackground = KATheme.inkDark,
124+ onSurface = KATheme.inkDark,
125+ ) else lightColorScheme(
126+ primary = KATheme.green,
127+ background = KATheme.paperLight,
128+ surface = KATheme.paperLight,
129+ onBackground = KATheme.inkLight,
130+ onSurface = KATheme.inkLight,
131+ )
132+ CompositionLocalProvider(LocalKADark provides dark) {
133+ MaterialTheme(colorScheme = scheme, content = content)
134+ }
135+}
136+
137+// MARK: - Haptique
138+
139+object Haptics {
140+ @Composable
141+ fun rememberTap(): () -> Unit {
142+ val h = LocalHapticFeedback.current
143+ return { h.performHapticFeedback(HapticFeedbackType.TextHandleMove) }
144+ }
145+}
146+
147+// MARK: - Carte « éditorial sharp » (bordure encre + ombre décalée dure)
148+
149+fun Modifier.kaCard(accent: Color? = null, radius: Dp = 14.dp): Modifier = composed {
150+ val dark = LocalKADark.current
151+ val ink = KATheme.ink(dark)
152+ val surface = KATheme.surface(dark)
153+ val shadow = (accent ?: ink).copy(alpha = if (dark) 0.25f else 0.16f)
154+ val border = ink.copy(alpha = if (dark) 0.35f else 0.85f)
155+ this.drawBehind {
156+ val r = CornerRadius(radius.toPx(), radius.toPx())
157+ val off = 5.dp.toPx()
158+ // ombre décalée dure
159+ drawRoundRect(color = shadow, topLeft = Offset(off, off), size = size, cornerRadius = r)
160+ // fond de carte
161+ drawRoundRect(color = surface, cornerRadius = r)
162+ // bordure encre
163+ drawRoundRect(color = border, cornerRadius = r, style = Stroke(width = 1.4.dp.toPx()))
164+ }
165+}
166+
167+// MARK: - Wordmark (« Lou » + boîte [Ka] accent, rotation -2°)
168+
169+@Composable
170+fun KAWordmark(universe: Universe, size: Float = 22f) {
171+ val dark = LocalKADark.current
172+ val parts = universe.wordmark.split("·", limit = 2)
173+ Row(verticalAlignment = Alignment.CenterVertically) {
174+ Text(
175+ text = parts.firstOrNull() ?: universe.wordmark,
176+ fontSize = size.sp,
177+ fontWeight = FontWeight.Bold,
178+ color = KATheme.ink(dark),
179+ )
180+ if (parts.size > 1) {
181+ Spacer(Modifier.width(3.dp))
182+ Text(
183+ text = parts[1],
184+ fontSize = (size * 0.86f).sp,
185+ fontWeight = FontWeight.Bold,
186+ color = universe.accent,
187+ modifier = Modifier
188+ .rotate(-2f)
189+ .background(KATheme.inkLight, RoundedCornerShape((size * 0.26f).dp))
190+ .padding(horizontal = (size * 0.28f).dp, vertical = (size * 0.08f).dp),
191+ )
192+ }
193+ }
194+}
195+
196+@Composable
197+fun GroupeKAMark(size: Float = 24f) {
198+ val dark = LocalKADark.current
199+ Row(verticalAlignment = Alignment.CenterVertically) {
200+ Text("Groupe", fontSize = size.sp, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
201+ Spacer(Modifier.width(4.dp))
202+ Text(
203+ "KA",
204+ fontSize = (size * 0.9f).sp,
205+ fontWeight = FontWeight.Bold,
206+ color = KATheme.lime,
207+ modifier = Modifier
208+ .rotate(-2f)
209+ .background(KATheme.inkLight, RoundedCornerShape((size * 0.28f).dp))
210+ .padding(horizontal = (size * 0.3f).dp, vertical = (size * 0.1f).dp),
211+ )
212+ }
213+}
214+
215+// MARK: - Puce mono (klabel)
216+
217+@Composable
218+fun KAChip(text: String, accent: Color? = null) {
219+ val dark = LocalKADark.current
220+ val ink = KATheme.ink(dark)
221+ Text(
222+ text = text.uppercase(),
223+ fontSize = 10.sp,
224+ fontWeight = FontWeight.Bold,
225+ fontFamily = FontFamily.Monospace,
226+ color = ink,
227+ maxLines = 1,
228+ modifier = Modifier
229+ .clip(CircleShape)
230+ .background((accent ?: ink).copy(alpha = 0.12f))
231+ .border(1.dp, ink.copy(alpha = 0.4f), CircleShape)
232+ .padding(horizontal = 9.dp, vertical = 4.dp),
233+ )
234+}
235+
236+// MARK: - Icônes d'univers (clé symbol → Material)
237+
238+fun universeIcon(symbol: String): ImageVector = when (symbol) {
239+ "key" -> Icons.Filled.Key
240+ "house" -> Icons.Filled.Home
241+ "chart" -> Icons.Filled.TrendingUp
242+ "car" -> Icons.Filled.DirectionsCar
243+ "box" -> Icons.Filled.Inventory2
244+ "cart" -> Icons.Filled.ShoppingCart
245+ "restaurant" -> Icons.Filled.Restaurant
246+ "ticket" -> Icons.Filled.ConfirmationNumber
247+ "sparkles" -> Icons.Filled.AutoAwesome
248+ "briefcase" -> Icons.Filled.Work
249+ "search" -> Icons.Filled.Search
250+ "terminal" -> Icons.Filled.Terminal
251+ else -> Icons.Filled.Photo
252+}
253+
254+// MARK: - Image réseau fluide (Coil, placeholder accent)
255+
256+@Composable
257+fun KAImage(url: String?, accent: Color, symbol: String, modifier: Modifier = Modifier) {
258+ SubcomposeAsyncImage(
259+ model = url,
260+ contentDescription = null,
261+ contentScale = ContentScale.Crop,
262+ modifier = modifier,
263+ loading = {
264+ Box(Modifier.background(accent.copy(alpha = 0.08f)), contentAlignment = Alignment.Center) {
265+ Icon(universeIcon(symbol), null, tint = accent.copy(alpha = 0.35f))
266+ }
267+ },
268+ error = {
269+ Box(Modifier.background(accent.copy(alpha = 0.1f)), contentAlignment = Alignment.Center) {
270+ Icon(universeIcon(symbol), null, tint = accent.copy(alpha = 0.6f))
271+ }
272+ },
273+ )
274+}
275+
276+// MARK: - Rangée d'item universelle
277+
278+@Composable
279+fun KAItemRow(item: KAItem, modifier: Modifier = Modifier) {
280+ val dark = LocalKADark.current
281+ val universe = Ecosystem.universe(item.universeID)
282+ val accent = universe?.accent ?: Color.Gray
283+ val favCollections = FavoritesStore.shared?.collections?.collectAsState()
284+ val isFav = favCollections?.value?.any { c -> c.items.any { it.id == item.id } } == true
285+ Row(modifier = modifier.fillMaxWidth().kaCard(accent).padding(12.dp)) {
286+ if (item.imageURL != null) {
287+ KAImage(
288+ url = item.imageURL, accent = accent, symbol = universe?.symbol ?: "photo",
289+ modifier = Modifier
290+ .size(74.dp)
291+ .clip(RoundedCornerShape(10.dp))
292+ .border(1.dp, KATheme.ink(dark).copy(alpha = 0.25f), RoundedCornerShape(10.dp)),
293+ )
294+ } else {
295+ Box(
296+ Modifier
297+ .width(5.dp)
298+ .height(74.dp)
299+ .clip(RoundedCornerShape(8.dp))
300+ .background(accent.copy(alpha = 0.9f)),
301+ )
302+ }
303+ Spacer(Modifier.width(12.dp))
304+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
305+ Text(
306+ item.title,
307+ style = MaterialTheme.typography.titleSmall,
308+ fontWeight = FontWeight.Bold,
309+ maxLines = 2, overflow = TextOverflow.Ellipsis,
310+ color = KATheme.ink(dark),
311+ )
312+ if (!item.subtitle.isNullOrEmpty()) {
313+ Text(
314+ item.subtitle!!,
315+ style = MaterialTheme.typography.bodySmall,
316+ color = KATheme.ink2(dark),
317+ maxLines = 2, overflow = TextOverflow.Ellipsis,
318+ )
319+ }
320+ Row(verticalAlignment = Alignment.CenterVertically) {
321+ if (item.priceLabel != null) {
322+ Text(
323+ item.priceLabel!!,
324+ style = MaterialTheme.typography.bodyMedium,
325+ fontWeight = FontWeight.Bold,
326+ color = accent,
327+ )
328+ Spacer(Modifier.width(8.dp))
329+ }
330+ if (!item.city.isNullOrEmpty()) {
331+ Text(item.city, style = MaterialTheme.typography.labelSmall, color = KATheme.ink2(dark))
332+ }
333+ Spacer(Modifier.weight(1f))
334+ if (universe != null) KAChip(universe.wordmark, universe.accent)
335+ }
336+ }
337+ if (isFav) {
338+ Spacer(Modifier.width(6.dp))
339+ Icon(Icons.Filled.Favorite, "Dans vos favoris", tint = Color.Red, modifier = Modifier.size(14.dp))
340+ }
341+ }
342+}
343+
344+// MARK: - Squelette de chargement (liste)
345+
346+@Composable
347+fun KASkeletonRow() {
348+ val dark = LocalKADark.current
349+ val transition = rememberInfiniteTransition(label = "shimmer")
350+ val alpha by transition.animateFloat(
351+ initialValue = 0.9f, targetValue = 0.5f,
352+ animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
353+ label = "alpha",
354+ )
355+ val quaternary = KATheme.ink(dark).copy(alpha = 0.12f)
356+ Row(Modifier.fillMaxWidth().alpha(alpha).kaCard().padding(12.dp)) {
357+ Box(Modifier.size(74.dp).clip(RoundedCornerShape(10.dp)).background(quaternary))
358+ Spacer(Modifier.width(12.dp))
359+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
360+ Box(Modifier.fillMaxWidth().height(14.dp).clip(RoundedCornerShape(4.dp)).background(quaternary))
361+ Box(Modifier.width(180.dp).height(11.dp).clip(RoundedCornerShape(4.dp)).background(quaternary))
362+ Box(Modifier.width(90.dp).height(11.dp).clip(RoundedCornerShape(4.dp)).background(quaternary))
363+ }
364+ }
365+}
366+
367+// MARK: - États
368+
369+@Composable
370+fun KAEmptyState(icon: ImageVector, title: String, message: String? = null) {
371+ val dark = LocalKADark.current
372+ Column(
373+ Modifier.fillMaxWidth().padding(30.dp),
374+ horizontalAlignment = Alignment.CenterHorizontally,
375+ verticalArrangement = Arrangement.spacedBy(10.dp),
376+ ) {
377+ Icon(icon, null, tint = KATheme.ink2(dark), modifier = Modifier.size(40.dp))
378+ Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark))
379+ if (message != null) {
380+ Text(
381+ message,
382+ style = MaterialTheme.typography.bodyMedium,
383+ color = KATheme.ink2(dark),
384+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
385+ )
386+ }
387+ }
388+}
added app/src/main/res/drawable/splash.xml +9 −0
@@ -0,0 +1,9 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
3+ <item android:drawable="@color/ka_ink" />
4+ <item android:gravity="center">
5+ <bitmap
6+ android:src="@mipmap/ic_launcher"
7+ android:gravity="center" />
8+ </item>
9+</layer-list>
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
@@ -0,0 +1,6 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <color name="ka_paper">#f5f3ee</color>
4+ <color name="ka_ink">#141814</color>
5+ <color name="ka_lime">#d9f26b</color>
6+</resources>
added app/src/main/res/values/strings.xml +23 −0
@@ -0,0 +1,23 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <string name="app_name">KA</string>
4+ <string name="tab_home">Accueil</string>
5+ <string name="tab_search">Recherche</string>
6+ <string name="tab_universes">Univers</string>
7+ <string name="tab_favorites">Favoris</string>
8+ <string name="tab_profile">Profil</string>
9+ <string name="agent_open">Ouvrir KA Agent, l\'assistant de l\'écosystème</string>
10+ <string name="action_apply">Appliquer</string>
11+ <string name="action_clear">Effacer</string>
12+ <string name="action_cancel">Annuler</string>
13+ <string name="action_create">Créer</string>
14+ <string name="action_share">Partager</string>
15+ <string name="action_see_all">Tout voir</string>
16+ <string name="action_source">Voir à la source</string>
17+ <string name="filters">Filtres</string>
18+ <string name="favorites_add">Ajouter aux favoris</string>
19+ <string name="favorites_remove">Retirer des favoris</string>
20+ <string name="offline">Vous êtes hors ligne.</string>
21+ <string name="service_down">Le service ne répond pas.</string>
22+ <string name="no_results">Aucun résultat</string>
23+</resources>
added app/src/main/res/values/themes.xml +17 −0
@@ -0,0 +1,17 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <!-- Thème clair par défaut : papier #f5f3ee / encre #141814 -->
4+ <style name="Theme.KA" parent="android:Theme.Material.Light.NoActionBar">
5+ <item name="android:windowBackground">@color/ka_paper</item>
6+ <item name="android:statusBarColor">@color/ka_paper</item>
7+ <item name="android:windowLightStatusBar">true</item>
8+ <item name="android:navigationBarColor">@color/ka_paper</item>
9+ </style>
10+
11+ <!-- Splash simple encre avec l'icône KA lime centrée -->
12+ <style name="Theme.KA.Splash" parent="Theme.KA">
13+ <item name="android:windowBackground">@drawable/splash</item>
14+ <item name="android:statusBarColor">@color/ka_ink</item>
15+ <item name="android:windowLightStatusBar">false</item>
16+ </style>
17+</resources>
added app/src/test/java/com/groupeka/ka/AdapterTests.kt +147 −0
@@ -0,0 +1,147 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// AdapterTests.kt — tests des parcours critiques hors réseau : mapping JSON →
3+// KAItem pour chaque adaptateur d'univers (mêmes échantillons réels que la
4+// suite iOS KATests.swift), construction des URL de liste, favoris/collections.
5+package com.groupeka.ka
6+
7+import com.groupeka.ka.core.Ecosystem
8+import com.groupeka.ka.core.FavoritesStore
9+import com.groupeka.ka.core.KAItem
10+import com.groupeka.ka.core.UniverseService
11+import kotlinx.serialization.json.Json
12+import kotlinx.serialization.json.JsonObject
13+import org.junit.Assert.assertEquals
14+import org.junit.Assert.assertFalse
15+import org.junit.Assert.assertNotNull
16+import org.junit.Assert.assertNull
17+import org.junit.Assert.assertTrue
18+import org.junit.Test
19+import java.io.File
20+
21+class AdapterTests {
22+
23+ private fun decode(json: String): JsonObject =
24+ Json.parseToJsonElement(json) as JsonObject
25+
26+ @Test
27+ fun louKaMapping() {
28+ val u = Ecosystem.universe("lou-ka")!!
29+ val o = decode("""{"uid":"a:1","title":"4½ Limoilou","city":"Québec","unit_type":"4½","price":1200,"url":"https://x.com/a","lat":46.8,"lng":-71.2,"address":"12 rue X"}""")
30+ val item = u.map!!(o)!!
31+ assertEquals("lou-ka", item.universeID)
32+ assertEquals("4½ Limoilou", item.title)
33+ assertTrue(item.priceLabel!!.contains("1"))
34+ assertEquals("Québec", item.city)
35+ assertNotNull(item.latitude)
36+ assertTrue(item.facts.any { it.label == "Taille" && it.value == "4½" })
37+ }
38+
39+ @Test
40+ fun autoKaMapping() {
41+ val u = Ecosystem.universe("auto-ka")!!
42+ val o = decode("""{"uid":"v:1","title":"Kia Forte 2010","make":"Kia","model":"Forte","year":2010,"price":1595,"price_label":"1 595 ${'$'}","mileage_label":"260 789 km","url":"https://x.com/v"}""")
43+ val item = u.map!!(o)!!
44+ assertEquals("1 595 $", item.priceLabel)
45+ assertTrue(item.subtitle!!.contains("2010"))
46+ assertTrue(item.facts.any { it.label == "Marque" && it.value == "Kia" })
47+ }
48+
49+ @Test
50+ fun jobKaSalary() {
51+ val u = Ecosystem.universe("job-ka")!!
52+ val o = decode("""{"uid":"j:1","title":"Analyste","employer":"TD","city":"Montréal","salary_year_min":65000}""")
53+ val item = u.map!!(o)!!
54+ assertTrue(item.priceLabel!!.contains("/an"))
55+ assertTrue(item.subtitle!!.contains("TD"))
56+ }
57+
58+ @Test
59+ fun trouveKaStripsHTML() {
60+ val u = Ecosystem.universe("trouve-ka")!!
61+ val o = decode("""{"url":"https://y.qc","title":"La <em>poutine</em>","snippet":"un plat &amp; une légende","domain":"y.qc"}""")
62+ val item = u.map!!(o)!!
63+ assertEquals("La poutine", item.title)
64+ assertEquals("un plat & une légende", item.subtitle)
65+ }
66+
67+ @Test
68+ fun foodKaIgnoresPlaceholderPrice() {
69+ val u = Ecosystem.universe("food-ka")!!
70+ val o = decode("""{"uid":"p:1","name":"Prunes","price":0.01,"brand":"PA"}""")
71+ val item = u.map!!(o)!!
72+ assertNull("un prix placeholder (0,01 $) ne doit pas s'afficher", item.priceLabel)
73+ }
74+
75+ @Test
76+ fun foodKaShowsSalePrice() {
77+ val u = Ecosystem.universe("food-ka")!!
78+ val o = decode("""{"uid":"p:2","name":"Beurre","price":4.99,"regular_price":6.49,"on_sale":true,"brand":"Natrel"}""")
79+ val item = u.map!!(o)!!
80+ assertTrue("prix barré attendu", item.priceLabel!!.contains("rég."))
81+ assertTrue(item.facts.any { it.label == "Solde" })
82+ }
83+
84+ @Test
85+ fun creaKaPlatformsAndLinks() {
86+ val u = Ecosystem.universe("crea-ka")!!
87+ val o = decode("""{"cid":"c:1","display_name":"Créateur QC","niches":["humour"],"platforms":[{"platform":"youtube","url":"https://youtube.com/@qc","followers":120000,"handle":"qc"},{"platform":"tiktok","url":"https://tiktok.com/@qc","followers":50000}]}""")
88+ val item = u.map!!(o)!!
89+ assertTrue("abonnés cumulés attendus dans le sous-titre", item.subtitle!!.contains("abonnés"))
90+ assertEquals("https://youtube.com/@qc", item.url)
91+ assertTrue(item.links.any { it.label == "Youtube" && it.value == "https://youtube.com/@qc" })
92+ assertTrue(item.facts.any { it.label == "Youtube" })
93+ }
94+
95+ @Test
96+ fun firstUsableImageFromImagesArray() {
97+ val u = Ecosystem.universe("lou-ka")!!
98+ val o = decode("""{"uid":"a:2","title":"3½","images":["pas-une-url","https://cdn.x.com/1.jpg","https://cdn.x.com/2.jpg"]}""")
99+ val item = u.map!!(o)!!
100+ assertEquals("https://cdn.x.com/1.jpg", item.imageURL)
101+ }
102+
103+ @Test
104+ fun everyUniverseHasDistinctAccent() {
105+ val accents = Ecosystem.all.map { it.accentHex }
106+ assertEquals("chaque univers doit avoir un accent unique", accents.size, accents.toSet().size)
107+ // 12 univers — Groupe-KA (portail mère) est l'app elle-même, pas un univers listé
108+ assertEquals(12, Ecosystem.all.size)
109+ }
110+
111+ @Test
112+ fun listURLBuilding() {
113+ val sorti = Ecosystem.universe("sorti-ka")!!
114+ val url = UniverseService.listURL(sorti, query = "jazz", limit = 10)!!
115+ assertTrue("la query embarquée du listPath doit survivre", url.contains("upcoming=true"))
116+ assertTrue(url.contains("q=jazz"))
117+ assertTrue(url.contains("limit=10"))
118+ assertTrue(url.startsWith("https://www.sorti-ka.com/api/events"))
119+ }
120+
121+ @Test
122+ fun favoritesToggleAndCollections() {
123+ val file = File.createTempFile("ka-favoris", ".json").also { it.delete() }
124+ val store = FavoritesStore(file)
125+ val item = KAItem(id = "t:1", universeID = "lou-ka", title = "Test")
126+ assertFalse(store.isFavorite(item))
127+ store.toggle(item)
128+ assertTrue(store.isFavorite(item))
129+ store.addCollection("Week-end")
130+ val dest = store.collections.value.last()
131+ store.move(item, dest.id)
132+ assertTrue(store.collections.value.last().items.any { it.id == item.id })
133+ store.toggle(item)
134+ assertFalse(store.isFavorite(item))
135+ file.delete()
136+ }
137+
138+ @Test
139+ fun favoritesPersistAcrossInstances() {
140+ val file = File.createTempFile("ka-favoris", ".json").also { it.delete() }
141+ val store = FavoritesStore(file)
142+ store.toggle(KAItem(id = "t:2", universeID = "auto-ka", title = "Corolla"))
143+ val reloaded = FavoritesStore(file)
144+ assertTrue(reloaded.isFavorite(KAItem(id = "t:2", universeID = "auto-ka", title = "Corolla")))
145+ file.delete()
146+ }
147+}
added build.gradle.kts +8 −0
@@ -0,0 +1,8 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// Trio éprouvé : Gradle 8.13 + AGP 8.9.2 + Kotlin 2.1.20 (plugin Compose intégré).
3+plugins {
4+ id("com.android.application") version "8.9.2" apply false
5+ id("org.jetbrains.kotlin.android") version "2.1.20" apply false
6+ id("org.jetbrains.kotlin.plugin.compose") version "2.1.20" apply false
7+ id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20" apply false
8+}
added gradle.properties +5 −0
@@ -0,0 +1,5 @@
1+org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
2+android.useAndroidX=true
3+android.nonTransitiveRClass=true
4+org.gradle.parallel=true
5+org.gradle.caching=true
added gradle/wrapper/gradle-wrapper.jar +0 −0

Binary file not shown.

added gradle/wrapper/gradle-wrapper.properties +9 −0
@@ -0,0 +1,9 @@
1+distributionBase=GRADLE_USER_HOME
2+distributionPath=wrapper/dists
3+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
4+networkTimeout=10000
5+retries=0
6+retryBackOffMs=500
7+validateDistributionUrl=true
8+zipStoreBase=GRADLE_USER_HOME
9+zipStorePath=wrapper/dists
added gradlew +248 −0
@@ -0,0 +1,248 @@
1+#!/bin/sh
2+
3+#
4+# Copyright © 2015 the original authors.
5+#
6+# Licensed under the Apache License, Version 2.0 (the "License");
7+# you may not use this file except in compliance with the License.
8+# You may obtain a copy of the License at
9+#
10+# https://www.apache.org/licenses/LICENSE-2.0
11+#
12+# Unless required by applicable law or agreed to in writing, software
13+# distributed under the License is distributed on an "AS IS" BASIS,
14+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+# See the License for the specific language governing permissions and
16+# limitations under the License.
17+#
18+# SPDX-License-Identifier: Apache-2.0
19+#
20+
21+##############################################################################
22+#
23+# gradlew start up script for POSIX generated by Gradle.
24+#
25+# Important for running:
26+#
27+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28+# noncompliant, but you have some other compliant shell such as ksh or
29+# bash, then to run this script, type that shell name before the whole
30+# command line, like:
31+#
32+# ksh gradlew
33+#
34+# Busybox and similar reduced shells will NOT work, because this script
35+# requires all of these POSIX shell features:
36+# * functions;
37+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39+# * compound commands having a testable exit status, especially «case»;
40+# * various built-in commands including «command», «set», and «ulimit».
41+#
42+# Important for patching:
43+#
44+# (2) This script targets any POSIX shell, so it avoids extensions provided
45+# by Bash, Ksh, etc; in particular arrays are avoided.
46+#
47+# The "traditional" practice of packing multiple parameters into a
48+# space-separated string is a well documented source of bugs and security
49+# problems, so this is (mostly) avoided, by progressively accumulating
50+# options in "$@", and eventually passing that to Java.
51+#
52+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54+# see the in-line comments for details.
55+#
56+# There are tweaks for specific operating systems such as AIX, CygWin,
57+# Darwin, MinGW, and NonStop.
58+#
59+# (3) This script is generated from the Groovy template
60+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61+# within the Gradle project.
62+#
63+# You can find Gradle at https://github.com/gradle/gradle/.
64+#
65+##############################################################################
66+
67+# Attempt to set APP_HOME
68+
69+# Resolve links: $0 may be a link
70+app_path=$0
71+
72+# Need this for daisy-chained symlinks.
73+while
74+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
75+ [ -h "$app_path" ]
76+do
77+ ls=$( ls -ld "$app_path" )
78+ link=${ls#*' -> '}
79+ case $link in #(
80+ /*) app_path=$link ;; #(
81+ *) app_path=$APP_HOME$link ;;
82+ esac
83+done
84+
85+# This is normally unused
86+# shellcheck disable=SC2034
87+APP_BASE_NAME=${0##*/}
88+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
90+
91+# Use the maximum available, or set MAX_FD != -1 to use that value.
92+MAX_FD=maximum
93+
94+warn () {
95+ echo "$*"
96+} >&2
97+
98+die () {
99+ echo
100+ echo "$*"
101+ echo
102+ exit 1
103+} >&2
104+
105+# OS specific support (must be 'true' or 'false').
106+cygwin=false
107+msys=false
108+darwin=false
109+nonstop=false
110+case "$( uname )" in #(
111+ CYGWIN* ) cygwin=true ;; #(
112+ Darwin* ) darwin=true ;; #(
113+ MSYS* | MINGW* ) msys=true ;; #(
114+ NONSTOP* ) nonstop=true ;;
115+esac
116+
117+
118+
119+# Determine the Java command to use to start the JVM.
120+if [ -n "$JAVA_HOME" ] ; then
121+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
122+ # IBM's JDK on AIX uses strange locations for the executables
123+ JAVACMD=$JAVA_HOME/jre/sh/java
124+ else
125+ JAVACMD=$JAVA_HOME/bin/java
126+ fi
127+ if [ ! -x "$JAVACMD" ] ; then
128+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
129+
130+Please set the JAVA_HOME variable in your environment to match the
131+location of your Java installation."
132+ fi
133+else
134+ JAVACMD=java
135+ if ! command -v java >/dev/null 2>&1
136+ then
137+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
138+
139+Please set the JAVA_HOME variable in your environment to match the
140+location of your Java installation."
141+ fi
142+fi
143+
144+# Increase the maximum file descriptors if we can.
145+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
146+ case $MAX_FD in #(
147+ max*)
148+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
149+ # shellcheck disable=SC2039,SC3045
150+ MAX_FD=$( ulimit -H -n ) ||
151+ warn "Could not query maximum file descriptor limit"
152+ esac
153+ case $MAX_FD in #(
154+ '' | soft) :;; #(
155+ *)
156+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
157+ # shellcheck disable=SC2039,SC3045
158+ ulimit -n "$MAX_FD" ||
159+ warn "Could not set maximum file descriptor limit to $MAX_FD"
160+ esac
161+fi
162+
163+# Collect all arguments for the java command, stacking in reverse order:
164+# * args from the command line
165+# * the main class name
166+# * -classpath
167+# * -D...appname settings
168+# * --module-path (only if needed)
169+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
170+
171+# For Cygwin or MSYS, switch paths to Windows format before running java
172+if "$cygwin" || "$msys" ; then
173+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
174+
175+ JAVACMD=$( cygpath --unix "$JAVACMD" )
176+
177+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
178+ for arg do
179+ if
180+ case $arg in #(
181+ -*) false ;; # don't mess with options #(
182+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
183+ [ -e "$t" ] ;; #(
184+ *) false ;;
185+ esac
186+ then
187+ arg=$( cygpath --path --ignore --mixed "$arg" )
188+ fi
189+ # Roll the args list around exactly as many times as the number of
190+ # args, so each arg winds up back in the position where it started, but
191+ # possibly modified.
192+ #
193+ # NB: a `for` loop captures its iteration list before it begins, so
194+ # changing the positional parameters here affects neither the number of
195+ # iterations, nor the values presented in `arg`.
196+ shift # remove old arg
197+ set -- "$@" "$arg" # push replacement arg
198+ done
199+fi
200+
201+
202+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
203+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
204+
205+# Collect all arguments for the java command:
206+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
207+# and any embedded shellness will be escaped.
208+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
209+# treated as '${Hostname}' itself on the command line.
210+
211+set -- \
212+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
213+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
214+ "$@"
215+
216+# Stop when "xargs" is not available.
217+if ! command -v xargs >/dev/null 2>&1
218+then
219+ die "xargs is not available"
220+fi
221+
222+# Use "xargs" to parse quoted args.
223+#
224+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
225+#
226+# In Bash we could simply go:
227+#
228+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
229+# set -- "${ARGS[@]}" "$@"
230+#
231+# but POSIX shell has neither arrays nor command substitution, so instead we
232+# post-process each arg (as a line of input to sed) to backslash-escape any
233+# character that might be a shell metacharacter, then use eval to reverse
234+# that process (while maintaining the separation between arguments), and wrap
235+# the whole thing up as a single "set" statement.
236+#
237+# This will of course break if any of these variables contains a newline or
238+# an unmatched quote.
239+#
240+
241+eval "set -- $(
242+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
243+ xargs -n1 |
244+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
245+ tr '\n' ' '
246+ )" '"$@"'
247+
248+exec "$JAVACMD" "$@"
added gradlew.bat +82 −0
@@ -0,0 +1,82 @@
1+@rem
2+@rem Copyright 2015 the original author or authors.
3+@rem
4+@rem Licensed under the Apache License, Version 2.0 (the "License");
5+@rem you may not use this file except in compliance with the License.
6+@rem You may obtain a copy of the License at
7+@rem
8+@rem https://www.apache.org/licenses/LICENSE-2.0
9+@rem
10+@rem Unless required by applicable law or agreed to in writing, software
11+@rem distributed under the License is distributed on an "AS IS" BASIS,
12+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+@rem See the License for the specific language governing permissions and
14+@rem limitations under the License.
15+@rem
16+@rem SPDX-License-Identifier: Apache-2.0
17+@rem
18+
19+@if "%DEBUG%"=="" @echo off
20+@rem ##########################################################################
21+@rem
22+@rem gradlew startup script for Windows
23+@rem
24+@rem ##########################################################################
25+
26+@rem Set local scope for the variables, and ensure extensions are enabled
27+setlocal EnableExtensions
28+
29+set DIRNAME=%~dp0
30+if "%DIRNAME%"=="" set DIRNAME=.
31+@rem This is normally unused
32+set APP_BASE_NAME=%~n0
33+set APP_HOME=%DIRNAME%
34+
35+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
36+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37+
38+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40+
41+@rem Find java.exe
42+if defined JAVA_HOME goto findJavaFromJavaHome
43+
44+set JAVA_EXE=java.exe
45+%JAVA_EXE% -version >NUL 2>&1
46+if %ERRORLEVEL% equ 0 goto execute
47+
48+echo. 1>&2
49+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50+echo. 1>&2
51+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52+echo location of your Java installation. 1>&2
53+
54+"%COMSPEC%" /c exit 1
55+
56+:findJavaFromJavaHome
57+set JAVA_HOME=%JAVA_HOME:"=%
58+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59+
60+if exist "%JAVA_EXE%" goto execute
61+
62+echo. 1>&2
63+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64+echo. 1>&2
65+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66+echo location of your Java installation. 1>&2
67+
68+"%COMSPEC%" /c exit 1
69+
70+:execute
71+@rem Setup the command line
72+
73+
74+
75+@rem Execute gradlew
76+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
77+@rem which allows us to clear the local environment before executing the java command
78+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
79+
80+:exitWithErrorLevel
81+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
82+"%COMSPEC%" /c exit %ERRORLEVEL%
added settings.gradle.kts +17 −0
@@ -0,0 +1,17 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+pluginManagement {
3+ repositories {
4+ google()
5+ mavenCentral()
6+ gradlePluginPortal()
7+ }
8+}
9+dependencyResolutionManagement {
10+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
11+ repositories {
12+ google()
13+ mavenCentral()
14+ }
15+}
16+rootProject.name = "KA"
17+include(":app")
18
Menu

Theme