feat: authentification PKCE Keycloak au démarrage de l'application

Remplace le password grant par Authorization Code + PKCE via Custom Tab.
L'utilisateur est redirigé vers Keycloak à l'ouverture si non connecté,
le token est stocké dans EncryptedSharedPreferences et rafraîchi automatiquement.
Le deep link com.planify.mobile://auth/callback capture le code de retour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 09:24:58 +02:00
parent b08ceb5574
commit d099fc7da7
11 changed files with 441 additions and 141 deletions
+4 -1
View File
@@ -16,7 +16,7 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "0.0.7" versionName = "0.0.8"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -83,6 +83,9 @@ dependencies {
// WorkManager // WorkManager
implementation(libs.work.runtime.ktx) implementation(libs.work.runtime.ktx)
// Browser (Custom Tabs pour OAuth)
implementation(libs.androidx.browser)
// Serialization // Serialization
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
+11
View File
@@ -19,11 +19,22 @@
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.PlanifyMobile"> android:theme="@style/Theme.PlanifyMobile">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Callback OAuth2 PKCE depuis le Custom Tab Keycloak -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="com.planify.mobile"
android:host="auth"
android:pathPrefix="/callback" />
</intent-filter>
</activity> </activity>
<receiver <receiver
@@ -1,15 +1,22 @@
package com.planify.mobile.data.bonsai package com.planify.mobile.data.bonsai
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -23,40 +30,71 @@ class BonsaiAuthManager @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val httpClient: OkHttpClient, private val httpClient: OkHttpClient,
) { ) {
private val prefs by lazy { private val prefs: SharedPreferences
private val _isAuthenticated: MutableStateFlow<Boolean>
init {
val masterKey = MasterKey.Builder(context) val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
EncryptedSharedPreferences.create( prefs = EncryptedSharedPreferences.create(
context, context,
"bonsai_credentials", "bonsai_credentials",
masterKey, masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) )
_isAuthenticated = MutableStateFlow(prefs.getString(KEY_REFRESH_TOKEN, null) != null)
} }
val isLoggedIn: Boolean get() = prefs.getString(KEY_TOKEN, null) != null val isAuthenticated: StateFlow<Boolean> = _isAuthenticated
val isLoggedIn: Boolean get() = _isAuthenticated.value
fun getApiBaseUrl(): String = prefs.getString(KEY_API_URL, DEFAULT_API_URL) ?: DEFAULT_API_URL
fun getApiBaseUrl(): String = DEFAULT_API_URL
fun getUsername(): String = prefs.getString(KEY_USERNAME, "") ?: "" fun getUsername(): String = prefs.getString(KEY_USERNAME, "") ?: ""
fun getAuthHeader(): String? = prefs.getString(KEY_ACCESS_TOKEN, null)?.let { "Bearer $it" }
fun getAuthHeader(): String? = prefs.getString(KEY_TOKEN, null)?.let { "Bearer $it" } fun buildAuthUrl(): String {
val verifier = generateCodeVerifier()
val challenge = generateCodeChallenge(verifier)
val state = generateState()
suspend fun login(apiUrl: String, username: String, password: String): LoginResult = withContext(Dispatchers.IO) { prefs.edit()
val cleanUrl = apiUrl.trimEnd('/') .putString(KEY_PKCE_VERIFIER, verifier)
val tokenUrl = "$KEYCLOAK_BASE/realms/$REALM/protocol/openid-connect/token" .putString(KEY_OAUTH_STATE, state)
.apply()
return Uri.parse("$KEYCLOAK_BASE/realms/$REALM/protocol/openid-connect/auth")
.buildUpon()
.appendQueryParameter("client_id", CLIENT_ID)
.appendQueryParameter("response_type", "code")
.appendQueryParameter("redirect_uri", REDIRECT_URI)
.appendQueryParameter("scope", "openid profile email")
.appendQueryParameter("code_challenge", challenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("state", state)
.build()
.toString()
}
suspend fun exchangeCode(code: String, state: String): LoginResult = withContext(Dispatchers.IO) {
val storedState = prefs.getString(KEY_OAUTH_STATE, null)
if (storedState == null || storedState != state) {
return@withContext LoginResult.Failure("Erreur de sécurité : état invalide")
}
val verifier = prefs.getString(KEY_PKCE_VERIFIER, null)
?: return@withContext LoginResult.Failure("PKCE verifier manquant")
val body = FormBody.Builder() val body = FormBody.Builder()
.add("grant_type", "password") .add("grant_type", "authorization_code")
.add("client_id", CLIENT_ID) .add("client_id", CLIENT_ID)
.add("username", username) .add("code", code)
.add("password", password) .add("redirect_uri", REDIRECT_URI)
.add("code_verifier", verifier)
.build() .build()
val request = Request.Builder() val request = Request.Builder()
.url(tokenUrl) .url("$KEYCLOAK_BASE/realms/$REALM/protocol/openid-connect/token")
.post(body) .post(body)
.build() .build()
@@ -64,35 +102,110 @@ class BonsaiAuthManager @Inject constructor(
httpClient.newCall(request).execute().use { response -> httpClient.newCall(request).execute().use { response ->
val raw = response.body?.string() ?: "" val raw = response.body?.string() ?: ""
if (!response.isSuccessful) { if (!response.isSuccessful) {
val detail = runCatching { JSONObject(raw).optString("error_description", raw) }.getOrDefault(raw) val detail = runCatching {
JSONObject(raw).optString("error_description", raw)
}.getOrDefault(raw)
return@withContext LoginResult.Failure("HTTP ${response.code}: $detail") return@withContext LoginResult.Failure("HTTP ${response.code}: $detail")
} }
val json = JSONObject(raw) saveTokens(JSONObject(raw))
val token = json.getString("access_token")
prefs.edit() prefs.edit()
.putString(KEY_TOKEN, token) .remove(KEY_PKCE_VERIFIER)
.putString(KEY_API_URL, cleanUrl) .remove(KEY_OAUTH_STATE)
.putString(KEY_USERNAME, username)
.apply() .apply()
LoginResult.Success LoginResult.Success
} }
}.getOrElse { LoginResult.Failure(it.message ?: "Erreur réseau") } }.getOrElse { LoginResult.Failure(it.message ?: "Erreur réseau") }
} }
suspend fun refreshIfNeeded(): Boolean = withContext(Dispatchers.IO) {
val refreshToken = prefs.getString(KEY_REFRESH_TOKEN, null) ?: return@withContext false
val expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L)
if (System.currentTimeMillis() < expiresAt - 60_000L) return@withContext true
val body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", CLIENT_ID)
.add("refresh_token", refreshToken)
.build()
val request = Request.Builder()
.url("$KEYCLOAK_BASE/realms/$REALM/protocol/openid-connect/token")
.post(body)
.build()
runCatching {
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext false
val raw = response.body?.string() ?: return@withContext false
saveTokens(JSONObject(raw))
true
}
}.getOrDefault(false)
}
fun logout() { fun logout() {
prefs.edit() prefs.edit()
.remove(KEY_TOKEN) .remove(KEY_ACCESS_TOKEN)
.remove(KEY_REFRESH_TOKEN)
.remove(KEY_EXPIRES_AT)
.remove(KEY_USERNAME) .remove(KEY_USERNAME)
.apply() .apply()
_isAuthenticated.value = false
}
private fun saveTokens(json: JSONObject) {
val accessToken = json.getString("access_token")
val refreshToken = json.optString("refresh_token", "")
val expiresIn = json.optLong("expires_in", 300L)
val username = extractUsername(accessToken)
prefs.edit()
.putString(KEY_ACCESS_TOKEN, accessToken)
.putString(KEY_REFRESH_TOKEN, refreshToken.ifBlank { null })
.putLong(KEY_EXPIRES_AT, System.currentTimeMillis() + expiresIn * 1000L)
.putString(KEY_USERNAME, username)
.apply()
_isAuthenticated.value = true
}
private fun extractUsername(jwt: String): String = runCatching {
val payload = jwt.split(".").getOrNull(1) ?: return@runCatching ""
val padded = payload + "=".repeat((4 - payload.length % 4) % 4)
val decoded = Base64.getUrlDecoder().decode(padded)
val json = JSONObject(String(decoded))
json.optString("preferred_username", json.optString("sub", ""))
}.getOrDefault("")
private fun generateCodeVerifier(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private fun generateCodeChallenge(verifier: String): String {
val bytes = MessageDigest.getInstance("SHA-256")
.digest(verifier.toByteArray(Charsets.US_ASCII))
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private fun generateState(): String {
val bytes = ByteArray(16)
SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
} }
companion object { companion object {
const val DEFAULT_API_URL = "https://bonsai.goutailler-olivier.com/api" const val DEFAULT_API_URL = "https://bonsai.goutailler-olivier.com/api"
const val REDIRECT_URI = "com.planify.mobile://auth/callback"
private const val KEYCLOAK_BASE = "https://auth.goutailler-olivier.com" private const val KEYCLOAK_BASE = "https://auth.goutailler-olivier.com"
private const val REALM = "bonsai" private const val REALM = "bonsai"
private const val CLIENT_ID = "bonsai-webapp" private const val CLIENT_ID = "bonsai-webapp"
private const val KEY_TOKEN = "access_token" private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_API_URL = "api_url" private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_EXPIRES_AT = "expires_at"
private const val KEY_USERNAME = "username" private const val KEY_USERNAME = "username"
private const val KEY_PKCE_VERIFIER = "pkce_verifier"
private const val KEY_OAUTH_STATE = "oauth_state"
} }
} }
@@ -1,21 +1,56 @@
package com.planify.mobile.ui package com.planify.mobile.ui
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.planify.mobile.ui.auth.AuthStatus
import com.planify.mobile.ui.auth.AuthViewModel
import com.planify.mobile.ui.auth.LoginScreen
import com.planify.mobile.ui.theme.PlanifyTheme import com.planify.mobile.ui.theme.PlanifyTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val authViewModel: AuthViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
handleIntent(intent)
setContent { setContent {
PlanifyTheme { PlanifyTheme {
MainScreen() val status by authViewModel.status.collectAsState()
when (status) {
is AuthStatus.Authenticated -> MainScreen(authViewModel)
else -> LoginScreen(authViewModel)
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
val data = intent.data ?: return
if (data.scheme == "com.planify.mobile" && data.host == "auth") {
val code = data.getQueryParameter("code")
val state = data.getQueryParameter("state")
if (code != null && state != null) {
authViewModel.handleOAuthCallback(code, state)
} else {
val error = data.getQueryParameter("error_description")
?: data.getQueryParameter("error")
?: "Connexion annulée"
authViewModel.handleOAuthError(error)
} }
} }
} }
@@ -48,6 +48,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.planify.mobile.ui.auth.AuthViewModel
import com.planify.mobile.ui.navigation.DrawerViewModel import com.planify.mobile.ui.navigation.DrawerViewModel
import com.planify.mobile.ui.navigation.PlanifyNavHost import com.planify.mobile.ui.navigation.PlanifyNavHost
import com.planify.mobile.ui.navigation.Route import com.planify.mobile.ui.navigation.Route
@@ -56,7 +57,10 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) { fun MainScreen(
authViewModel: AuthViewModel,
viewModel: DrawerViewModel = hiltViewModel(),
) {
val navController = rememberNavController() val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -204,6 +208,7 @@ fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) {
) { padding -> ) { padding ->
PlanifyNavHost( PlanifyNavHost(
navController = navController, navController = navController,
authViewModel = authViewModel,
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
) )
@@ -0,0 +1,83 @@
package com.planify.mobile.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.bonsai.BonsaiSyncManager
import com.planify.mobile.data.bonsai.LoginResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed class AuthStatus {
object Checking : AuthStatus()
object NotAuthenticated : AuthStatus()
data class Authenticated(val username: String) : AuthStatus()
data class Error(val message: String) : AuthStatus()
}
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authManager: BonsaiAuthManager,
private val syncManager: BonsaiSyncManager,
) : ViewModel() {
private val _status = MutableStateFlow<AuthStatus>(AuthStatus.Checking)
val status: StateFlow<AuthStatus> = _status.asStateFlow()
init {
checkAuthOnStartup()
}
private fun checkAuthOnStartup() {
viewModelScope.launch {
if (!authManager.isLoggedIn) {
_status.value = AuthStatus.NotAuthenticated
return@launch
}
val refreshOk = authManager.refreshIfNeeded()
if (refreshOk) {
_status.value = AuthStatus.Authenticated(authManager.getUsername())
syncManager.sync()
} else {
authManager.logout()
_status.value = AuthStatus.NotAuthenticated
}
}
}
fun buildAuthUrl(): String = authManager.buildAuthUrl()
fun handleOAuthCallback(code: String, state: String) {
_status.value = AuthStatus.Checking
viewModelScope.launch {
when (val result = authManager.exchangeCode(code, state)) {
is LoginResult.Success -> {
_status.value = AuthStatus.Authenticated(authManager.getUsername())
syncManager.sync()
}
is LoginResult.Failure -> {
_status.value = AuthStatus.Error(result.message)
}
}
}
}
fun logout() {
authManager.logout()
_status.value = AuthStatus.NotAuthenticated
}
fun clearError() {
_status.value = AuthStatus.NotAuthenticated
}
fun handleOAuthError(message: String) {
_status.value = AuthStatus.Error(message)
}
}
@@ -0,0 +1,136 @@
package com.planify.mobile.ui.auth
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.R
private val MintBackground = Color(0xFFF0FDF4)
private val BonsaiGreen = Color(0xFF38A169)
@Composable
fun LoginScreen(viewModel: AuthViewModel = hiltViewModel()) {
val status by viewModel.status.collectAsState()
val context = LocalContext.current
Box(
modifier = Modifier
.fillMaxSize()
.background(MintBackground),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(16.dp),
shadowElevation = 4.dp,
color = Color.White,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 32.dp),
) {
Image(
painter = painterResource(id = R.drawable.ic_bonsai_foreground),
contentDescription = "Bonsai",
modifier = Modifier.size(72.dp),
)
Spacer(Modifier.height(12.dp))
Text(
text = "Bonsai",
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
color = Color(0xFF111827),
)
Spacer(Modifier.height(4.dp))
Text(
text = "Connexion",
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
color = Color(0xFF374151),
)
Spacer(Modifier.height(24.dp))
when (status) {
is AuthStatus.Checking -> {
CircularProgressIndicator(color = BonsaiGreen)
Spacer(Modifier.height(8.dp))
Text(
text = "Vérification…",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF6B7280),
)
}
is AuthStatus.Error -> {
Text(
text = (status as AuthStatus.Error).message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 12.dp),
)
LoginButton {
val url = viewModel.buildAuthUrl()
CustomTabsIntent.Builder().setShowTitle(true).build()
.launchUrl(context, Uri.parse(url))
}
}
else -> {
LoginButton {
val url = viewModel.buildAuthUrl()
CustomTabsIntent.Builder().setShowTitle(true).build()
.launchUrl(context, Uri.parse(url))
}
}
}
}
}
}
}
@Composable
private fun LoginButton(onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = BonsaiGreen),
shape = RoundedCornerShape(8.dp),
) {
Text("Se connecter", color = Color.White, fontWeight = FontWeight.Medium)
}
}
@@ -7,6 +7,7 @@ import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.planify.mobile.ui.auth.AuthViewModel
import com.planify.mobile.ui.filter.FilterScreen import com.planify.mobile.ui.filter.FilterScreen
import com.planify.mobile.ui.inbox.InboxScreen import com.planify.mobile.ui.inbox.InboxScreen
import com.planify.mobile.ui.label.LabelScreen import com.planify.mobile.ui.label.LabelScreen
@@ -19,6 +20,7 @@ import com.planify.mobile.ui.today.TodayScreen
@Composable @Composable
fun PlanifyNavHost( fun PlanifyNavHost(
navController: NavHostController, navController: NavHostController,
authViewModel: AuthViewModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
NavHost( NavHost(
@@ -74,7 +76,7 @@ fun PlanifyNavHost(
} }
composable(Route.Settings.path) { composable(Route.Settings.path) {
SettingsScreen() SettingsScreen(authViewModel = authViewModel)
} }
} }
} }
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -22,7 +21,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
@@ -31,19 +29,16 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.preferences.ThemeMode import com.planify.mobile.data.preferences.ThemeMode
import com.planify.mobile.ui.auth.AuthViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
authViewModel: AuthViewModel,
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
@@ -54,7 +49,7 @@ fun SettingsScreen(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
) { ) {
// ── Apparence ─────────────────────────────────────────────────────── // ── Apparence ───────────────────────────────────────────────────────
SectionTitle("Apparence") SectionTitle("Apparence")
ListItem( ListItem(
headlineContent = { Text("Thème") }, headlineContent = { Text("Thème") },
@@ -74,7 +69,7 @@ fun SettingsScreen(
HorizontalDivider(Modifier.padding(horizontal = 16.dp)) HorizontalDivider(Modifier.padding(horizontal = 16.dp))
// ── Bonsai ────────────────────────────────────────────────────────── // ── Bonsai ──────────────────────────────────────────────────────────
SectionTitle("Bonsai") SectionTitle("Bonsai")
if (state.isLoggedIn) { if (state.isLoggedIn) {
@@ -93,7 +88,7 @@ fun SettingsScreen(
}, },
) )
state.syncSuccess && run { if (state.syncSuccess) {
ListItem( ListItem(
leadingContent = { leadingContent = {
Icon( Icon(
@@ -102,9 +97,10 @@ fun SettingsScreen(
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
) )
}, },
headlineContent = { Text("Synchronisation réussie", color = MaterialTheme.colorScheme.primary) }, headlineContent = {
Text("Synchronisation réussie", color = MaterialTheme.colorScheme.primary)
},
) )
true
} }
state.syncError?.let { error -> state.syncError?.let { error ->
@@ -117,7 +113,7 @@ fun SettingsScreen(
} }
OutlinedButton( OutlinedButton(
onClick = viewModel::logout, onClick = authViewModel::logout,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -126,11 +122,10 @@ fun SettingsScreen(
Text("Se déconnecter") Text("Se déconnecter")
} }
} else { } else {
BonsaiLoginForm( ListItem(
initialUrl = state.apiUrl, leadingContent = { Icon(Icons.Outlined.AccountCircle, null) },
isLoading = state.loginInProgress, headlineContent = { Text("Non connecté") },
error = state.loginError, supportingContent = { Text("Relancez l'application pour vous connecter") },
onLogin = viewModel::login,
) )
} }
@@ -152,67 +147,6 @@ fun SettingsScreen(
} }
} }
@Composable
private fun BonsaiLoginForm(
initialUrl: String,
isLoading: Boolean,
error: String?,
onLogin: (url: String, username: String, password: String) -> Unit,
) {
var url by remember { mutableStateOf(initialUrl.ifBlank { BonsaiAuthManager.DEFAULT_API_URL }) }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("URL du serveur") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Nom d'utilisateur") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Mot de passe") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
)
Spacer(Modifier.height(12.dp))
error?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 8.dp),
)
}
Button(
onClick = { onLogin(url.trim(), username.trim(), password) },
enabled = !isLoading && url.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.padding(end = 8.dp),
color = MaterialTheme.colorScheme.onPrimary,
)
}
Text("Se connecter à Bonsai")
}
}
}
@Composable @Composable
private fun SectionTitle(text: String) { private fun SectionTitle(text: String) {
Text( Text(
@@ -4,14 +4,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.bonsai.BonsaiAuthManager import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.bonsai.BonsaiSyncManager import com.planify.mobile.data.bonsai.BonsaiSyncManager
import com.planify.mobile.data.bonsai.LoginResult
import com.planify.mobile.data.bonsai.SyncResult import com.planify.mobile.data.bonsai.SyncResult
import com.planify.mobile.data.preferences.AppPreferences import com.planify.mobile.data.preferences.AppPreferences
import com.planify.mobile.data.preferences.ThemeMode import com.planify.mobile.data.preferences.ThemeMode
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -23,9 +21,6 @@ data class SettingsUiState(
val notificationsEnabled: Boolean = true, val notificationsEnabled: Boolean = true,
val isLoggedIn: Boolean = false, val isLoggedIn: Boolean = false,
val username: String = "", val username: String = "",
val apiUrl: String = BonsaiAuthManager.DEFAULT_API_URL,
val loginInProgress: Boolean = false,
val loginError: String? = null,
val syncInProgress: Boolean = false, val syncInProgress: Boolean = false,
val syncError: String? = null, val syncError: String? = null,
val syncSuccess: Boolean = false, val syncSuccess: Boolean = false,
@@ -42,12 +37,21 @@ class SettingsViewModel @Inject constructor(
SettingsUiState( SettingsUiState(
isLoggedIn = authManager.isLoggedIn, isLoggedIn = authManager.isLoggedIn,
username = authManager.getUsername(), username = authManager.getUsername(),
apiUrl = authManager.getApiBaseUrl(),
) )
) )
val uiState = combine(prefs.themeMode, prefs.notificationsEnabled, _extra) { theme, notifs, extra -> val uiState = combine(
extra.copy(themeMode = theme, notificationsEnabled = notifs) prefs.themeMode,
prefs.notificationsEnabled,
authManager.isAuthenticated,
_extra,
) { theme, notifs, isAuth, extra ->
extra.copy(
themeMode = theme,
notificationsEnabled = notifs,
isLoggedIn = isAuth,
username = if (isAuth) authManager.getUsername() else "",
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
fun setTheme(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) } fun setTheme(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
@@ -56,34 +60,6 @@ class SettingsViewModel @Inject constructor(
prefs.setNotificationsEnabled(enabled) prefs.setNotificationsEnabled(enabled)
} }
fun login(apiUrl: String, username: String, password: String) {
_extra.update { it.copy(loginInProgress = true, loginError = null) }
viewModelScope.launch {
when (val result = authManager.login(apiUrl, username, password)) {
is LoginResult.Success -> {
_extra.update {
it.copy(
loginInProgress = false,
isLoggedIn = true,
username = authManager.getUsername(),
apiUrl = authManager.getApiBaseUrl(),
)
}
// Sync immediately after login
syncNow()
}
is LoginResult.Failure -> {
_extra.update { it.copy(loginInProgress = false, loginError = result.message) }
}
}
}
}
fun logout() {
authManager.logout()
_extra.update { it.copy(isLoggedIn = false, username = "", syncSuccess = false) }
}
fun syncNow() { fun syncNow() {
_extra.update { it.copy(syncInProgress = true, syncError = null, syncSuccess = false) } _extra.update { it.copy(syncInProgress = true, syncError = null, syncSuccess = false) }
viewModelScope.launch { viewModelScope.launch {
+2
View File
@@ -16,6 +16,7 @@ datastore = "1.1.1"
securityCrypto = "1.1.0-alpha06" securityCrypto = "1.1.0-alpha06"
workManager = "2.10.0" workManager = "2.10.0"
serialization = "1.7.3" serialization = "1.7.3"
browser = "1.8.0"
junit = "4.13.2" junit = "4.13.2"
junitExt = "1.2.1" junitExt = "1.2.1"
espressoCore = "3.6.1" espressoCore = "3.6.1"
@@ -50,6 +51,7 @@ datastore-preferences = { group = "androidx.datastore", name = "datastore-prefer
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }