From 8e0b7292281e05bbb62e610ab5328efa62db3913 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 21 Mar 2026 08:15:03 +0500 Subject: [PATCH 1/4] feat: implement persistent device flow polling and manual status refresh - Implement `SavedStateHandle` in `AuthenticationViewModel` to persist and restore device flow session state across process death. - Add background polling for GitHub device tokens using a dedicated `pollingJob` with a 15-second interval. - Update `AuthenticationRepository` to support single poll attempts with granular error handling for pending, expired, or denied authorizations. - Enhance `AuthenticationRoot` UI with a "Check status" button to allow users to manually trigger a token poll. - Add `OnResumed` lifecycle effect to automatically trigger a status check when the user returns to the app from the browser. - Include new localized strings for polling status and manual refresh actions. - Improve error categorization and logging for the authentication workflow. --- .../composeResources/values/strings.xml | 2 + .../AuthenticationRepositoryImpl.kt | 55 +++++ .../repository/AuthenticationRepository.kt | 8 + .../auth/presentation/AuthenticationAction.kt | 4 + .../auth/presentation/AuthenticationRoot.kt | 36 ++++ .../auth/presentation/AuthenticationState.kt | 1 + .../presentation/AuthenticationViewModel.kt | 189 ++++++++++++++++-- 7 files changed, 277 insertions(+), 18 deletions(-) diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 1017ec087..a07523217 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -444,6 +444,8 @@ Please try signing in again to get a new code. Please check your internet connection and try again. You denied the authorization request. Try again if this was unintentional. + I already authorized + Checking… Read More diff --git a/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/repository/AuthenticationRepositoryImpl.kt b/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/repository/AuthenticationRepositoryImpl.kt index 729cd8517..b82472b2d 100644 --- a/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/repository/AuthenticationRepositoryImpl.kt +++ b/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/repository/AuthenticationRepositoryImpl.kt @@ -223,6 +223,61 @@ class AuthenticationRepositoryImpl( } } + override suspend fun pollDeviceTokenOnce(deviceCode: String): Result = + withContext(Dispatchers.IO) { + val clientId = BuildKonfig.GITHUB_CLIENT_ID + try { + val res = GitHubAuthApi.pollDeviceToken(clientId, deviceCode) + val success = res.getOrNull()?.toDomain() + + if (success != null) { + logger.debug("✅ Single poll: Token received! Saving...") + saveTokenWithVerification(success) + Result.success(success) + } else { + val error = res.exceptionOrNull() + val errorMsg = (error?.message ?: "").lowercase() + + when { + "authorization_pending" in errorMsg || "slow_down" in errorMsg -> { + Result.success(null) + } + + "access_denied" in errorMsg -> { + Result.failure( + Exception("Authentication was denied. Please try again if this was a mistake."), + ) + } + + "expired_token" in errorMsg || + "expired_device_code" in errorMsg || + "token_expired" in errorMsg -> { + Result.failure( + Exception("Authorization code expired. Please try again."), + ) + } + + "bad_verification_code" in errorMsg || + "incorrect_device_code" in errorMsg -> { + Result.failure( + Exception("Invalid verification code. Please restart authentication."), + ) + } + + else -> { + logger.debug("⚠️ Single poll unknown error: $errorMsg") + Result.success(null) + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.debug("⚠️ Single poll network error: ${e.message}") + Result.success(null) + } + } + private fun isNetworkError(errorMsg: String): Boolean = errorMsg.contains("unable to resolve") || errorMsg.contains("no address") || diff --git a/feature/auth/domain/src/commonMain/kotlin/zed/rainxch/auth/domain/repository/AuthenticationRepository.kt b/feature/auth/domain/src/commonMain/kotlin/zed/rainxch/auth/domain/repository/AuthenticationRepository.kt index 0b891e1bf..553785342 100644 --- a/feature/auth/domain/src/commonMain/kotlin/zed/rainxch/auth/domain/repository/AuthenticationRepository.kt +++ b/feature/auth/domain/src/commonMain/kotlin/zed/rainxch/auth/domain/repository/AuthenticationRepository.kt @@ -10,4 +10,12 @@ interface AuthenticationRepository { suspend fun startDeviceFlow(): GithubDeviceStart suspend fun awaitDeviceToken(start: GithubDeviceStart): GithubDeviceTokenSuccess + + /** + * Single poll attempt. Returns: + * - [Result.success] with non-null [GithubDeviceTokenSuccess] if user authorized + * - [Result.success] with null if authorization is still pending (keep polling) + * - [Result.failure] on terminal errors (denied, expired, invalid code) + */ + suspend fun pollDeviceTokenOnce(deviceCode: String): Result } diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationAction.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationAction.kt index 0fb5d9a86..3dfd9aa2e 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationAction.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationAction.kt @@ -22,4 +22,8 @@ sealed interface AuthenticationAction { ) : AuthenticationAction data object SkipLogin : AuthenticationAction + + data object PollNow : AuthenticationAction + + data object OnResumed : AuthenticationAction } diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt index e5143cafb..80fd8f2a2 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.OpenWith +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -23,6 +24,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -37,6 +39,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -48,8 +52,10 @@ import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.app_icon +import zed.rainxch.githubstore.core.presentation.res.auth_check_status import zed.rainxch.githubstore.core.presentation.res.auth_code_expires_in import zed.rainxch.githubstore.core.presentation.res.auth_error_with_message +import zed.rainxch.githubstore.core.presentation.res.auth_polling_status import zed.rainxch.githubstore.core.presentation.res.continue_as_guest import zed.rainxch.githubstore.core.presentation.res.copy_code import zed.rainxch.githubstore.core.presentation.res.enter_code_on_github @@ -71,6 +77,10 @@ fun AuthenticationRoot( ) { val state by viewModel.state.collectAsStateWithLifecycle() + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.onAction(AuthenticationAction.OnResumed) + } + ObserveAsEvents(viewModel.events) { event -> when (event) { AuthenticationEvents.OnNavigateToMain -> { @@ -306,6 +316,32 @@ fun StateDevicePrompt( }, ) + Spacer(Modifier.height(12.dp)) + + OutlinedButton( + onClick = { onAction(AuthenticationAction.PollNow) }, + enabled = !state.isPolling, + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + + Spacer(Modifier.size(8.dp)) + + Text( + text = + if (state.isPolling) { + stringResource(Res.string.auth_polling_status) + } else { + stringResource(Res.string.auth_check_status) + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + Spacer(Modifier.weight(2f)) } } diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationState.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationState.kt index 15fdb737a..692210a10 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationState.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationState.kt @@ -6,4 +6,5 @@ data class AuthenticationState( val loginState: AuthLoginState = AuthLoginState.LoggedOut, val copied: Boolean = false, val info: String? = null, + val isPolling: Boolean = false, ) diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt index e3f1b6328..a4ea234ae 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt @@ -1,5 +1,6 @@ package zed.rainxch.auth.presentation +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CancellationException @@ -22,7 +23,6 @@ import zed.rainxch.auth.presentation.mapper.toUi import zed.rainxch.auth.presentation.model.AuthLoginState import zed.rainxch.auth.presentation.model.GithubDeviceStartUi import zed.rainxch.core.domain.logging.GitHubStoreLogger -import zed.rainxch.core.domain.model.GithubDeviceStart import zed.rainxch.core.domain.utils.BrowserHelper import zed.rainxch.core.domain.utils.ClipboardHelper import zed.rainxch.githubstore.core.presentation.res.* @@ -33,9 +33,11 @@ class AuthenticationViewModel( private val clipboardHelper: ClipboardHelper, private val scope: CoroutineScope, private val logger: GitHubStoreLogger, + private val savedStateHandle: SavedStateHandle, ) : ViewModel() { private var hasLoadedInitialData = false private var countdownJob: Job? = null + private var pollingJob: Job? = null private val _state: MutableStateFlow = MutableStateFlow(AuthenticationState()) @@ -63,6 +65,7 @@ class AuthenticationViewModel( } } + restoreFromSavedState() hasLoadedInitialData = true } }.stateIn( @@ -104,14 +107,28 @@ class AuthenticationViewModel( AuthenticationAction.SkipLogin -> { _events.trySend(AuthenticationEvents.OnNavigateToMain) } + + AuthenticationAction.PollNow -> { + val loginState = _state.value.loginState + if (loginState is AuthLoginState.DevicePrompt && !_state.value.isPolling) { + pollOnce(loginState.start.deviceCode) + } + } + + AuthenticationAction.OnResumed -> { + val loginState = _state.value.loginState + if (loginState is AuthLoginState.DevicePrompt && !_state.value.isPolling) { + pollOnce(loginState.start.deviceCode) + } + } } } - private fun startCountdown(start: GithubDeviceStart) { + private fun startCountdown(remainingSeconds: Int) { countdownJob?.cancel() countdownJob = viewModelScope.launch { - var remaining = start.expiresInSec + var remaining = remainingSeconds while (remaining > 0) { _state.update { currentState -> val loginState = currentState.loginState @@ -126,6 +143,9 @@ class AuthenticationViewModel( delay(1000L) remaining-- } + + pollingJob?.cancel() + clearSavedState() _state.update { it.copy( loginState = @@ -146,19 +166,23 @@ class AuthenticationViewModel( authenticationRepository.startDeviceFlow() } + val startUi = start.toUi() + withContext(Dispatchers.Main.immediate) { _state.update { it.copy( loginState = AuthLoginState.DevicePrompt( - start = start.toUi(), + start = startUi, remainingSeconds = start.expiresInSec, ), copied = false, ) } - startCountdown(start) + saveToSavedState(start.deviceCode, startUi) + startCountdown(start.expiresInSec) + startPolling(start.deviceCode) try { clipboardHelper.copy( @@ -170,21 +194,12 @@ class AuthenticationViewModel( logger.debug("Failed to copy to clipboard: ${e.message}") } } - - withContext(Dispatchers.IO) { - authenticationRepository.awaitDeviceToken(start = start) - } - - countdownJob?.cancel() - - withContext(Dispatchers.Main.immediate) { - _state.update { it.copy(loginState = AuthLoginState.LoggedIn) } - _events.trySend(AuthenticationEvents.OnNavigateToMain) - } } catch (e: CancellationException) { throw e } catch (t: Throwable) { countdownJob?.cancel() + pollingJob?.cancel() + clearSavedState() val (message, hint) = categorizeError(t) withContext(Dispatchers.Main.immediate) { _state.update { @@ -201,6 +216,121 @@ class AuthenticationViewModel( } } + private fun startPolling(deviceCode: String) { + pollingJob?.cancel() + pollingJob = + viewModelScope.launch { + while (true) { + delay(POLL_INTERVAL_MS) + doPoll(deviceCode) + } + } + } + + private fun pollOnce(deviceCode: String) { + viewModelScope.launch { + doPoll(deviceCode) + } + } + + private suspend fun doPoll(deviceCode: String) { + _state.update { it.copy(isPolling = true) } + try { + val result = + withContext(Dispatchers.IO) { + authenticationRepository.pollDeviceTokenOnce(deviceCode) + } + + result + .onSuccess { token -> + if (token != null) { + pollingJob?.cancel() + countdownJob?.cancel() + clearSavedState() + _state.update { + it.copy(loginState = AuthLoginState.LoggedIn, isPolling = false) + } + _events.trySend(AuthenticationEvents.OnNavigateToMain) + } else { + _state.update { it.copy(isPolling = false) } + } + }.onFailure { error -> + pollingJob?.cancel() + countdownJob?.cancel() + clearSavedState() + val (message, hint) = categorizeError(error) + _state.update { + it.copy( + loginState = + AuthLoginState.Error(message = message, recoveryHint = hint), + isPolling = false, + ) + } + } + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + _state.update { it.copy(isPolling = false) } + logger.debug("Unexpected poll error: ${t.message}") + } + } + + // region SavedStateHandle + + private fun saveToSavedState( + deviceCode: String, + startUi: GithubDeviceStartUi, + ) { + savedStateHandle[KEY_DEVICE_CODE] = deviceCode + savedStateHandle[KEY_USER_CODE] = startUi.userCode + savedStateHandle[KEY_VERIFICATION_URI] = startUi.verificationUri + savedStateHandle[KEY_VERIFICATION_URI_COMPLETE] = startUi.verificationUriComplete + savedStateHandle[KEY_INTERVAL_SEC] = startUi.intervalSec + savedStateHandle[KEY_EXPIRES_IN_SEC] = startUi.expiresInSec + savedStateHandle[KEY_START_TIME_MILLIS] = System.currentTimeMillis() + } + + private fun clearSavedState() { + SAVED_STATE_KEYS.forEach { savedStateHandle.remove(it) } + } + + private fun restoreFromSavedState() { + val deviceCode = savedStateHandle.get(KEY_DEVICE_CODE) ?: return + val userCode = savedStateHandle.get(KEY_USER_CODE) ?: return + val verificationUri = savedStateHandle.get(KEY_VERIFICATION_URI) ?: return + val expiresInSec = savedStateHandle.get(KEY_EXPIRES_IN_SEC) ?: return + val intervalSec = savedStateHandle.get(KEY_INTERVAL_SEC) ?: 5 + val startTimeMillis = savedStateHandle.get(KEY_START_TIME_MILLIS) ?: return + + val elapsedSec = ((System.currentTimeMillis() - startTimeMillis) / 1000).toInt() + val remainingSec = expiresInSec - elapsedSec + + if (remainingSec <= 0) { + clearSavedState() + return + } + + val startUi = + GithubDeviceStartUi( + deviceCode = deviceCode, + userCode = userCode, + verificationUri = verificationUri, + verificationUriComplete = savedStateHandle.get(KEY_VERIFICATION_URI_COMPLETE), + intervalSec = intervalSec, + expiresInSec = expiresInSec, + ) + + _state.update { + it.copy(loginState = AuthLoginState.DevicePrompt(startUi, remainingSec)) + } + + startCountdown(remainingSec) + startPolling(deviceCode) + pollOnce(deviceCode) + } + + // endregion + private suspend fun categorizeError(t: Throwable): Pair { val msg = t.message ?: return getString(Res.string.error_unknown) to null val lowerMsg = msg.lowercase() @@ -230,6 +360,7 @@ class AuthenticationViewModel( override fun onCleared() { super.onCleared() countdownJob?.cancel() + pollingJob?.cancel() } private fun openGitHub(start: GithubDeviceStartUi) { @@ -238,7 +369,7 @@ class AuthenticationViewModel( val url = start.verificationUriComplete ?: start.verificationUri browserHelper.openUrl(url) } catch (e: Exception) { - logger.debug("⚠️ Failed to open browser: ${e.message}") + logger.debug("Failed to open browser: ${e.message}") } } } @@ -260,7 +391,7 @@ class AuthenticationViewModel( ) } } catch (e: Exception) { - logger.debug("⚠️ Failed to copy to clipboard: ${e.message}") + logger.debug("Failed to copy to clipboard: ${e.message}") _state.update { val currentRemaining = (it.loginState as? AuthLoginState.DevicePrompt)?.remainingSeconds ?: 0 @@ -272,4 +403,26 @@ class AuthenticationViewModel( } } } + + companion object { + private const val KEY_DEVICE_CODE = "auth_device_code" + private const val KEY_USER_CODE = "auth_user_code" + private const val KEY_VERIFICATION_URI = "auth_verification_uri" + private const val KEY_VERIFICATION_URI_COMPLETE = "auth_verification_uri_complete" + private const val KEY_INTERVAL_SEC = "auth_interval_sec" + private const val KEY_EXPIRES_IN_SEC = "auth_expires_in_sec" + private const val KEY_START_TIME_MILLIS = "auth_start_time_millis" + private const val POLL_INTERVAL_MS = 15_000L + + private val SAVED_STATE_KEYS = + listOf( + KEY_DEVICE_CODE, + KEY_USER_CODE, + KEY_VERIFICATION_URI, + KEY_VERIFICATION_URI_COMPLETE, + KEY_INTERVAL_SEC, + KEY_EXPIRES_IN_SEC, + KEY_START_TIME_MILLIS, + ) + } } From 4bbbd371fd6ca18c453d6188be0a877043f85ed4 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 21 Mar 2026 08:26:54 +0500 Subject: [PATCH 2/4] locale: add translations for authentication status and polling - Add localized strings for `auth_check_status` ("I have already authorized") and `auth_polling_status` ("Checking...") across multiple languages. - Provide translations for Arabic, Bengali, French, Hindi, Italian, Japanese, Korean, Polish, Russian, Spanish, Turkish, and Chinese (Simplified). --- .../src/commonMain/composeResources/values-ar/strings-ar.xml | 2 ++ .../src/commonMain/composeResources/values-bn/strings-bn.xml | 2 ++ .../src/commonMain/composeResources/values-es/strings-es.xml | 2 ++ .../src/commonMain/composeResources/values-fr/strings-fr.xml | 2 ++ .../src/commonMain/composeResources/values-hi/strings-hi.xml | 2 ++ .../src/commonMain/composeResources/values-it/strings-it.xml | 2 ++ .../src/commonMain/composeResources/values-ja/strings-ja.xml | 3 +++ .../src/commonMain/composeResources/values-ko/strings-ko.xml | 3 +++ .../src/commonMain/composeResources/values-pl/strings-pl.xml | 2 ++ .../src/commonMain/composeResources/values-ru/strings-ru.xml | 2 ++ .../src/commonMain/composeResources/values-tr/strings-tr.xml | 2 ++ .../composeResources/values-zh-rCN/strings-zh-rCN.xml | 3 +++ 12 files changed, 27 insertions(+) diff --git a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml index 8158b3d08..35f029c3d 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -432,6 +432,8 @@ يرجى محاولة تسجيل الدخول مرة أخرى للحصول على رمز جديد. يرجى التحقق من اتصالك بالإنترنت والمحاولة مرة أخرى. لقد رفضت طلب التفويض. حاول مرة أخرى إذا كان ذلك غير مقصود. + لقد قمت بالتفويض بالفعل + جارٍ التحقق… اقرأ المزيد diff --git a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml index ca0c68470..6971e27cd 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -432,6 +432,8 @@ একটি নতুন কোড পেতে অনুগ্রহ করে আবার সাইন ইন করার চেষ্টা করুন। অনুগ্রহ করে আপনার ইন্টারনেট সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন। আপনি অনুমোদনের অনুরোধ প্রত্যাখ্যান করেছেন। এটি অনিচ্ছাকৃত হলে আবার চেষ্টা করুন। + আমি ইতিমধ্যেই অনুমোদন করেছি + যাচাই করা হচ্ছে… আরও পড়ুন diff --git a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml index 87a0ad826..28424e88a 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -397,6 +397,8 @@ Intenta iniciar sesión de nuevo para obtener un nuevo código. Revisa tu conexión a internet e intenta de nuevo. Rechazaste la solicitud de autorización. Intenta de nuevo si fue involuntario. + Ya he autorizado + Comprobando… Leer más diff --git a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml index 8f01c8b86..d9d65eea1 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -397,6 +397,8 @@ Veuillez réessayer de vous connecter pour obtenir un nouveau code. Vérifiez votre connexion internet et réessayez. Vous avez refusé la demande d\'autorisation. Réessayez si c\'était involontaire. + J’ai déjà autorisé + Vérification… Lire la suite diff --git a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml index ff3b2b756..2ce5c1a21 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -432,6 +432,8 @@ नया कोड प्राप्त करने के लिए कृपया फिर से साइन इन करें। कृपया अपना इंटरनेट कनेक्शन जाँचें और पुनः प्रयास करें। आपने प्राधिकरण अनुरोध अस्वीकार कर दिया। यदि यह अनजाने में हुआ तो पुनः प्रयास करें। + मैं पहले ही अनुमति दे चुका हूँ + जाँच की जा रही है… और पढ़ें diff --git a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml index 498f721e7..957deeece 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -433,6 +433,8 @@ Riprova ad accedere per ottenere un nuovo codice. Controlla la tua connessione internet e riprova. Hai rifiutato la richiesta di autorizzazione. Riprova se è stato involontario. + Ho già autorizzato + Verifica in corso… Leggi di più diff --git a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml index 65d3a228e..c4b9ab014 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -397,6 +397,9 @@ 新しいコードを取得するために再度サインインしてください。 インターネット接続を確認して再試行してください。 認証リクエストを拒否しました。意図しない場合は再試行してください。 + すでに認証しました + 確認中… + もっと読む 折りたたむ diff --git a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml index d1f949f8d..7721ce97d 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -430,6 +430,9 @@ 새 코드를 받으려면 다시 로그인해 주세요. 인터넷 연결을 확인하고 다시 시도하세요. 인증 요청을 거부했습니다. 의도하지 않은 경우 다시 시도하세요. + 이미 인증했습니다 + 확인 중… + 더 보기 간략히 diff --git a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml index 971e8658c..ecd35ee50 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -395,6 +395,8 @@ Spróbuj zalogować się ponownie, aby uzyskać nowy kod. Sprawdź połączenie internetowe i spróbuj ponownie. Odrzuciłeś żądanie autoryzacji. Spróbuj ponownie, jeśli było to niezamierzone. + Już autoryzowałem + Sprawdzanie… Czytaj więcej diff --git a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml index 249956a81..d170c42ef 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -397,6 +397,8 @@ Пожалуйста, попробуйте войти снова для получения нового кода. Проверьте подключение к интернету и попробуйте снова. Вы отклонили запрос авторизации. Попробуйте снова, если это было непреднамеренно. + Я уже авторизовался + Проверка… Читать далее diff --git a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml index 6ec426a87..1e07311ec 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -429,6 +429,8 @@ Yeni bir kod almak için lütfen tekrar giriş yapmayı deneyin. Lütfen internet bağlantınızı kontrol edin ve tekrar deneyin. Yetkilendirme isteğini reddettiniz. İstemeden yaptıysanız tekrar deneyin. + Zaten yetkilendirdim + Kontrol ediliyor… Devamını oku diff --git a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml index 7836bf1f9..49232ec44 100644 --- a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml +++ b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml @@ -398,6 +398,9 @@ 请重新登录以获取新的验证码。 请检查您的网络连接并重试。 您拒绝了授权请求。如果是误操作,请重试。 + 我已经授权 + 正在检查… + 阅读更多 收起 From 4c10aafb0b0d00e97ac85547a0583a8ef50eb2f5 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 21 Mar 2026 08:54:23 +0500 Subject: [PATCH 3/4] ui: enhance authentication screen with animations and improved layout - Implement `AnimatedContent` for smooth transitions between different authentication states (LoggedOut, DevicePrompt, Pending, LoggedIn, Error). - Add scale and fade animations for the app icon and status indicators using `animateFloatAsState` and `AnimatedVisibility`. - Refactor state-specific UI into dedicated private composables (`StateLoggedOut`, `StateDevicePrompt`, `StatePending`, `StateLoggedIn`, `StateError`) for better maintainability. - Enhance the `StateDevicePrompt` with an animated countdown progress bar, urgent color states for expiring codes, and improved typography. - Modernize the UI using `ElevatedCard`, `FilledTonalButton`, and standardized spacing/padding across all states. - Improve visual feedback for the "copy code" action and polling status with icon animations and a `CircularProgressIndicator`. - Update previews to cover more authentication scenarios including logged-in and error states with recovery hints. --- .../auth/presentation/AuthenticationRoot.kt | 657 +++++++++++++----- .../presentation/AuthenticationViewModel.kt | 32 +- 2 files changed, 483 insertions(+), 206 deletions(-) diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt index 80fd8f2a2..46a14c46f 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt @@ -1,8 +1,22 @@ package zed.rainxch.auth.presentation -import androidx.compose.foundation.BorderStroke +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -11,34 +25,46 @@ 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.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.OpenWith import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.Card +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -110,198 +136,352 @@ fun AuthenticationScreen( Modifier .fillMaxSize() .padding(innerPadding) - .padding(vertical = 32.dp, horizontal = 16.dp), + .padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { + Spacer(Modifier.height(48.dp)) + + val iconScale by animateFloatAsState( + targetValue = + when (state.loginState) { + is AuthLoginState.LoggedIn -> 0.9f + is AuthLoginState.Error -> 0.95f + else -> 1f + }, + animationSpec = + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + label = "icon_scale", + ) + Image( painter = painterResource(Res.drawable.app_icon), contentDescription = null, modifier = Modifier - .size(150.dp) - .clip(RoundedCornerShape(32.dp)), + .size(120.dp) + .graphicsLayer { + scaleX = iconScale + scaleY = iconScale + }.clip(RoundedCornerShape(28.dp)), contentScale = ContentScale.Crop, ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(24.dp)) + + AnimatedContent( + targetState = state.loginState, + transitionSpec = { + val enter = + fadeIn(tween(350)) + + slideInVertically( + animationSpec = + spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + initialOffsetY = { it / 5 }, + ) + val exit = fadeOut(tween(200)) + enter togetherWith exit + }, + contentKey = { it::class }, + modifier = Modifier.fillMaxWidth().weight(1f), + label = "auth_state", + ) { authState -> + when (authState) { + is AuthLoginState.LoggedOut -> { + StateLoggedOut(onAction = onAction) + } - when (val authState = state.loginState) { - is AuthLoginState.LoggedOut -> { - StateLoggedOut( - onAction = onAction, - ) - } + is AuthLoginState.DevicePrompt -> { + StateDevicePrompt( + state = state, + authState = authState, + onAction = onAction, + ) + } - is AuthLoginState.DevicePrompt -> { - StateDevicePrompt( - state = state, - authState = authState, - onAction = onAction, - ) + is AuthLoginState.Pending -> { + StatePending() + } + + is AuthLoginState.LoggedIn -> { + StateLoggedIn() + } + + is AuthLoginState.Error -> { + StateError( + authState = authState, + onAction = onAction, + ) + } } + } + } + } +} - is AuthLoginState.Pending -> { - CircularWavyProgressIndicator() +@Composable +private fun StateLoggedOut(onAction: (AuthenticationAction) -> Unit) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(Res.string.unlock_full_experience), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(24.dp)) - Text( - text = stringResource(Res.string.waiting_for_authorization), + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(32.dp), + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Row( + modifier = Modifier.padding(20.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = + Modifier + .size(48.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.OpenWith, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, ) } - is AuthLoginState.LoggedIn -> { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { Text( - text = stringResource(Res.string.signed_in), - style = MaterialTheme.typography.titleLarge, + text = stringResource(Res.string.more_requests), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, ) - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource(Res.string.redirecting_message), + text = stringResource(Res.string.more_requests_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + } + } - is AuthLoginState.Error -> { - Spacer(Modifier.weight(1f)) - Text( - text = - stringResource( - Res.string.auth_error_with_message, - authState.message, - ), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.error, - ) - - authState.recoveryHint?.let { hint -> - Spacer(Modifier.height(8.dp)) - Text( - text = hint, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center, - ) - } - - Spacer(Modifier.height(12.dp)) - - GithubStoreButton( - text = stringResource(Res.string.try_again), - onClick = { - onAction(AuthenticationAction.StartLogin) - }, - modifier = Modifier.fillMaxWidth(.7f), - ) + Spacer(Modifier.weight(1f)) - Spacer(Modifier.height(8.dp)) + GithubStoreButton( + text = stringResource(Res.string.sign_in_with_github), + onClick = { onAction(AuthenticationAction.StartLogin) }, + icon = { + Icon( + painter = painterResource(Res.drawable.ic_github), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) - TextButton( - onClick = { onAction(AuthenticationAction.SkipLogin) }, - ) { - Text( - text = stringResource(Res.string.continue_as_guest), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline, - ) - } + Spacer(Modifier.height(8.dp)) - Spacer(Modifier.weight(2f)) - } - } + TextButton(onClick = { onAction(AuthenticationAction.SkipLogin) }) { + Text( + text = stringResource(Res.string.continue_as_guest), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + ) } + + Spacer(Modifier.height(16.dp)) } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun StateDevicePrompt( +private fun StateDevicePrompt( state: AuthenticationState, authState: AuthLoginState.DevicePrompt, onAction: (AuthenticationAction) -> Unit, ) { Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.weight(1f)) - Text( - text = stringResource(Res.string.enter_code_on_github), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - - Spacer(Modifier.height(8.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(32.dp), + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), ) { - Text( - text = authState.start.userCode, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - ) - - IconButton( - shapes = IconButtonDefaults.shapes(), - onClick = { - onAction(AuthenticationAction.CopyCode(authState.start)) - }, - colors = - IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Icon( - imageVector = - if (state.copied) { - Icons.Default.DoneAll - } else { - Icons.Default.ContentCopy - }, - contentDescription = stringResource(Res.string.copy_code), + Text( + text = stringResource(Res.string.enter_code_on_github), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - } - } - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(16.dp)) - state.info?.let { info -> - Text( - text = info, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.primary, - ) - } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = authState.start.userCode, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + letterSpacing = 2.sp, + ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.width(12.dp)) - if (authState.remainingSeconds > 0) { - val minutes = authState.remainingSeconds / 60 - val seconds = authState.remainingSeconds % 60 - val formatted = - remember(minutes, seconds) { - "%02d:%02d".format(minutes, seconds) + IconButton( + shapes = IconButtonDefaults.shapes(), + onClick = { + onAction(AuthenticationAction.CopyCode(authState.start)) + }, + colors = + IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + AnimatedContent( + targetState = state.copied, + transitionSpec = { + (scaleIn( + spring(dampingRatio = Spring.DampingRatioMediumBouncy), + ) + fadeIn()) togetherWith (scaleOut() + fadeOut()) + }, + label = "copy_icon", + ) { isCopied -> + Icon( + imageVector = + if (isCopied) { + Icons.Default.DoneAll + } else { + Icons.Default.ContentCopy + }, + contentDescription = stringResource(Res.string.copy_code), + ) + } + } } - Text( - text = stringResource(Res.string.auth_code_expires_in, formatted), - style = MaterialTheme.typography.bodyMedium, - color = - if (authState.remainingSeconds < 60) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.outline - }, - ) - Spacer(Modifier.height(16.dp)) + state.info?.let { info -> + Spacer(Modifier.height(12.dp)) + + Text( + text = info, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + ) + } + + if (authState.remainingSeconds > 0) { + Spacer(Modifier.height(20.dp)) + + val progress = + authState.remainingSeconds.toFloat() / + authState.start.expiresInSec.toFloat() + + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = tween(900), + label = "countdown_progress", + ) + + val isUrgent = authState.remainingSeconds < 60 + + val progressColor by animateColorAsState( + targetValue = + if (isUrgent) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + animationSpec = tween(500), + label = "progress_color", + ) + + val timerColor by animateColorAsState( + targetValue = + if (isUrgent) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.outline + }, + animationSpec = tween(500), + label = "timer_color", + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = + Modifier + .weight(1f) + .clip(RoundedCornerShape(4.dp)), + color = progressColor, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + + Spacer(Modifier.width(12.dp)) + + val minutes = authState.remainingSeconds / 60 + val seconds = authState.remainingSeconds % 60 + val formatted = + remember(minutes, seconds) { + "%02d:%02d".format(minutes, seconds) + } + + Text( + text = formatted, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = timerColor, + ) + } + } + } } + Spacer(Modifier.height(24.dp)) + GithubStoreButton( text = stringResource(Res.string.open_github), onClick = { @@ -314,22 +494,32 @@ fun StateDevicePrompt( modifier = Modifier.size(24.dp), ) }, + modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(12.dp)) - OutlinedButton( + FilledTonalButton( onClick = { onAction(AuthenticationAction.PollNow) }, enabled = !state.isPolling, shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) + if (state.isPolling) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } else { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } - Spacer(Modifier.size(8.dp)) + Spacer(Modifier.width(8.dp)) Text( text = @@ -338,7 +528,7 @@ fun StateDevicePrompt( } else { stringResource(Res.string.auth_check_status) }, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.labelLarge, ) } @@ -346,94 +536,163 @@ fun StateDevicePrompt( } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun StateLoggedOut(onAction: (AuthenticationAction) -> Unit) { +private fun StatePending() { Column( + modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, ) { + CircularWavyProgressIndicator( + modifier = Modifier.size(64.dp), + ) + + Spacer(Modifier.height(24.dp)) + Text( - text = stringResource(Res.string.unlock_full_experience), - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, + text = stringResource(Res.string.waiting_for_authorization), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) + } +} + +@Composable +private fun StateLoggedIn() { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + var visible by remember { mutableStateOf(false) } - Spacer(Modifier.height(32.dp)) + LaunchedEffect(Unit) { visible = true } - Card( - border = BorderStroke(1.dp, MaterialTheme.colorScheme.secondary), + AnimatedVisibility( + visible = visible, + enter = + scaleIn( + spring(dampingRatio = Spring.DampingRatioMediumBouncy), + ) + fadeIn(), + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(72.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(Modifier.height(20.dp)) + + Text( + text = stringResource(Res.string.signed_in), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = stringResource(Res.string.redirecting_message), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun StateError( + authState: AuthLoginState.Error, + onAction: (AuthenticationAction) -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.weight(1f)) + + ElevatedCard( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(24.dp), + shape = RoundedCornerShape(32.dp), + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), ) { Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( - imageVector = Icons.Default.OpenWith, + imageVector = Icons.Default.Warning, contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onErrorContainer, ) - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(16.dp)) Text( - text = stringResource(Res.string.more_requests), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, + text = + stringResource( + Res.string.auth_error_with_message, + authState.message, + ), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + textAlign = TextAlign.Center, ) - Text( - text = stringResource(Res.string.more_requests_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) + authState.recoveryHint?.let { hint -> + Spacer(Modifier.height(8.dp)) + + Text( + text = hint, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + ) + } } } - Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) GithubStoreButton( - text = stringResource(Res.string.sign_in_with_github), - onClick = { - onAction(AuthenticationAction.StartLogin) - }, - icon = { - Icon( - painter = painterResource(Res.drawable.ic_github), - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - }, + text = stringResource(Res.string.try_again), + onClick = { onAction(AuthenticationAction.StartLogin) }, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(8.dp)) - TextButton( - onClick = { onAction(AuthenticationAction.SkipLogin) }, - ) { + TextButton(onClick = { onAction(AuthenticationAction.SkipLogin) }) { Text( text = stringResource(Res.string.continue_as_guest), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline, ) } + + Spacer(Modifier.weight(2f)) } } @Preview @Composable -private fun Preview() { +private fun PreviewError() { GithubStoreTheme { AuthenticationScreen( state = AuthenticationState( loginState = AuthLoginState.Error( - message = "Halo", + message = "Network timeout", + recoveryHint = "Check your internet connection", ), ), onAction = {}, @@ -443,7 +702,7 @@ private fun Preview() { @Preview @Composable -private fun Preview1() { +private fun PreviewLoggedOut() { GithubStoreTheme { AuthenticationScreen( state = @@ -457,7 +716,7 @@ private fun Preview1() { @Preview @Composable -private fun Preview2() { +private fun PreviewDevicePrompt() { GithubStoreTheme { AuthenticationScreen( state = @@ -468,9 +727,25 @@ private fun Preview2() { deviceCode = "", userCode = "2102-UHHUF", verificationUri = "", - expiresInSec = 10, + expiresInSec = 900, ), + remainingSeconds = 847, ), + copied = true, + ), + onAction = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewLoggedIn() { + GithubStoreTheme { + AuthenticationScreen( + state = + AuthenticationState( + loginState = AuthLoginState.LoggedIn, ), onAction = {}, ) diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt index a4ea234ae..1017e29c1 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.getString @@ -108,18 +109,10 @@ class AuthenticationViewModel( _events.trySend(AuthenticationEvents.OnNavigateToMain) } - AuthenticationAction.PollNow -> { - val loginState = _state.value.loginState - if (loginState is AuthLoginState.DevicePrompt && !_state.value.isPolling) { - pollOnce(loginState.start.deviceCode) - } - } - - AuthenticationAction.OnResumed -> { - val loginState = _state.value.loginState - if (loginState is AuthLoginState.DevicePrompt && !_state.value.isPolling) { - pollOnce(loginState.start.deviceCode) - } + AuthenticationAction.PollNow, + AuthenticationAction.OnResumed, + -> { + tryPollIfReady() } } } @@ -158,6 +151,13 @@ class AuthenticationViewModel( } } + private fun tryPollIfReady() { + val loginState = _state.value.loginState + if (loginState is AuthLoginState.DevicePrompt && !_state.value.isPolling) { + pollOnce(loginState.start.deviceCode) + } + } + private fun startLogin() { viewModelScope.launch { try { @@ -220,7 +220,7 @@ class AuthenticationViewModel( pollingJob?.cancel() pollingJob = viewModelScope.launch { - while (true) { + while (isActive) { delay(POLL_INTERVAL_MS) doPoll(deviceCode) } @@ -383,7 +383,8 @@ class AuthenticationViewModel( ) _state.update { - val currentRemaining = (it.loginState as? AuthLoginState.DevicePrompt)?.remainingSeconds ?: 0 + val currentRemaining = + (it.loginState as? AuthLoginState.DevicePrompt)?.remainingSeconds ?: 0 it.copy( loginState = AuthLoginState.DevicePrompt(start, currentRemaining), @@ -393,7 +394,8 @@ class AuthenticationViewModel( } catch (e: Exception) { logger.debug("Failed to copy to clipboard: ${e.message}") _state.update { - val currentRemaining = (it.loginState as? AuthLoginState.DevicePrompt)?.remainingSeconds ?: 0 + val currentRemaining = + (it.loginState as? AuthLoginState.DevicePrompt)?.remainingSeconds ?: 0 it.copy( loginState = AuthLoginState.DevicePrompt(start, currentRemaining), From 8afc1a057da6d5802e9d13a6d0aa877aa7d6c0ba Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 21 Mar 2026 12:26:44 +0500 Subject: [PATCH 4/4] feat: use dynamic polling interval for device authentication - Implement `getPollingIntervalMs` to retrieve the polling interval from the `DevicePrompt` state. - Update `startPolling` to use the dynamic interval instead of a fixed constant. - Replace the hardcoded `POLL_INTERVAL_MS` (15s) with a `DEFAULT_POLL_INTERVAL_SEC` (5s) as a fallback. - Ensure the polling delay is converted correctly from seconds to milliseconds. --- .../auth/presentation/AuthenticationViewModel.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt index 1017e29c1..f304d1410 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt @@ -218,15 +218,24 @@ class AuthenticationViewModel( private fun startPolling(deviceCode: String) { pollingJob?.cancel() + val intervalMs = getPollingIntervalMs() pollingJob = viewModelScope.launch { while (isActive) { - delay(POLL_INTERVAL_MS) + delay(intervalMs) doPoll(deviceCode) } } } + private fun getPollingIntervalMs(): Long { + val loginState = _state.value.loginState + val intervalSec = + (loginState as? AuthLoginState.DevicePrompt)?.start?.intervalSec + ?: DEFAULT_POLL_INTERVAL_SEC + return (intervalSec * 1000).toLong() + } + private fun pollOnce(deviceCode: String) { viewModelScope.launch { doPoll(deviceCode) @@ -414,7 +423,7 @@ class AuthenticationViewModel( private const val KEY_INTERVAL_SEC = "auth_interval_sec" private const val KEY_EXPIRES_IN_SEC = "auth_expires_in_sec" private const val KEY_START_TIME_MILLIS = "auth_start_time_millis" - private const val POLL_INTERVAL_MS = 15_000L + private const val DEFAULT_POLL_INTERVAL_SEC = 5 private val SAVED_STATE_KEYS = listOf(