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:
@@ -19,11 +19,22 @@
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.PlanifyMobile">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
|
||||
<receiver
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
package com.planify.mobile.data.bonsai
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.Base64
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -23,40 +30,71 @@ class BonsaiAuthManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val httpClient: OkHttpClient,
|
||||
) {
|
||||
private val prefs by lazy {
|
||||
private val prefs: SharedPreferences
|
||||
private val _isAuthenticated: MutableStateFlow<Boolean>
|
||||
|
||||
init {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"bonsai_credentials",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
_isAuthenticated = MutableStateFlow(prefs.getString(KEY_REFRESH_TOKEN, null) != null)
|
||||
}
|
||||
|
||||
val isLoggedIn: Boolean get() = prefs.getString(KEY_TOKEN, null) != null
|
||||
|
||||
fun getApiBaseUrl(): String = prefs.getString(KEY_API_URL, DEFAULT_API_URL) ?: DEFAULT_API_URL
|
||||
val isAuthenticated: StateFlow<Boolean> = _isAuthenticated
|
||||
val isLoggedIn: Boolean get() = _isAuthenticated.value
|
||||
|
||||
fun getApiBaseUrl(): String = DEFAULT_API_URL
|
||||
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) {
|
||||
val cleanUrl = apiUrl.trimEnd('/')
|
||||
val tokenUrl = "$KEYCLOAK_BASE/realms/$REALM/protocol/openid-connect/token"
|
||||
prefs.edit()
|
||||
.putString(KEY_PKCE_VERIFIER, verifier)
|
||||
.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()
|
||||
.add("grant_type", "password")
|
||||
.add("grant_type", "authorization_code")
|
||||
.add("client_id", CLIENT_ID)
|
||||
.add("username", username)
|
||||
.add("password", password)
|
||||
.add("code", code)
|
||||
.add("redirect_uri", REDIRECT_URI)
|
||||
.add("code_verifier", verifier)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(tokenUrl)
|
||||
.url("$KEYCLOAK_BASE/realms/$REALM/protocol/openid-connect/token")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
@@ -64,35 +102,110 @@ class BonsaiAuthManager @Inject constructor(
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
val raw = response.body?.string() ?: ""
|
||||
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")
|
||||
}
|
||||
val json = JSONObject(raw)
|
||||
val token = json.getString("access_token")
|
||||
saveTokens(JSONObject(raw))
|
||||
prefs.edit()
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putString(KEY_API_URL, cleanUrl)
|
||||
.putString(KEY_USERNAME, username)
|
||||
.remove(KEY_PKCE_VERIFIER)
|
||||
.remove(KEY_OAUTH_STATE)
|
||||
.apply()
|
||||
LoginResult.Success
|
||||
}
|
||||
}.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() {
|
||||
prefs.edit()
|
||||
.remove(KEY_TOKEN)
|
||||
.remove(KEY_ACCESS_TOKEN)
|
||||
.remove(KEY_REFRESH_TOKEN)
|
||||
.remove(KEY_EXPIRES_AT)
|
||||
.remove(KEY_USERNAME)
|
||||
.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 {
|
||||
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 REALM = "bonsai"
|
||||
private const val CLIENT_ID = "bonsai-webapp"
|
||||
private const val KEY_TOKEN = "access_token"
|
||||
private const val KEY_API_URL = "api_url"
|
||||
private const val KEY_ACCESS_TOKEN = "access_token"
|
||||
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_PKCE_VERIFIER = "pkce_verifier"
|
||||
private const val KEY_OAUTH_STATE = "oauth_state"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,56 @@
|
||||
package com.planify.mobile.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
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 dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val authViewModel: AuthViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
handleIntent(intent)
|
||||
setContent {
|
||||
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.navigation.compose.currentBackStackEntryAsState
|
||||
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.PlanifyNavHost
|
||||
import com.planify.mobile.ui.navigation.Route
|
||||
@@ -56,7 +57,10 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) {
|
||||
fun MainScreen(
|
||||
authViewModel: AuthViewModel,
|
||||
viewModel: DrawerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -204,6 +208,7 @@ fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) {
|
||||
) { padding ->
|
||||
PlanifyNavHost(
|
||||
navController = navController,
|
||||
authViewModel = authViewModel,
|
||||
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.composable
|
||||
import androidx.navigation.navArgument
|
||||
import com.planify.mobile.ui.auth.AuthViewModel
|
||||
import com.planify.mobile.ui.filter.FilterScreen
|
||||
import com.planify.mobile.ui.inbox.InboxScreen
|
||||
import com.planify.mobile.ui.label.LabelScreen
|
||||
@@ -19,6 +20,7 @@ import com.planify.mobile.ui.today.TodayScreen
|
||||
@Composable
|
||||
fun PlanifyNavHost(
|
||||
navController: NavHostController,
|
||||
authViewModel: AuthViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
NavHost(
|
||||
@@ -74,7 +76,7 @@ fun PlanifyNavHost(
|
||||
}
|
||||
|
||||
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.CheckCircle
|
||||
import androidx.compose.material.icons.outlined.Sync
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -22,7 +21,6 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
@@ -31,19 +29,16 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.planify.mobile.data.bonsai.BonsaiAuthManager
|
||||
import com.planify.mobile.data.preferences.ThemeMode
|
||||
import com.planify.mobile.ui.auth.AuthViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
authViewModel: AuthViewModel,
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
@@ -54,7 +49,7 @@ fun SettingsScreen(
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
// ── Apparence ───────────────────────────────────────────────────────
|
||||
// ── Apparence ────────────────────────────────────────────────────────
|
||||
SectionTitle("Apparence")
|
||||
ListItem(
|
||||
headlineContent = { Text("Thème") },
|
||||
@@ -74,7 +69,7 @@ fun SettingsScreen(
|
||||
|
||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
// ── Bonsai ──────────────────────────────────────────────────────────
|
||||
// ── Bonsai ───────────────────────────────────────────────────────────
|
||||
SectionTitle("Bonsai")
|
||||
|
||||
if (state.isLoggedIn) {
|
||||
@@ -93,7 +88,7 @@ fun SettingsScreen(
|
||||
},
|
||||
)
|
||||
|
||||
state.syncSuccess && run {
|
||||
if (state.syncSuccess) {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
@@ -102,9 +97,10 @@ fun SettingsScreen(
|
||||
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 ->
|
||||
@@ -117,7 +113,7 @@ fun SettingsScreen(
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = viewModel::logout,
|
||||
onClick = authViewModel::logout,
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -126,11 +122,10 @@ fun SettingsScreen(
|
||||
Text("Se déconnecter")
|
||||
}
|
||||
} else {
|
||||
BonsaiLoginForm(
|
||||
initialUrl = state.apiUrl,
|
||||
isLoading = state.loginInProgress,
|
||||
error = state.loginError,
|
||||
onLogin = viewModel::login,
|
||||
ListItem(
|
||||
leadingContent = { Icon(Icons.Outlined.AccountCircle, null) },
|
||||
headlineContent = { Text("Non connecté") },
|
||||
supportingContent = { Text("Relancez l'application pour vous connecter") },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
private fun SectionTitle(text: String) {
|
||||
Text(
|
||||
|
||||
@@ -4,14 +4,12 @@ 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 com.planify.mobile.data.bonsai.SyncResult
|
||||
import com.planify.mobile.data.preferences.AppPreferences
|
||||
import com.planify.mobile.data.preferences.ThemeMode
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -23,9 +21,6 @@ data class SettingsUiState(
|
||||
val notificationsEnabled: Boolean = true,
|
||||
val isLoggedIn: Boolean = false,
|
||||
val username: String = "",
|
||||
val apiUrl: String = BonsaiAuthManager.DEFAULT_API_URL,
|
||||
val loginInProgress: Boolean = false,
|
||||
val loginError: String? = null,
|
||||
val syncInProgress: Boolean = false,
|
||||
val syncError: String? = null,
|
||||
val syncSuccess: Boolean = false,
|
||||
@@ -42,12 +37,21 @@ class SettingsViewModel @Inject constructor(
|
||||
SettingsUiState(
|
||||
isLoggedIn = authManager.isLoggedIn,
|
||||
username = authManager.getUsername(),
|
||||
apiUrl = authManager.getApiBaseUrl(),
|
||||
)
|
||||
)
|
||||
|
||||
val uiState = combine(prefs.themeMode, prefs.notificationsEnabled, _extra) { theme, notifs, extra ->
|
||||
extra.copy(themeMode = theme, notificationsEnabled = notifs)
|
||||
val uiState = combine(
|
||||
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())
|
||||
|
||||
fun setTheme(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||
@@ -56,34 +60,6 @@ class SettingsViewModel @Inject constructor(
|
||||
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() {
|
||||
_extra.update { it.copy(syncInProgress = true, syncError = null, syncSuccess = false) }
|
||||
viewModelScope.launch {
|
||||
|
||||
Reference in New Issue
Block a user