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,