diff --git a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d11..00000000 --- a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml deleted file mode 100644 index e93e11ad..00000000 --- a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt index 686cef05..65a950e7 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt @@ -16,6 +16,7 @@ import zed.rainxch.githubstore.app.deeplink.DeepLinkParser import zed.rainxch.githubstore.app.navigation.AppNavigation import zed.rainxch.githubstore.app.navigation.GithubStoreGraph import zed.rainxch.githubstore.app.components.RateLimitDialog +import zed.rainxch.githubstore.app.components.SessionExpiredDialog @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -70,6 +71,18 @@ fun App(deepLinkUri: String? = null) { } } + if (state.showSessionExpiredDialog) { + SessionExpiredDialog( + onDismiss = { + viewModel.onAction(MainAction.DismissSessionExpiredDialog) + }, + onSignIn = { + viewModel.onAction(MainAction.DismissSessionExpiredDialog) + navBackStack.navigate(GithubStoreGraph.AuthenticationScreen) + } + ) + } + AppNavigation( navController = navBackStack ) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainAction.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainAction.kt index 02b68d58..4700f16d 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainAction.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainAction.kt @@ -2,4 +2,5 @@ package zed.rainxch.githubstore sealed interface MainAction { data object DismissRateLimitDialog : MainAction + data object DismissSessionExpiredDialog : MainAction } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainState.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainState.kt index ebede0fb..aa5dd430 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainState.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainState.kt @@ -8,6 +8,7 @@ data class MainState( val isLoggedIn: Boolean = false, val rateLimitInfo: RateLimitInfo? = null, val showRateLimitDialog: Boolean = false, + val showSessionExpiredDialog: Boolean = false, val currentColorTheme: AppTheme = AppTheme.OCEAN, val isAmoledTheme: Boolean = false, val isDarkTheme: Boolean? = null, diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt index 36c2b854..8fe7f665 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt @@ -89,6 +89,12 @@ class MainViewModel( } } + viewModelScope.launch { + authenticationState.sessionExpiredEvent.collect { + _state.update { it.copy(showSessionExpiredDialog = true) } + } + } + viewModelScope.launch(Dispatchers.IO) { syncUseCase().onSuccess { installedAppsRepository.checkAllForUpdates() @@ -101,6 +107,9 @@ class MainViewModel( MainAction.DismissRateLimitDialog -> { _state.update { it.copy(showRateLimitDialog = false) } } + MainAction.DismissSessionExpiredDialog -> { + _state.update { it.copy(showSessionExpiredDialog = false) } + } } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/SessionExpiredDialog.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/SessionExpiredDialog.kt new file mode 100644 index 00000000..30be5a9b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/SessionExpiredDialog.kt @@ -0,0 +1,77 @@ +package zed.rainxch.githubstore.app.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.githubstore.core.presentation.res.* + +@Composable +fun SessionExpiredDialog( + onDismiss: () -> Unit, + onSignIn: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Default.LockOpen, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + title = { + Text( + text = stringResource(Res.string.session_expired_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Black, + color = MaterialTheme.colorScheme.onSurface + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(Res.string.session_expired_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + + Text( + text = stringResource(Res.string.session_expired_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + }, + confirmButton = { + Button(onClick = onSignIn) { + Text( + text = stringResource(Res.string.sign_in_again), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimary + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(Res.string.continue_as_guest), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + ) +} diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt index 0184dfcb..6a0854d1 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt @@ -4,7 +4,9 @@ import android.content.Context import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import co.touchlab.kermit.Logger @@ -13,11 +15,11 @@ import java.util.concurrent.TimeUnit object UpdateScheduler { private const val DEFAULT_INTERVAL_HOURS = 6L + private const val IMMEDIATE_CHECK_WORK_NAME = "github_store_immediate_update_check" fun schedule( context: Context, intervalHours: Long = DEFAULT_INTERVAL_HOURS, - replace: Boolean = false ) { val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) @@ -34,25 +36,37 @@ object UpdateScheduler { ) .build() - val policy = if (replace) { - ExistingPeriodicWorkPolicy.UPDATE - } else { - ExistingPeriodicWorkPolicy.KEEP - } - + // UPDATE replaces any existing schedule so new intervals and code changes take effect WorkManager.getInstance(context) .enqueueUniquePeriodicWork( uniqueWorkName = UpdateCheckWorker.WORK_NAME, - existingPeriodicWorkPolicy = policy, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, request = request ) - Logger.i { "UpdateScheduler: Scheduled periodic update check every ${intervalHours}h (policy=$policy)" } + // Run an immediate one-time check so users get notified sooner + // rather than waiting up to intervalHours for the first periodic run. + // Uses KEEP policy so it doesn't re-enqueue if one is already pending. + val immediateRequest = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setInitialDelay(1, TimeUnit.MINUTES) + .build() + + WorkManager.getInstance(context) + .enqueueUniqueWork( + IMMEDIATE_CHECK_WORK_NAME, + ExistingWorkPolicy.KEEP, + immediateRequest + ) + + Logger.i { "UpdateScheduler: Scheduled periodic update check every ${intervalHours}h + immediate check" } } fun cancel(context: Context) { WorkManager.getInstance(context) .cancelUniqueWork(UpdateCheckWorker.WORK_NAME) + WorkManager.getInstance(context) + .cancelUniqueWork(IMMEDIATE_CHECK_WORK_NAME) Logger.i { "UpdateScheduler: Cancelled periodic update checks" } } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt index ce2a8f47..6fc974ac 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt @@ -85,6 +85,11 @@ class CacheManager( cacheDao.deleteByPrefix(prefix) } + suspend fun clearAll() { + memoryCache.clear() + cacheDao.deleteAll() + } + suspend fun cleanupExpired() { val currentTime = now() val expiredKeys = memoryCache.entries diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/TokenStore.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/TokenStore.kt index 569bc7f7..197c2c39 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/TokenStore.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/TokenStore.kt @@ -9,4 +9,5 @@ interface TokenStore { fun blockingCurrentToken() : GithubDeviceTokenSuccessDto? suspend fun save(token: GithubDeviceTokenSuccessDto) suspend fun clear() + suspend fun isTokenExpired(): Boolean } \ No newline at end of file diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt index 0cd3d2bc..de191f5c 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.dto.GithubDeviceTokenSuccessDto +import kotlin.time.Clock class DefaultTokenStore( private val dataStore: DataStore, @@ -19,7 +20,10 @@ class DefaultTokenStore( private val json = Json { ignoreUnknownKeys = true } override suspend fun save(token: GithubDeviceTokenSuccessDto) { - val jsonString = json.encodeToString(GithubDeviceTokenSuccessDto.serializer(), token) + val stamped = token.copy( + savedAtEpochMillis = token.savedAtEpochMillis ?: Clock.System.now().toEpochMilliseconds() + ) + val jsonString = json.encodeToString(GithubDeviceTokenSuccessDto.serializer(), stamped) dataStore.edit { preferences -> preferences[TOKEN_KEY] = jsonString } @@ -50,8 +54,15 @@ class DefaultTokenStore( }.getOrNull() } - override suspend fun clear() { dataStore.edit { it.remove(TOKEN_KEY) } } + + override suspend fun isTokenExpired(): Boolean { + val token = currentToken() ?: return true + val savedAt = token.savedAtEpochMillis ?: return false + val expiresIn = token.expiresIn ?: return false + val expiresAtMillis = savedAt + (expiresIn * 1000L) + return Clock.System.now().toEpochMilliseconds() > expiresAtMillis + } } \ No newline at end of file diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index a97408e7..b50b9a31 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -143,6 +143,7 @@ val networkModule = module { GitHubClientProvider( tokenStore = get(), rateLimitRepository = get(), + authenticationState = get(), proxyConfigFlow = ProxyManager.currentProxyConfig ) } @@ -150,7 +151,9 @@ val networkModule = module { single { createGitHubHttpClient( tokenStore = get(), - rateLimitRepository = get() + rateLimitRepository = get(), + authenticationState = get(), + scope = get() ) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubDeviceTokenSuccessDto.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubDeviceTokenSuccessDto.kt index 5505450c..77d3dcad 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubDeviceTokenSuccessDto.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubDeviceTokenSuccessDto.kt @@ -10,5 +10,6 @@ data class GithubDeviceTokenSuccessDto( @SerialName("expires_in") val expiresIn: Long? = null, @SerialName("scope") val scope: String? = null, @SerialName("refresh_token") val refreshToken: String? = null, - @SerialName("refresh_token_expires_in") val refreshTokenExpiresIn: Long? = null + @SerialName("refresh_token_expires_in") val refreshTokenExpiresIn: Long? = null, + @SerialName("saved_at") val savedAtEpochMillis: Long? = null ) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt index ef9ce955..1cd99705 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt @@ -14,11 +14,13 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.domain.model.ProxyConfig +import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.core.domain.repository.RateLimitRepository class GitHubClientProvider( private val tokenStore: TokenStore, private val rateLimitRepository: RateLimitRepository, + private val authenticationState: AuthenticationState, proxyConfigFlow: StateFlow ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -28,6 +30,8 @@ class GitHubClientProvider( private var currentClient: HttpClient = createGitHubHttpClient( tokenStore = tokenStore, rateLimitRepository = rateLimitRepository, + authenticationState = authenticationState, + scope = scope, proxyConfig = proxyConfigFlow.value ) @@ -41,6 +45,8 @@ class GitHubClientProvider( currentClient = createGitHubHttpClient( tokenStore = tokenStore, rateLimitRepository = rateLimitRepository, + authenticationState = authenticationState, + scope = scope, proxyConfig = proxyConfig ) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt index da2c54a5..e7da52d7 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt @@ -11,10 +11,13 @@ import io.ktor.serialization.kotlinx.json.* import io.ktor.util.network.UnresolvedAddressException import kotlinx.coroutines.flow.Flow import kotlinx.serialization.json.Json +import kotlinx.coroutines.CoroutineScope import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.network.interceptor.RateLimitInterceptor +import zed.rainxch.core.data.network.interceptor.UnauthorizedInterceptor import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.model.RateLimitException +import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.core.domain.repository.RateLimitRepository import java.io.IOException import kotlin.coroutines.cancellation.CancellationException @@ -24,6 +27,8 @@ expect fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient fun createGitHubHttpClient( tokenStore: TokenStore, rateLimitRepository: RateLimitRepository, + authenticationState: AuthenticationState? = null, + scope: CoroutineScope? = null, proxyConfig: ProxyConfig = ProxyConfig.None ): HttpClient { val json = Json { @@ -36,6 +41,13 @@ fun createGitHubHttpClient( this.rateLimitRepository = rateLimitRepository } + if (authenticationState != null && scope != null) { + install(UnauthorizedInterceptor) { + this.authenticationState = authenticationState + this.scope = scope + } + } + install(ContentNegotiation) { json(json) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/UnauthorizedInterceptor.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/UnauthorizedInterceptor.kt new file mode 100644 index 00000000..b602ae15 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/UnauthorizedInterceptor.kt @@ -0,0 +1,48 @@ +package zed.rainxch.core.data.network.interceptor + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpClientPlugin +import io.ktor.client.statement.HttpReceivePipeline +import io.ktor.util.AttributeKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import zed.rainxch.core.domain.repository.AuthenticationState + +class UnauthorizedInterceptor( + private val authenticationState: AuthenticationState, + private val scope: CoroutineScope +) { + + class Config { + var authenticationState: AuthenticationState? = null + var scope: CoroutineScope? = null + } + + companion object Plugin : HttpClientPlugin { + override val key: AttributeKey = + AttributeKey("UnauthorizedInterceptor") + + override fun prepare(block: Config.() -> Unit): UnauthorizedInterceptor { + val config = Config().apply(block) + return UnauthorizedInterceptor( + authenticationState = requireNotNull(config.authenticationState) { + "AuthenticationState must be provided" + }, + scope = requireNotNull(config.scope) { + "CoroutineScope must be provided" + } + ) + } + + override fun install(plugin: UnauthorizedInterceptor, scope: HttpClient) { + scope.receivePipeline.intercept(HttpReceivePipeline.After) { + if (subject.status.value == 401) { + plugin.scope.launch { + plugin.authenticationState.notifySessionExpired() + } + } + proceedWith(subject) + } + } + } +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AuthenticationStateImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AuthenticationStateImpl.kt index 50ff8dbe..ba1bc5d6 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AuthenticationStateImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AuthenticationStateImpl.kt @@ -1,13 +1,24 @@ package zed.rainxch.core.data.repository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.domain.repository.AuthenticationState -class AuthenticationStateImpl ( +class AuthenticationStateImpl( private val tokenStore: TokenStore, ) : AuthenticationState { + + private val _sessionExpiredEvent = MutableSharedFlow(extraBufferCapacity = 1) + override val sessionExpiredEvent: SharedFlow = _sessionExpiredEvent.asSharedFlow() + + private val sessionExpiredMutex = Mutex() + override fun isUserLoggedIn(): Flow { return tokenStore .tokenFlow() @@ -19,4 +30,12 @@ class AuthenticationStateImpl ( override suspend fun isCurrentlyUserLoggedIn(): Boolean { return tokenStore.currentToken() != null } + + override suspend fun notifySessionExpired() { + sessionExpiredMutex.withLock { + if (tokenStore.currentToken() == null) return@withLock + tokenStore.clear() + _sessionExpiredEvent.emit(Unit) + } + } } \ No newline at end of file diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AuthenticationState.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AuthenticationState.kt index 248cc391..ca3c7fe5 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AuthenticationState.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AuthenticationState.kt @@ -1,8 +1,11 @@ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow interface AuthenticationState { fun isUserLoggedIn() : Flow suspend fun isCurrentlyUserLoggedIn() : Boolean + val sessionExpiredEvent: SharedFlow + suspend fun notifySessionExpired() } \ No newline at end of file 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 71a5791b..671e6bc6 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -407,4 +407,17 @@ GitHub-এ আপনার স্টার করা রিপোজিটরি স্থানীয়ভাবে সংরক্ষিত আপনার প্রিয় রিপোজিটরি + + সেশনের মেয়াদ শেষ + আপনার GitHub সেশনের মেয়াদ শেষ হয়ে গেছে বা টোকেনটি প্রত্যাহার করা হয়েছে। প্রমাণিত বৈশিষ্ট্যগুলি ব্যবহার চালিয়ে যেতে অনুগ্রহ করে আবার সাইন ইন করুন। + আপনি সীমিত API অনুরোধ সহ অতিথি হিসেবে ব্রাউজ করতে পারেন। + আবার সাইন ইন করুন + অতিথি হিসেবে চালিয়ে যান + এটি আপনার স্থানীয় সেশন এবং ক্যাশ ডেটা মুছে ফেলবে। সম্পূর্ণরূপে অ্যাক্সেস প্রত্যাহার করতে, GitHub Settings > Applications এ যান। + কোডের মেয়াদ শেষ হবে %1$s এ + ডিভাইস কোডের মেয়াদ শেষ হয়ে গেছে। + একটি নতুন কোড পেতে অনুগ্রহ করে আবার সাইন ইন করার চেষ্টা করুন। + অনুগ্রহ করে আপনার ইন্টারনেট সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন। + আপনি অনুমোদনের অনুরোধ প্রত্যাখ্যান করেছেন। এটি অনিচ্ছাকৃত হলে আবার চেষ্টা করুন। + 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 f1b6d9d7..29367d9b 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -372,4 +372,17 @@ Tus repositorios destacados de GitHub Tus repositorios favoritos guardados localmente + + Sesión expirada + Tu sesión de GitHub ha expirado o el token fue revocado. Inicia sesión nuevamente para continuar usando las funciones autenticadas. + Puedes seguir navegando como invitado con solicitudes de API limitadas. + Iniciar sesión de nuevo + Continuar como invitado + Esto borrará tu sesión local y los datos en caché. Para revocar el acceso completamente, visita GitHub Settings > Applications. + El código expira en %1$s + El código del dispositivo ha expirado. + 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. + \ No newline at end of file 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 4280fd37..61422b74 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -372,4 +372,17 @@ Vos dépôts étoilés sur GitHub Vos dépôts favoris enregistrés localement + + Session expirée + Votre session GitHub a expiré ou le jeton a été révoqué. Veuillez vous reconnecter pour continuer à utiliser les fonctionnalités authentifiées. + Vous pouvez toujours naviguer en tant qu\'invité avec des requêtes API limitées. + Se reconnecter + Continuer en tant qu\'invité + Cela effacera votre session locale et les données en cache. Pour révoquer complètement l\'accès, visitez GitHub Settings > Applications. + Le code expire dans %1$s + Le code de l\'appareil a expiré. + 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. + \ No newline at end of file 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 3d5c9fa8..e42699d6 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -407,4 +407,17 @@ GitHub पर आपकी स्टार की गई रिपॉजिटरी स्थानीय रूप से सहेजी गई आपकी पसंदीदा रिपॉजिटरी + + सत्र समाप्त + आपका GitHub सत्र समाप्त हो गया है या टोकन रद्द कर दिया गया है। प्रमाणित सुविधाओं का उपयोग जारी रखने के लिए कृपया फिर से साइन इन करें। + आप सीमित API अनुरोधों के साथ अतिथि के रूप में ब्राउज़ कर सकते हैं। + फिर से साइन इन करें + अतिथि के रूप में जारी रखें + यह आपका स्थानीय सत्र और कैश डेटा साफ़ कर देगा। पूर्ण रूप से पहुँच रद्द करने के लिए, GitHub Settings > Applications पर जाएँ। + कोड %1$s में समाप्त होगा + डिवाइस कोड की अवधि समाप्त हो गई है। + नया कोड प्राप्त करने के लिए कृपया फिर से साइन इन करें। + कृपया अपना इंटरनेट कनेक्शन जाँचें और पुनः प्रयास करें। + आपने प्राधिकरण अनुरोध अस्वीकार कर दिया। यदि यह अनजाने में हुआ तो पुनः प्रयास करें। + 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 95787604..8c0c1c23 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -408,4 +408,17 @@ I tuoi repository preferiti su GitHub I tuoi repository preferiti salvati localmente + + Sessione scaduta + La tua sessione GitHub è scaduta o il token è stato revocato. Accedi di nuovo per continuare a utilizzare le funzionalità autenticate. + Puoi continuare a navigare come ospite con richieste API limitate. + Accedi di nuovo + Continua come ospite + Questo cancellerà la sessione locale e i dati nella cache. Per revocare completamente l\'accesso, visita GitHub Settings > Applications. + Il codice scade tra %1$s + Il codice del dispositivo è scaduto. + 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. + \ No newline at end of file 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 4faefcb4..2f77ab29 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -371,4 +371,17 @@ ログイン GitHubのスター付きリポジトリ ローカルに保存されたお気に入りリポジトリ + + + セッション期限切れ + GitHubセッションの有効期限が切れたか、トークンが取り消されました。認証機能を引き続き使用するには、再度サインインしてください。 + 制限されたAPIリクエストでゲストとして閲覧を続けることができます。 + 再度サインイン + ゲストとして続行 + ローカルセッションとキャッシュデータが消去されます。アクセスを完全に取り消すには、GitHub Settings > Applicationsにアクセスしてください。 + コードの有効期限: %1$s + デバイスコードの有効期限が切れました。 + 新しいコードを取得するために再度サインインしてください。 + インターネット接続を確認して再試行してください。 + 認証リクエストを拒否しました。意図しない場合は再試行してください。 \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml b/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml index c3a73a83..ea518a21 100644 --- a/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml +++ b/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml @@ -404,4 +404,17 @@ 로그인 GitHub에서 별표한 저장소 로컬에 저장된 즐겨찾기 저장소 + + + 세션 만료 + GitHub 세션이 만료되었거나 토큰이 취소되었습니다. 인증된 기능을 계속 사용하려면 다시 로그인하세요. + 제한된 API 요청으로 게스트로 계속 탐색할 수 있습니다. + 다시 로그인 + 게스트로 계속 + 로컬 세션과 캐시 데이터가 삭제됩니다. 접근을 완전히 취소하려면 GitHub Settings > Applications를 방문하세요. + 코드 만료까지 %1$s + 디바이스 코드가 만료되었습니다. + 새 코드를 받으려면 다시 로그인해 주세요. + 인터넷 연결을 확인하고 다시 시도하세요. + 인증 요청을 거부했습니다. 의도하지 않은 경우 다시 시도하세요. \ No newline at end of file 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 d87c1591..894f0dd0 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -370,4 +370,17 @@ Twoje repozytoria oznaczone gwiazdką na GitHubie Twoje ulubione repozytoria zapisane lokalnie + + Sesja wygasła + Twoja sesja GitHub wygasła lub token został unieważniony. Zaloguj się ponownie, aby kontynuować korzystanie z funkcji wymagających uwierzytelnienia. + Możesz nadal przeglądać jako gość z ograniczoną liczbą zapytań API. + Zaloguj się ponownie + Kontynuuj jako gość + Spowoduje to wyczyszczenie lokalnej sesji i danych z pamięci podręcznej. Aby całkowicie cofnąć dostęp, odwiedź GitHub Settings > Applications. + Kod wygasa za %1$s + Kod urządzenia wygasł. + 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. + \ No newline at end of file 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 b28c02b0..045bbff8 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -372,4 +372,17 @@ Ваши избранные репозитории на GitHub Ваши избранные репозитории, сохранённые локально + + Сессия истекла + Ваша сессия GitHub истекла или токен был отозван. Пожалуйста, войдите снова для продолжения использования авторизованных функций. + Вы можете продолжить просмотр как гость с ограниченным количеством API-запросов. + Войти снова + Продолжить как гость + Это очистит вашу локальную сессию и кэшированные данные. Чтобы полностью отозвать доступ, перейдите в GitHub Settings > Applications. + Код истекает через %1$s + Срок действия кода устройства истёк. + Пожалуйста, попробуйте войти снова для получения нового кода. + Проверьте подключение к интернету и попробуйте снова. + Вы отклонили запрос авторизации. Попробуйте снова, если это было непреднамеренно. + \ No newline at end of file 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 cdc302b9..8f4fa07b 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -404,4 +404,17 @@ GitHub'daki yıldızlı repolarınız Yerel olarak kaydedilen favori repolarınız + + Oturum Süresi Doldu + GitHub oturumunuzun süresi doldu veya token iptal edildi. Kimliği doğrulanmış özellikleri kullanmaya devam etmek için lütfen tekrar giriş yapın. + Sınırlı API istekleriyle misafir olarak gezinmeye devam edebilirsiniz. + Tekrar Giriş Yap + Misafir olarak devam et + Bu işlem yerel oturumunuzu ve önbellek verilerinizi temizleyecektir. Erişimi tamamen iptal etmek için GitHub Settings > Applications sayfasını ziyaret edin. + Kodun süresi %1$s sonra dolacak + Cihaz kodunun süresi doldu. + 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. + 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 fb031268..918ea9cb 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 @@ -372,4 +372,17 @@ 登录 您在 GitHub 上收藏的仓库 本地保存的收藏仓库 + + + 会话已过期 + 您的 GitHub 会话已过期或令牌已被撤销。请重新登录以继续使用认证功能。 + 您仍可以访客身份浏览,但 API 请求次数有限。 + 重新登录 + 以访客身份继续 + 这将清除您的本地会话和缓存数据。要完全撤销访问权限,请访问 GitHub Settings > Applications。 + 验证码将在 %1$s 后过期 + 设备验证码已过期。 + 请重新登录以获取新的验证码。 + 请检查您的网络连接并重试。 + 您拒绝了授权请求。如果是误操作,请重试。 \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index db5106c0..a9ddb448 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -400,4 +400,21 @@ Login Your Starred Repositories from GitHub Your Favourite Repositories saved locally + + + Session Expired + Your GitHub session has expired or the token was revoked. Please sign in again to continue using authenticated features. + You can still browse as a guest with limited API requests. + Sign In Again + Continue as Guest + + + This will clear your local session and cached data. To fully revoke access, visit GitHub Settings > Applications. + + + Code expires in %1$s + The device code has expired. + 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. \ No newline at end of file diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/RepositoryCard.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/RepositoryCard.kt index b2745c4a..f9c9d637 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/RepositoryCard.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/RepositoryCard.kt @@ -4,11 +4,10 @@ import androidx.compose.foundation.clickable 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 -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset @@ -23,10 +22,8 @@ import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Update -import androidx.compose.material.icons.outlined.CallSplit import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -39,13 +36,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.skydoves.landscapist.coil3.CoilImage -import com.skydoves.landscapist.components.rememberImageComponent -import com.skydoves.landscapist.crossfade.CrossfadePlugin -import zed.rainxch.githubstore.core.presentation.res.* import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview import zed.rainxch.core.domain.model.GithubRepoSummary @@ -53,7 +47,13 @@ import zed.rainxch.core.domain.model.GithubUser import zed.rainxch.core.presentation.model.DiscoveryRepository import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.formatReleasedAt -import zed.rainxch.core.presentation.utils.formatUpdatedAt +import zed.rainxch.core.presentation.utils.hasWeekNotPassed +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.forked_repository +import zed.rainxch.githubstore.core.presentation.res.home_view_details +import zed.rainxch.githubstore.core.presentation.res.installed +import zed.rainxch.githubstore.core.presentation.res.open_in_browser +import zed.rainxch.githubstore.core.presentation.res.update_available @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class) @Composable @@ -237,8 +237,16 @@ fun RepositoryCard( Spacer(Modifier.height(12.dp)) + val releasedAtText = buildAnnotatedString { + if (hasWeekNotPassed(discoveryRepository.repository.updatedAt)) { + append("🔥 ") + } + + append(formatReleasedAt(discoveryRepository.repository.updatedAt)) + } + Text( - text = formatReleasedAt(discoveryRepository.repository.updatedAt), + text = releasedAtText, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.outline, maxLines = 1, diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt index cdc90950..06181a72 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt @@ -8,30 +8,23 @@ import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import kotlin.time.Clock import kotlin.time.Duration +import kotlin.time.Duration.Companion.days import kotlin.time.ExperimentalTime import kotlin.time.Instant @OptIn(ExperimentalTime::class) -@Composable -fun formatUpdatedAt(isoInstant: String): String { - val updated = Instant.parse(isoInstant) - val now = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds()) - val diff: Duration = now - updated - - val hoursDiff = diff.inWholeHours - val daysDiff = diff.inWholeDays - - return when { - hoursDiff < 1 -> stringResource(Res.string.updated_just_now) - hoursDiff < 24 -> stringResource(Res.string.updated_hours_ago, hoursDiff) - daysDiff == 1L -> stringResource(Res.string.updated_yesterday) - daysDiff < 7 -> stringResource(Res.string.updated_days_ago, daysDiff) - else -> { - val date = updated.toLocalDateTime(TimeZone.currentSystemDefault()).date - stringResource(Res.string.updated_on_date, date.toString()) - } +fun hasWeekNotPassed(isoInstant: String): Boolean { + val updated = try { + Instant.parse(isoInstant) + } catch (_: IllegalArgumentException) { + return false } + val now = Clock.System.now() + val diff = now - updated + + return diff < 7.days } + @OptIn(ExperimentalTime::class) @Composable fun formatReleasedAt(isoInstant: String): String { @@ -54,36 +47,6 @@ fun formatReleasedAt(isoInstant: String): String { } } -@OptIn(ExperimentalTime::class) -suspend fun formatUpdatedAt(epochMillis: Long): String { - val updated = Instant.fromEpochMilliseconds(epochMillis) - val now = Clock.System.now() - val diff: Duration = now - updated - - val hoursDiff = diff.inWholeHours - val daysDiff = diff.inWholeDays - - return when { - hoursDiff < 1 -> - getString(Res.string.updated_just_now) - - hoursDiff < 24 -> - getString(Res.string.updated_hours_ago, hoursDiff) - - daysDiff == 1L -> - getString(Res.string.updated_yesterday) - - daysDiff < 7 -> - getString(Res.string.updated_days_ago, daysDiff) - - else -> { - val date = updated - .toLocalDateTime(TimeZone.currentSystemDefault()) - .date - getString(Res.string.updated_on_date, date.toString()) - } - } -} @OptIn(ExperimentalTime::class) suspend fun formatAddedAt(epochMillis: Long): String { val updated = Instant.fromEpochMilliseconds(epochMillis) 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 3e2e80a8..9aff95df 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 @@ -9,4 +9,5 @@ sealed interface AuthenticationAction { data object MarkLoggedOut : AuthenticationAction data object MarkLoggedIn : AuthenticationAction data class OnInfo(val message: String) : AuthenticationAction + data object SkipLogin : AuthenticationAction } \ No newline at end of file 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 66f03a87..97b75ff9 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 @@ -25,8 +25,10 @@ import androidx.compose.material3.IconButtonDefaults 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.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -145,6 +147,16 @@ fun AuthenticationScreen( 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( @@ -154,6 +166,19 @@ fun AuthenticationScreen( }, modifier = Modifier.fillMaxWidth(.7f) ) + + Spacer(Modifier.height(8.dp)) + + 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)) } } @@ -224,6 +249,25 @@ fun StateDevicePrompt( Spacer(Modifier.height(16.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) + } + 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)) + } + GithubStoreButton( text = stringResource(Res.string.open_github), onClick = { @@ -309,6 +353,18 @@ fun StateLoggedOut( }, modifier = Modifier.fillMaxWidth() ) + + Spacer(Modifier.height(8.dp)) + + TextButton( + onClick = { onAction(AuthenticationAction.SkipLogin) } + ) { + Text( + text = stringResource(Res.string.continue_as_guest), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + } } } 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 96218cd8..79aba7ec 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 @@ -6,7 +6,9 @@ import zed.rainxch.githubstore.core.presentation.res.* import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.onStart @@ -32,6 +34,7 @@ class AuthenticationViewModel( ) : ViewModel() { private var hasLoadedInitialData = false + private var countdownJob: Job? = null private val _state: MutableStateFlow = MutableStateFlow(AuthenticationState()) @@ -80,6 +83,38 @@ class AuthenticationViewModel( ) } } + AuthenticationAction.SkipLogin -> { + _events.trySend(AuthenticationEvents.OnNavigateToMain) + } + } + } + + private fun startCountdown(start: GithubDeviceStart) { + countdownJob?.cancel() + countdownJob = viewModelScope.launch { + var remaining = start.expiresInSec + while (remaining > 0) { + _state.update { currentState -> + val loginState = currentState.loginState + if (loginState is AuthLoginState.DevicePrompt) { + currentState.copy( + loginState = loginState.copy(remainingSeconds = remaining) + ) + } else { + return@launch + } + } + delay(1000L) + remaining-- + } + _state.update { + it.copy( + loginState = AuthLoginState.Error( + message = getString(Res.string.auth_error_code_expired), + recoveryHint = getString(Res.string.auth_hint_try_again) + ) + ) + } } } @@ -93,11 +128,16 @@ class AuthenticationViewModel( withContext(Dispatchers.Main.immediate) { _state.update { it.copy( - loginState = AuthLoginState.DevicePrompt(start), + loginState = AuthLoginState.DevicePrompt( + start = start, + remainingSeconds = start.expiresInSec + ), copied = false ) } + startCountdown(start) + try { clipboardHelper.copy( label = getString(Res.string.enter_code_on_github), @@ -105,7 +145,7 @@ class AuthenticationViewModel( ) _state.update { it.copy(copied = true) } } catch (e: Exception) { - logger.debug("⚠️ Failed to copy to clipboard: ${e.message}") + logger.debug("Failed to copy to clipboard: ${e.message}") } } @@ -113,6 +153,8 @@ class AuthenticationViewModel( authenticationRepository.awaitDeviceToken(start = start) } + countdownJob?.cancel() + withContext(Dispatchers.Main.immediate) { _state.update { it.copy(loginState = AuthLoginState.LoggedIn) } _events.trySend(AuthenticationEvents.OnNavigateToMain) @@ -121,11 +163,14 @@ class AuthenticationViewModel( } catch (e: CancellationException) { throw e } catch (t: Throwable) { + countdownJob?.cancel() + val (message, hint) = categorizeError(t) withContext(Dispatchers.Main.immediate) { _state.update { it.copy( loginState = AuthLoginState.Error( - t.message ?: getString(Res.string.error_unknown) + message = message, + recoveryHint = hint ) ) } @@ -134,6 +179,27 @@ class AuthenticationViewModel( } } + private suspend fun categorizeError(t: Throwable): Pair { + val msg = t.message ?: return getString(Res.string.error_unknown) to null + val lowerMsg = msg.lowercase() + return when { + "timeout" in lowerMsg || "timed out" in lowerMsg -> + msg to getString(Res.string.auth_hint_check_connection) + "network" in lowerMsg || "unresolvedaddress" in lowerMsg || "connect" in lowerMsg -> + msg to getString(Res.string.auth_hint_check_connection) + "expired" in lowerMsg || "expire" in lowerMsg -> + msg to getString(Res.string.auth_hint_try_again) + "denied" in lowerMsg || "access_denied" in lowerMsg -> + msg to getString(Res.string.auth_hint_denied) + else -> msg to null + } + } + + override fun onCleared() { + super.onCleared() + countdownJob?.cancel() + } + private fun openGitHub(start: GithubDeviceStart) { viewModelScope.launch(Dispatchers.Main.immediate) { try { @@ -154,16 +220,20 @@ class AuthenticationViewModel( ) _state.update { + val currentRemaining = (it.loginState as? AuthLoginState.DevicePrompt)?.remainingSeconds ?: 0 + it.copy( - loginState = AuthLoginState.DevicePrompt(start), + loginState = AuthLoginState.DevicePrompt(start, currentRemaining), copied = true ) } } catch (e: Exception) { logger.debug("⚠️ Failed to copy to clipboard: ${e.message}") _state.update { + val currentRemaining = (it.loginState as? AuthLoginState.DevicePrompt)?.remainingSeconds ?: 0 + it.copy( - loginState = AuthLoginState.DevicePrompt(start), + loginState = AuthLoginState.DevicePrompt(start, currentRemaining), copied = false ) } diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/model/AuthLoginState.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/model/AuthLoginState.kt index 86f6596e..37bf6ec3 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/model/AuthLoginState.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/model/AuthLoginState.kt @@ -6,9 +6,13 @@ sealed interface AuthLoginState { data object LoggedOut : AuthLoginState data class DevicePrompt( val start: GithubDeviceStart, + val remainingSeconds: Int = 0, ) : AuthLoginState data object Pending : AuthLoginState data object LoggedIn : AuthLoginState - data class Error(val message: String) : AuthLoginState + data class Error( + val message: String, + val recoveryHint: String? = null, + ) : AuthLoginState } \ No newline at end of file diff --git a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt index 9490243f..653bed76 100644 --- a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt +++ b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt @@ -82,6 +82,6 @@ class ProfileRepositoryImpl( override suspend fun logout() { tokenStore.clear() - cacheManager.invalidate(CACHE_KEY) + cacheManager.clearAll() } } diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt index 57446e0d..b4475962 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt @@ -59,6 +59,12 @@ fun LogoutDialog( color = MaterialTheme.colorScheme.onSurfaceVariant ) + Text( + text = stringResource(Res.string.logout_revocation_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,