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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user