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,
+ )
+ }
}