SPB Git forge

spb/ka-android

Public
4commits 1branches 0releases
1.6 MBsize
maindefault branch
1 mo agolast push
Kotlin 100%

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 1 mo ago (Aug 18, 2026)

45 changed files +5,131 −0

added .gitignore +10 −0
@@ -0,0 +1,10 @@
1 +.gradle/
2 +build/
3 +app/build/
4 +local.properties
5 +.DS_Store
6 +*.iml
7 +.idea/
8 +captures/
9 +.externalNativeBuild/
10 +.cxx/
added README.md +81 −0
@@ -0,0 +1,81 @@
1 +# KA Android — la super-app Groupe-KA
2 +
3 +Portage Android natif complet de l'app iOS « KA » : tout l'écosystème Groupe-KA
4 +(12 univers ·Ka) dans une seule app **Kotlin + Jetpack Compose**, compilée en
5 +ligne de commande (aucun Android Studio requis).
6 +
7 +## Build
8 +
9 +```bash
10 +export JAVA_HOME=$(brew --prefix openjdk@21)
11 +# local.properties doit pointer vers le SDK :
12 +# sdk.dir=/opt/homebrew/share/android-commandlinetools
13 +./gradlew assembleDebug # → app/build/outputs/apk/debug/app-debug.apk
14 +./gradlew test # tests JVM des adaptateurs d'univers (12 tests)
15 +```
16 +
17 +Trio de versions éprouvé : **Gradle 8.13 + AGP 8.9.2 + Kotlin 2.1.20**
18 +(plugin Compose intégré à Kotlin 2.x), Compose BOM 2024.09.03, minSdk 26,
19 +target/compileSdk 34. Portrait-first (téléphones).
20 +
21 +## Architecture — miroir exact de l'app iOS (`~/Desktop/KA`)
22 +
23 +| iOS (SwiftUI) | Android (Compose) |
24 +|-----------------------------|-----------------------------------------------------|
25 +| `Core/Ecosystem.swift` | `core/Ecosystem.kt` — les 12 univers config-driven |
26 +| `Core/Services.swift` | `core/Services.kt` — client JSON + cache disque, fetch univers, liveTotal, **AgentService SSE**, menus resto, Vrai-Prix |
27 +| `Core/KAFilters.swift` | `core/Filters.kt` — catalogue de filtres métier |
28 +| `Core/FavoritesStore.swift` | `core/FavoritesStore.kt` — collections multi-univers persistées (JSON) |
29 +| `Core/KAID.swift` | `auth/KaId.kt` — SSO hub via Chrome Custom Tabs (client `ka-android`, scheme `ka-android://auth/callback`, échange vérifié sur api-ka) |
30 +| `Design/Theme.swift` | `ui/theme/Theme.kt` — papier/encre, carte à ombre décalée dure, wordmarks « Lou[Ka] » (-2°), chips mono |
31 +| `Features/*` | `ui/*Screen.kt` — Accueil vivant, Recherche fan-out, grille Univers + explorateur + fiche, Vrai-Prix natif, menus resto, Favoris, Profil, chat KA Agent |
32 +| `App/KAApp.swift` | `MainActivity.kt` + `KAApp.kt` — 5 onglets + bulle Agent flottante, onboarding |
33 +
34 +Le **thème clair est le défaut** (papier `#f5f3ee` / encre `#141814`) ; sombre
35 +et automatique dans Profil → Apparence. Tous les textes sont en français.
36 +
37 +### Le cœur : `core/` (pur JVM, testable sans Android)
38 +
39 +- `Json.kt` — lecture « au mieux » sur `JsonElement` (miroir de `JSONValue`) :
40 + `str()`, `num()`, `.text` (string / nombre formaté / liste jointe).
41 +- `Ecosystem.kt` — chaque univers = id, wordmark, tagline, accent hex, domaine,
42 + unité, `listPath`/`itemsKey`/`searchParam`, clés de stats, et **mapping
43 + JSON → KAItem** avec toutes les subtilités iOS : première image http de
44 + `images[]`, seuils anti-placeholders (prix ≤ 0,2 $ épicerie, ≤ 50 $ loyer,
45 + ≤ 1000 $ propriété, salaires > 20 000 $/an ou > 10 $/h), prix barré Food·Ka,
46 + fourchettes salariales Job·Ka, plateformes + abonnés cumulés + liens Créa·Ka,
47 + strip-HTML Trouve·Ka.
48 +- `Services.kt` — cache disque par URL (l'app sert la dernière copie hors
49 + ligne), fan-out `liveTotal` (`/api/stats`, `/api/status` pour trouve-ka),
50 + SSE OkHttp pour KA Agent (`POST https://www.api-ka.com/api/agent/chat`,
51 + events `delta`/`tool`/`done`/`error`).
52 +
53 +## Ajouter un univers
54 +
55 +Ajouter une entrée dans `Ecosystem.all` (`core/Ecosystem.kt`) : identité,
56 +config d'API et lambda de mapping. Rien d'autre — la grille, la recherche
57 +universelle, l'accueil, les compteurs et la fiche le prennent en charge
58 +automatiquement. (Ajouter des filtres métier dans `core/Filters.kt` et une
59 +icône dans `universeIcon()` si souhaité.)
60 +
61 +## Écarts volontaires vs iOS (v1)
62 +
63 +- **Carte unifiée absente** : la MapView iOS (MapKit) n'est pas portée —
64 + prévue pour une version ultérieure (Google Maps/MapLibre à trancher).
65 + Les latitudes/longitudes sont déjà mappées dans `KAItem`.
66 +- **Widgets** : pas d'équivalent des widgets iOS (KAWidgets) en v1.
67 +- Le SSO utilise Chrome Custom Tabs (au lieu d'`ASWebAuthenticationSession`) ;
68 + le retour se fait par `ka-android://auth/callback` (intent-filter,
69 + `launchMode=singleTask`), puis échange `{ka_token}` sur
70 + `https://www.api-ka.com/api/ios/auth/exchange` (accepte l'audience
71 + `ka-android`).
72 +- Haptique légère (API Compose) plutôt que le moteur riche d'iOS.
73 +- Icônes Material en remplacement des SF Symbols (mapping dans `universeIcon`).
74 +
75 +## Tests
76 +
77 +`app/src/test/java/com/groupeka/ka/AdapterTests.kt` — mêmes échantillons JSON
78 +que `KATests.swift` iOS : mapping Lou·Ka/Auto·Ka/Job·Ka/Food·Ka/Créa·Ka/
79 +Trouve·Ka, première image de `images[]`, accents uniques (12 univers),
80 +construction d'URL de liste (query embarquée `upcoming=true` préservée),
81 +favoris/collections + persistance.
added app/build.gradle.kts +56 −0
@@ -0,0 +1,56 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +plugins {
3 + id("com.android.application")
4 + id("org.jetbrains.kotlin.android")
5 + id("org.jetbrains.kotlin.plugin.compose")
6 + id("org.jetbrains.kotlin.plugin.serialization")
7 +}
8 +
9 +android {
10 + namespace = "com.groupeka.ka"
11 + compileSdk = 34
12 +
13 + defaultConfig {
14 + applicationId = "com.groupeka.ka"
15 + minSdk = 26
16 + targetSdk = 34
17 + versionCode = 1
18 + versionName = "1.0.0"
19 + }
20 +
21 + buildTypes {
22 + release {
23 + isMinifyEnabled = false
24 + }
25 + }
26 + compileOptions {
27 + sourceCompatibility = JavaVersion.VERSION_17
28 + targetCompatibility = JavaVersion.VERSION_17
29 + }
30 + kotlinOptions {
31 + jvmTarget = "17"
32 + }
33 + buildFeatures {
34 + compose = true
35 + }
36 +}
37 +
38 +dependencies {
39 + val composeBom = platform("androidx.compose:compose-bom:2024.09.03")
40 + implementation(composeBom)
41 + implementation("androidx.compose.ui:ui")
42 + implementation("androidx.compose.material3:material3")
43 + implementation("androidx.compose.material:material-icons-extended")
44 + implementation("androidx.activity:activity-compose:1.9.2")
45 + implementation("androidx.navigation:navigation-compose:2.8.1")
46 + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6")
47 + implementation("io.coil-kt:coil-compose:2.7.0")
48 + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
49 + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
50 + implementation("com.squareup.okhttp3:okhttp:4.12.0")
51 + implementation("androidx.browser:browser:1.8.0")
52 + implementation("androidx.core:core-ktx:1.13.1")
53 +
54 + testImplementation("junit:junit:4.13.2")
55 + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
56 +}
added app/src/main/AndroidManifest.xml +36 −0
@@ -0,0 +1,36 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3 +
4 + <uses-permission android:name="android.permission.INTERNET" />
5 +
6 + <application
7 + android:name=".KAApp"
8 + android:allowBackup="true"
9 + android:icon="@mipmap/ic_launcher"
10 + android:roundIcon="@mipmap/ic_launcher_round"
11 + android:label="@string/app_name"
12 + android:supportsRtl="true"
13 + android:theme="@style/Theme.KA">
14 +
15 + <activity
16 + android:name=".MainActivity"
17 + android:exported="true"
18 + android:launchMode="singleTask"
19 + android:screenOrientation="portrait"
20 + android:configChanges="orientation|screenSize|keyboardHidden|uiMode"
21 + android:windowSoftInputMode="adjustResize"
22 + android:theme="@style/Theme.KA.Splash">
23 + <intent-filter>
24 + <action android:name="android.intent.action.MAIN" />
25 + <category android:name="android.intent.category.LAUNCHER" />
26 + </intent-filter>
27 + <!-- Retour du SSO KA ID : ka-android://auth/callback -->
28 + <intent-filter>
29 + <action android:name="android.intent.action.VIEW" />
30 + <category android:name="android.intent.category.DEFAULT" />
31 + <category android:name="android.intent.category.BROWSABLE" />
32 + <data android:scheme="ka-android" android:host="auth" />
33 + </intent-filter>
34 + </activity>
35 + </application>
36 +</manifest>
added app/src/main/java/com/groupeka/ka/KAApp.kt +21 −0
@@ -0,0 +1,21 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KAApp.kt — Application : initialise le cache JSON disque, les préférences,
3 +// les favoris et le profil KA ID persistant.
4 +package com.groupeka.ka
5 +
6 +import android.app.Application
7 +import com.groupeka.ka.auth.KAIDManager
8 +import com.groupeka.ka.core.ApiClient
9 +import com.groupeka.ka.core.FavoritesStore
10 +import com.groupeka.ka.core.Prefs
11 +import java.io.File
12 +
13 +class KAApp : Application() {
14 + override fun onCreate() {
15 + super.onCreate()
16 + ApiClient.cacheDir = File(cacheDir, "ka-json")
17 + Prefs.init(this)
18 + FavoritesStore.shared = FavoritesStore(File(filesDir, "ka-favoris.json"))
19 + KAIDManager.restore(this)
20 + }
21 +}
added app/src/main/java/com/groupeka/ka/MainActivity.kt +215 −0
@@ -0,0 +1,215 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// MainActivity.kt — point d'entrée : onboarding au premier lancement, puis la
3 +// super-app (5 onglets + bulle KA Agent flottante partout). Reçoit aussi le
4 +// retour SSO ka-android://auth/callback.
5 +package com.groupeka.ka
6 +
7 +import android.content.Intent
8 +import android.net.Uri
9 +import android.os.Bundle
10 +import androidx.activity.ComponentActivity
11 +import androidx.activity.compose.setContent
12 +import androidx.compose.foundation.background
13 +import androidx.compose.foundation.border
14 +import androidx.compose.foundation.layout.Box
15 +import androidx.compose.foundation.layout.fillMaxSize
16 +import androidx.compose.foundation.layout.padding
17 +import androidx.compose.foundation.layout.size
18 +import androidx.compose.foundation.shape.CircleShape
19 +import androidx.compose.material.icons.Icons
20 +import androidx.compose.material.icons.filled.Favorite
21 +import androidx.compose.material.icons.filled.Home
22 +import androidx.compose.material.icons.filled.Person
23 +import androidx.compose.material.icons.filled.Search
24 +import androidx.compose.material.icons.filled.Workspaces
25 +import androidx.compose.material3.Icon
26 +import androidx.compose.material3.MaterialTheme
27 +import androidx.compose.material3.NavigationBar
28 +import androidx.compose.material3.NavigationBarItem
29 +import androidx.compose.material3.NavigationBarItemDefaults
30 +import androidx.compose.material3.Scaffold
31 +import androidx.compose.material3.Surface
32 +import androidx.compose.material3.Text
33 +import androidx.compose.runtime.Composable
34 +import androidx.compose.runtime.getValue
35 +import androidx.compose.ui.Alignment
36 +import androidx.compose.ui.Modifier
37 +import androidx.compose.ui.draw.shadow
38 +import androidx.compose.ui.graphics.vector.ImageVector
39 +import androidx.compose.ui.text.font.FontWeight
40 +import androidx.compose.ui.unit.dp
41 +import androidx.compose.ui.unit.sp
42 +import androidx.compose.foundation.clickable
43 +import androidx.lifecycle.lifecycleScope
44 +import androidx.navigation.NavHostController
45 +import androidx.navigation.compose.NavHost
46 +import androidx.navigation.compose.composable
47 +import androidx.navigation.compose.currentBackStackEntryAsState
48 +import androidx.navigation.compose.rememberNavController
49 +import com.groupeka.ka.auth.KAIDManager
50 +import com.groupeka.ka.core.ApiClient
51 +import com.groupeka.ka.core.Ecosystem
52 +import com.groupeka.ka.core.KAItem
53 +import com.groupeka.ka.core.Prefs
54 +import com.groupeka.ka.core.VPResult
55 +import com.groupeka.ka.ui.AgentChatScreen
56 +import com.groupeka.ka.ui.EstimateScreen
57 +import com.groupeka.ka.ui.FavoritesScreen
58 +import com.groupeka.ka.ui.HomeScreen
59 +import com.groupeka.ka.ui.ItemDetailScreen
60 +import com.groupeka.ka.ui.OnboardingScreen
61 +import com.groupeka.ka.ui.ProfileScreen
62 +import com.groupeka.ka.ui.SearchScreen
63 +import com.groupeka.ka.ui.UniverseHomeScreen
64 +import com.groupeka.ka.ui.UniversesScreen
65 +import com.groupeka.ka.ui.theme.KAAppTheme
66 +import com.groupeka.ka.ui.theme.KATheme
67 +import com.groupeka.ka.ui.theme.LocalKADark
68 +import kotlinx.coroutines.launch
69 +import kotlinx.serialization.encodeToString
70 +import kotlinx.serialization.serializer
71 +import java.nio.charset.StandardCharsets
72 +import java.util.Base64
73 +
74 +// MARK: - Encodage d'objets dans les routes de navigation
75 +
76 +inline fun <reified T> encodeNavArg(value: T): String =
77 + Base64.getUrlEncoder().withoutPadding()
78 + .encodeToString(ApiClient.json.encodeToString(serializer<T>(), value).toByteArray(StandardCharsets.UTF_8))
79 +
80 +inline fun <reified T> decodeNavArg(raw: String): T? = runCatching {
81 + ApiClient.json.decodeFromString(
82 + serializer<T>(),
83 + String(Base64.getUrlDecoder().decode(raw), StandardCharsets.UTF_8),
84 + )
85 +}.getOrNull()
86 +
87 +class MainActivity : ComponentActivity() {
88 +
89 + override fun onCreate(savedInstanceState: Bundle?) {
90 + super.onCreate(savedInstanceState)
91 + handleAuthIntent(intent)
92 + setContent {
93 + KAAppTheme {
94 + Surface(Modifier.fillMaxSize(), color = KATheme.paper(LocalKADark.current)) {
95 + if (Prefs.onboarded) RootView() else OnboardingScreen()
96 + }
97 + }
98 + }
99 + }
100 +
101 + override fun onNewIntent(intent: Intent) {
102 + super.onNewIntent(intent)
103 + handleAuthIntent(intent)
104 + }
105 +
106 + override fun onResume() {
107 + super.onResume()
108 + // retour du Custom Tab sans callback : libérer l'état « occupé »
109 + KAIDManager.cancelIfPending()
110 + }
111 +
112 + private fun handleAuthIntent(intent: Intent?) {
113 + val uri: Uri = intent?.data ?: return
114 + if (uri.scheme == "ka-android" && uri.host == "auth") {
115 + lifecycleScope.launch {
116 + KAIDManager.handleCallback(uri)
117 + KAIDManager.persist(this@MainActivity)
118 + }
119 + }
120 + }
121 +}
122 +
123 +// MARK: - Racine : 5 onglets + bulle Agent
124 +
125 +private data class Tab(val route: String, val label: String, val icon: ImageVector)
126 +
127 +private val tabs = listOf(
128 + Tab("home", "Accueil", Icons.Filled.Home),
129 + Tab("search", "Recherche", Icons.Filled.Search),
130 + Tab("universes", "Univers", Icons.Filled.Workspaces),
131 + Tab("favorites", "Favoris", Icons.Filled.Favorite),
132 + Tab("profile", "Profil", Icons.Filled.Person),
133 +)
134 +
135 +@Composable
136 +fun RootView() {
137 + val nav: NavHostController = rememberNavController()
138 + val backStack by nav.currentBackStackEntryAsState()
139 + val route = backStack?.destination?.route
140 + val onTab = tabs.any { it.route == route }
141 +
142 + Scaffold(
143 + containerColor = KATheme.paper(LocalKADark.current),
144 + bottomBar = {
145 + if (onTab) {
146 + NavigationBar(containerColor = KATheme.surface(LocalKADark.current)) {
147 + tabs.forEach { tab ->
148 + NavigationBarItem(
149 + selected = route == tab.route,
150 + onClick = {
151 + nav.navigate(tab.route) {
152 + popUpTo(nav.graph.startDestinationId) { saveState = true }
153 + launchSingleTop = true
154 + restoreState = true
155 + }
156 + },
157 + icon = { Icon(tab.icon, null) },
158 + label = { Text(tab.label, fontSize = 10.sp) },
159 + colors = NavigationBarItemDefaults.colors(
160 + selectedIconColor = KATheme.green,
161 + selectedTextColor = KATheme.green,
162 + indicatorColor = KATheme.green.copy(alpha = 0.14f),
163 + ),
164 + )
165 + }
166 + }
167 + }
168 + },
169 + ) { padding ->
170 + Box(Modifier.fillMaxSize().padding(padding)) {
171 + NavHost(navController = nav, startDestination = "home") {
172 + composable("home") { HomeScreen(nav) }
173 + composable("search") { SearchScreen(nav) }
174 + composable("universes") { UniversesScreen(nav) }
175 + composable("favorites") { FavoritesScreen(nav) }
176 + composable("profile") { ProfileScreen() }
177 + composable("universe/{id}") { entry ->
178 + val id = entry.arguments?.getString("id")
179 + Ecosystem.universe(id ?: "")?.let { UniverseHomeScreen(it, nav) }
180 + }
181 + composable("item/{data}") { entry ->
182 + val item = entry.arguments?.getString("data")?.let { decodeNavArg<KAItem>(it) }
183 + if (item != null) ItemDetailScreen(item, nav)
184 + }
185 + composable("estimate/{data}") { entry ->
186 + val res = entry.arguments?.getString("data")?.let { decodeNavArg<VPResult>(it) }
187 + if (res != null) EstimateScreen(res)
188 + }
189 + composable("agent") { AgentChatScreen(onClose = { nav.popBackStack() }) }
190 + }
191 +
192 + // Bulle KA Agent — flottante au-dessus de la tab bar, partout sauf dans l'agent
193 + if (route != "agent") {
194 + Box(
195 + Modifier
196 + .align(Alignment.BottomEnd)
197 + .padding(end = 16.dp, bottom = 18.dp)
198 + .size(54.dp)
199 + .shadow(3.dp, CircleShape)
200 + .background(KATheme.lime, CircleShape)
201 + .border(2.dp, KATheme.inkLight, CircleShape)
202 + .clickable { nav.navigate("agent") { launchSingleTop = true } },
203 + contentAlignment = Alignment.Center,
204 + ) {
205 + Text(
206 + "Ka",
207 + fontSize = 19.sp,
208 + fontWeight = FontWeight.Bold,
209 + color = KATheme.inkLight,
210 + )
211 + }
212 + }
213 + }
214 + }
215 +}
added app/src/main/java/com/groupeka/ka/auth/KaId.kt +140 −0
@@ -0,0 +1,140 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KaId.kt — connexion KA ID NATIVE : Chrome Custom Tabs vers le hub
3 +// groupe-ka.com (SSO officiel de l'écosystème, client « ka-android »), retour
4 +// par le scheme ka-android://auth/callback, puis ÉCHANGE VÉRIFIÉ côté serveur
5 +// (api-ka valide la signature du jeton — aucun secret dans l'app) qui renvoie
6 +// le profil.
7 +package com.groupeka.ka.auth
8 +
9 +import android.content.Context
10 +import android.net.Uri
11 +import androidx.browser.customtabs.CustomTabsIntent
12 +import com.groupeka.ka.core.ApiClient
13 +import com.groupeka.ka.core.objectOrNull
14 +import com.groupeka.ka.core.str
15 +import kotlinx.coroutines.Dispatchers
16 +import kotlinx.coroutines.flow.MutableStateFlow
17 +import kotlinx.coroutines.flow.StateFlow
18 +import kotlinx.coroutines.withContext
19 +import kotlinx.serialization.Serializable
20 +import kotlinx.serialization.encodeToString
21 +import kotlinx.serialization.json.buildJsonObject
22 +import kotlinx.serialization.json.put
23 +import okhttp3.MediaType.Companion.toMediaType
24 +import okhttp3.Request
25 +import okhttp3.RequestBody.Companion.toRequestBody
26 +import java.io.IOException
27 +import java.util.UUID
28 +
29 +@Serializable
30 +data class KAIDProfile(
31 + val kaID: String,
32 + val name: String? = null,
33 + val email: String? = null,
34 + val picture: String? = null,
35 + val roleLabel: String? = null,
36 + val city: String? = null,
37 + val bio: String? = null,
38 +)
39 +
40 +object KAIDManager {
41 + private const val STORE_KEY = "ka.id.profile"
42 + private const val PREFS = "ka_prefs"
43 +
44 + val profile = MutableStateFlow<KAIDProfile?>(null)
45 + val busy = MutableStateFlow(false)
46 + val lastError = MutableStateFlow<String?>(null)
47 +
48 + private var pendingState: String? = null
49 +
50 + fun restore(context: Context) {
51 + val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
52 + .getString(STORE_KEY, null) ?: return
53 + profile.value = runCatching {
54 + ApiClient.json.decodeFromString<KAIDProfile>(raw)
55 + }.getOrNull()
56 + }
57 +
58 + /** Ouvre le hub SSO dans un Chrome Custom Tab (client déjà enregistré). */
59 + fun login(context: Context) {
60 + if (busy.value) return
61 + busy.value = true
62 + lastError.value = null
63 + val state = UUID.randomUUID().toString()
64 + pendingState = state
65 + val uri = Uri.parse("https://www.groupe-ka.com/sso/authorize").buildUpon()
66 + .appendQueryParameter("client_id", "ka-android")
67 + .appendQueryParameter("redirect_uri", "ka-android://auth/callback")
68 + .appendQueryParameter("state", state)
69 + .build()
70 + try {
71 + CustomTabsIntent.Builder().setShowTitle(true).build().launchUrl(context, uri)
72 + } catch (e: Exception) {
73 + busy.value = false
74 + lastError.value = "Aucun navigateur disponible pour la connexion."
75 + }
76 + }
77 +
78 + /** L'utilisateur est revenu du Custom Tab sans jeton (retour dans l'app). */
79 + fun cancelIfPending() {
80 + if (profile.value == null) busy.value = false
81 + }
82 +
83 + /** Appelé par MainActivity quand ka-android://auth/callback arrive. */
84 + suspend fun handleCallback(uri: Uri) {
85 + val state = uri.getQueryParameter("state")
86 + val token = uri.getQueryParameter("ka_token")
87 + if (state != pendingState || token == null) {
88 + lastError.value = "Réponse du hub invalide."
89 + busy.value = false
90 + return
91 + }
92 + exchange(token)
93 + }
94 +
95 + private suspend fun exchange(token: String) = withContext(Dispatchers.IO) {
96 + try {
97 + val payload = buildJsonObject { put("ka_token", token) }.toString()
98 + val req = Request.Builder()
99 + .url("https://www.api-ka.com/api/ios/auth/exchange")
100 + .post(payload.toRequestBody("application/json".toMediaType()))
101 + .build()
102 + ApiClient.http.newCall(req).execute().use { resp ->
103 + if (!resp.isSuccessful) throw IOException("HTTP ${resp.code}")
104 + val maybeObj = ApiClient.json.parseToJsonElement(resp.body?.string() ?: "{}").objectOrNull
105 + val kaID = maybeObj?.str("ka_id")
106 + if (maybeObj == null || kaID == null) {
107 + lastError.value = "Vérification du compte impossible."
108 + return@use
109 + }
110 + val obj = maybeObj
111 + val hub = obj["profile"].objectOrNull
112 + profile.value = KAIDProfile(
113 + kaID = kaID,
114 + name = obj.str("name") ?: hub?.str("name"),
115 + email = obj.str("email"),
116 + picture = obj.str("picture") ?: hub?.str("picture"),
117 + roleLabel = hub?.str("role_label"),
118 + city = hub?.str("city"),
119 + bio = hub?.str("bio"),
120 + )
121 + }
122 + } catch (e: Exception) {
123 + lastError.value = "Réseau indisponible — réessayez."
124 + } finally {
125 + busy.value = false
126 + }
127 + }
128 +
129 + fun persist(context: Context) {
130 + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
131 + val p = profile.value
132 + if (p == null) prefs.edit().remove(STORE_KEY).apply()
133 + else prefs.edit().putString(STORE_KEY, ApiClient.json.encodeToString(p)).apply()
134 + }
135 +
136 + fun logout(context: Context) {
137 + profile.value = null
138 + persist(context)
139 + }
140 +}
added app/src/main/java/com/groupeka/ka/core/Ecosystem.kt +345 −0
@@ -0,0 +1,345 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Ecosystem.kt — la source de vérité des 13 univers Groupe-KA dans l'app :
3 +// identité (nom, accent, symbole, tagline) + configuration d'API (endpoint de
4 +// liste, clé des items, mapping JSON → KAItem). Ajouter un univers = ajouter
5 +// une entrée ici, rien d'autre. Miroir exact de KA/Core/Ecosystem.swift (iOS).
6 +package com.groupeka.ka.core
7 +
8 +import kotlinx.serialization.Serializable
9 +import kotlinx.serialization.json.JsonObject
10 +import java.text.NumberFormat
11 +import java.util.Locale
12 +import java.util.UUID
13 +
14 +// MARK: - L'élément universel
15 +
16 +@Serializable
17 +data class KAItem(
18 + val id: String,
19 + val universeID: String,
20 + var title: String,
21 + var subtitle: String? = null,
22 + var priceLabel: String? = null,
23 + val city: String? = null,
24 + var url: String? = null,
25 + val imageURL: String? = null,
26 + val latitude: Double? = null,
27 + val longitude: Double? = null,
28 + /** Description longue (fiche) */
29 + val detail: String? = null,
30 + /** Paires libres affichées sur la fiche (« Année : 2021 », « Salaire : 65 000 $ »…) */
31 + var facts: List<Fact> = emptyList(),
32 + /** Liens riches (boutons) — ex. les comptes d'un créateur */
33 + var links: List<Fact> = emptyList(),
34 +) {
35 + @Serializable
36 + data class Fact(val label: String, val value: String)
37 +}
38 +
39 +// MARK: - Univers
40 +
41 +data class Universe(
42 + val id: String,
43 + val wordmark: String, // « Lou·Ka »
44 + val name: String, // « Lou-KA »
45 + val tagline: String,
46 + val accentHex: String,
47 + val symbol: String, // clé d'icône (mappée en Material côté UI)
48 + val domain: String,
49 + val unit: String, // « logements », « offres »…
50 + /** Chemin de la liste (null = univers sans liste native, ex. api-ka) */
51 + val listPath: String?,
52 + val itemsKey: String,
53 + val searchParam: String,
54 + val statTotalKeys: List<String>, // clés du total dans /api/stats
55 + val map: ((JsonObject) -> KAItem?)?,
56 +) {
57 + val baseURL: String get() = "https://$domain"
58 +}
59 +
60 +object Ecosystem {
61 + const val hubURL = "https://www.groupe-ka.com"
62 + const val signupURL = "https://www.groupe-ka.com/connexion"
63 + const val statusURL = "https://www.groupe-ka.com/status"
64 + val contacts: List<Pair<String, String>> = listOf(
65 + "contact@groupe-ka.com" to "Projets, partenariats & données",
66 + "info@groupe-ka.com" to "Médias & questions générales",
67 + "admin@groupe-ka.com" to "Légal, vie privée & Loi 25",
68 + )
69 + val legal: List<Pair<String, String>> = listOf(
70 + "Conditions d'utilisation" to "/conditions",
71 + "Politique de confidentialité" to "/confidentialite",
72 + "Renseignements personnels (Loi 25)" to "/loi-25",
73 + "Transparence des robots" to "/bots",
74 + )
75 + const val disclaimer = "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction. Données lues à la source — rien d'inventé, tout est traçable."
76 +
77 + fun universe(id: String): Universe? = all.firstOrNull { it.id == id }
78 +
79 + // Aides de mapping communes
80 + private fun base(o: JsonObject, universe: String, idKeys: List<String> = listOf("uid")): KAItem {
81 + val id: String? = idKeys.firstNotNullOfOrNull { k -> o[k].text }
82 + // première image utilisable : champ `images` (liste) ou `image`
83 + var image: String? = o["images"].arrayOrNull
84 + ?.mapNotNull { it.text }
85 + ?.firstOrNull { it.startsWith("http") }
86 + if (image == null) image = o.str("image", "image_url", "photo", "thumbnail")
87 + return KAItem(
88 + id = "$universe:${id ?: UUID.randomUUID()}",
89 + universeID = universe,
90 + title = o.str("title", "name", "display_name") ?: "Sans titre",
91 + city = o.str("city"),
92 + url = o.str("url"),
93 + imageURL = image,
94 + latitude = o.num("lat", "latitude"),
95 + longitude = o.num("lng", "lon", "longitude"),
96 + detail = o.str("description", "menu_summary", "bio")?.strippingHTML()?.trim(),
97 + )
98 + }
99 +
100 + // MARK: les 12 univers (+ le hub, sans liste)
101 + val all: List<Universe> = listOf(
102 + Universe(
103 + id = "lou-ka", wordmark = "Lou·Ka", name = "Lou-KA",
104 + tagline = "Tous les logements à louer", accentHex = "#ff6a00",
105 + symbol = "key", domain = "www.lou-ka.com", unit = "logements",
106 + listPath = "/api/listings", itemsKey = "listings", searchParam = "q",
107 + statTotalKeys = listOf("total"),
108 + map = { o ->
109 + val it = base(o, "lou-ka")
110 + it.subtitle = listOfNotNull(o.str("unit_type"), o.str("address")).joinToString(" · ")
111 + o.num("price")?.let { p -> if (p > 50) it.priceLabel = p.money0 + "/mois" }
112 + it.facts = listOfNotNull(
113 + o.str("unit_type")?.let { v -> KAItem.Fact("Taille", v) },
114 + o.str("address")?.let { v -> KAItem.Fact("Adresse", v) },
115 + o.str("available_by")?.let { v -> KAItem.Fact("Disponible", v) },
116 + o.num("area_sqft")?.takeIf { v -> v > 50 }?.let { v -> KAItem.Fact("Superficie", "${v.toInt()} pi²") },
117 + o.str("source")?.let { v -> KAItem.Fact("Gestionnaire", v) },
118 + )
119 + it
120 + }),
121 + Universe(
122 + id = "immo-ka", wordmark = "Immo·Ka", name = "Immo-KA",
123 + tagline = "Toutes les propriétés à vendre", accentHex = "#e23744",
124 + symbol = "house", domain = "www.immo-ka.com", unit = "propriétés",
125 + listPath = "/api/listings", itemsKey = "listings", searchParam = "q",
126 + statTotalKeys = listOf("total"),
127 + map = { o ->
128 + val it = base(o, "immo-ka")
129 + it.subtitle = listOfNotNull(o.str("property_type"), o.str("region")).joinToString(" · ")
130 + o.num("price")?.let { p -> if (p > 1000) it.priceLabel = p.money0 }
131 + it.facts = listOfNotNull(
132 + o.num("bedrooms")?.let { v -> KAItem.Fact("Chambres", v.toInt().toString()) },
133 + o.num("bathrooms")?.let { v -> KAItem.Fact("Salles de bain", v.toInt().toString()) },
134 + o.num("area_sqft")?.takeIf { v -> v > 50 }?.let { v -> KAItem.Fact("Superficie", "${v.toInt()} pi²") },
135 + o.str("address")?.let { v -> KAItem.Fact("Adresse", v) },
136 + o.str("source")?.let { v -> KAItem.Fact("Source", v) },
137 + )
138 + it
139 + }),
140 + Universe(
141 + id = "vrai-prix", wordmark = "Vrai-Prix", name = "Vrai-Prix",
142 + tagline = "La valeur réelle de chaque propriété", accentHex = "#ff5148",
143 + symbol = "chart", domain = "www.vrai-prix.com", unit = "propriétés estimées",
144 + listPath = null, itemsKey = "", searchParam = "q",
145 + statTotalKeys = listOf("units_total"), map = null),
146 + Universe(
147 + id = "auto-ka", wordmark = "Auto·Ka", name = "Auto-KA",
148 + tagline = "Les voitures usagées du Québec", accentHex = "#ff5a2a",
149 + symbol = "car", domain = "www.auto-ka.com", unit = "véhicules",
150 + listPath = "/api/vehicles", itemsKey = "vehicles", searchParam = "q",
151 + statTotalKeys = listOf("total"),
152 + map = { o ->
153 + val it = base(o, "auto-ka")
154 + it.subtitle = listOfNotNull(o.num("year")?.let { v -> v.toInt().toString() }, o.str("mileage_label"))
155 + .joinToString(" · ")
156 + it.priceLabel = o.str("price_label") ?: o.num("price")?.money0
157 + it.facts = listOfNotNull(
158 + o.str("make")?.let { v -> KAItem.Fact("Marque", v) },
159 + o.str("model")?.let { v -> KAItem.Fact("Modèle", v) },
160 + o.str("transmission")?.let { v -> KAItem.Fact("Boîte", v) },
161 + o.str("fuel")?.let { v -> KAItem.Fact("Carburant", v) },
162 + o.str("drivetrain")?.let { v -> KAItem.Fact("Rouage", v) },
163 + o.str("body_type")?.let { v -> KAItem.Fact("Carrosserie", v) },
164 + o.str("dealer_name")?.let { v -> KAItem.Fact("Concessionnaire", v) },
165 + o.str("mileage_label")?.let { v -> KAItem.Fact("Kilométrage", v) },
166 + )
167 + it
168 + }),
169 + Universe(
170 + id = "fabri-ka", wordmark = "Fabri·Ka", name = "Fabri-KA",
171 + tagline = "Les produits fabriqués au Québec", accentHex = "#c4532e",
172 + symbol = "box", domain = "www.fabri-ka.com", unit = "produits",
173 + listPath = "/api/products", itemsKey = "items", searchParam = "q",
174 + statTotalKeys = listOf("totals.products", "total"),
175 + map = { o ->
176 + val it = base(o, "fabri-ka")
177 + it.subtitle = o.str("store_id", "store")
178 + it.priceLabel = o.str("price_label") ?: o.num("price")?.money2
179 + it.facts = listOfNotNull(o.str("category")?.let { v -> KAItem.Fact("Catégorie", v) })
180 + it
181 + }),
182 + Universe(
183 + id = "food-ka", wordmark = "Food·Ka", name = "Food-KA",
184 + tagline = "Les prix d'épicerie, suivis à la source", accentHex = "#1f9d55",
185 + symbol = "cart", domain = "www.food-ka.com", unit = "produits",
186 + listPath = "/api/products", itemsKey = "products", searchParam = "q",
187 + statTotalKeys = listOf("total"),
188 + map = { o ->
189 + val it = base(o, "food-ka")
190 + it.subtitle = listOfNotNull(o.str("brand"), o.str("size_label")).joinToString(" · ")
191 + val onSale = o["on_sale"].boolOrNull == true
192 + o.num("price")?.let { p ->
193 + if (p > 0.2) {
194 + val reg = o.num("regular_price")
195 + it.priceLabel = if (onSale && reg != null && reg > p)
196 + "${p.money2} 🏷️ (rég. ${reg.money2})" else p.money2
197 + }
198 + }
199 + it.facts = listOfNotNull(
200 + o.str("category")?.let { v -> KAItem.Fact("Catégorie", v) },
201 + o.num("unit_price")?.takeIf { v -> v > 0.001 }?.let { v -> KAItem.Fact("Prix unitaire", v.money2) },
202 + if (onSale) KAItem.Fact("Solde", "Oui 🏷️") else null,
203 + )
204 + it
205 + }),
206 + Universe(
207 + id = "resto-ka", wordmark = "Resto·Ka", name = "Resto-KA",
208 + tagline = "Chaque resto, chaque plat, chaque prix", accentHex = "#f08c00",
209 + symbol = "restaurant", domain = "www.resto-ka.com", unit = "restaurants",
210 + listPath = "/api/restaurants", itemsKey = "restaurants", searchParam = "q",
211 + statTotalKeys = listOf("restaurants", "total"),
212 + map = { o ->
213 + val it = base(o, "resto-ka")
214 + val cuisines = o["cuisines"].text
215 + it.subtitle = listOfNotNull(cuisines, o.str("price_range")).joinToString(" · ")
216 + it.facts = listOfNotNull(
217 + o.str("address")?.let { v -> KAItem.Fact("Adresse", v) },
218 + o.str("chain")?.let { v -> KAItem.Fact("Chaîne", v) },
219 + )
220 + it
221 + }),
222 + Universe(
223 + id = "sorti-ka", wordmark = "Sorti·Ka", name = "Sorti-KA",
224 + tagline = "Toutes les sorties, dans les 17 régions", accentHex = "#d6336c",
225 + symbol = "ticket", domain = "www.sorti-ka.com", unit = "événements",
226 + listPath = "/api/events?upcoming=true", itemsKey = "events", searchParam = "q",
227 + statTotalKeys = listOf("total_active", "total"),
228 + map = { o ->
229 + val it = base(o, "sorti-ka")
230 + it.subtitle = listOfNotNull(o.str("start_date"), o.str("venue")).joinToString(" · ")
231 + it.facts = listOfNotNull(
232 + o.str("start_date")?.let { v -> KAItem.Fact("Début", v) },
233 + o.str("end_date")?.let { v -> KAItem.Fact("Fin", v) },
234 + o.str("venue")?.let { v -> KAItem.Fact("Lieu", v) },
235 + o.str("region")?.let { v -> KAItem.Fact("Région", v) },
236 + if (o["is_free"].boolOrNull == true) KAItem.Fact("Entrée", "Gratuite 🎉") else null,
237 + o.num("price_min")?.takeIf { v -> v > 1 }?.let { v -> KAItem.Fact("Billets dès", v.money0) },
238 + )
239 + it
240 + }),
241 + Universe(
242 + id = "crea-ka", wordmark = "Créa·Ka", name = "Créa-KA",
243 + tagline = "Les créateurs d'ici, tous leurs liens", accentHex = "#7048e8",
244 + symbol = "sparkles", domain = "www.crea-ka.com", unit = "créateurs",
245 + listPath = "/api/creators", itemsKey = "items", searchParam = "q",
246 + statTotalKeys = listOf("creators", "total"),
247 + map = { o ->
248 + val it = base(o, "crea-ka", idKeys = listOf("cid", "uid", "display_name"))
249 + val plats = o["platforms"].arrayOrNull?.mapNotNull { p -> p.objectOrNull } ?: emptyList()
250 + val totalFollowers = plats.mapNotNull { p -> p.num("followers") }.sum()
251 + it.subtitle = listOfNotNull(
252 + o["niches"].text,
253 + if (totalFollowers > 0) totalFollowers.compact + " abonnés" else null,
254 + ).joinToString(" · ")
255 + if (it.url == null) it.url = plats.firstOrNull()?.str("url")
256 + it.facts = plats.take(6).mapNotNull { pl ->
257 + val name = pl.str("platform") ?: return@mapNotNull null
258 + val f = pl.num("followers")?.compact ?: ""
259 + KAItem.Fact(
260 + name.replaceFirstChar { c -> c.uppercase() },
261 + if (f.isEmpty()) "@" + (pl.str("handle") ?: "") else "$f abonnés",
262 + )
263 + }
264 + it.links = plats.take(6).mapNotNull { pl ->
265 + val name = pl.str("platform") ?: return@mapNotNull null
266 + val url = pl.str("url") ?: return@mapNotNull null
267 + KAItem.Fact(name.replaceFirstChar { c -> c.uppercase() }, url)
268 + }
269 + it
270 + }),
271 + Universe(
272 + id = "job-ka", wordmark = "Job·Ka", name = "Job-KA",
273 + tagline = "Tous les emplois des employeurs québécois", accentHex = "#0c8599",
274 + symbol = "briefcase", domain = "www.job-ka.com", unit = "offres d'emploi",
275 + listPath = "/api/jobs", itemsKey = "jobs", searchParam = "q",
276 + statTotalKeys = listOf("total"),
277 + map = { o ->
278 + val it = base(o, "job-ka")
279 + it.subtitle = listOfNotNull(o.str("employer"), o.str("city")).joinToString(" · ")
280 + val sMin = o.num("salary_year_min")
281 + val sMax = o.num("salary_year_max")
282 + if (sMin != null && sMin > 20000) {
283 + it.priceLabel = if (sMax != null && sMax > sMin)
284 + "${sMin.money0}–${sMax.money0}/an" else sMin.money0 + "/an"
285 + } else {
286 + o.num("salary_hour_min")?.let { h -> if (h > 10) it.priceLabel = h.money2 + "/h" }
287 + }
288 + it.facts = listOfNotNull(
289 + o.str("employer")?.let { v -> KAItem.Fact("Employeur", v) },
290 + o.str("work_mode")?.let { v -> KAItem.Fact("Mode de travail", v) },
291 + o.str("employment_type")?.let { v -> KAItem.Fact("Type", v) },
292 + o.str("ats")?.let { v -> KAItem.Fact("Plateforme carrière", v) },
293 + o.str("date_posted")?.let { v -> KAItem.Fact("Publiée le", v.take(10)) },
294 + )
295 + it
296 + }),
297 + Universe(
298 + id = "trouve-ka", wordmark = "Trouve·Ka", name = "Trouve-KA",
299 + tagline = "Le moteur de recherche du web québécois", accentHex = "#1c7ed6",
300 + symbol = "search", domain = "www.trouve-ka.com", unit = "pages indexées",
301 + listPath = "/api/search", itemsKey = "results", searchParam = "q",
302 + statTotalKeys = listOf("pages_indexed"),
303 + map = { o ->
304 + val it = base(o, "trouve-ka", idKeys = listOf("url"))
305 + it.title = (o.str("title") ?: "Page web").strippingHTML()
306 + it.subtitle = o.str("snippet")?.strippingHTML()
307 + it.facts = listOfNotNull(o.str("domain")?.let { v -> KAItem.Fact("Domaine", v) })
308 + it
309 + }),
310 + Universe(
311 + id = "api-ka", wordmark = "API·Ka", name = "API-KA",
312 + tagline = "La donnée de l'écosystème, par API", accentHex = "#3b5bdb",
313 + symbol = "terminal", domain = "www.api-ka.com", unit = "appels API",
314 + listPath = null, itemsKey = "", searchParam = "q",
315 + statTotalKeys = listOf("total"), map = null),
316 + )
317 +}
318 +
319 +// MARK: - petites extensions
320 +
321 +/** 17 100 000 → « 17,1 M », 5 200 → « 5,2 k » */
322 +val Double.compact: String
323 + get() = when {
324 + this >= 1_000_000 -> String.format(Locale.US, "%.1f M", this / 1_000_000).replace(".", ",")
325 + this >= 10_000 -> String.format(Locale.US, "%.0f k", this / 1_000)
326 + this >= 1_000 -> String.format(Locale.US, "%.1f k", this / 1_000).replace(".", ",")
327 + else -> this.toInt().toString()
328 + }
329 +
330 +private val frCA: Locale = Locale.CANADA_FRENCH
331 +
332 +val Double.money0: String
333 + get() = NumberFormat.getCurrencyInstance(frCA).apply { maximumFractionDigits = 0 }.format(this)
334 +
335 +val Double.money2: String
336 + get() = NumberFormat.getCurrencyInstance(frCA).apply { maximumFractionDigits = 2 }.format(this)
337 +
338 +/** Nombre entier localisé fr-CA (« 47 840 »). */
339 +val Int.frFormatted: String
340 + get() = NumberFormat.getIntegerInstance(frCA).format(this)
341 +
342 +fun String.strippingHTML(): String =
343 + replace(Regex("<[^>]+>"), "")
344 + .replace("&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