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" }