diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7642878..31cb424 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,7 +16,7 @@ android { minSdk = 26 targetSdk = 35 versionCode = 1 - versionName = "0.0.7" + versionName = "0.0.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -83,6 +83,9 @@ dependencies { // WorkManager implementation(libs.work.runtime.ktx) + // Browser (Custom Tabs pour OAuth) + implementation(libs.androidx.browser) + // Serialization implementation(libs.kotlinx.serialization.json) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 055d5fb..0ac74ba 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,11 +19,22 @@ + + + + + + + + + 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 = _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" } } diff --git a/app/src/main/java/com/planify/mobile/ui/MainActivity.kt b/app/src/main/java/com/planify/mobile/ui/MainActivity.kt index b2fe2d3..b7c29c8 100644 --- a/app/src/main/java/com/planify/mobile/ui/MainActivity.kt +++ b/app/src/main/java/com/planify/mobile/ui/MainActivity.kt @@ -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) } } } diff --git a/app/src/main/java/com/planify/mobile/ui/MainScreen.kt b/app/src/main/java/com/planify/mobile/ui/MainScreen.kt index 12a0f54..e3a4c96 100644 --- a/app/src/main/java/com/planify/mobile/ui/MainScreen.kt +++ b/app/src/main/java/com/planify/mobile/ui/MainScreen.kt @@ -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), ) diff --git a/app/src/main/java/com/planify/mobile/ui/auth/AuthViewModel.kt b/app/src/main/java/com/planify/mobile/ui/auth/AuthViewModel.kt new file mode 100644 index 0000000..43a2ade --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/auth/AuthViewModel.kt @@ -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.Checking) + val status: StateFlow = _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) + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/auth/LoginScreen.kt b/app/src/main/java/com/planify/mobile/ui/auth/LoginScreen.kt new file mode 100644 index 0000000..bcb8046 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/auth/LoginScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt b/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt index 5d589cd..eaf07d5 100644 --- a/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt +++ b/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt @@ -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) } } } diff --git a/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt b/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt index c97e96e..ca68caa 100644 --- a/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt @@ -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( diff --git a/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt index 61d8ea7..0b393c9 100644 --- a/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt @@ -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 { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f8f7aad..a04aa1f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ datastore = "1.1.1" securityCrypto = "1.1.0-alpha06" workManager = "2.10.0" serialization = "1.7.3" +browser = "1.8.0" junit = "4.13.2" junitExt = "1.2.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" } 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" } +androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }