KA Android — portage natif complet de la super-app iOS Groupe-KA
Kotlin + Jetpack Compose (Gradle 8.13 / AGP 8.9.2 / Kotlin 2.1.20), minSdk 26. - 12 univers config-driven (miroir d'Ecosystem.swift, mêmes seuils et mappings) - Client JSON + cache disque hors ligne, stats live, KA Agent SSE - Accueil vivant, recherche universelle fan-out, explorateur + fiche, Vrai-Prix natif, menus resto, favoris/collections, profil + KA ID (Custom Tabs, client ka-android), onboarding animé - Design éditorial sharp : papier/encre, ombre décalée dure, wordmarks -2°, thème clair par défaut ; 12 tests JVM verts ; APK debug vérifié Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
45 changed files +5,131 −0
added
.gitignore
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +.gradle/ | |
| 2 | +build/ | |
| 3 | +app/build/ | |
| 4 | +local.properties | |
| 5 | +.DS_Store | |
| 6 | +*.iml | |
| 7 | +.idea/ | |
| 8 | +captures/ | |
| 9 | +.externalNativeBuild/ | |
| 10 | +.cxx/ | |
added
README.md
+81 −0
@@ -0,0 +1,81 @@ | ||
| 1 | +# KA Android — la super-app Groupe-KA | |
| 2 | + | |
| 3 | +Portage Android natif complet de l'app iOS « KA » : tout l'écosystème Groupe-KA | |
| 4 | +(12 univers ·Ka) dans une seule app **Kotlin + Jetpack Compose**, compilée en | |
| 5 | +ligne de commande (aucun Android Studio requis). | |
| 6 | + | |
| 7 | +## Build | |
| 8 | + | |
| 9 | +```bash | |
| 10 | +export JAVA_HOME=$(brew --prefix openjdk@21) | |
| 11 | +# local.properties doit pointer vers le SDK : | |
| 12 | +# sdk.dir=/opt/homebrew/share/android-commandlinetools | |
| 13 | +./gradlew assembleDebug # → app/build/outputs/apk/debug/app-debug.apk | |
| 14 | +./gradlew test # tests JVM des adaptateurs d'univers (12 tests) | |
| 15 | +``` | |
| 16 | + | |
| 17 | +Trio de versions éprouvé : **Gradle 8.13 + AGP 8.9.2 + Kotlin 2.1.20** | |
| 18 | +(plugin Compose intégré à Kotlin 2.x), Compose BOM 2024.09.03, minSdk 26, | |
| 19 | +target/compileSdk 34. Portrait-first (téléphones). | |
| 20 | + | |
| 21 | +## Architecture — miroir exact de l'app iOS (`~/Desktop/KA`) | |
| 22 | + | |
| 23 | +| iOS (SwiftUI) | Android (Compose) | | |
| 24 | +|-----------------------------|-----------------------------------------------------| | |
| 25 | +| `Core/Ecosystem.swift` | `core/Ecosystem.kt` — les 12 univers config-driven | | |
| 26 | +| `Core/Services.swift` | `core/Services.kt` — client JSON + cache disque, fetch univers, liveTotal, **AgentService SSE**, menus resto, Vrai-Prix | | |
| 27 | +| `Core/KAFilters.swift` | `core/Filters.kt` — catalogue de filtres métier | | |
| 28 | +| `Core/FavoritesStore.swift` | `core/FavoritesStore.kt` — collections multi-univers persistées (JSON) | | |
| 29 | +| `Core/KAID.swift` | `auth/KaId.kt` — SSO hub via Chrome Custom Tabs (client `ka-android`, scheme `ka-android://auth/callback`, échange vérifié sur api-ka) | | |
| 30 | +| `Design/Theme.swift` | `ui/theme/Theme.kt` — papier/encre, carte à ombre décalée dure, wordmarks « Lou[Ka] » (-2°), chips mono | | |
| 31 | +| `Features/*` | `ui/*Screen.kt` — Accueil vivant, Recherche fan-out, grille Univers + explorateur + fiche, Vrai-Prix natif, menus resto, Favoris, Profil, chat KA Agent | | |
| 32 | +| `App/KAApp.swift` | `MainActivity.kt` + `KAApp.kt` — 5 onglets + bulle Agent flottante, onboarding | | |
| 33 | + | |
| 34 | +Le **thème clair est le défaut** (papier `#f5f3ee` / encre `#141814`) ; sombre | |
| 35 | +et automatique dans Profil → Apparence. Tous les textes sont en français. | |
| 36 | + | |
| 37 | +### Le cœur : `core/` (pur JVM, testable sans Android) | |
| 38 | + | |
| 39 | +- `Json.kt` — lecture « au mieux » sur `JsonElement` (miroir de `JSONValue`) : | |
| 40 | + `str()`, `num()`, `.text` (string / nombre formaté / liste jointe). | |
| 41 | +- `Ecosystem.kt` — chaque univers = id, wordmark, tagline, accent hex, domaine, | |
| 42 | + unité, `listPath`/`itemsKey`/`searchParam`, clés de stats, et **mapping | |
| 43 | + JSON → KAItem** avec toutes les subtilités iOS : première image http de | |
| 44 | + `images[]`, seuils anti-placeholders (prix ≤ 0,2 $ épicerie, ≤ 50 $ loyer, | |
| 45 | + ≤ 1000 $ propriété, salaires > 20 000 $/an ou > 10 $/h), prix barré Food·Ka, | |
| 46 | + fourchettes salariales Job·Ka, plateformes + abonnés cumulés + liens Créa·Ka, | |
| 47 | + strip-HTML Trouve·Ka. | |
| 48 | +- `Services.kt` — cache disque par URL (l'app sert la dernière copie hors | |
| 49 | + ligne), fan-out `liveTotal` (`/api/stats`, `/api/status` pour trouve-ka), | |
| 50 | + SSE OkHttp pour KA Agent (`POST https://www.api-ka.com/api/agent/chat`, | |
| 51 | + events `delta`/`tool`/`done`/`error`). | |
| 52 | + | |
| 53 | +## Ajouter un univers | |
| 54 | + | |
| 55 | +Ajouter une entrée dans `Ecosystem.all` (`core/Ecosystem.kt`) : identité, | |
| 56 | +config d'API et lambda de mapping. Rien d'autre — la grille, la recherche | |
| 57 | +universelle, l'accueil, les compteurs et la fiche le prennent en charge | |
| 58 | +automatiquement. (Ajouter des filtres métier dans `core/Filters.kt` et une | |
| 59 | +icône dans `universeIcon()` si souhaité.) | |
| 60 | + | |
| 61 | +## Écarts volontaires vs iOS (v1) | |
| 62 | + | |
| 63 | +- **Carte unifiée absente** : la MapView iOS (MapKit) n'est pas portée — | |
| 64 | + prévue pour une version ultérieure (Google Maps/MapLibre à trancher). | |
| 65 | + Les latitudes/longitudes sont déjà mappées dans `KAItem`. | |
| 66 | +- **Widgets** : pas d'équivalent des widgets iOS (KAWidgets) en v1. | |
| 67 | +- Le SSO utilise Chrome Custom Tabs (au lieu d'`ASWebAuthenticationSession`) ; | |
| 68 | + le retour se fait par `ka-android://auth/callback` (intent-filter, | |
| 69 | + `launchMode=singleTask`), puis échange `{ka_token}` sur | |
| 70 | + `https://www.api-ka.com/api/ios/auth/exchange` (accepte l'audience | |
| 71 | + `ka-android`). | |
| 72 | +- Haptique légère (API Compose) plutôt que le moteur riche d'iOS. | |
| 73 | +- Icônes Material en remplacement des SF Symbols (mapping dans `universeIcon`). | |
| 74 | + | |
| 75 | +## Tests | |
| 76 | + | |
| 77 | +`app/src/test/java/com/groupeka/ka/AdapterTests.kt` — mêmes échantillons JSON | |
| 78 | +que `KATests.swift` iOS : mapping Lou·Ka/Auto·Ka/Job·Ka/Food·Ka/Créa·Ka/ | |
| 79 | +Trouve·Ka, première image de `images[]`, accents uniques (12 univers), | |
| 80 | +construction d'URL de liste (query embarquée `upcoming=true` préservée), | |
| 81 | +favoris/collections + persistance. | |
added
app/build.gradle.kts
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +plugins { | |
| 3 | + id("com.android.application") | |
| 4 | + id("org.jetbrains.kotlin.android") | |
| 5 | + id("org.jetbrains.kotlin.plugin.compose") | |
| 6 | + id("org.jetbrains.kotlin.plugin.serialization") | |
| 7 | +} | |
| 8 | + | |
| 9 | +android { | |
| 10 | + namespace = "com.groupeka.ka" | |
| 11 | + compileSdk = 34 | |
| 12 | + | |
| 13 | + defaultConfig { | |
| 14 | + applicationId = "com.groupeka.ka" | |
| 15 | + minSdk = 26 | |
| 16 | + targetSdk = 34 | |
| 17 | + versionCode = 1 | |
| 18 | + versionName = "1.0.0" | |
| 19 | + } | |
| 20 | + | |
| 21 | + buildTypes { | |
| 22 | + release { | |
| 23 | + isMinifyEnabled = false | |
| 24 | + } | |
| 25 | + } | |
| 26 | + compileOptions { | |
| 27 | + sourceCompatibility = JavaVersion.VERSION_17 | |
| 28 | + targetCompatibility = JavaVersion.VERSION_17 | |
| 29 | + } | |
| 30 | + kotlinOptions { | |
| 31 | + jvmTarget = "17" | |
| 32 | + } | |
| 33 | + buildFeatures { | |
| 34 | + compose = true | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +dependencies { | |
| 39 | + val composeBom = platform("androidx.compose:compose-bom:2024.09.03") | |
| 40 | + implementation(composeBom) | |
| 41 | + implementation("androidx.compose.ui:ui") | |
| 42 | + implementation("androidx.compose.material3:material3") | |
| 43 | + implementation("androidx.compose.material:material-icons-extended") | |
| 44 | + implementation("androidx.activity:activity-compose:1.9.2") | |
| 45 | + implementation("androidx.navigation:navigation-compose:2.8.1") | |
| 46 | + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6") | |
| 47 | + implementation("io.coil-kt:coil-compose:2.7.0") | |
| 48 | + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") | |
| 49 | + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") | |
| 50 | + implementation("com.squareup.okhttp3:okhttp:4.12.0") | |
| 51 | + implementation("androidx.browser:browser:1.8.0") | |
| 52 | + implementation("androidx.core:core-ktx:1.13.1") | |
| 53 | + | |
| 54 | + testImplementation("junit:junit:4.13.2") | |
| 55 | + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") | |
| 56 | +} | |
added
app/src/main/AndroidManifest.xml
+36 −0
@@ -0,0 +1,36 @@ | ||
| 1 | +<?xml version="1.0" encoding="utf-8"?> | |
| 2 | +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> | |
| 3 | + | |
| 4 | + <uses-permission android:name="android.permission.INTERNET" /> | |
| 5 | + | |
| 6 | + <application | |
| 7 | + android:name=".KAApp" | |
| 8 | + android:allowBackup="true" | |
| 9 | + android:icon="@mipmap/ic_launcher" | |
| 10 | + android:roundIcon="@mipmap/ic_launcher_round" | |
| 11 | + android:label="@string/app_name" | |
| 12 | + android:supportsRtl="true" | |
| 13 | + android:theme="@style/Theme.KA"> | |
| 14 | + | |
| 15 | + <activity | |
| 16 | + android:name=".MainActivity" | |
| 17 | + android:exported="true" | |
| 18 | + android:launchMode="singleTask" | |
| 19 | + android:screenOrientation="portrait" | |
| 20 | + android:configChanges="orientation|screenSize|keyboardHidden|uiMode" | |
| 21 | + android:windowSoftInputMode="adjustResize" | |
| 22 | + android:theme="@style/Theme.KA.Splash"> | |
| 23 | + <intent-filter> | |
| 24 | + <action android:name="android.intent.action.MAIN" /> | |
| 25 | + <category android:name="android.intent.category.LAUNCHER" /> | |
| 26 | + </intent-filter> | |
| 27 | + <!-- Retour du SSO KA ID : ka-android://auth/callback --> | |
| 28 | + <intent-filter> | |
| 29 | + <action android:name="android.intent.action.VIEW" /> | |
| 30 | + <category android:name="android.intent.category.DEFAULT" /> | |
| 31 | + <category android:name="android.intent.category.BROWSABLE" /> | |
| 32 | + <data android:scheme="ka-android" android:host="auth" /> | |
| 33 | + </intent-filter> | |
| 34 | + </activity> | |
| 35 | + </application> | |
| 36 | +</manifest> | |
added
app/src/main/java/com/groupeka/ka/KAApp.kt
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KAApp.kt — Application : initialise le cache JSON disque, les préférences, | |
| 3 | +// les favoris et le profil KA ID persistant. | |
| 4 | +package com.groupeka.ka | |
| 5 | + | |
| 6 | +import android.app.Application | |
| 7 | +import com.groupeka.ka.auth.KAIDManager | |
| 8 | +import com.groupeka.ka.core.ApiClient | |
| 9 | +import com.groupeka.ka.core.FavoritesStore | |
| 10 | +import com.groupeka.ka.core.Prefs | |
| 11 | +import java.io.File | |
| 12 | + | |
| 13 | +class KAApp : Application() { | |
| 14 | + override fun onCreate() { | |
| 15 | + super.onCreate() | |
| 16 | + ApiClient.cacheDir = File(cacheDir, "ka-json") | |
| 17 | + Prefs.init(this) | |
| 18 | + FavoritesStore.shared = FavoritesStore(File(filesDir, "ka-favoris.json")) | |
| 19 | + KAIDManager.restore(this) | |
| 20 | + } | |
| 21 | +} | |
added
app/src/main/java/com/groupeka/ka/MainActivity.kt
+215 −0
@@ -0,0 +1,215 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// MainActivity.kt — point d'entrée : onboarding au premier lancement, puis la | |
| 3 | +// super-app (5 onglets + bulle KA Agent flottante partout). Reçoit aussi le | |
| 4 | +// retour SSO ka-android://auth/callback. | |
| 5 | +package com.groupeka.ka | |
| 6 | + | |
| 7 | +import android.content.Intent | |
| 8 | +import android.net.Uri | |
| 9 | +import android.os.Bundle | |
| 10 | +import androidx.activity.ComponentActivity | |
| 11 | +import androidx.activity.compose.setContent | |
| 12 | +import androidx.compose.foundation.background | |
| 13 | +import androidx.compose.foundation.border | |
| 14 | +import androidx.compose.foundation.layout.Box | |
| 15 | +import androidx.compose.foundation.layout.fillMaxSize | |
| 16 | +import androidx.compose.foundation.layout.padding | |
| 17 | +import androidx.compose.foundation.layout.size | |
| 18 | +import androidx.compose.foundation.shape.CircleShape | |
| 19 | +import androidx.compose.material.icons.Icons | |
| 20 | +import androidx.compose.material.icons.filled.Favorite | |
| 21 | +import androidx.compose.material.icons.filled.Home | |
| 22 | +import androidx.compose.material.icons.filled.Person | |
| 23 | +import androidx.compose.material.icons.filled.Search | |
| 24 | +import androidx.compose.material.icons.filled.Workspaces | |
| 25 | +import androidx.compose.material3.Icon | |
| 26 | +import androidx.compose.material3.MaterialTheme | |
| 27 | +import androidx.compose.material3.NavigationBar | |
| 28 | +import androidx.compose.material3.NavigationBarItem | |
| 29 | +import androidx.compose.material3.NavigationBarItemDefaults | |
| 30 | +import androidx.compose.material3.Scaffold | |
| 31 | +import androidx.compose.material3.Surface | |
| 32 | +import androidx.compose.material3.Text | |
| 33 | +import androidx.compose.runtime.Composable | |
| 34 | +import androidx.compose.runtime.getValue | |
| 35 | +import androidx.compose.ui.Alignment | |
| 36 | +import androidx.compose.ui.Modifier | |
| 37 | +import androidx.compose.ui.draw.shadow | |
| 38 | +import androidx.compose.ui.graphics.vector.ImageVector | |
| 39 | +import androidx.compose.ui.text.font.FontWeight | |
| 40 | +import androidx.compose.ui.unit.dp | |
| 41 | +import androidx.compose.ui.unit.sp | |
| 42 | +import androidx.compose.foundation.clickable | |
| 43 | +import androidx.lifecycle.lifecycleScope | |
| 44 | +import androidx.navigation.NavHostController | |
| 45 | +import androidx.navigation.compose.NavHost | |
| 46 | +import androidx.navigation.compose.composable | |
| 47 | +import androidx.navigation.compose.currentBackStackEntryAsState | |
| 48 | +import androidx.navigation.compose.rememberNavController | |
| 49 | +import com.groupeka.ka.auth.KAIDManager | |
| 50 | +import com.groupeka.ka.core.ApiClient | |
| 51 | +import com.groupeka.ka.core.Ecosystem | |
| 52 | +import com.groupeka.ka.core.KAItem | |
| 53 | +import com.groupeka.ka.core.Prefs | |
| 54 | +import com.groupeka.ka.core.VPResult | |
| 55 | +import com.groupeka.ka.ui.AgentChatScreen | |
| 56 | +import com.groupeka.ka.ui.EstimateScreen | |
| 57 | +import com.groupeka.ka.ui.FavoritesScreen | |
| 58 | +import com.groupeka.ka.ui.HomeScreen | |
| 59 | +import com.groupeka.ka.ui.ItemDetailScreen | |
| 60 | +import com.groupeka.ka.ui.OnboardingScreen | |
| 61 | +import com.groupeka.ka.ui.ProfileScreen | |
| 62 | +import com.groupeka.ka.ui.SearchScreen | |
| 63 | +import com.groupeka.ka.ui.UniverseHomeScreen | |
| 64 | +import com.groupeka.ka.ui.UniversesScreen | |
| 65 | +import com.groupeka.ka.ui.theme.KAAppTheme | |
| 66 | +import com.groupeka.ka.ui.theme.KATheme | |
| 67 | +import com.groupeka.ka.ui.theme.LocalKADark | |
| 68 | +import kotlinx.coroutines.launch | |
| 69 | +import kotlinx.serialization.encodeToString | |
| 70 | +import kotlinx.serialization.serializer | |
| 71 | +import java.nio.charset.StandardCharsets | |
| 72 | +import java.util.Base64 | |
| 73 | + | |
| 74 | +// MARK: - Encodage d'objets dans les routes de navigation | |
| 75 | + | |
| 76 | +inline fun <reified T> encodeNavArg(value: T): String = | |
| 77 | + Base64.getUrlEncoder().withoutPadding() | |
| 78 | + .encodeToString(ApiClient.json.encodeToString(serializer<T>(), value).toByteArray(StandardCharsets.UTF_8)) | |
| 79 | + | |
| 80 | +inline fun <reified T> decodeNavArg(raw: String): T? = runCatching { | |
| 81 | + ApiClient.json.decodeFromString( | |
| 82 | + serializer<T>(), | |
| 83 | + String(Base64.getUrlDecoder().decode(raw), StandardCharsets.UTF_8), | |
| 84 | + ) | |
| 85 | +}.getOrNull() | |
| 86 | + | |
| 87 | +class MainActivity : ComponentActivity() { | |
| 88 | + | |
| 89 | + override fun onCreate(savedInstanceState: Bundle?) { | |
| 90 | + super.onCreate(savedInstanceState) | |
| 91 | + handleAuthIntent(intent) | |
| 92 | + setContent { | |
| 93 | + KAAppTheme { | |
| 94 | + Surface(Modifier.fillMaxSize(), color = KATheme.paper(LocalKADark.current)) { | |
| 95 | + if (Prefs.onboarded) RootView() else OnboardingScreen() | |
| 96 | + } | |
| 97 | + } | |
| 98 | + } | |
| 99 | + } | |
| 100 | + | |
| 101 | + override fun onNewIntent(intent: Intent) { | |
| 102 | + super.onNewIntent(intent) | |
| 103 | + handleAuthIntent(intent) | |
| 104 | + } | |
| 105 | + | |
| 106 | + override fun onResume() { | |
| 107 | + super.onResume() | |
| 108 | + // retour du Custom Tab sans callback : libérer l'état « occupé » | |
| 109 | + KAIDManager.cancelIfPending() | |
| 110 | + } | |
| 111 | + | |
| 112 | + private fun handleAuthIntent(intent: Intent?) { | |
| 113 | + val uri: Uri = intent?.data ?: return | |
| 114 | + if (uri.scheme == "ka-android" && uri.host == "auth") { | |
| 115 | + lifecycleScope.launch { | |
| 116 | + KAIDManager.handleCallback(uri) | |
| 117 | + KAIDManager.persist(this@MainActivity) | |
| 118 | + } | |
| 119 | + } | |
| 120 | + } | |
| 121 | +} | |
| 122 | + | |
| 123 | +// MARK: - Racine : 5 onglets + bulle Agent | |
| 124 | + | |
| 125 | +private data class Tab(val route: String, val label: String, val icon: ImageVector) | |
| 126 | + | |
| 127 | +private val tabs = listOf( | |
| 128 | + Tab("home", "Accueil", Icons.Filled.Home), | |
| 129 | + Tab("search", "Recherche", Icons.Filled.Search), | |
| 130 | + Tab("universes", "Univers", Icons.Filled.Workspaces), | |
| 131 | + Tab("favorites", "Favoris", Icons.Filled.Favorite), | |
| 132 | + Tab("profile", "Profil", Icons.Filled.Person), | |
| 133 | +) | |
| 134 | + | |
| 135 | +@Composable | |
| 136 | +fun RootView() { | |
| 137 | + val nav: NavHostController = rememberNavController() | |
| 138 | + val backStack by nav.currentBackStackEntryAsState() | |
| 139 | + val route = backStack?.destination?.route | |
| 140 | + val onTab = tabs.any { it.route == route } | |
| 141 | + | |
| 142 | + Scaffold( | |
| 143 | + containerColor = KATheme.paper(LocalKADark.current), | |
| 144 | + bottomBar = { | |
| 145 | + if (onTab) { | |
| 146 | + NavigationBar(containerColor = KATheme.surface(LocalKADark.current)) { | |
| 147 | + tabs.forEach { tab -> | |
| 148 | + NavigationBarItem( | |
| 149 | + selected = route == tab.route, | |
| 150 | + onClick = { | |
| 151 | + nav.navigate(tab.route) { | |
| 152 | + popUpTo(nav.graph.startDestinationId) { saveState = true } | |
| 153 | + launchSingleTop = true | |
| 154 | + restoreState = true | |
| 155 | + } | |
| 156 | + }, | |
| 157 | + icon = { Icon(tab.icon, null) }, | |
| 158 | + label = { Text(tab.label, fontSize = 10.sp) }, | |
| 159 | + colors = NavigationBarItemDefaults.colors( | |
| 160 | + selectedIconColor = KATheme.green, | |
| 161 | + selectedTextColor = KATheme.green, | |
| 162 | + indicatorColor = KATheme.green.copy(alpha = 0.14f), | |
| 163 | + ), | |
| 164 | + ) | |
| 165 | + } | |
| 166 | + } | |
| 167 | + } | |
| 168 | + }, | |
| 169 | + ) { padding -> | |
| 170 | + Box(Modifier.fillMaxSize().padding(padding)) { | |
| 171 | + NavHost(navController = nav, startDestination = "home") { | |
| 172 | + composable("home") { HomeScreen(nav) } | |
| 173 | + composable("search") { SearchScreen(nav) } | |
| 174 | + composable("universes") { UniversesScreen(nav) } | |
| 175 | + composable("favorites") { FavoritesScreen(nav) } | |
| 176 | + composable("profile") { ProfileScreen() } | |
| 177 | + composable("universe/{id}") { entry -> | |
| 178 | + val id = entry.arguments?.getString("id") | |
| 179 | + Ecosystem.universe(id ?: "")?.let { UniverseHomeScreen(it, nav) } | |
| 180 | + } | |
| 181 | + composable("item/{data}") { entry -> | |
| 182 | + val item = entry.arguments?.getString("data")?.let { decodeNavArg<KAItem>(it) } | |
| 183 | + if (item != null) ItemDetailScreen(item, nav) | |
| 184 | + } | |
| 185 | + composable("estimate/{data}") { entry -> | |
| 186 | + val res = entry.arguments?.getString("data")?.let { decodeNavArg<VPResult>(it) } | |
| 187 | + if (res != null) EstimateScreen(res) | |
| 188 | + } | |
| 189 | + composable("agent") { AgentChatScreen(onClose = { nav.popBackStack() }) } | |
| 190 | + } | |
| 191 | + | |
| 192 | + // Bulle KA Agent — flottante au-dessus de la tab bar, partout sauf dans l'agent | |
| 193 | + if (route != "agent") { | |
| 194 | + Box( | |
| 195 | + Modifier | |
| 196 | + .align(Alignment.BottomEnd) | |
| 197 | + .padding(end = 16.dp, bottom = 18.dp) | |
| 198 | + .size(54.dp) | |
| 199 | + .shadow(3.dp, CircleShape) | |
| 200 | + .background(KATheme.lime, CircleShape) | |
| 201 | + .border(2.dp, KATheme.inkLight, CircleShape) | |
| 202 | + .clickable { nav.navigate("agent") { launchSingleTop = true } }, | |
| 203 | + contentAlignment = Alignment.Center, | |
| 204 | + ) { | |
| 205 | + Text( | |
| 206 | + "Ka", | |
| 207 | + fontSize = 19.sp, | |
| 208 | + fontWeight = FontWeight.Bold, | |
| 209 | + color = KATheme.inkLight, | |
| 210 | + ) | |
| 211 | + } | |
| 212 | + } | |
| 213 | + } | |
| 214 | + } | |
| 215 | +} | |
added
app/src/main/java/com/groupeka/ka/auth/KaId.kt
+140 −0
@@ -0,0 +1,140 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KaId.kt — connexion KA ID NATIVE : Chrome Custom Tabs vers le hub | |
| 3 | +// groupe-ka.com (SSO officiel de l'écosystème, client « ka-android »), retour | |
| 4 | +// par le scheme ka-android://auth/callback, puis ÉCHANGE VÉRIFIÉ côté serveur | |
| 5 | +// (api-ka valide la signature du jeton — aucun secret dans l'app) qui renvoie | |
| 6 | +// le profil. | |
| 7 | +package com.groupeka.ka.auth | |
| 8 | + | |
| 9 | +import android.content.Context | |
| 10 | +import android.net.Uri | |
| 11 | +import androidx.browser.customtabs.CustomTabsIntent | |
| 12 | +import com.groupeka.ka.core.ApiClient | |
| 13 | +import com.groupeka.ka.core.objectOrNull | |
| 14 | +import com.groupeka.ka.core.str | |
| 15 | +import kotlinx.coroutines.Dispatchers | |
| 16 | +import kotlinx.coroutines.flow.MutableStateFlow | |
| 17 | +import kotlinx.coroutines.flow.StateFlow | |
| 18 | +import kotlinx.coroutines.withContext | |
| 19 | +import kotlinx.serialization.Serializable | |
| 20 | +import kotlinx.serialization.encodeToString | |
| 21 | +import kotlinx.serialization.json.buildJsonObject | |
| 22 | +import kotlinx.serialization.json.put | |
| 23 | +import okhttp3.MediaType.Companion.toMediaType | |
| 24 | +import okhttp3.Request | |
| 25 | +import okhttp3.RequestBody.Companion.toRequestBody | |
| 26 | +import java.io.IOException | |
| 27 | +import java.util.UUID | |
| 28 | + | |
| 29 | +@Serializable | |
| 30 | +data class KAIDProfile( | |
| 31 | + val kaID: String, | |
| 32 | + val name: String? = null, | |
| 33 | + val email: String? = null, | |
| 34 | + val picture: String? = null, | |
| 35 | + val roleLabel: String? = null, | |
| 36 | + val city: String? = null, | |
| 37 | + val bio: String? = null, | |
| 38 | +) | |
| 39 | + | |
| 40 | +object KAIDManager { | |
| 41 | + private const val STORE_KEY = "ka.id.profile" | |
| 42 | + private const val PREFS = "ka_prefs" | |
| 43 | + | |
| 44 | + val profile = MutableStateFlow<KAIDProfile?>(null) | |
| 45 | + val busy = MutableStateFlow(false) | |
| 46 | + val lastError = MutableStateFlow<String?>(null) | |
| 47 | + | |
| 48 | + private var pendingState: String? = null | |
| 49 | + | |
| 50 | + fun restore(context: Context) { | |
| 51 | + val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) | |
| 52 | + .getString(STORE_KEY, null) ?: return | |
| 53 | + profile.value = runCatching { | |
| 54 | + ApiClient.json.decodeFromString<KAIDProfile>(raw) | |
| 55 | + }.getOrNull() | |
| 56 | + } | |
| 57 | + | |
| 58 | + /** Ouvre le hub SSO dans un Chrome Custom Tab (client déjà enregistré). */ | |
| 59 | + fun login(context: Context) { | |
| 60 | + if (busy.value) return | |
| 61 | + busy.value = true | |
| 62 | + lastError.value = null | |
| 63 | + val state = UUID.randomUUID().toString() | |
| 64 | + pendingState = state | |
| 65 | + val uri = Uri.parse("https://www.groupe-ka.com/sso/authorize").buildUpon() | |
| 66 | + .appendQueryParameter("client_id", "ka-android") | |
| 67 | + .appendQueryParameter("redirect_uri", "ka-android://auth/callback") | |
| 68 | + .appendQueryParameter("state", state) | |
| 69 | + .build() | |
| 70 | + try { | |
| 71 | + CustomTabsIntent.Builder().setShowTitle(true).build().launchUrl(context, uri) | |
| 72 | + } catch (e: Exception) { | |
| 73 | + busy.value = false | |
| 74 | + lastError.value = "Aucun navigateur disponible pour la connexion." | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + /** L'utilisateur est revenu du Custom Tab sans jeton (retour dans l'app). */ | |
| 79 | + fun cancelIfPending() { | |
| 80 | + if (profile.value == null) busy.value = false | |
| 81 | + } | |
| 82 | + | |
| 83 | + /** Appelé par MainActivity quand ka-android://auth/callback arrive. */ | |
| 84 | + suspend fun handleCallback(uri: Uri) { | |
| 85 | + val state = uri.getQueryParameter("state") | |
| 86 | + val token = uri.getQueryParameter("ka_token") | |
| 87 | + if (state != pendingState || token == null) { | |
| 88 | + lastError.value = "Réponse du hub invalide." | |
| 89 | + busy.value = false | |
| 90 | + return | |
| 91 | + } | |
| 92 | + exchange(token) | |
| 93 | + } | |
| 94 | + | |
| 95 | + private suspend fun exchange(token: String) = withContext(Dispatchers.IO) { | |
| 96 | + try { | |
| 97 | + val payload = buildJsonObject { put("ka_token", token) }.toString() | |
| 98 | + val req = Request.Builder() | |
| 99 | + .url("https://www.api-ka.com/api/ios/auth/exchange") | |
| 100 | + .post(payload.toRequestBody("application/json".toMediaType())) | |
| 101 | + .build() | |
| 102 | + ApiClient.http.newCall(req).execute().use { resp -> | |
| 103 | + if (!resp.isSuccessful) throw IOException("HTTP ${resp.code}") | |
| 104 | + val maybeObj = ApiClient.json.parseToJsonElement(resp.body?.string() ?: "{}").objectOrNull | |
| 105 | + val kaID = maybeObj?.str("ka_id") | |
| 106 | + if (maybeObj == null || kaID == null) { | |
| 107 | + lastError.value = "Vérification du compte impossible." | |
| 108 | + return@use | |
| 109 | + } | |
| 110 | + val obj = maybeObj | |
| 111 | + val hub = obj["profile"].objectOrNull | |
| 112 | + profile.value = KAIDProfile( | |
| 113 | + kaID = kaID, | |
| 114 | + name = obj.str("name") ?: hub?.str("name"), | |
| 115 | + email = obj.str("email"), | |
| 116 | + picture = obj.str("picture") ?: hub?.str("picture"), | |
| 117 | + roleLabel = hub?.str("role_label"), | |
| 118 | + city = hub?.str("city"), | |
| 119 | + bio = hub?.str("bio"), | |
| 120 | + ) | |
| 121 | + } | |
| 122 | + } catch (e: Exception) { | |
| 123 | + lastError.value = "Réseau indisponible — réessayez." | |
| 124 | + } finally { | |
| 125 | + busy.value = false | |
| 126 | + } | |
| 127 | + } | |
| 128 | + | |
| 129 | + fun persist(context: Context) { | |
| 130 | + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) | |
| 131 | + val p = profile.value | |
| 132 | + if (p == null) prefs.edit().remove(STORE_KEY).apply() | |
| 133 | + else prefs.edit().putString(STORE_KEY, ApiClient.json.encodeToString(p)).apply() | |
| 134 | + } | |
| 135 | + | |
| 136 | + fun logout(context: Context) { | |
| 137 | + profile.value = null | |
| 138 | + persist(context) | |
| 139 | + } | |
| 140 | +} | |
added
app/src/main/java/com/groupeka/ka/core/Ecosystem.kt
+345 −0
@@ -0,0 +1,345 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Ecosystem.kt — la source de vérité des 13 univers Groupe-KA dans l'app : | |
| 3 | +// identité (nom, accent, symbole, tagline) + configuration d'API (endpoint de | |
| 4 | +// liste, clé des items, mapping JSON → KAItem). Ajouter un univers = ajouter | |
| 5 | +// une entrée ici, rien d'autre. Miroir exact de KA/Core/Ecosystem.swift (iOS). | |
| 6 | +package com.groupeka.ka.core | |
| 7 | + | |
| 8 | +import kotlinx.serialization.Serializable | |
| 9 | +import kotlinx.serialization.json.JsonObject | |
| 10 | +import java.text.NumberFormat | |
| 11 | +import java.util.Locale | |
| 12 | +import java.util.UUID | |
| 13 | + | |
| 14 | +// MARK: - L'élément universel | |
| 15 | + | |
| 16 | +@Serializable | |
| 17 | +data class KAItem( | |
| 18 | + val id: String, | |
| 19 | + val universeID: String, | |
| 20 | + var title: String, | |
| 21 | + var subtitle: String? = null, | |
| 22 | + var priceLabel: String? = null, | |
| 23 | + val city: String? = null, | |
| 24 | + var url: String? = null, | |
| 25 | + val imageURL: String? = null, | |
| 26 | + val latitude: Double? = null, | |
| 27 | + val longitude: Double? = null, | |
| 28 | + /** Description longue (fiche) */ | |
| 29 | + val detail: String? = null, | |
| 30 | + /** Paires libres affichées sur la fiche (« Année : 2021 », « Salaire : 65 000 $ »…) */ | |
| 31 | + var facts: List<Fact> = emptyList(), | |
| 32 | + /** Liens riches (boutons) — ex. les comptes d'un créateur */ | |
| 33 | + var links: List<Fact> = emptyList(), | |
| 34 | +) { | |
| 35 | + @Serializable | |
| 36 | + data class Fact(val label: String, val value: String) | |
| 37 | +} | |
| 38 | + | |
| 39 | +// MARK: - Univers | |
| 40 | + | |
| 41 | +data class Universe( | |
| 42 | + val id: String, | |
| 43 | + val wordmark: String, // « Lou·Ka » | |
| 44 | + val name: String, // « Lou-KA » | |
| 45 | + val tagline: String, | |
| 46 | + val accentHex: String, | |
| 47 | + val symbol: String, // clé d'icône (mappée en Material côté UI) | |
| 48 | + val domain: String, | |
| 49 | + val unit: String, // « logements », « offres »… | |
| 50 | + /** Chemin de la liste (null = univers sans liste native, ex. api-ka) */ | |
| 51 | + val listPath: String?, | |
| 52 | + val itemsKey: String, | |
| 53 | + val searchParam: String, | |
| 54 | + val statTotalKeys: List<String>, // clés du total dans /api/stats | |
| 55 | + val map: ((JsonObject) -> KAItem?)?, | |
| 56 | +) { | |
| 57 | + val baseURL: String get() = "https://$domain" | |
| 58 | +} | |
| 59 | + | |
| 60 | +object Ecosystem { | |
| 61 | + const val hubURL = "https://www.groupe-ka.com" | |
| 62 | + const val signupURL = "https://www.groupe-ka.com/connexion" | |
| 63 | + const val statusURL = "https://www.groupe-ka.com/status" | |
| 64 | + val contacts: List<Pair<String, String>> = listOf( | |
| 65 | + "contact@groupe-ka.com" to "Projets, partenariats & données", | |
| 66 | + "info@groupe-ka.com" to "Médias & questions générales", | |
| 67 | + "admin@groupe-ka.com" to "Légal, vie privée & Loi 25", | |
| 68 | + ) | |
| 69 | + val legal: List<Pair<String, String>> = listOf( | |
| 70 | + "Conditions d'utilisation" to "/conditions", | |
| 71 | + "Politique de confidentialité" to "/confidentialite", | |
| 72 | + "Renseignements personnels (Loi 25)" to "/loi-25", | |
| 73 | + "Transparence des robots" to "/bots", | |
| 74 | + ) | |
| 75 | + const val disclaimer = "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction. Données lues à la source — rien d'inventé, tout est traçable." | |
| 76 | + | |
| 77 | + fun universe(id: String): Universe? = all.firstOrNull { it.id == id } | |
| 78 | + | |
| 79 | + // Aides de mapping communes | |
| 80 | + private fun base(o: JsonObject, universe: String, idKeys: List<String> = listOf("uid")): KAItem { | |
| 81 | + val id: String? = idKeys.firstNotNullOfOrNull { k -> o[k].text } | |
| 82 | + // première image utilisable : champ `images` (liste) ou `image` | |
| 83 | + var image: String? = o["images"].arrayOrNull | |
| 84 | + ?.mapNotNull { it.text } | |
| 85 | + ?.firstOrNull { it.startsWith("http") } | |
| 86 | + if (image == null) image = o.str("image", "image_url", "photo", "thumbnail") | |
| 87 | + return KAItem( | |
| 88 | + id = "$universe:${id ?: UUID.randomUUID()}", | |
| 89 | + universeID = universe, | |
| 90 | + title = o.str("title", "name", "display_name") ?: "Sans titre", | |
| 91 | + city = o.str("city"), | |
| 92 | + url = o.str("url"), | |
| 93 | + imageURL = image, | |
| 94 | + latitude = o.num("lat", "latitude"), | |
| 95 | + longitude = o.num("lng", "lon", "longitude"), | |
| 96 | + detail = o.str("description", "menu_summary", "bio")?.strippingHTML()?.trim(), | |
| 97 | + ) | |
| 98 | + } | |
| 99 | + | |
| 100 | + // MARK: les 12 univers (+ le hub, sans liste) | |
| 101 | + val all: List<Universe> = listOf( | |
| 102 | + Universe( | |
| 103 | + id = "lou-ka", wordmark = "Lou·Ka", name = "Lou-KA", | |
| 104 | + tagline = "Tous les logements à louer", accentHex = "#ff6a00", | |
| 105 | + symbol = "key", domain = "www.lou-ka.com", unit = "logements", | |
| 106 | + listPath = "/api/listings", itemsKey = "listings", searchParam = "q", | |
| 107 | + statTotalKeys = listOf("total"), | |
| 108 | + map = { o -> | |
| 109 | + val it = base(o, "lou-ka") | |
| 110 | + it.subtitle = listOfNotNull(o.str("unit_type"), o.str("address")).joinToString(" · ") | |
| 111 | + o.num("price")?.let { p -> if (p > 50) it.priceLabel = p.money0 + "/mois" } | |
| 112 | + it.facts = listOfNotNull( | |
| 113 | + o.str("unit_type")?.let { v -> KAItem.Fact("Taille", v) }, | |
| 114 | + o.str("address")?.let { v -> KAItem.Fact("Adresse", v) }, | |
| 115 | + o.str("available_by")?.let { v -> KAItem.Fact("Disponible", v) }, | |
| 116 | + o.num("area_sqft")?.takeIf { v -> v > 50 }?.let { v -> KAItem.Fact("Superficie", "${v.toInt()} pi²") }, | |
| 117 | + o.str("source")?.let { v -> KAItem.Fact("Gestionnaire", v) }, | |
| 118 | + ) | |
| 119 | + it | |
| 120 | + }), | |
| 121 | + Universe( | |
| 122 | + id = "immo-ka", wordmark = "Immo·Ka", name = "Immo-KA", | |
| 123 | + tagline = "Toutes les propriétés à vendre", accentHex = "#e23744", | |
| 124 | + symbol = "house", domain = "www.immo-ka.com", unit = "propriétés", | |
| 125 | + listPath = "/api/listings", itemsKey = "listings", searchParam = "q", | |
| 126 | + statTotalKeys = listOf("total"), | |
| 127 | + map = { o -> | |
| 128 | + val it = base(o, "immo-ka") | |
| 129 | + it.subtitle = listOfNotNull(o.str("property_type"), o.str("region")).joinToString(" · ") | |
| 130 | + o.num("price")?.let { p -> if (p > 1000) it.priceLabel = p.money0 } | |
| 131 | + it.facts = listOfNotNull( | |
| 132 | + o.num("bedrooms")?.let { v -> KAItem.Fact("Chambres", v.toInt().toString()) }, | |
| 133 | + o.num("bathrooms")?.let { v -> KAItem.Fact("Salles de bain", v.toInt().toString()) }, | |
| 134 | + o.num("area_sqft")?.takeIf { v -> v > 50 }?.let { v -> KAItem.Fact("Superficie", "${v.toInt()} pi²") }, | |
| 135 | + o.str("address")?.let { v -> KAItem.Fact("Adresse", v) }, | |
| 136 | + o.str("source")?.let { v -> KAItem.Fact("Source", v) }, | |
| 137 | + ) | |
| 138 | + it | |
| 139 | + }), | |
| 140 | + Universe( | |
| 141 | + id = "vrai-prix", wordmark = "Vrai-Prix", name = "Vrai-Prix", | |
| 142 | + tagline = "La valeur réelle de chaque propriété", accentHex = "#ff5148", | |
| 143 | + symbol = "chart", domain = "www.vrai-prix.com", unit = "propriétés estimées", | |
| 144 | + listPath = null, itemsKey = "", searchParam = "q", | |
| 145 | + statTotalKeys = listOf("units_total"), map = null), | |
| 146 | + Universe( | |
| 147 | + id = "auto-ka", wordmark = "Auto·Ka", name = "Auto-KA", | |
| 148 | + tagline = "Les voitures usagées du Québec", accentHex = "#ff5a2a", | |
| 149 | + symbol = "car", domain = "www.auto-ka.com", unit = "véhicules", | |
| 150 | + listPath = "/api/vehicles", itemsKey = "vehicles", searchParam = "q", | |
| 151 | + statTotalKeys = listOf("total"), | |
| 152 | + map = { o -> | |
| 153 | + val it = base(o, "auto-ka") | |
| 154 | + it.subtitle = listOfNotNull(o.num("year")?.let { v -> v.toInt().toString() }, o.str("mileage_label")) | |
| 155 | + .joinToString(" · ") | |
| 156 | + it.priceLabel = o.str("price_label") ?: o.num("price")?.money0 | |
| 157 | + it.facts = listOfNotNull( | |
| 158 | + o.str("make")?.let { v -> KAItem.Fact("Marque", v) }, | |
| 159 | + o.str("model")?.let { v -> KAItem.Fact("Modèle", v) }, | |
| 160 | + o.str("transmission")?.let { v -> KAItem.Fact("Boîte", v) }, | |
| 161 | + o.str("fuel")?.let { v -> KAItem.Fact("Carburant", v) }, | |
| 162 | + o.str("drivetrain")?.let { v -> KAItem.Fact("Rouage", v) }, | |
| 163 | + o.str("body_type")?.let { v -> KAItem.Fact("Carrosserie", v) }, | |
| 164 | + o.str("dealer_name")?.let { v -> KAItem.Fact("Concessionnaire", v) }, | |
| 165 | + o.str("mileage_label")?.let { v -> KAItem.Fact("Kilométrage", v) }, | |
| 166 | + ) | |
| 167 | + it | |
| 168 | + }), | |
| 169 | + Universe( | |
| 170 | + id = "fabri-ka", wordmark = "Fabri·Ka", name = "Fabri-KA", | |
| 171 | + tagline = "Les produits fabriqués au Québec", accentHex = "#c4532e", | |
| 172 | + symbol = "box", domain = "www.fabri-ka.com", unit = "produits", | |
| 173 | + listPath = "/api/products", itemsKey = "items", searchParam = "q", | |
| 174 | + statTotalKeys = listOf("totals.products", "total"), | |
| 175 | + map = { o -> | |
| 176 | + val it = base(o, "fabri-ka") | |
| 177 | + it.subtitle = o.str("store_id", "store") | |
| 178 | + it.priceLabel = o.str("price_label") ?: o.num("price")?.money2 | |
| 179 | + it.facts = listOfNotNull(o.str("category")?.let { v -> KAItem.Fact("Catégorie", v) }) | |
| 180 | + it | |
| 181 | + }), | |
| 182 | + Universe( | |
| 183 | + id = "food-ka", wordmark = "Food·Ka", name = "Food-KA", | |
| 184 | + tagline = "Les prix d'épicerie, suivis à la source", accentHex = "#1f9d55", | |
| 185 | + symbol = "cart", domain = "www.food-ka.com", unit = "produits", | |
| 186 | + listPath = "/api/products", itemsKey = "products", searchParam = "q", | |
| 187 | + statTotalKeys = listOf("total"), | |
| 188 | + map = { o -> | |
| 189 | + val it = base(o, "food-ka") | |
| 190 | + it.subtitle = listOfNotNull(o.str("brand"), o.str("size_label")).joinToString(" · ") | |
| 191 | + val onSale = o["on_sale"].boolOrNull == true | |
| 192 | + o.num("price")?.let { p -> | |
| 193 | + if (p > 0.2) { | |
| 194 | + val reg = o.num("regular_price") | |
| 195 | + it.priceLabel = if (onSale && reg != null && reg > p) | |
| 196 | + "${p.money2} 🏷️ (rég. ${reg.money2})" else p.money2 | |
| 197 | + } | |
| 198 | + } | |
| 199 | + it.facts = listOfNotNull( | |
| 200 | + o.str("category")?.let { v -> KAItem.Fact("Catégorie", v) }, | |
| 201 | + o.num("unit_price")?.takeIf { v -> v > 0.001 }?.let { v -> KAItem.Fact("Prix unitaire", v.money2) }, | |
| 202 | + if (onSale) KAItem.Fact("Solde", "Oui 🏷️") else null, | |
| 203 | + ) | |
| 204 | + it | |
| 205 | + }), | |
| 206 | + Universe( | |
| 207 | + id = "resto-ka", wordmark = "Resto·Ka", name = "Resto-KA", | |
| 208 | + tagline = "Chaque resto, chaque plat, chaque prix", accentHex = "#f08c00", | |
| 209 | + symbol = "restaurant", domain = "www.resto-ka.com", unit = "restaurants", | |
| 210 | + listPath = "/api/restaurants", itemsKey = "restaurants", searchParam = "q", | |
| 211 | + statTotalKeys = listOf("restaurants", "total"), | |
| 212 | + map = { o -> | |
| 213 | + val it = base(o, "resto-ka") | |
| 214 | + val cuisines = o["cuisines"].text | |
| 215 | + it.subtitle = listOfNotNull(cuisines, o.str("price_range")).joinToString(" · ") | |
| 216 | + it.facts = listOfNotNull( | |
| 217 | + o.str("address")?.let { v -> KAItem.Fact("Adresse", v) }, | |
| 218 | + o.str("chain")?.let { v -> KAItem.Fact("Chaîne", v) }, | |
| 219 | + ) | |
| 220 | + it | |
| 221 | + }), | |
| 222 | + Universe( | |
| 223 | + id = "sorti-ka", wordmark = "Sorti·Ka", name = "Sorti-KA", | |
| 224 | + tagline = "Toutes les sorties, dans les 17 régions", accentHex = "#d6336c", | |
| 225 | + symbol = "ticket", domain = "www.sorti-ka.com", unit = "événements", | |
| 226 | + listPath = "/api/events?upcoming=true", itemsKey = "events", searchParam = "q", | |
| 227 | + statTotalKeys = listOf("total_active", "total"), | |
| 228 | + map = { o -> | |
| 229 | + val it = base(o, "sorti-ka") | |
| 230 | + it.subtitle = listOfNotNull(o.str("start_date"), o.str("venue")).joinToString(" · ") | |
| 231 | + it.facts = listOfNotNull( | |
| 232 | + o.str("start_date")?.let { v -> KAItem.Fact("Début", v) }, | |
| 233 | + o.str("end_date")?.let { v -> KAItem.Fact("Fin", v) }, | |
| 234 | + o.str("venue")?.let { v -> KAItem.Fact("Lieu", v) }, | |
| 235 | + o.str("region")?.let { v -> KAItem.Fact("Région", v) }, | |
| 236 | + if (o["is_free"].boolOrNull == true) KAItem.Fact("Entrée", "Gratuite 🎉") else null, | |
| 237 | + o.num("price_min")?.takeIf { v -> v > 1 }?.let { v -> KAItem.Fact("Billets dès", v.money0) }, | |
| 238 | + ) | |
| 239 | + it | |
| 240 | + }), | |
| 241 | + Universe( | |
| 242 | + id = "crea-ka", wordmark = "Créa·Ka", name = "Créa-KA", | |
| 243 | + tagline = "Les créateurs d'ici, tous leurs liens", accentHex = "#7048e8", | |
| 244 | + symbol = "sparkles", domain = "www.crea-ka.com", unit = "créateurs", | |
| 245 | + listPath = "/api/creators", itemsKey = "items", searchParam = "q", | |
| 246 | + statTotalKeys = listOf("creators", "total"), | |
| 247 | + map = { o -> | |
| 248 | + val it = base(o, "crea-ka", idKeys = listOf("cid", "uid", "display_name")) | |
| 249 | + val plats = o["platforms"].arrayOrNull?.mapNotNull { p -> p.objectOrNull } ?: emptyList() | |
| 250 | + val totalFollowers = plats.mapNotNull { p -> p.num("followers") }.sum() | |
| 251 | + it.subtitle = listOfNotNull( | |
| 252 | + o["niches"].text, | |
| 253 | + if (totalFollowers > 0) totalFollowers.compact + " abonnés" else null, | |
| 254 | + ).joinToString(" · ") | |
| 255 | + if (it.url == null) it.url = plats.firstOrNull()?.str("url") | |
| 256 | + it.facts = plats.take(6).mapNotNull { pl -> | |
| 257 | + val name = pl.str("platform") ?: return@mapNotNull null | |
| 258 | + val f = pl.num("followers")?.compact ?: "" | |
| 259 | + KAItem.Fact( | |
| 260 | + name.replaceFirstChar { c -> c.uppercase() }, | |
| 261 | + if (f.isEmpty()) "@" + (pl.str("handle") ?: "") else "$f abonnés", | |
| 262 | + ) | |
| 263 | + } | |
| 264 | + it.links = plats.take(6).mapNotNull { pl -> | |
| 265 | + val name = pl.str("platform") ?: return@mapNotNull null | |
| 266 | + val url = pl.str("url") ?: return@mapNotNull null | |
| 267 | + KAItem.Fact(name.replaceFirstChar { c -> c.uppercase() }, url) | |
| 268 | + } | |
| 269 | + it | |
| 270 | + }), | |
| 271 | + Universe( | |
| 272 | + id = "job-ka", wordmark = "Job·Ka", name = "Job-KA", | |
| 273 | + tagline = "Tous les emplois des employeurs québécois", accentHex = "#0c8599", | |
| 274 | + symbol = "briefcase", domain = "www.job-ka.com", unit = "offres d'emploi", | |
| 275 | + listPath = "/api/jobs", itemsKey = "jobs", searchParam = "q", | |
| 276 | + statTotalKeys = listOf("total"), | |
| 277 | + map = { o -> | |
| 278 | + val it = base(o, "job-ka") | |
| 279 | + it.subtitle = listOfNotNull(o.str("employer"), o.str("city")).joinToString(" · ") | |
| 280 | + val sMin = o.num("salary_year_min") | |
| 281 | + val sMax = o.num("salary_year_max") | |
| 282 | + if (sMin != null && sMin > 20000) { | |
| 283 | + it.priceLabel = if (sMax != null && sMax > sMin) | |
| 284 | + "${sMin.money0}–${sMax.money0}/an" else sMin.money0 + "/an" | |
| 285 | + } else { | |
| 286 | + o.num("salary_hour_min")?.let { h -> if (h > 10) it.priceLabel = h.money2 + "/h" } | |
| 287 | + } | |
| 288 | + it.facts = listOfNotNull( | |
| 289 | + o.str("employer")?.let { v -> KAItem.Fact("Employeur", v) }, | |
| 290 | + o.str("work_mode")?.let { v -> KAItem.Fact("Mode de travail", v) }, | |
| 291 | + o.str("employment_type")?.let { v -> KAItem.Fact("Type", v) }, | |
| 292 | + o.str("ats")?.let { v -> KAItem.Fact("Plateforme carrière", v) }, | |
| 293 | + o.str("date_posted")?.let { v -> KAItem.Fact("Publiée le", v.take(10)) }, | |
| 294 | + ) | |
| 295 | + it | |
| 296 | + }), | |
| 297 | + Universe( | |
| 298 | + id = "trouve-ka", wordmark = "Trouve·Ka", name = "Trouve-KA", | |
| 299 | + tagline = "Le moteur de recherche du web québécois", accentHex = "#1c7ed6", | |
| 300 | + symbol = "search", domain = "www.trouve-ka.com", unit = "pages indexées", | |
| 301 | + listPath = "/api/search", itemsKey = "results", searchParam = "q", | |
| 302 | + statTotalKeys = listOf("pages_indexed"), | |
| 303 | + map = { o -> | |
| 304 | + val it = base(o, "trouve-ka", idKeys = listOf("url")) | |
| 305 | + it.title = (o.str("title") ?: "Page web").strippingHTML() | |
| 306 | + it.subtitle = o.str("snippet")?.strippingHTML() | |
| 307 | + it.facts = listOfNotNull(o.str("domain")?.let { v -> KAItem.Fact("Domaine", v) }) | |
| 308 | + it | |
| 309 | + }), | |
| 310 | + Universe( | |
| 311 | + id = "api-ka", wordmark = "API·Ka", name = "API-KA", | |
| 312 | + tagline = "La donnée de l'écosystème, par API", accentHex = "#3b5bdb", | |
| 313 | + symbol = "terminal", domain = "www.api-ka.com", unit = "appels API", | |
| 314 | + listPath = null, itemsKey = "", searchParam = "q", | |
| 315 | + statTotalKeys = listOf("total"), map = null), | |
| 316 | + ) | |
| 317 | +} | |
| 318 | + | |
| 319 | +// MARK: - petites extensions | |
| 320 | + | |
| 321 | +/** 17 100 000 → « 17,1 M », 5 200 → « 5,2 k » */ | |
| 322 | +val Double.compact: String | |
| 323 | + get() = when { | |
| 324 | + this >= 1_000_000 -> String.format(Locale.US, "%.1f M", this / 1_000_000).replace(".", ",") | |
| 325 | + this >= 10_000 -> String.format(Locale.US, "%.0f k", this / 1_000) | |
| 326 | + this >= 1_000 -> String.format(Locale.US, "%.1f k", this / 1_000).replace(".", ",") | |
| 327 | + else -> this.toInt().toString() | |
| 328 | + } | |
| 329 | + | |
| 330 | +private val frCA: Locale = Locale.CANADA_FRENCH | |
| 331 | + | |
| 332 | +val Double.money0: String | |
| 333 | + get() = NumberFormat.getCurrencyInstance(frCA).apply { maximumFractionDigits = 0 }.format(this) | |
| 334 | + | |
| 335 | +val Double.money2: String | |
| 336 | + get() = NumberFormat.getCurrencyInstance(frCA).apply { maximumFractionDigits = 2 }.format(this) | |
| 337 | + | |
| 338 | +/** Nombre entier localisé fr-CA (« 47 840 »). */ | |
| 339 | +val Int.frFormatted: String | |
| 340 | + get() = NumberFormat.getIntegerInstance(frCA).format(this) | |
| 341 | + | |
| 342 | +fun String.strippingHTML(): String = | |
| 343 | + replace(Regex("<[^>]+>"), "") | |
| 344 | + .replace("&", "&") | |
| 345 | + .replace(" ", " ") | |
added
app/src/main/java/com/groupeka/ka/core/FavoritesStore.kt
+89 −0
@@ -0,0 +1,89 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// FavoritesStore.kt — favoris et collections UNIFIÉS (tous univers dans une | |
| 3 | +// même collection : « Déménagement à Val-d'Or » peut contenir un 4½, une auto | |
| 4 | +// et un resto). Persistance JSON locale (consultation hors ligne). | |
| 5 | +package com.groupeka.ka.core | |
| 6 | + | |
| 7 | +import kotlinx.coroutines.flow.MutableStateFlow | |
| 8 | +import kotlinx.coroutines.flow.StateFlow | |
| 9 | +import kotlinx.serialization.Serializable | |
| 10 | +import kotlinx.serialization.encodeToString | |
| 11 | +import kotlinx.serialization.json.Json | |
| 12 | +import java.io.File | |
| 13 | +import java.util.UUID | |
| 14 | + | |
| 15 | +@Serializable | |
| 16 | +data class FavCollection( | |
| 17 | + val id: String = UUID.randomUUID().toString(), | |
| 18 | + val name: String, | |
| 19 | + val items: List<KAItem> = emptyList(), | |
| 20 | +) | |
| 21 | + | |
| 22 | +class FavoritesStore(private val file: File) { | |
| 23 | + | |
| 24 | + companion object { | |
| 25 | + /** Singleton initialisé au démarrage de l'app (filesDir). */ | |
| 26 | + @Volatile | |
| 27 | + var shared: FavoritesStore? = null | |
| 28 | + } | |
| 29 | + | |
| 30 | + private val json = Json { ignoreUnknownKeys = true } | |
| 31 | + private val _collections = MutableStateFlow<List<FavCollection>>(emptyList()) | |
| 32 | + val collections: StateFlow<List<FavCollection>> = _collections | |
| 33 | + | |
| 34 | + init { | |
| 35 | + val loaded = runCatching { | |
| 36 | + json.decodeFromString<List<FavCollection>>(file.readText()) | |
| 37 | + }.getOrNull() ?: emptyList() | |
| 38 | + _collections.value = loaded.ifEmpty { listOf(FavCollection(name = "Mes favoris")) } | |
| 39 | + } | |
| 40 | + | |
| 41 | + private fun update(block: (List<FavCollection>) -> List<FavCollection>) { | |
| 42 | + _collections.value = block(_collections.value) | |
| 43 | + runCatching { | |
| 44 | + file.parentFile?.mkdirs() | |
| 45 | + file.writeText(json.encodeToString(_collections.value)) | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + // MARK: API | |
| 50 | + | |
| 51 | + val allItems: List<KAItem> get() = _collections.value.flatMap { it.items } | |
| 52 | + | |
| 53 | + fun isFavorite(item: KAItem): Boolean = | |
| 54 | + _collections.value.any { c -> c.items.any { it.id == item.id } } | |
| 55 | + | |
| 56 | + fun toggle(item: KAItem, collectionID: String? = null) { | |
| 57 | + if (isFavorite(item)) { | |
| 58 | + update { cols -> cols.map { c -> c.copy(items = c.items.filterNot { it.id == item.id }) } } | |
| 59 | + } else { | |
| 60 | + update { cols -> | |
| 61 | + val idx = cols.indexOfFirst { it.id == collectionID }.let { if (it >= 0) it else 0 } | |
| 62 | + cols.mapIndexed { i, c -> | |
| 63 | + if (i == idx) c.copy(items = listOf(item) + c.items) else c | |
| 64 | + } | |
| 65 | + } | |
| 66 | + } | |
| 67 | + } | |
| 68 | + | |
| 69 | + fun addCollection(name: String) { | |
| 70 | + val trimmed = name.trim() | |
| 71 | + if (trimmed.isEmpty()) return | |
| 72 | + update { it + FavCollection(name = trimmed) } | |
| 73 | + } | |
| 74 | + | |
| 75 | + fun removeCollection(id: String) { | |
| 76 | + if (_collections.value.size <= 1) return | |
| 77 | + update { cols -> cols.filterNot { it.id == id } } | |
| 78 | + } | |
| 79 | + | |
| 80 | + fun move(item: KAItem, collectionID: String) { | |
| 81 | + update { cols -> | |
| 82 | + cols.map { c -> | |
| 83 | + val without = c.items.filterNot { it.id == item.id } | |
| 84 | + if (c.id == collectionID) c.copy(items = listOf(item) + without) | |
| 85 | + else c.copy(items = without) | |
| 86 | + } | |
| 87 | + } | |
| 88 | + } | |
| 89 | +} | |
added
app/src/main/java/com/groupeka/ka/core/Filters.kt
+60 −0
@@ -0,0 +1,60 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Filters.kt — les FILTRES MÉTIER de chaque univers, comme sur les sites web : | |
| 3 | +// ville/taille/prix (Lou·Ka), marque/année/km (Auto·Ka), gratuit (Sorti·Ka), | |
| 4 | +// en solde (Food·Ka), cuisine (Resto·Ka)… Chaque filtre mappe directement un | |
| 5 | +// paramètre de l'API du site. Miroir de KA/Core/KAFilters.swift. | |
| 6 | +package com.groupeka.ka.core | |
| 7 | + | |
| 8 | +data class KAFilter( | |
| 9 | + val id: String, // nom du paramètre API (ou préfixe pour minMax) | |
| 10 | + val label: String, | |
| 11 | + val kind: Kind, | |
| 12 | +) { | |
| 13 | + sealed class Kind { | |
| 14 | + data class Text(val placeholder: String) : Kind() // champ libre → param | |
| 15 | + data class Options(val options: List<String>) : Kind() // choix unique → param | |
| 16 | + data class MinMax( | |
| 17 | + val minParam: String, val maxParam: String, | |
| 18 | + val unit: String, val step: Double, | |
| 19 | + val min: Double, val max: Double, | |
| 20 | + ) : Kind() | |
| 21 | + data class Toggle(val value: String) : Kind() // interrupteur → param=value | |
| 22 | + } | |
| 23 | +} | |
| 24 | + | |
| 25 | +object FilterCatalog { | |
| 26 | + fun filters(universeID: String): List<KAFilter> = when (universeID) { | |
| 27 | + "lou-ka" -> listOf( | |
| 28 | + KAFilter("city", "Ville", KAFilter.Kind.Text("Québec, Montréal…")), | |
| 29 | + KAFilter("unit_type", "Taille", KAFilter.Kind.Options(listOf("Studio", "1½", "2½", "3½", "4½", "5½", "6½"))), | |
| 30 | + KAFilter("loyer", "Loyer", KAFilter.Kind.MinMax("price_min", "price_max", "$", 50.0, 300.0, 4000.0)), | |
| 31 | + ) | |
| 32 | + "immo-ka" -> listOf( | |
| 33 | + KAFilter("city", "Ville", KAFilter.Kind.Text("Lévis, Gatineau…")), | |
| 34 | + KAFilter("prix", "Prix", KAFilter.Kind.MinMax("price_min", "price_max", "$", 25000.0, 50000.0, 2000000.0)), | |
| 35 | + ) | |
| 36 | + "auto-ka" -> listOf( | |
| 37 | + KAFilter("make", "Marque", KAFilter.Kind.Text("Toyota, Kia…")), | |
| 38 | + KAFilter("model", "Modèle", KAFilter.Kind.Text("Corolla…")), | |
| 39 | + KAFilter("annee", "Année", KAFilter.Kind.MinMax("year_min", "year_max", "", 1.0, 2000.0, 2026.0)), | |
| 40 | + KAFilter("prix", "Prix", KAFilter.Kind.MinMax("price_min", "price_max", "$", 1000.0, 1000.0, 120000.0)), | |
| 41 | + KAFilter("km_max", "Km max", KAFilter.Kind.Options(listOf("50000", "100000", "150000", "200000"))), | |
| 42 | + ) | |
| 43 | + "job-ka" -> listOf( | |
| 44 | + KAFilter("city", "Ville", KAFilter.Kind.Text("Montréal, Québec…")), | |
| 45 | + ) | |
| 46 | + "food-ka" -> listOf( | |
| 47 | + KAFilter("on_sale", "En solde 🏷️", KAFilter.Kind.Toggle("true")), | |
| 48 | + ) | |
| 49 | + "resto-ka" -> listOf( | |
| 50 | + KAFilter("city", "Ville", KAFilter.Kind.Text("Montréal…")), | |
| 51 | + KAFilter("cuisine", "Cuisine", KAFilter.Kind.Options(listOf("italien", "quebecois", "sushi", "bbq-grillades", "mexicain", "indien", "vegetarien", "dejeuner"))), | |
| 52 | + ) | |
| 53 | + "sorti-ka" -> listOf( | |
| 54 | + KAFilter("city", "Ville", KAFilter.Kind.Text("Sherbrooke…")), | |
| 55 | + KAFilter("region", "Région", KAFilter.Kind.Options(listOf("Montréal", "Capitale-Nationale", "Montérégie", "Estrie", "Laurentides", "Outaouais", "Mauricie"))), | |
| 56 | + KAFilter("free", "Gratuit 🎉", KAFilter.Kind.Toggle("true")), | |
| 57 | + ) | |
| 58 | + else -> emptyList() | |
| 59 | + } | |
| 60 | +} | |
added
app/src/main/java/com/groupeka/ka/core/Json.kt
+52 −0
@@ -0,0 +1,52 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Json.kt — helpers de lecture « au mieux » sur JsonElement (les API des | |
| 3 | +// univers ont chacune leur forme) — miroir de JSONValue côté iOS. | |
| 4 | +package com.groupeka.ka.core | |
| 5 | + | |
| 6 | +import kotlinx.serialization.json.JsonArray | |
| 7 | +import kotlinx.serialization.json.JsonElement | |
| 8 | +import kotlinx.serialization.json.JsonNull | |
| 9 | +import kotlinx.serialization.json.JsonObject | |
| 10 | +import kotlinx.serialization.json.JsonPrimitive | |
| 11 | + | |
| 12 | +/** Texte « au mieux » : string non vide, nombre formaté, liste jointe. */ | |
| 13 | +val JsonElement?.text: String? | |
| 14 | + get() = when (this) { | |
| 15 | + null, is JsonNull -> null | |
| 16 | + is JsonPrimitive -> when { | |
| 17 | + isString -> content.ifEmpty { null } | |
| 18 | + content == "true" || content == "false" -> null // bool ≠ texte (miroir iOS) | |
| 19 | + else -> content.toDoubleOrNull()?.let { n -> | |
| 20 | + if (n == Math.rint(n) && !n.isInfinite()) n.toLong().toString() else n.toString() | |
| 21 | + } | |
| 22 | + } | |
| 23 | + is JsonArray -> mapNotNull { it.text }.ifEmpty { null }?.joinToString(", ") | |
| 24 | + else -> null | |
| 25 | + } | |
| 26 | + | |
| 27 | +val JsonElement?.numberOrNull: Double? | |
| 28 | + get() = (this as? JsonPrimitive)?.takeIf { !it.isString }?.content?.toDoubleOrNull() | |
| 29 | + | |
| 30 | +val JsonElement?.stringOrNull: String? | |
| 31 | + get() = (this as? JsonPrimitive)?.takeIf { it.isString }?.content | |
| 32 | + | |
| 33 | +val JsonElement?.arrayOrNull: JsonArray? | |
| 34 | + get() = this as? JsonArray | |
| 35 | + | |
| 36 | +val JsonElement?.objectOrNull: JsonObject? | |
| 37 | + get() = this as? JsonObject | |
| 38 | + | |
| 39 | +val JsonElement?.boolOrNull: Boolean? | |
| 40 | + get() = (this as? JsonPrimitive)?.takeIf { !it.isString }?.content?.toBooleanStrictOrNull() | |
| 41 | + | |
| 42 | +/** Première valeur textuelle trouvée parmi les clés. */ | |
| 43 | +fun JsonObject.str(vararg keys: String): String? { | |
| 44 | + for (k in keys) this[k].text?.let { return it } | |
| 45 | + return null | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** Premier nombre trouvé parmi les clés. */ | |
| 49 | +fun JsonObject.num(vararg keys: String): Double? { | |
| 50 | + for (k in keys) this[k].numberOrNull?.let { return it } | |
| 51 | + return null | |
| 52 | +} | |
added
app/src/main/java/com/groupeka/ka/core/Prefs.kt
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Prefs.kt — préférences locales (onboarding, apparence, univers favoris, | |
| 3 | +// historique de recherche) observables par Compose. | |
| 4 | +package com.groupeka.ka.core | |
| 5 | + | |
| 6 | +import android.content.Context | |
| 7 | +import android.content.SharedPreferences | |
| 8 | +import androidx.compose.runtime.getValue | |
| 9 | +import androidx.compose.runtime.mutableStateOf | |
| 10 | +import androidx.compose.runtime.setValue | |
| 11 | + | |
| 12 | +object Prefs { | |
| 13 | + private lateinit var sp: SharedPreferences | |
| 14 | + | |
| 15 | + var onboarded by mutableStateOf(false) | |
| 16 | + private set | |
| 17 | + /** « clair » (défaut), « sombre », « auto » */ | |
| 18 | + var appearance by mutableStateOf("clair") | |
| 19 | + private set | |
| 20 | + /** ids d'univers favoris, séparés par des virgules */ | |
| 21 | + var favUniverses by mutableStateOf("") | |
| 22 | + private set | |
| 23 | + /** historique de recherche, séparé par des sauts de ligne */ | |
| 24 | + var searchHistory by mutableStateOf("") | |
| 25 | + private set | |
| 26 | + | |
| 27 | + fun init(context: Context) { | |
| 28 | + sp = context.getSharedPreferences("ka_prefs", Context.MODE_PRIVATE) | |
| 29 | + onboarded = sp.getBoolean("ka.onboarded", false) | |
| 30 | + appearance = sp.getString("ka.appearance", "clair") ?: "clair" | |
| 31 | + favUniverses = sp.getString("ka.favUniverses", "") ?: "" | |
| 32 | + searchHistory = sp.getString("ka.search.history", "") ?: "" | |
| 33 | + } | |
| 34 | + | |
| 35 | + fun setOnboardedDone() { onboarded = true; sp.edit().putBoolean("ka.onboarded", true).apply() } | |
| 36 | + fun setAppearanceValue(v: String) { appearance = v; sp.edit().putString("ka.appearance", v).apply() } | |
| 37 | + fun setFavUniversesValue(v: String) { favUniverses = v; sp.edit().putString("ka.favUniverses", v).apply() } | |
| 38 | + fun setSearchHistoryValue(v: String) { searchHistory = v; sp.edit().putString("ka.search.history", v).apply() } | |
| 39 | +} | |
added
app/src/main/java/com/groupeka/ka/core/Services.kt
+262 −0
@@ -0,0 +1,262 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Services.kt — réseau : client JSON avec cache disque (mode hors ligne de | |
| 3 | +// consultation), service d'univers (listes/recherche), stats live, KA Agent | |
| 4 | +// (chat SSE vers l'API centrale api-ka) et menus Resto·Ka. | |
| 5 | +package com.groupeka.ka.core | |
| 6 | + | |
| 7 | +import kotlinx.coroutines.Dispatchers | |
| 8 | +import kotlinx.coroutines.channels.awaitClose | |
| 9 | +import kotlinx.coroutines.flow.Flow | |
| 10 | +import kotlinx.coroutines.flow.callbackFlow | |
| 11 | +import kotlinx.coroutines.flow.flowOn | |
| 12 | +import kotlinx.coroutines.withContext | |
| 13 | +import kotlinx.serialization.json.Json | |
| 14 | +import kotlinx.serialization.json.JsonElement | |
| 15 | +import kotlinx.serialization.json.JsonObject | |
| 16 | +import kotlinx.serialization.json.buildJsonArray | |
| 17 | +import kotlinx.serialization.json.buildJsonObject | |
| 18 | +import kotlinx.serialization.json.put | |
| 19 | +import okhttp3.MediaType.Companion.toMediaType | |
| 20 | +import okhttp3.OkHttpClient | |
| 21 | +import okhttp3.Request | |
| 22 | +import okhttp3.RequestBody.Companion.toRequestBody | |
| 23 | +import java.io.File | |
| 24 | +import java.io.IOException | |
| 25 | +import java.net.URLEncoder | |
| 26 | +import java.util.Base64 | |
| 27 | +import java.util.concurrent.TimeUnit | |
| 28 | + | |
| 29 | +// MARK: - Client HTTP + cache disque | |
| 30 | + | |
| 31 | +object ApiClient { | |
| 32 | + val json = Json { ignoreUnknownKeys = true; isLenient = true } | |
| 33 | + | |
| 34 | + val http: OkHttpClient by lazy { | |
| 35 | + OkHttpClient.Builder() | |
| 36 | + .connectTimeout(15, TimeUnit.SECONDS) | |
| 37 | + .readTimeout(15, TimeUnit.SECONDS) | |
| 38 | + .build() | |
| 39 | + } | |
| 40 | + | |
| 41 | + /** Dossier du cache JSON — injecté au démarrage (cacheDir Android, temp en test). */ | |
| 42 | + @Volatile | |
| 43 | + var cacheDir: File? = null | |
| 44 | + set(value) { | |
| 45 | + field = value?.also { it.mkdirs() } | |
| 46 | + } | |
| 47 | + | |
| 48 | + private fun cacheFile(url: String): File? { | |
| 49 | + val dir = cacheDir ?: return null | |
| 50 | + val name = Base64.getEncoder().encodeToString(url.toByteArray()).replace("/", "_") | |
| 51 | + return File(dir, name.takeLast(120) + ".json") | |
| 52 | + } | |
| 53 | + | |
| 54 | + /** JSON brut ; en cas d'échec réseau, sert la dernière copie disque (hors ligne). */ | |
| 55 | + suspend fun fetchJson(url: String, ttlSeconds: Long = 120): JsonElement = withContext(Dispatchers.IO) { | |
| 56 | + val file = cacheFile(url) | |
| 57 | + if (file != null && file.exists() && System.currentTimeMillis() - file.lastModified() < ttlSeconds * 1000) { | |
| 58 | + runCatching { json.parseToJsonElement(file.readText()) }.getOrNull()?.let { return@withContext it } | |
| 59 | + } | |
| 60 | + try { | |
| 61 | + val req = Request.Builder().url(url) | |
| 62 | + .header("User-Agent", "KA-Android/1.0 (+https://www.groupe-ka.com)") | |
| 63 | + .build() | |
| 64 | + http.newCall(req).execute().use { resp -> | |
| 65 | + if (!resp.isSuccessful) throw IOException("HTTP ${resp.code}") | |
| 66 | + val body = resp.body?.string() ?: throw IOException("corps vide") | |
| 67 | + val value = json.parseToJsonElement(body) | |
| 68 | + runCatching { file?.writeText(body) } | |
| 69 | + value | |
| 70 | + } | |
| 71 | + } catch (e: Exception) { | |
| 72 | + // hors ligne : dernier contenu connu | |
| 73 | + if (file != null && file.exists()) { | |
| 74 | + runCatching { json.parseToJsonElement(file.readText()) }.getOrNull()?.let { return@withContext it } | |
| 75 | + } | |
| 76 | + throw e | |
| 77 | + } | |
| 78 | + } | |
| 79 | +} | |
| 80 | + | |
| 81 | +// MARK: - Univers : listes, recherche, stats | |
| 82 | + | |
| 83 | +object UniverseService { | |
| 84 | + | |
| 85 | + fun listURL( | |
| 86 | + u: Universe, | |
| 87 | + query: String? = null, | |
| 88 | + city: String? = null, | |
| 89 | + limit: Int = 30, | |
| 90 | + params: Map<String, String> = emptyMap(), | |
| 91 | + ): String? { | |
| 92 | + val path = u.listPath ?: return null | |
| 93 | + // listPath peut contenir déjà une query (ex. events?upcoming=true) | |
| 94 | + val split = path.split("?", limit = 2) | |
| 95 | + val items = mutableListOf<Pair<String, String?>>() | |
| 96 | + if (split.size > 1) { | |
| 97 | + split[1].split("&").forEach { part -> | |
| 98 | + val kv = part.split("=", limit = 2) | |
| 99 | + items.add(kv[0] to kv.getOrNull(1)) | |
| 100 | + } | |
| 101 | + } | |
| 102 | + if (!query.isNullOrEmpty()) items.add(u.searchParam to query) | |
| 103 | + if (!city.isNullOrEmpty()) items.add("city" to city) | |
| 104 | + params.entries.sortedBy { it.key }.forEach { (k, v) -> if (v.isNotEmpty()) items.add(k to v) } | |
| 105 | + items.add("limit" to limit.toString()) | |
| 106 | + val qs = items.joinToString("&") { (k, v) -> | |
| 107 | + if (v == null) k else "$k=${URLEncoder.encode(v, "UTF-8").replace("+", "%20")}" | |
| 108 | + } | |
| 109 | + return "${u.baseURL}${split[0]}?$qs" | |
| 110 | + } | |
| 111 | + | |
| 112 | + suspend fun fetch( | |
| 113 | + u: Universe, | |
| 114 | + query: String? = null, | |
| 115 | + city: String? = null, | |
| 116 | + limit: Int = 30, | |
| 117 | + params: Map<String, String> = emptyMap(), | |
| 118 | + ): List<KAItem> { | |
| 119 | + val url = listURL(u, query, city, limit, params) ?: return emptyList() | |
| 120 | + val map = u.map ?: return emptyList() | |
| 121 | + val root = ApiClient.fetchJson(url) | |
| 122 | + val obj = root.objectOrNull ?: JsonObject(emptyMap()) | |
| 123 | + val raw = obj[u.itemsKey].arrayOrNull | |
| 124 | + ?: obj["items"].arrayOrNull ?: obj["results"].arrayOrNull ?: obj["hits"].arrayOrNull | |
| 125 | + ?: root.arrayOrNull ?: emptyList() | |
| 126 | + return raw.mapNotNull { el -> el.objectOrNull?.let(map) } | |
| 127 | + } | |
| 128 | + | |
| 129 | + /** Total « en direct » depuis /api/stats (clé selon l'univers, chemins a.b acceptés). */ | |
| 130 | + suspend fun liveTotal(u: Universe): Int? { | |
| 131 | + val path = if (u.id == "trouve-ka") "/api/status" else "/api/stats" | |
| 132 | + val root = runCatching { ApiClient.fetchJson("https://${u.domain}$path", ttlSeconds = 300) } | |
| 133 | + .getOrNull() ?: return null | |
| 134 | + for (key in u.statTotalKeys) { | |
| 135 | + var cur: JsonElement? = root | |
| 136 | + for (part in key.split(".")) cur = cur.objectOrNull?.get(part) | |
| 137 | + cur.numberOrNull?.let { return it.toInt() } | |
| 138 | + } | |
| 139 | + return null | |
| 140 | + } | |
| 141 | +} | |
| 142 | + | |
| 143 | +// MARK: - KA Agent (SSE) | |
| 144 | + | |
| 145 | +sealed class AgentEvent { | |
| 146 | + data class Delta(val text: String) : AgentEvent() | |
| 147 | + data class Tool(val name: String) : AgentEvent() | |
| 148 | + data object Done : AgentEvent() | |
| 149 | + data class Error(val message: String) : AgentEvent() | |
| 150 | +} | |
| 151 | + | |
| 152 | +object AgentService { | |
| 153 | + const val endpoint = "https://www.api-ka.com/api/agent/chat" | |
| 154 | + | |
| 155 | + fun stream(site: String, messages: List<Map<String, String>>): Flow<AgentEvent> = callbackFlow { | |
| 156 | + val payload = buildJsonObject { | |
| 157 | + put("site", site) | |
| 158 | + put("messages", buildJsonArray { | |
| 159 | + messages.forEach { m -> | |
| 160 | + add(buildJsonObject { | |
| 161 | + put("role", m["role"] ?: "user") | |
| 162 | + put("content", m["content"] ?: "") | |
| 163 | + }) | |
| 164 | + } | |
| 165 | + }) | |
| 166 | + } | |
| 167 | + val client = ApiClient.http.newBuilder().readTimeout(90, TimeUnit.SECONDS).build() | |
| 168 | + val req = Request.Builder().url(endpoint) | |
| 169 | + .post(payload.toString().toRequestBody("application/json".toMediaType())) | |
| 170 | + .build() | |
| 171 | + val call = client.newCall(req) | |
| 172 | + try { | |
| 173 | + call.execute().use { resp -> | |
| 174 | + if (!resp.isSuccessful) throw IOException("HTTP ${resp.code}") | |
| 175 | + val source = resp.body?.source() ?: throw IOException("corps vide") | |
| 176 | + var event = "" | |
| 177 | + while (true) { | |
| 178 | + val line = source.readUtf8Line() ?: break | |
| 179 | + when { | |
| 180 | + line.startsWith("event: ") -> event = line.removePrefix("event: ") | |
| 181 | + line.startsWith("data: ") -> { | |
| 182 | + val obj = runCatching { | |
| 183 | + ApiClient.json.parseToJsonElement(line.removePrefix("data: ")).objectOrNull | |
| 184 | + }.getOrNull() ?: JsonObject(emptyMap()) | |
| 185 | + when (event) { | |
| 186 | + "delta" -> obj["text"].stringOrNull?.let { trySend(AgentEvent.Delta(it)) } | |
| 187 | + "tool" -> trySend(AgentEvent.Tool(obj["name"].stringOrNull ?: "recherche")) | |
| 188 | + "done" -> { trySend(AgentEvent.Done); close(); return@use } | |
| 189 | + "error" -> trySend(AgentEvent.Error(obj["message"].stringOrNull ?: "erreur")) | |
| 190 | + } | |
| 191 | + } | |
| 192 | + } | |
| 193 | + } | |
| 194 | + } | |
| 195 | + close() | |
| 196 | + } catch (e: Exception) { | |
| 197 | + close(e) | |
| 198 | + } | |
| 199 | + awaitClose { call.cancel() } | |
| 200 | + }.flowOn(Dispatchers.IO) | |
| 201 | +} | |
| 202 | + | |
| 203 | +// MARK: - Menu de resto (Resto·Ka) | |
| 204 | + | |
| 205 | +data class RestoMenuSection(val name: String, val items: List<Pair<String, String?>>) | |
| 206 | + | |
| 207 | +object RestoMenuLoader { | |
| 208 | + suspend fun load(uid: String): List<RestoMenuSection> { | |
| 209 | + val root = runCatching { | |
| 210 | + ApiClient.fetchJson("https://www.resto-ka.com/api/restaurants/$uid", ttlSeconds = 600) | |
| 211 | + }.getOrNull() ?: return emptyList() | |
| 212 | + val menus = root.objectOrNull?.get("menus").arrayOrNull ?: return emptyList() | |
| 213 | + val sections = mutableListOf<RestoMenuSection>() | |
| 214 | + for (menu in menus.take(1)) { | |
| 215 | + for (s in menu.objectOrNull?.get("sections").arrayOrNull ?: emptyList()) { | |
| 216 | + val so = s.objectOrNull ?: continue | |
| 217 | + val name = so.str("name") ?: continue | |
| 218 | + val items = (so["items"].arrayOrNull ?: emptyList()).take(30).mapNotNull { i -> | |
| 219 | + val io = i.objectOrNull ?: return@mapNotNull null | |
| 220 | + val n = io.str("name") ?: return@mapNotNull null | |
| 221 | + n to io.num("price")?.takeIf { it > 0 }?.money2 | |
| 222 | + } | |
| 223 | + if (items.isNotEmpty()) sections.add(RestoMenuSection(name, items)) | |
| 224 | + } | |
| 225 | + } | |
| 226 | + return sections | |
| 227 | + } | |
| 228 | +} | |
| 229 | + | |
| 230 | +// MARK: - Vrai-Prix (recherche + estimation) | |
| 231 | + | |
| 232 | +@kotlinx.serialization.Serializable | |
| 233 | +data class VPResult(val id: String, val adresse: String, val municipalite: String, val type: String?) | |
| 234 | + | |
| 235 | +object VraiPrixService { | |
| 236 | + suspend fun search(q: String): List<VPResult> { | |
| 237 | + val url = "https://www.vrai-prix.com/api/search?q=${URLEncoder.encode(q, "UTF-8").replace("+", "%20")}" | |
| 238 | + val root = runCatching { ApiClient.fetchJson(url) }.getOrNull() ?: return emptyList() | |
| 239 | + val arr = root.objectOrNull?.get("results").arrayOrNull ?: return emptyList() | |
| 240 | + return arr.mapNotNull { v -> | |
| 241 | + val o = v.objectOrNull ?: return@mapNotNull null | |
| 242 | + val id = o.str("id") ?: return@mapNotNull null | |
| 243 | + val ad = o.str("adresse") ?: return@mapNotNull null | |
| 244 | + val apt = o.str("apt") | |
| 245 | + VPResult( | |
| 246 | + id = id, | |
| 247 | + adresse = if (!apt.isNullOrEmpty()) "$ad app. $apt" else ad, | |
| 248 | + municipalite = o.str("municipalite") ?: "", | |
| 249 | + type = o.str("typeProp"), | |
| 250 | + ) | |
| 251 | + } | |
| 252 | + } | |
| 253 | + | |
| 254 | + /** Renvoie (unit, result) de /api/estimate?id= */ | |
| 255 | + suspend fun estimate(id: String): Pair<JsonObject?, JsonObject?>? { | |
| 256 | + val root = runCatching { | |
| 257 | + ApiClient.fetchJson("https://www.vrai-prix.com/api/estimate?id=$id", ttlSeconds = 3600) | |
| 258 | + }.getOrNull() ?: return null | |
| 259 | + val obj = root.objectOrNull ?: return null | |
| 260 | + return (obj["unit"].objectOrNull) to (obj["result"].objectOrNull) | |
| 261 | + } | |
| 262 | +} | |
added
app/src/main/java/com/groupeka/ka/ui/AgentChatScreen.kt
+273 −0
@@ -0,0 +1,273 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// AgentChatScreen.kt — KA Agent natif : le même assistant IA que sur les sites | |
| 3 | +// (via l'API centrale api-ka), en chat plein écran, flux token par token + | |
| 4 | +// puces d'outils. | |
| 5 | +package com.groupeka.ka.ui | |
| 6 | + | |
| 7 | +import androidx.compose.foundation.background | |
| 8 | +import androidx.compose.foundation.border | |
| 9 | +import androidx.compose.foundation.clickable | |
| 10 | +import androidx.compose.foundation.layout.Arrangement | |
| 11 | +import androidx.compose.foundation.layout.Box | |
| 12 | +import androidx.compose.foundation.layout.Column | |
| 13 | +import androidx.compose.foundation.layout.PaddingValues | |
| 14 | +import androidx.compose.foundation.layout.Row | |
| 15 | +import androidx.compose.foundation.layout.Spacer | |
| 16 | +import androidx.compose.foundation.layout.fillMaxSize | |
| 17 | +import androidx.compose.foundation.layout.fillMaxWidth | |
| 18 | +import androidx.compose.foundation.layout.imePadding | |
| 19 | +import androidx.compose.foundation.layout.padding | |
| 20 | +import androidx.compose.foundation.layout.size | |
| 21 | +import androidx.compose.foundation.layout.width | |
| 22 | +import androidx.compose.foundation.lazy.LazyColumn | |
| 23 | +import androidx.compose.foundation.lazy.rememberLazyListState | |
| 24 | +import androidx.compose.foundation.shape.CircleShape | |
| 25 | +import androidx.compose.foundation.shape.RoundedCornerShape | |
| 26 | +import androidx.compose.material.icons.Icons | |
| 27 | +import androidx.compose.material.icons.automirrored.filled.Send | |
| 28 | +import androidx.compose.material.icons.filled.Cancel | |
| 29 | +import androidx.compose.material.icons.filled.Search | |
| 30 | +import androidx.compose.material3.CircularProgressIndicator | |
| 31 | +import androidx.compose.material3.Icon | |
| 32 | +import androidx.compose.material3.IconButton | |
| 33 | +import androidx.compose.material3.MaterialTheme | |
| 34 | +import androidx.compose.material3.OutlinedTextField | |
| 35 | +import androidx.compose.material3.OutlinedTextFieldDefaults | |
| 36 | +import androidx.compose.material3.Text | |
| 37 | +import androidx.compose.runtime.Composable | |
| 38 | +import androidx.compose.runtime.LaunchedEffect | |
| 39 | +import androidx.compose.runtime.getValue | |
| 40 | +import androidx.compose.runtime.mutableStateListOf | |
| 41 | +import androidx.compose.runtime.mutableStateOf | |
| 42 | +import androidx.compose.runtime.remember | |
| 43 | +import androidx.compose.runtime.rememberCoroutineScope | |
| 44 | +import androidx.compose.runtime.setValue | |
| 45 | +import androidx.compose.ui.Alignment | |
| 46 | +import androidx.compose.ui.Modifier | |
| 47 | +import androidx.compose.ui.draw.rotate | |
| 48 | +import androidx.compose.ui.graphics.PathEffect | |
| 49 | +import androidx.compose.ui.text.font.FontFamily | |
| 50 | +import androidx.compose.ui.text.font.FontWeight | |
| 51 | +import androidx.compose.ui.unit.dp | |
| 52 | +import androidx.compose.ui.unit.sp | |
| 53 | +import com.groupeka.ka.core.AgentEvent | |
| 54 | +import com.groupeka.ka.core.AgentService | |
| 55 | +import com.groupeka.ka.ui.theme.KATheme | |
| 56 | +import com.groupeka.ka.ui.theme.LocalKADark | |
| 57 | +import com.groupeka.ka.ui.theme.kaCard | |
| 58 | +import kotlinx.coroutines.launch | |
| 59 | +import java.util.UUID | |
| 60 | + | |
| 61 | +data class AgentMessage( | |
| 62 | + val id: String = UUID.randomUUID().toString(), | |
| 63 | + val role: String, // user / assistant / tool | |
| 64 | + var content: String, | |
| 65 | +) | |
| 66 | + | |
| 67 | +@Composable | |
| 68 | +fun AgentChatScreen(onClose: () -> Unit) { | |
| 69 | + val dark = LocalKADark.current | |
| 70 | + val scope = rememberCoroutineScope() | |
| 71 | + val messages = remember { mutableStateListOf<AgentMessage>() } | |
| 72 | + var input by remember { mutableStateOf("") } | |
| 73 | + var busy by remember { mutableStateOf(false) } | |
| 74 | + val listState = rememberLazyListState() | |
| 75 | + | |
| 76 | + fun send(text: String) { | |
| 77 | + val q = text.trim() | |
| 78 | + if (q.isEmpty() || busy) return | |
| 79 | + messages.add(AgentMessage(role = "user", content = q)) | |
| 80 | + messages.add(AgentMessage(role = "assistant", content = "")) | |
| 81 | + busy = true | |
| 82 | + val history = messages | |
| 83 | + .filter { it.role == "user" || (it.role == "assistant" && it.content.isNotEmpty()) } | |
| 84 | + .takeLast(16) | |
| 85 | + .map { mapOf("role" to it.role, "content" to it.content) } | |
| 86 | + | |
| 87 | + scope.launch { | |
| 88 | + try { | |
| 89 | + AgentService.stream(site = "groupe-ka", messages = history).collect { event -> | |
| 90 | + when (event) { | |
| 91 | + is AgentEvent.Delta -> { | |
| 92 | + val i = messages.indexOfLast { it.role == "assistant" } | |
| 93 | + if (i >= 0) messages[i] = messages[i].copy(content = messages[i].content + event.text) | |
| 94 | + } | |
| 95 | + is AgentEvent.Tool -> { | |
| 96 | + // insérer la puce outil AVANT la bulle assistante en cours | |
| 97 | + val i = messages.indexOfLast { it.role == "assistant" } | |
| 98 | + if (i >= 0) messages.add(i, AgentMessage(role = "tool", content = event.name.replace("_", " "))) | |
| 99 | + } | |
| 100 | + is AgentEvent.Error -> { | |
| 101 | + val i = messages.indexOfLast { it.role == "assistant" } | |
| 102 | + if (i >= 0 && messages[i].content.isEmpty()) { | |
| 103 | + messages[i] = messages[i].copy(content = "Désolé, une erreur est survenue (${event.message}). Réessayez.") | |
| 104 | + } | |
| 105 | + } | |
| 106 | + is AgentEvent.Done -> Unit | |
| 107 | + } | |
| 108 | + } | |
| 109 | + } catch (e: Exception) { | |
| 110 | + val i = messages.indexOfLast { it.role == "assistant" } | |
| 111 | + if (i >= 0 && messages[i].content.isEmpty()) { | |
| 112 | + messages[i] = messages[i].copy(content = "Impossible de joindre KA Agent — vérifiez votre connexion.") | |
| 113 | + } | |
| 114 | + } | |
| 115 | + busy = false | |
| 116 | + } | |
| 117 | + } | |
| 118 | + | |
| 119 | + LaunchedEffect(messages.size, messages.lastOrNull()?.content) { | |
| 120 | + if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1) | |
| 121 | + } | |
| 122 | + | |
| 123 | + Column(Modifier.fillMaxSize().imePadding()) { | |
| 124 | + // — en-tête | |
| 125 | + Row( | |
| 126 | + Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), | |
| 127 | + verticalAlignment = Alignment.CenterVertically, | |
| 128 | + ) { | |
| 129 | + Text("KA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark)) | |
| 130 | + Spacer(Modifier.width(6.dp)) | |
| 131 | + Text( | |
| 132 | + "Agent", | |
| 133 | + fontSize = 13.sp, | |
| 134 | + fontWeight = FontWeight.Bold, | |
| 135 | + color = KATheme.lime, | |
| 136 | + modifier = Modifier | |
| 137 | + .rotate(-2f) | |
| 138 | + .background(KATheme.inkLight, RoundedCornerShape(6.dp)) | |
| 139 | + .padding(horizontal = 7.dp, vertical = 2.dp), | |
| 140 | + ) | |
| 141 | + Spacer(Modifier.width(6.dp)) | |
| 142 | + Text("· IA de l'écosystème", style = MaterialTheme.typography.labelSmall, color = KATheme.ink2(dark)) | |
| 143 | + Spacer(Modifier.weight(1f)) | |
| 144 | + IconButton(onClick = onClose) { | |
| 145 | + Icon(Icons.Filled.Cancel, "Fermer KA Agent", tint = KATheme.ink2(dark)) | |
| 146 | + } | |
| 147 | + } | |
| 148 | + | |
| 149 | + LazyColumn( | |
| 150 | + state = listState, | |
| 151 | + contentPadding = PaddingValues(14.dp), | |
| 152 | + verticalArrangement = Arrangement.spacedBy(10.dp), | |
| 153 | + modifier = Modifier.weight(1f), | |
| 154 | + ) { | |
| 155 | + if (messages.isEmpty()) { | |
| 156 | + item { | |
| 157 | + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(4.dp)) { | |
| 158 | + Text("👋 Je suis KA Agent.", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = KATheme.ink(dark)) | |
| 159 | + Text( | |
| 160 | + "Posez-moi n'importe quelle question sur l'écosystème Groupe KA et ses données : logements, propriétés, autos, emplois, prix d'épicerie, restos, sorties, créateurs, statistiques…", | |
| 161 | + style = MaterialTheme.typography.bodyMedium, | |
| 162 | + color = KATheme.ink2(dark), | |
| 163 | + ) | |
| 164 | + listOf( | |
| 165 | + "Combien de logements à louer ?", | |
| 166 | + "Un resto italien à Montréal", | |
| 167 | + "Les sorties gratuites ce week-end", | |
| 168 | + "C'est quoi le Groupe KA ?", | |
| 169 | + ).forEach { s -> | |
| 170 | + Text( | |
| 171 | + s, | |
| 172 | + style = MaterialTheme.typography.labelMedium, | |
| 173 | + fontWeight = FontWeight.SemiBold, | |
| 174 | + color = KATheme.ink(dark), | |
| 175 | + modifier = Modifier | |
| 176 | + .background(KATheme.ink(dark).copy(alpha = 0.08f), CircleShape) | |
| 177 | + .clickable { send(s) } | |
| 178 | + .padding(horizontal = 11.dp, vertical = 7.dp), | |
| 179 | + ) | |
| 180 | + } | |
| 181 | + } | |
| 182 | + } | |
| 183 | + } | |
| 184 | + messages.forEach { m -> | |
| 185 | + item(key = m.id) { Bubble(m) } | |
| 186 | + } | |
| 187 | + } | |
| 188 | + | |
| 189 | + // — barre de saisie | |
| 190 | + Row( | |
| 191 | + Modifier | |
| 192 | + .fillMaxWidth() | |
| 193 | + .background(KATheme.surface(dark)) | |
| 194 | + .padding(12.dp), | |
| 195 | + verticalAlignment = Alignment.Bottom, | |
| 196 | + ) { | |
| 197 | + OutlinedTextField( | |
| 198 | + value = input, | |
| 199 | + onValueChange = { input = it }, | |
| 200 | + placeholder = { Text("Posez votre question…", color = KATheme.ink2(dark)) }, | |
| 201 | + maxLines = 4, | |
| 202 | + colors = OutlinedTextFieldDefaults.colors( | |
| 203 | + focusedBorderColor = KATheme.ink(dark), | |
| 204 | + unfocusedBorderColor = KATheme.ink(dark).copy(alpha = 0.35f), | |
| 205 | + ), | |
| 206 | + modifier = Modifier.weight(1f), | |
| 207 | + ) | |
| 208 | + Spacer(Modifier.width(8.dp)) | |
| 209 | + val canSend = !busy && input.trim().isNotEmpty() | |
| 210 | + Box( | |
| 211 | + Modifier | |
| 212 | + .size(48.dp) | |
| 213 | + .background(if (canSend) KATheme.inkLight else KATheme.inkLight.copy(alpha = 0.4f), CircleShape) | |
| 214 | + .clickable(enabled = canSend) { send(input); input = "" }, | |
| 215 | + contentAlignment = Alignment.Center, | |
| 216 | + ) { | |
| 217 | + Icon(Icons.AutoMirrored.Filled.Send, "Envoyer", tint = KATheme.lime, modifier = Modifier.size(20.dp)) | |
| 218 | + } | |
| 219 | + } | |
| 220 | + } | |
| 221 | +} | |
| 222 | + | |
| 223 | +@Composable | |
| 224 | +private fun Bubble(m: AgentMessage) { | |
| 225 | + val dark = LocalKADark.current | |
| 226 | + when (m.role) { | |
| 227 | + "user" -> Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { | |
| 228 | + Text( | |
| 229 | + m.content, | |
| 230 | + color = KATheme.inkLight, | |
| 231 | + modifier = Modifier | |
| 232 | + .background(KATheme.lime, RoundedCornerShape(13.dp)) | |
| 233 | + .padding(horizontal = 13.dp, vertical = 9.dp), | |
| 234 | + ) | |
| 235 | + } | |
| 236 | + "tool" -> Row(verticalAlignment = Alignment.CenterVertically) { | |
| 237 | + Icon(Icons.Filled.Search, null, tint = KATheme.ink2(dark), modifier = Modifier.size(12.dp)) | |
| 238 | + Spacer(Modifier.width(5.dp)) | |
| 239 | + Text( | |
| 240 | + m.content.uppercase(), | |
| 241 | + fontSize = 10.sp, | |
| 242 | + fontFamily = FontFamily.Monospace, | |
| 243 | + fontWeight = FontWeight.Bold, | |
| 244 | + color = KATheme.ink2(dark), | |
| 245 | + modifier = Modifier | |
| 246 | + .dashedBorder() | |
| 247 | + .padding(horizontal = 10.dp, vertical = 5.dp), | |
| 248 | + ) | |
| 249 | + } | |
| 250 | + else -> Row(Modifier.fillMaxWidth()) { | |
| 251 | + if (m.content.isEmpty()) { | |
| 252 | + CircularProgressIndicator(Modifier.size(20.dp).padding(2.dp), color = KATheme.green, strokeWidth = 2.dp) | |
| 253 | + } else { | |
| 254 | + Text( | |
| 255 | + m.content, | |
| 256 | + color = KATheme.ink(dark), | |
| 257 | + modifier = Modifier | |
| 258 | + .kaCard() | |
| 259 | + .padding(horizontal = 13.dp, vertical = 9.dp), | |
| 260 | + ) | |
| 261 | + } | |
| 262 | + } | |
| 263 | + } | |
| 264 | +} | |
| 265 | + | |
| 266 | +/** bordure pointillée façon puce d'outil */ | |
| 267 | +private fun Modifier.dashedBorder(): Modifier = this.then( | |
| 268 | + Modifier.border( | |
| 269 | + width = 1.dp, | |
| 270 | + color = androidx.compose.ui.graphics.Color.Gray, | |
| 271 | + shape = CircleShape, | |
| 272 | + ), | |
| 273 | +) | |
added
app/src/main/java/com/groupeka/ka/ui/FavoritesScreen.kt
+184 −0
@@ -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 & 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 | ||