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 @@ 请重新登录以获取新的验证码。 请检查您的网络连接并重试。 您拒绝了授权请求。如果是误操作,请重试。 + 我已经授权 + 正在检查… + 阅读更多 收起 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..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,32 +25,48 @@ 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.material3.Card +import androidx.compose.material.icons.filled.Refresh +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.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 import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -48,8 +78,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 +103,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 -> { @@ -100,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) + } + + is AuthLoginState.DevicePrompt -> { + StateDevicePrompt( + state = state, + authState = authState, + onAction = onAction, + ) + } - when (val authState = state.loginState) { - is AuthLoginState.LoggedOut -> { - StateLoggedOut( - onAction = onAction, - ) - } + is AuthLoginState.Pending -> { + StatePending() + } - is AuthLoginState.DevicePrompt -> { - StateDevicePrompt( - state = state, - authState = authState, - onAction = onAction, - ) + 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 = { @@ -304,100 +494,205 @@ fun StateDevicePrompt( modifier = Modifier.size(24.dp), ) }, + modifier = Modifier.fillMaxWidth(), ) + Spacer(Modifier.height(12.dp)) + + FilledTonalButton( + onClick = { onAction(AuthenticationAction.PollNow) }, + enabled = !state.isPolling, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + ) { + 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.width(8.dp)) + + Text( + text = + if (state.isPolling) { + stringResource(Res.string.auth_polling_status) + } else { + stringResource(Res.string.auth_check_status) + }, + style = MaterialTheme.typography.labelLarge, + ) + } + Spacer(Modifier.weight(2f)) } } +@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) } + + LaunchedEffect(Unit) { visible = true } + + 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, + ) + } +} - Spacer(Modifier.height(32.dp)) +@Composable +private fun StateError( + authState: AuthLoginState.Error, + onAction: (AuthenticationAction) -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.weight(1f)) - Card( - border = BorderStroke(1.dp, MaterialTheme.colorScheme.secondary), + 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 = {}, @@ -407,7 +702,7 @@ private fun Preview() { @Preview @Composable -private fun Preview1() { +private fun PreviewLoggedOut() { GithubStoreTheme { AuthenticationScreen( state = @@ -421,7 +716,7 @@ private fun Preview1() { @Preview @Composable -private fun Preview2() { +private fun PreviewDevicePrompt() { GithubStoreTheme { AuthenticationScreen( state = @@ -432,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/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..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 @@ -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 @@ -14,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 @@ -22,7 +24,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 +34,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 +66,7 @@ class AuthenticationViewModel( } } + restoreFromSavedState() hasLoadedInitialData = true } }.stateIn( @@ -104,14 +108,20 @@ class AuthenticationViewModel( AuthenticationAction.SkipLogin -> { _events.trySend(AuthenticationEvents.OnNavigateToMain) } + + AuthenticationAction.PollNow, + AuthenticationAction.OnResumed, + -> { + tryPollIfReady() + } } } - 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 +136,9 @@ class AuthenticationViewModel( delay(1000L) remaining-- } + + pollingJob?.cancel() + clearSavedState() _state.update { it.copy( loginState = @@ -138,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 { @@ -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,130 @@ class AuthenticationViewModel( } } + private fun startPolling(deviceCode: String) { + pollingJob?.cancel() + val intervalMs = getPollingIntervalMs() + pollingJob = + viewModelScope.launch { + while (isActive) { + 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) + } + } + + 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 +369,7 @@ class AuthenticationViewModel( override fun onCleared() { super.onCleared() countdownJob?.cancel() + pollingJob?.cancel() } private fun openGitHub(start: GithubDeviceStartUi) { @@ -238,7 +378,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}") } } } @@ -252,7 +392,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), @@ -260,9 +401,10 @@ 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 + val currentRemaining = + (it.loginState as? AuthLoginState.DevicePrompt)?.remainingSeconds ?: 0 it.copy( loginState = AuthLoginState.DevicePrompt(start, currentRemaining), @@ -272,4 +414,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 DEFAULT_POLL_INTERVAL_SEC = 5 + + 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, + ) + } }