From 8da15327043a73763615ca360cc7d74b0c8fb106 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Wed, 25 Feb 2026 22:57:54 +0500 Subject: [PATCH 1/5] feat(profile): Implement network proxy configuration settings This commit introduces a new Network section in the profile settings, allowing users to configure HTTP, SOCKS, or system-wide proxy settings. The configuration is persisted using DataStore and applied via a `ProxyManager`. - **feat(profile)**: Added `networkSection` UI component to `ProfileRoot` featuring a `ProxyTypeCard` and a `ProxyDetailsCard` for host, port, and authentication credentials. - **feat(core/domain)**: Introduced `ProxyRepository` interface and `ProxyConfig` models (None, System, HTTP, SOCKS). - **feat(core/data)**: Implemented `ProxyRepositoryImpl` using `DataStore` to persist proxy settings. - **feat(profile)**: Updated `ProfileViewModel` and `ProfileState` to handle proxy actions (type selection, host/port/credential changes, and saving). - **refactor(app)**: Updated `MainViewModel` to initialize and apply the saved proxy configuration on application startup. - **i18n**: Added string resources for proxy types, labels, and status messages. - **chore(di)**: Registered `ProxyRepository` in the dependency injection modules. --- .../zed/rainxch/githubstore/MainViewModel.kt | 27 +- .../githubstore/app/di/SharedModules.kt | 3 +- .../zed/rainxch/core/data/di/SharedModule.kt | 8 + .../data/repository/ProxyRepositoryImpl.kt | 115 +++++++++ .../core/domain/repository/ProxyRepository.kt | 9 + .../composeResources/values/strings.xml | 14 + .../profile/presentation/ProfileAction.kt | 6 + .../profile/presentation/ProfileEvent.kt | 1 + .../profile/presentation/ProfileRoot.kt | 16 ++ .../profile/presentation/ProfileState.kt | 23 +- .../profile/presentation/ProfileViewModel.kt | 90 ++++++- .../components/sections/Network.kt | 239 ++++++++++++++++++ 12 files changed, 545 insertions(+), 6 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ProxyRepository.kt create mode 100644 feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt index 36c2b854..464a9c3f 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt @@ -7,8 +7,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.first +import zed.rainxch.core.data.network.ProxyManager +import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.core.domain.repository.InstalledAppsRepository +import zed.rainxch.core.domain.repository.ProxyRepository import zed.rainxch.core.domain.repository.RateLimitRepository import zed.rainxch.core.domain.repository.ThemesRepository import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase @@ -18,7 +22,8 @@ class MainViewModel( private val installedAppsRepository: InstalledAppsRepository, private val authenticationState: AuthenticationState, private val rateLimitRepository: RateLimitRepository, - private val syncUseCase: SyncInstalledAppsUseCase + private val syncUseCase: SyncInstalledAppsUseCase, + private val proxyRepository: ProxyRepository ) : ViewModel() { private val _state = MutableStateFlow(MainState()) @@ -94,6 +99,26 @@ class MainViewModel( installedAppsRepository.checkAllForUpdates() } } + + viewModelScope.launch(Dispatchers.IO) { + val savedConfig = proxyRepository.getProxyConfig().first() + when (savedConfig) { + is ProxyConfig.None -> ProxyManager.setNoProxy() + is ProxyConfig.System -> ProxyManager.setSystemProxy() + is ProxyConfig.Http -> ProxyManager.setHttpProxy( + host = savedConfig.host, + port = savedConfig.port, + username = savedConfig.username, + password = savedConfig.password + ) + is ProxyConfig.Socks -> ProxyManager.setSocksProxy( + host = savedConfig.host, + port = savedConfig.port, + username = savedConfig.username, + password = savedConfig.password + ) + } + } } fun onAction(action: MainAction) { diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt index 010d3afd..6866c4ce 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt @@ -15,7 +15,8 @@ val mainModule: Module = module { installedAppsRepository = get(), rateLimitRepository = get(), syncUseCase = get(), - authenticationState = get() + authenticationState = get(), + proxyRepository = get() ) } } \ 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 4f2d957b..92befa13 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 @@ -21,6 +21,7 @@ import zed.rainxch.core.data.repository.FavouritesRepositoryImpl import zed.rainxch.core.data.repository.InstalledAppsRepositoryImpl import zed.rainxch.core.data.repository.RateLimitRepositoryImpl import zed.rainxch.core.data.repository.StarredRepositoryImpl +import zed.rainxch.core.data.repository.ProxyRepositoryImpl import zed.rainxch.core.data.repository.ThemesRepositoryImpl import zed.rainxch.core.domain.getPlatform import zed.rainxch.core.domain.logging.GitHubStoreLogger @@ -28,6 +29,7 @@ import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository +import zed.rainxch.core.domain.repository.ProxyRepository import zed.rainxch.core.domain.repository.RateLimitRepository import zed.rainxch.core.domain.repository.StarredRepository import zed.rainxch.core.domain.repository.ThemesRepository @@ -84,6 +86,12 @@ val coreModule = module { ) } + single { + ProxyRepositoryImpl( + preferences = get() + ) + } + single { SyncInstalledAppsUseCase( packageMonitor = get(), diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt new file mode 100644 index 00000000..f25ddce1 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt @@ -0,0 +1,115 @@ +package zed.rainxch.core.data.repository + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import zed.rainxch.core.data.network.ProxyManager +import zed.rainxch.core.domain.model.ProxyConfig +import zed.rainxch.core.domain.repository.ProxyRepository + +class ProxyRepositoryImpl( + private val preferences: DataStore +) : ProxyRepository { + + private val proxyTypeKey = stringPreferencesKey("proxy_type") + private val proxyHostKey = stringPreferencesKey("proxy_host") + private val proxyPortKey = intPreferencesKey("proxy_port") + private val proxyUsernameKey = stringPreferencesKey("proxy_username") + private val proxyPasswordKey = stringPreferencesKey("proxy_password") + + override fun getProxyConfig(): Flow { + return preferences.data.map { prefs -> + when (prefs[proxyTypeKey]) { + "system" -> ProxyConfig.System + "http" -> ProxyConfig.Http( + host = prefs[proxyHostKey] ?: "", + port = prefs[proxyPortKey] ?: 8080, + username = prefs[proxyUsernameKey], + password = prefs[proxyPasswordKey] + ) + "socks" -> ProxyConfig.Socks( + host = prefs[proxyHostKey] ?: "", + port = prefs[proxyPortKey] ?: 1080, + username = prefs[proxyUsernameKey], + password = prefs[proxyPasswordKey] + ) + else -> ProxyConfig.None + } + } + } + + override suspend fun setProxyConfig(config: ProxyConfig) { + applyToProxyManager(config) + preferences.edit { prefs -> + when (config) { + is ProxyConfig.None -> { + prefs[proxyTypeKey] = "none" + prefs.remove(proxyHostKey) + prefs.remove(proxyPortKey) + prefs.remove(proxyUsernameKey) + prefs.remove(proxyPasswordKey) + } + is ProxyConfig.System -> { + prefs[proxyTypeKey] = "system" + prefs.remove(proxyHostKey) + prefs.remove(proxyPortKey) + prefs.remove(proxyUsernameKey) + prefs.remove(proxyPasswordKey) + } + is ProxyConfig.Http -> { + prefs[proxyTypeKey] = "http" + prefs[proxyHostKey] = config.host + prefs[proxyPortKey] = config.port + if (config.username != null) { + prefs[proxyUsernameKey] = config.username!! + } else { + prefs.remove(proxyUsernameKey) + } + if (config.password != null) { + prefs[proxyPasswordKey] = config.password!! + } else { + prefs.remove(proxyPasswordKey) + } + } + is ProxyConfig.Socks -> { + prefs[proxyTypeKey] = "socks" + prefs[proxyHostKey] = config.host + prefs[proxyPortKey] = config.port + if (config.username != null) { + prefs[proxyUsernameKey] = config.username!! + } else { + prefs.remove(proxyUsernameKey) + } + if (config.password != null) { + prefs[proxyPasswordKey] = config.password!! + } else { + prefs.remove(proxyPasswordKey) + } + } + } + } + } + + private fun applyToProxyManager(config: ProxyConfig) { + when (config) { + is ProxyConfig.None -> ProxyManager.setNoProxy() + is ProxyConfig.System -> ProxyManager.setSystemProxy() + is ProxyConfig.Http -> ProxyManager.setHttpProxy( + host = config.host, + port = config.port, + username = config.username, + password = config.password + ) + is ProxyConfig.Socks -> ProxyManager.setSocksProxy( + host = config.host, + port = config.port, + username = config.username, + password = config.password + ) + } + } +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ProxyRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ProxyRepository.kt new file mode 100644 index 00000000..0d8b710f --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ProxyRepository.kt @@ -0,0 +1,9 @@ +package zed.rainxch.core.domain.repository + +import kotlinx.coroutines.flow.Flow +import zed.rainxch.core.domain.model.ProxyConfig + +interface ProxyRepository { + fun getProxyConfig(): Flow + suspend fun setProxyConfig(config: ProxyConfig) +} diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 7f90bf06..cddf9bb7 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -108,6 +108,7 @@ APPEARANCE + NETWORK ABOUT @@ -123,6 +124,19 @@ Logout + + Proxy Type + None + System + HTTP + SOCKS + Host + Port + Username (optional) + Password (optional) + Save + Proxy settings saved + Logged out successfully, redirecting... diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt index 746f2d71..5be135de 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt @@ -13,4 +13,10 @@ sealed interface ProfileAction { data object OnLogoutDismiss : ProfileAction data object OnHelpClick : ProfileAction data class OnFontThemeSelected(val fontTheme: FontTheme) : ProfileAction + data class OnProxyTypeSelected(val type: ProxyType) : ProfileAction + data class OnProxyHostChanged(val host: String) : ProfileAction + data class OnProxyPortChanged(val port: String) : ProfileAction + data class OnProxyUsernameChanged(val username: String) : ProfileAction + data class OnProxyPasswordChanged(val password: String) : ProfileAction + data object OnProxySave : ProfileAction } \ No newline at end of file diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileEvent.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileEvent.kt index e77a358c..9d5b82fd 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileEvent.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileEvent.kt @@ -3,4 +3,5 @@ package zed.rainxch.profile.presentation sealed interface ProfileEvent { data object OnLogoutSuccessful : ProfileEvent data class OnLogoutError(val message: String) : ProfileEvent + data object OnProxySaved : ProfileEvent } \ No newline at end of file diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt index e1e719ea..dbf9f56f 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt @@ -40,6 +40,7 @@ import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.profile.presentation.components.LogoutDialog import zed.rainxch.profile.presentation.components.sections.about import zed.rainxch.profile.presentation.components.sections.logout +import zed.rainxch.profile.presentation.components.sections.networkSection import zed.rainxch.profile.presentation.components.sections.profile import zed.rainxch.profile.presentation.components.sections.settings @@ -67,6 +68,12 @@ fun ProfileRoot( snackbarState.showSnackbar(event.message) } } + + ProfileEvent.OnProxySaved -> { + coroutineScope.launch { + snackbarState.showSnackbar(getString(Res.string.proxy_saved)) + } + } } } @@ -140,6 +147,15 @@ fun ProfileScreen( Spacer(Modifier.height(16.dp)) } + networkSection( + state = state, + onAction = onAction + ) + + item { + Spacer(Modifier.height(16.dp)) + } + about( versionName = state.versionName, onAction = onAction diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt index fd676000..933f2934 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt @@ -2,6 +2,7 @@ package zed.rainxch.profile.presentation import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.FontTheme +import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.profile.domain.model.UserProfile data class ProfileState( @@ -12,5 +13,23 @@ data class ProfileState( val isUserLoggedIn: Boolean = false, val isAmoledThemeEnabled: Boolean = false, val isDarkTheme: Boolean? = null, - val versionName: String = "" -) \ No newline at end of file + val versionName: String = "", + val proxyType: ProxyType = ProxyType.NONE, + val proxyHost: String = "", + val proxyPort: String = "", + val proxyUsername: String = "", + val proxyPassword: String = "", +) + +enum class ProxyType { + NONE, SYSTEM, HTTP, SOCKS; + + companion object { + fun fromConfig(config: ProxyConfig): ProxyType = when (config) { + is ProxyConfig.None -> NONE + is ProxyConfig.System -> SYSTEM + is ProxyConfig.Http -> HTTP + is ProxyConfig.Socks -> SOCKS + } + } +} \ No newline at end of file diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt index c08726bf..d963a243 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import zed.rainxch.core.domain.model.ProxyConfig +import zed.rainxch.core.domain.repository.ProxyRepository import zed.rainxch.core.domain.repository.ThemesRepository import zed.rainxch.core.domain.utils.BrowserHelper import zed.rainxch.profile.domain.repository.ProfileRepository @@ -17,7 +19,8 @@ import zed.rainxch.profile.domain.repository.ProfileRepository class ProfileViewModel( private val browserHelper: BrowserHelper, private val themesRepository: ThemesRepository, - private val profileRepository: ProfileRepository + private val profileRepository: ProfileRepository, + private val proxyRepository: ProxyRepository ) : ViewModel() { private var hasLoadedInitialData = false @@ -29,6 +32,7 @@ class ProfileViewModel( loadCurrentTheme() collectIsUserLoggedIn() loadVersionName() + loadProxyConfig() hasLoadedInitialData = true } @@ -95,6 +99,38 @@ class ProfileViewModel( } } + private fun loadProxyConfig() { + viewModelScope.launch { + proxyRepository.getProxyConfig().collect { config -> + _state.update { + it.copy( + proxyType = ProxyType.fromConfig(config), + proxyHost = when (config) { + is ProxyConfig.Http -> config.host + is ProxyConfig.Socks -> config.host + else -> it.proxyHost + }, + proxyPort = when (config) { + is ProxyConfig.Http -> config.port.toString() + is ProxyConfig.Socks -> config.port.toString() + else -> it.proxyPort + }, + proxyUsername = when (config) { + is ProxyConfig.Http -> config.username ?: "" + is ProxyConfig.Socks -> config.username ?: "" + else -> it.proxyUsername + }, + proxyPassword = when (config) { + is ProxyConfig.Http -> config.password ?: "" + is ProxyConfig.Socks -> config.password ?: "" + else -> it.proxyPassword + } + ) + } + } + } + } + fun onAction(action: ProfileAction) { when (action) { ProfileAction.OnHelpClick -> { @@ -162,7 +198,57 @@ class ProfileViewModel( themesRepository.setDarkTheme(action.isDarkTheme) } } + + is ProfileAction.OnProxyTypeSelected -> { + _state.update { it.copy(proxyType = action.type) } + if (action.type == ProxyType.NONE || action.type == ProxyType.SYSTEM) { + val config = when (action.type) { + ProxyType.NONE -> ProxyConfig.None + ProxyType.SYSTEM -> ProxyConfig.System + else -> return + } + viewModelScope.launch { + proxyRepository.setProxyConfig(config) + } + } + } + + is ProfileAction.OnProxyHostChanged -> { + _state.update { it.copy(proxyHost = action.host) } + } + + is ProfileAction.OnProxyPortChanged -> { + _state.update { it.copy(proxyPort = action.port) } + } + + is ProfileAction.OnProxyUsernameChanged -> { + _state.update { it.copy(proxyUsername = action.username) } + } + + is ProfileAction.OnProxyPasswordChanged -> { + _state.update { it.copy(proxyPassword = action.password) } + } + + ProfileAction.OnProxySave -> { + val currentState = _state.value + val port = currentState.proxyPort.toIntOrNull() ?: return + val host = currentState.proxyHost.takeIf { it.isNotBlank() } ?: return + val username = currentState.proxyUsername.takeIf { it.isNotBlank() } + val password = currentState.proxyPassword.takeIf { it.isNotBlank() } + + val config = when (currentState.proxyType) { + ProxyType.HTTP -> ProxyConfig.Http(host, port, username, password) + ProxyType.SOCKS -> ProxyConfig.Socks(host, port, username, password) + ProxyType.NONE -> ProxyConfig.None + ProxyType.SYSTEM -> ProxyConfig.System + } + + viewModelScope.launch { + proxyRepository.setProxyConfig(config) + _events.send(ProfileEvent.OnProxySaved) + } + } } } -} \ No newline at end of file +} diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt new file mode 100644 index 00000000..64e8e57c --- /dev/null +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt @@ -0,0 +1,239 @@ +package zed.rainxch.profile.presentation.components.sections + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.githubstore.core.presentation.res.* +import zed.rainxch.profile.presentation.ProfileAction +import zed.rainxch.profile.presentation.ProfileState +import zed.rainxch.profile.presentation.ProxyType +import zed.rainxch.profile.presentation.components.SectionHeader + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +fun LazyListScope.networkSection( + state: ProfileState, + onAction: (ProfileAction) -> Unit, +) { + item { + SectionHeader( + text = stringResource(Res.string.section_network) + ) + + Spacer(Modifier.height(8.dp)) + + ProxyTypeCard( + selectedType = state.proxyType, + onTypeSelected = { type -> + onAction(ProfileAction.OnProxyTypeSelected(type)) + } + ) + + AnimatedVisibility( + visible = state.proxyType == ProxyType.HTTP || state.proxyType == ProxyType.SOCKS, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column { + Spacer(Modifier.height(16.dp)) + + ProxyDetailsCard( + state = state, + onAction = onAction + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ProxyTypeCard( + selectedType: ProxyType, + onTypeSelected: (ProxyType) -> Unit +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + shape = RoundedCornerShape(20.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = stringResource(Res.string.proxy_type), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) + + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ProxyType.entries.forEach { type -> + ProxyTypeChip( + label = when (type) { + ProxyType.NONE -> stringResource(Res.string.proxy_none) + ProxyType.SYSTEM -> stringResource(Res.string.proxy_system) + ProxyType.HTTP -> stringResource(Res.string.proxy_http) + ProxyType.SOCKS -> stringResource(Res.string.proxy_socks) + }, + isSelected = selectedType == type, + onClick = { onTypeSelected(type) }, + modifier = Modifier.weight(1f) + ) + } + } + } + } +} + +@Composable +private fun ProxyTypeChip( + label: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background( + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + } + ) + .clickable(onClick = onClick) + .padding(vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ProxyDetailsCard( + state: ProfileState, + onAction: (ProfileAction) -> Unit +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + shape = RoundedCornerShape(20.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = state.proxyHost, + onValueChange = { onAction(ProfileAction.OnProxyHostChanged(it)) }, + label = { Text(stringResource(Res.string.proxy_host)) }, + singleLine = true, + modifier = Modifier.weight(2f), + shape = RoundedCornerShape(12.dp) + ) + + OutlinedTextField( + value = state.proxyPort, + onValueChange = { onAction(ProfileAction.OnProxyPortChanged(it)) }, + label = { Text(stringResource(Res.string.proxy_port)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp) + ) + } + + OutlinedTextField( + value = state.proxyUsername, + onValueChange = { onAction(ProfileAction.OnProxyUsernameChanged(it)) }, + label = { Text(stringResource(Res.string.proxy_username)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) + + OutlinedTextField( + value = state.proxyPassword, + onValueChange = { onAction(ProfileAction.OnProxyPasswordChanged(it)) }, + label = { Text(stringResource(Res.string.proxy_password)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) + + TextButton( + onClick = { onAction(ProfileAction.OnProxySave) }, + modifier = Modifier.align(Alignment.End), + enabled = state.proxyHost.isNotBlank() && state.proxyPort.toIntOrNull() != null + ) { + Icon( + imageVector = Icons.Default.Save, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.size(6.dp)) + Text(stringResource(Res.string.proxy_save)) + } + } + } +} From 782e8c0b97510727ca0315a5c10970bee90cd73a Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Thu, 26 Feb 2026 05:47:23 +0500 Subject: [PATCH 2/5] feat(profile): Improve proxy settings and network handling This commit enhances the proxy configuration UI and ensures proxy settings are applied correctly during application startup and runtime. - **feat(profile)**: Added a password visibility toggle for proxy authentication. - **feat(profile)**: Improved proxy settings validation, including port range checking (1-65535) and UI feedback for invalid inputs. - **feat(profile)**: Added descriptive text for "None" and "System" proxy types and updated the "Save" button to a `FilledTonalButton` with validation logic. - **fix(network)**: Moved proxy initialization to a synchronous `runBlocking` block within `networkModule` to ensure settings are applied before the first network request. - **fix(network)**: Refactored `GitHubClientProvider` to use a thread-safe volatile client that updates reactively when proxy configurations change. - **fix(data)**: Hardened `ProxyRepositoryImpl` with better validation when loading saved configurations and ensured persistence occurs before in-memory application. - **fix(android)**: Reset the default `java.net.Authenticator` in `HttpClientFactory` to prevent credential leakage across client instances. - **refactor**: Cleaned up `MainViewModel` and `SharedModules` by removing redundant proxy initialization logic. --- .../zed/rainxch/githubstore/MainViewModel.kt | 27 +------ .../githubstore/app/di/SharedModules.kt | 3 +- .../data/network/HttpClientFactory.android.kt | 2 + .../zed/rainxch/core/data/di/SharedModule.kt | 26 +++++++ .../core/data/network/GitHubClientProvider.kt | 57 +++++++-------- .../data/repository/ProxyRepositoryImpl.kt | 43 ++++++++---- .../profile/presentation/ProfileAction.kt | 1 + .../profile/presentation/ProfileEvent.kt | 1 + .../profile/presentation/ProfileRoot.kt | 6 ++ .../profile/presentation/ProfileState.kt | 1 + .../profile/presentation/ProfileViewModel.kt | 23 ++++-- .../components/sections/Network.kt | 70 +++++++++++++++++-- 12 files changed, 179 insertions(+), 81 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt index 464a9c3f..36c2b854 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt @@ -7,12 +7,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.flow.first -import zed.rainxch.core.data.network.ProxyManager -import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.core.domain.repository.InstalledAppsRepository -import zed.rainxch.core.domain.repository.ProxyRepository import zed.rainxch.core.domain.repository.RateLimitRepository import zed.rainxch.core.domain.repository.ThemesRepository import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase @@ -22,8 +18,7 @@ class MainViewModel( private val installedAppsRepository: InstalledAppsRepository, private val authenticationState: AuthenticationState, private val rateLimitRepository: RateLimitRepository, - private val syncUseCase: SyncInstalledAppsUseCase, - private val proxyRepository: ProxyRepository + private val syncUseCase: SyncInstalledAppsUseCase ) : ViewModel() { private val _state = MutableStateFlow(MainState()) @@ -99,26 +94,6 @@ class MainViewModel( installedAppsRepository.checkAllForUpdates() } } - - viewModelScope.launch(Dispatchers.IO) { - val savedConfig = proxyRepository.getProxyConfig().first() - when (savedConfig) { - is ProxyConfig.None -> ProxyManager.setNoProxy() - is ProxyConfig.System -> ProxyManager.setSystemProxy() - is ProxyConfig.Http -> ProxyManager.setHttpProxy( - host = savedConfig.host, - port = savedConfig.port, - username = savedConfig.username, - password = savedConfig.password - ) - is ProxyConfig.Socks -> ProxyManager.setSocksProxy( - host = savedConfig.host, - port = savedConfig.port, - username = savedConfig.username, - password = savedConfig.password - ) - } - } } fun onAction(action: MainAction) { diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt index 6866c4ce..010d3afd 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt @@ -15,8 +15,7 @@ val mainModule: Module = module { installedAppsRepository = get(), rateLimitRepository = get(), syncUseCase = get(), - authenticationState = get(), - proxyRepository = get() + authenticationState = get() ) } } \ No newline at end of file diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt index e4a59a29..7ad21621 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt @@ -10,6 +10,8 @@ import java.net.PasswordAuthentication import java.net.ProxySelector actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient { + java.net.Authenticator.setDefault(null) + return HttpClient(OkHttp) { engine { when (proxyConfig) { 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 92befa13..afc7fa0b 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 @@ -4,6 +4,8 @@ import io.ktor.client.HttpClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import org.koin.dsl.module import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.data_source.impl.DefaultTokenStore @@ -26,6 +28,7 @@ import zed.rainxch.core.data.repository.ThemesRepositoryImpl import zed.rainxch.core.domain.getPlatform import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.Platform +import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository @@ -104,6 +107,29 @@ val coreModule = module { val networkModule = module { single { + // Load saved proxy config SYNCHRONOUSLY before creating the client provider + // so the very first HTTP client uses the correct proxy. This is critical for + // users in regions where direct GitHub access is blocked (e.g. China). + runBlocking { + val config = get().getProxyConfig().first() + when (config) { + is ProxyConfig.None -> ProxyManager.setNoProxy() + is ProxyConfig.System -> ProxyManager.setSystemProxy() + is ProxyConfig.Http -> ProxyManager.setHttpProxy( + host = config.host, + port = config.port, + username = config.username, + password = config.password + ) + is ProxyConfig.Socks -> ProxyManager.setSocksProxy( + host = config.host, + port = config.port, + username = config.username, + password = config.password + ) + } + } + GitHubClientProvider( tokenStore = get(), rateLimitRepository = get(), 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 f0afb0c9..ef9ce955 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 @@ -5,13 +5,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import zed.rainxch.core.data.data_source.TokenStore @@ -24,37 +22,36 @@ class GitHubClientProvider( proxyConfigFlow: StateFlow ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private var currentClient: HttpClient? = null private val mutex = Mutex() - private val _client: StateFlow = proxyConfigFlow - .map { proxyConfig -> - mutex.withLock { - currentClient?.close() - val newClient = createGitHubHttpClient( - tokenStore = tokenStore, - rateLimitRepository = rateLimitRepository, - proxyConfig = proxyConfig - ) - currentClient = newClient - newClient + @Volatile + private var currentClient: HttpClient = createGitHubHttpClient( + tokenStore = tokenStore, + rateLimitRepository = rateLimitRepository, + proxyConfig = proxyConfigFlow.value + ) + + init { + proxyConfigFlow + .drop(1) + .distinctUntilChanged() + .onEach { proxyConfig -> + mutex.withLock { + currentClient.close() + currentClient = createGitHubHttpClient( + tokenStore = tokenStore, + rateLimitRepository = rateLimitRepository, + proxyConfig = proxyConfig + ) + } } - } - .stateIn( - scope = scope, - started = SharingStarted.Eagerly, - initialValue = createGitHubHttpClient( - tokenStore = tokenStore, - rateLimitRepository = rateLimitRepository, - proxyConfig = proxyConfigFlow.value - ).also { currentClient = it } - ) + .launchIn(scope) + } - /** Get the current HttpClient (always up to date with proxy settings) */ - val client: HttpClient get() = _client.value + val client: HttpClient get() = currentClient fun close() { - currentClient?.close() + currentClient.close() scope.cancel() } } \ No newline at end of file diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt index f25ddce1..1531dca8 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt @@ -25,25 +25,41 @@ class ProxyRepositoryImpl( return preferences.data.map { prefs -> when (prefs[proxyTypeKey]) { "system" -> ProxyConfig.System - "http" -> ProxyConfig.Http( - host = prefs[proxyHostKey] ?: "", - port = prefs[proxyPortKey] ?: 8080, - username = prefs[proxyUsernameKey], - password = prefs[proxyPasswordKey] - ) - "socks" -> ProxyConfig.Socks( - host = prefs[proxyHostKey] ?: "", - port = prefs[proxyPortKey] ?: 1080, - username = prefs[proxyUsernameKey], - password = prefs[proxyPasswordKey] - ) + "http" -> { + val host = prefs[proxyHostKey]?.takeIf { it.isNotBlank() } + val port = prefs[proxyPortKey]?.takeIf { it in 1..65535 } + if (host != null && port != null) { + ProxyConfig.Http( + host = host, + port = port, + username = prefs[proxyUsernameKey], + password = prefs[proxyPasswordKey] + ) + } else { + ProxyConfig.None + } + } + "socks" -> { + val host = prefs[proxyHostKey]?.takeIf { it.isNotBlank() } + val port = prefs[proxyPortKey]?.takeIf { it in 1..65535 } + if (host != null && port != null) { + ProxyConfig.Socks( + host = host, + port = port, + username = prefs[proxyUsernameKey], + password = prefs[proxyPasswordKey] + ) + } else { + ProxyConfig.None + } + } else -> ProxyConfig.None } } } override suspend fun setProxyConfig(config: ProxyConfig) { - applyToProxyManager(config) + // Persist first so config survives crashes, then apply in-memory preferences.edit { prefs -> when (config) { is ProxyConfig.None -> { @@ -92,6 +108,7 @@ class ProxyRepositoryImpl( } } } + applyToProxyManager(config) } private fun applyToProxyManager(config: ProxyConfig) { diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt index 5be135de..6aae32ee 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt @@ -18,5 +18,6 @@ sealed interface ProfileAction { data class OnProxyPortChanged(val port: String) : ProfileAction data class OnProxyUsernameChanged(val username: String) : ProfileAction data class OnProxyPasswordChanged(val password: String) : ProfileAction + data object OnProxyPasswordVisibilityToggle : ProfileAction data object OnProxySave : ProfileAction } \ No newline at end of file diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileEvent.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileEvent.kt index 9d5b82fd..9bb5f0ae 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileEvent.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileEvent.kt @@ -4,4 +4,5 @@ sealed interface ProfileEvent { data object OnLogoutSuccessful : ProfileEvent data class OnLogoutError(val message: String) : ProfileEvent data object OnProxySaved : ProfileEvent + data class OnProxySaveError(val message: String) : ProfileEvent } \ No newline at end of file diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt index dbf9f56f..a93e47d9 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt @@ -74,6 +74,12 @@ fun ProfileRoot( snackbarState.showSnackbar(getString(Res.string.proxy_saved)) } } + + is ProfileEvent.OnProxySaveError -> { + coroutineScope.launch { + snackbarState.showSnackbar(event.message) + } + } } } diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt index 933f2934..b474cc58 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt @@ -19,6 +19,7 @@ data class ProfileState( val proxyPort: String = "", val proxyUsername: String = "", val proxyPassword: String = "", + val isProxyPasswordVisible: Boolean = false, ) enum class ProxyType { diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt index d963a243..62f0cff3 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt @@ -229,10 +229,16 @@ class ProfileViewModel( _state.update { it.copy(proxyPassword = action.password) } } + ProfileAction.OnProxyPasswordVisibilityToggle -> { + _state.update { it.copy(isProxyPasswordVisible = !it.isProxyPasswordVisible) } + } + ProfileAction.OnProxySave -> { val currentState = _state.value - val port = currentState.proxyPort.toIntOrNull() ?: return - val host = currentState.proxyHost.takeIf { it.isNotBlank() } ?: return + val port = currentState.proxyPort.toIntOrNull() + ?.takeIf { it in 1..65535 } + ?: return + val host = currentState.proxyHost.trim().takeIf { it.isNotBlank() } ?: return val username = currentState.proxyUsername.takeIf { it.isNotBlank() } val password = currentState.proxyPassword.takeIf { it.isNotBlank() } @@ -244,8 +250,17 @@ class ProfileViewModel( } viewModelScope.launch { - proxyRepository.setProxyConfig(config) - _events.send(ProfileEvent.OnProxySaved) + runCatching { + proxyRepository.setProxyConfig(config) + }.onSuccess { + _events.send(ProfileEvent.OnProxySaved) + }.onFailure { error -> + _events.send( + ProfileEvent.OnProxySaveError( + error.message ?: "Failed to save proxy settings" + ) + ) + } } } } diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt index 64e8e57c..772c5830 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt @@ -20,14 +20,17 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -35,6 +38,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.* @@ -62,6 +66,24 @@ fun LazyListScope.networkSection( } ) + // Description text for None / System proxy types + AnimatedVisibility( + visible = state.proxyType == ProxyType.NONE || state.proxyType == ProxyType.SYSTEM, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Text( + text = when (state.proxyType) { + ProxyType.SYSTEM -> stringResource(Res.string.proxy_system_description) + else -> stringResource(Res.string.proxy_none_description) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp, top = 12.dp) + ) + } + + // Details card for HTTP / SOCKS proxy types AnimatedVisibility( visible = state.proxyType == ProxyType.HTTP || state.proxyType == ProxyType.SOCKS, enter = expandVertically() + fadeIn(), @@ -166,6 +188,13 @@ private fun ProxyDetailsCard( state: ProfileState, onAction: (ProfileAction) -> Unit ) { + val portValue = state.proxyPort + val isPortInvalid = portValue.isNotEmpty() && + (portValue.toIntOrNull()?.let { it !in 1..65535 } ?: true) + val isFormValid = state.proxyHost.isNotBlank() && + portValue.isNotEmpty() && + portValue.toIntOrNull()?.let { it in 1..65535 } == true + ElevatedCard( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.elevatedCardColors( @@ -177,6 +206,7 @@ private fun ProxyDetailsCard( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { + // Host + Port row Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp) @@ -185,6 +215,7 @@ private fun ProxyDetailsCard( value = state.proxyHost, onValueChange = { onAction(ProfileAction.OnProxyHostChanged(it)) }, label = { Text(stringResource(Res.string.proxy_host)) }, + placeholder = { Text("127.0.0.1") }, singleLine = true, modifier = Modifier.weight(2f), shape = RoundedCornerShape(12.dp) @@ -194,13 +225,19 @@ private fun ProxyDetailsCard( value = state.proxyPort, onValueChange = { onAction(ProfileAction.OnProxyPortChanged(it)) }, label = { Text(stringResource(Res.string.proxy_port)) }, + placeholder = { Text("1080") }, singleLine = true, + isError = isPortInvalid, + supportingText = if (isPortInvalid) { + { Text(stringResource(Res.string.proxy_port_error)) } + } else null, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f), shape = RoundedCornerShape(12.dp) ) } + // Username OutlinedTextField( value = state.proxyUsername, onValueChange = { onAction(ProfileAction.OnProxyUsernameChanged(it)) }, @@ -210,28 +247,49 @@ private fun ProxyDetailsCard( shape = RoundedCornerShape(12.dp) ) + // Password with visibility toggle OutlinedTextField( value = state.proxyPassword, onValueChange = { onAction(ProfileAction.OnProxyPasswordChanged(it)) }, label = { Text(stringResource(Res.string.proxy_password)) }, singleLine = true, - visualTransformation = PasswordVisualTransformation(), + visualTransformation = if (state.isProxyPasswordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton( + onClick = { onAction(ProfileAction.OnProxyPasswordVisibilityToggle) } + ) { + Icon( + imageVector = if (state.isProxyPasswordVisible) { + Icons.Default.VisibilityOff + } else { + Icons.Default.Visibility + }, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp) ) - TextButton( + // Save button + FilledTonalButton( onClick = { onAction(ProfileAction.OnProxySave) }, - modifier = Modifier.align(Alignment.End), - enabled = state.proxyHost.isNotBlank() && state.proxyPort.toIntOrNull() != null + enabled = isFormValid, + modifier = Modifier.align(Alignment.End) ) { Icon( imageVector = Icons.Default.Save, contentDescription = null, modifier = Modifier.size(18.dp) ) - Spacer(Modifier.size(6.dp)) + Spacer(Modifier.size(8.dp)) Text(stringResource(Res.string.proxy_save)) } } From a4965c5617e2aa9078d89956db56d24cb9810770 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Thu, 26 Feb 2026 05:47:31 +0500 Subject: [PATCH 3/5] i18n: Add proxy and network settings translations This commit adds localizations for proxy configuration and a new "Network" section across multiple languages. - **i18n**: Added translations for proxy-related strings including proxy types (HTTP, SOCKS, System, None), host, port, authentication fields, and validation error messages. - **i18n**: Added a new "Network" section header (`section_network`) to all supported locales. - **i18n**: Supported languages include Russian, French, Turkish, Italian, Hindi, Bengali, Korean, Chinese (Simplified), Japanese, Polish, Spanish, and the default English strings. - **chore**: Updated `proxy_save` string to "Save Proxy" in the base `strings.xml`. --- .../composeResources/values-bn/strings-bn.xml | 17 +++++++++++++++++ .../composeResources/values-es/strings-es.xml | 17 +++++++++++++++++ .../composeResources/values-fr/strings-fr.xml | 17 +++++++++++++++++ .../composeResources/values-hi/strings-hi.xml | 17 +++++++++++++++++ .../composeResources/values-it/strings-it.xml | 17 +++++++++++++++++ .../composeResources/values-ja/strings-ja.xml | 17 +++++++++++++++++ .../composeResources/values-kr/strings-kr.xml | 17 +++++++++++++++++ .../composeResources/values-pl/strings-pl.xml | 17 +++++++++++++++++ .../composeResources/values-ru/strings-ru.xml | 17 +++++++++++++++++ .../composeResources/values-tr/strings-tr.xml | 17 +++++++++++++++++ .../values-zh-rCN/strings-zh-rCN.xml | 17 +++++++++++++++++ .../composeResources/values/strings.xml | 5 ++++- 12 files changed, 191 insertions(+), 1 deletion(-) 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 bc09ee53..ca8572a1 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -109,6 +109,7 @@ চেহারা সম্পর্কে + নেটওয়ার্ক থিমের রঙ @@ -370,4 +371,20 @@ %1$d ঘণ্টা আগে আপডেট পরীক্ষা করা হচ্ছে… + + প্রক্সি ধরন + নেই + সিস্টেম + HTTP + SOCKS + হোস্ট + পোর্ট + ব্যবহারকারীর নাম (ঐচ্ছিক) + পাসওয়ার্ড (ঐচ্ছিক) + প্রক্সি সংরক্ষণ + প্রক্সি সেটিংস সংরক্ষিত হয়েছে + আপনার ডিভাইসের প্রক্সি সেটিংস ব্যবহার করে + পোর্ট ১–৬৫৫৩৫ এর মধ্যে হতে হবে + সরাসরি সংযোগ, কোনো প্রক্সি নেই + 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 18760f52..1d2b8400 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -91,6 +91,7 @@ APARIENCIA ACERCA DE + RED Color del tema Tema negro AMOLED @@ -335,4 +336,20 @@ hace %1$d h Comprobando actualizaciones… + + Tipo de proxy + Ninguno + Sistema + HTTP + SOCKS + Host + Puerto + Nombre de usuario (opcional) + Contraseña (opcional) + Guardar Proxy + Configuración de proxy guardada + Usa la configuración proxy del dispositivo + El puerto debe ser 1–65535 + Conexión directa, sin proxy + \ 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 bdd14de5..55f7ee06 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -91,6 +91,7 @@ APPARENCE À PROPOS + RÉSEAU Couleur du thème Thème noir AMOLED @@ -335,4 +336,20 @@ il y a %1$d h Vérification des mises à jour… + + Type de proxy + Aucun + Système + HTTP + SOCKS + Hôte + Port + Nom d\'utilisateur (facultatif) + Mot de passe (facultatif) + Sauvegarder le Proxy + Paramètres du proxy enregistrés + Utilise les paramètres proxy de l\'appareil + Le port doit être entre 1 et 65535 + Connexion directe, pas de proxy + \ 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 9a8d3b05..0f6d8cca 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -109,6 +109,7 @@ उपस्थिति के बारे में + नेटवर्क थीम रंग @@ -369,4 +370,20 @@ %1$d मिनट पहले %1$d घंटे पहले अपडेट की जाँच हो रही है… + + + प्रॉक्सी प्रकार + कोई नहीं + सिस्टम + HTTP + SOCKS + होस्ट + पोर्ट + उपयोगकर्ता नाम (वैकल्पिक) + पासवर्ड (वैकल्पिक) + प्रॉक्सी सहेजें + प्रॉक्सी सेटिंग्स सहेजी गईं + आपके डिवाइस की प्रॉक्सी सेटिंग का उपयोग करता है + पोर्ट 1–65535 के बीच होना चाहिए + सीधा कनेक्शन, कोई प्रॉक्सी नहीं 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 96576292..4ce2b8bf 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -109,6 +109,7 @@ ASPETTO INFORMAZIONI + RETE Colore del Tema @@ -371,4 +372,20 @@ %1$d h fa Controllo aggiornamenti… + + Tipo di proxy + Nessuno + Sistema + HTTP + SOCKS + Host + Porta + Nome utente (facoltativo) + Password (facoltativo) + Salva Proxy + Impostazioni proxy salvate + Usa le impostazioni proxy del dispositivo + La porta deve essere 1–65535 + Connessione diretta, nessun proxy + \ 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 9bfa17a0..7a0acb4c 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -91,6 +91,7 @@ 外観 情報 + ネットワーク テーマカラー AMOLED ブラックテーマ @@ -335,4 +336,20 @@ %1$d時間前 アップデートを確認中… + + プロキシの種類 + なし + システム + HTTP + SOCKS + ホスト + ポート + ユーザー名(任意) + パスワード(任意) + プロキシを保存 + プロキシ設定を保存しました + デバイスのプロキシ設定を使用します + ポートは1〜65535の範囲で指定してください + 直接接続、プロキシなし + \ 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 a7fbad23..294cbb28 100644 --- a/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml +++ b/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml @@ -107,6 +107,7 @@ 외관 정보 + 네트워크 테마 색상 @@ -368,4 +369,20 @@ %1$d시간 전 업데이트 확인 중… + + 프록시 유형 + 없음 + 시스템 + HTTP + SOCKS + 호스트 + 포트 + 사용자 이름 (선택 사항) + 비밀번호 (선택 사항) + 프록시 저장 + 프록시 설정이 저장되었습니다 + 기기의 프록시 설정을 사용합니다 + 포트는 1–65535 사이여야 합니다 + 직접 연결, 프록시 없음 + \ 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 68802d1b..1ce9edbc 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -92,6 +92,7 @@ WYGLĄD O APLIKACJI + SIEĆ Kolor motywu Motyw AMOLED Black @@ -333,4 +334,20 @@ %1$d godz. temu Sprawdzanie aktualizacji… + + Typ proxy + Brak + Systemowy + HTTP + SOCKS + Host + Port + Nazwa użytkownika (opcjonalnie) + Hasło (opcjonalnie) + Zapisz Proxy + Ustawienia proxy zostały zapisane + Używa ustawień proxy urządzenia + Port musi być z zakresu 1–65535 + Połączenie bezpośrednie, bez proxy + \ 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 024ecb30..b88c9bcc 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -91,6 +91,7 @@ ВНЕШНИЙ ВИД О ПРИЛОЖЕНИИ + СЕТЬ Цвет темы AMOLED чёрная тема @@ -335,4 +336,20 @@ %1$d ч назад Проверка обновлений… + + Тип прокси + Нет + Системный + HTTP + SOCKS + Хост + Порт + Имя пользователя (необязательно) + Пароль (необязательно) + Сохранить прокси + Настройки прокси сохранены + Использует прокси-настройки устройства + Порт должен быть 1–65535 + Прямое подключение, без прокси + \ 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 59274cd8..b7553bbd 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -108,6 +108,7 @@ GÖRÜNÜM HAKKINDA + Tema Rengi @@ -367,4 +368,20 @@ %1$d sa önce Güncellemeler kontrol ediliyor… + + Proxy Türü + Yok + Sistem + HTTP + SOCKS + Ana Bilgisayar + Port + Kullanıcı adı (isteğe bağlı) + Şifre (isteğe bağlı) + Proxy'yi Kaydet + Proxy ayarları kaydedildi + Cihazınızın proxy ayarlarını kullanır + Port 1–65535 arası olmalı + Doğrudan bağlantı, proxy yok + 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 04b000ad..0904b5f2 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 @@ -93,6 +93,7 @@ 外观 关于 + 网络 主题颜色 AMOLED 黑色主题 @@ -336,4 +337,20 @@ %1$d 小时前 正在检查更新… + + 代理类型 + + 系统代理 + HTTP + SOCKS + 主机地址 + 端口 + 用户名(可选) + 密码(可选) + 保存代理 + 代理设置已保存 + 使用设备的代理设置 + 端口必须为 1–65535 + 直连,不使用代理 + \ 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 cddf9bb7..211db65d 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -134,8 +134,11 @@ Port Username (optional) Password (optional) - Save + Save Proxy Proxy settings saved + Uses your device\'s proxy settings + Port must be 1–65535 + Direct connection, no proxy Logged out successfully, redirecting... From 8a7e5caa8f161fe44e6ae9e7e09a884552bd24ac Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Thu, 26 Feb 2026 14:14:18 +0500 Subject: [PATCH 4/5] feat(profile): Enhance proxy settings validation and improve UI This commit improves the robustness of proxy configuration in the profile section by adding validation and error handling, while also refining the UI and navigation state management. - **feat(profile)**: Added localized error messages for invalid proxy ports, missing hosts, and save failures in `ProfileViewModel`. - **feat(profile)**: Implemented `runCatching` for proxy configuration updates to provide explicit success/failure events. - **feat(ui)**: Introduced `LocalBottomNavigationHeight` to track the bottom bar's height globally via `onGloballyPositioned`. - **refactor(profile)**: Replaced custom `ProxyTypeChip` with Material3 `FilterChip` for a more standard selection UI in the Network section. - **refactor(profile)**: Updated `ProfileRoot` to adjust `SnackbarHost` padding dynamically based on the bottom navigation height, preventing overlap. - **fix(profile)**: Added accessibility content descriptions to the proxy password visibility toggle. --- .../app/navigation/AppNavigation.kt | 15 +++- .../locals/LocalBottomNavigationHeight.kt | 8 +++ .../profile/presentation/ProfileRoot.kt | 8 ++- .../profile/presentation/ProfileViewModel.kt | 36 ++++++++-- .../components/sections/Network.kt | 71 +++++++------------ 5 files changed, 86 insertions(+), 52 deletions(-) create mode 100644 core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/locals/LocalBottomNavigationHeight.kt diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt index 70f3d3b0..483858a5 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt @@ -8,8 +8,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -21,6 +27,7 @@ import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import zed.rainxch.apps.presentation.AppsRoot import zed.rainxch.auth.presentation.AuthenticationRoot +import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid import zed.rainxch.details.presentation.DetailsRoot import zed.rainxch.devprofile.presentation.DeveloperProfileRoot @@ -35,9 +42,12 @@ fun AppNavigation( navController: NavHostController ) { val liquidState = rememberLiquidState() + var bottomNavigationHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current CompositionLocalProvider( - value = LocalBottomNavigationLiquid provides liquidState + LocalBottomNavigationLiquid provides liquidState, + LocalBottomNavigationHeight provides bottomNavigationHeight ) { Box( modifier = Modifier.fillMaxSize() @@ -235,6 +245,9 @@ fun AppNavigation( .align(Alignment.BottomCenter) .navigationBarsPadding() .padding(bottom = 24.dp) + .onGloballyPositioned { coordinates -> + bottomNavigationHeight = with(density) { coordinates.size.height.toDp() } + } ) } } diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/locals/LocalBottomNavigationHeight.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/locals/LocalBottomNavigationHeight.kt new file mode 100644 index 00000000..6861e495 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/locals/LocalBottomNavigationHeight.kt @@ -0,0 +1,8 @@ +package zed.rainxch.core.presentation.locals + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.unit.Dp + +val LocalBottomNavigationHeight = compositionLocalOf { + error("Not initialized yet") +} \ No newline at end of file diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt index a93e47d9..a378a8f5 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt @@ -2,6 +2,7 @@ package zed.rainxch.profile.presentation import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -34,6 +35,7 @@ import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents @@ -119,9 +121,13 @@ fun ProfileScreen( snackbarState: SnackbarHostState ) { val liquidState = LocalBottomNavigationLiquid.current + val bottomNavHeight = LocalBottomNavigationHeight.current Scaffold( snackbarHost = { - SnackbarHost(hostState = snackbarState) + SnackbarHost( + hostState = snackbarState, + modifier = Modifier.padding(bottom = bottomNavHeight) + ) }, topBar = { TopAppBar(onAction) diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt index 62f0cff3..a0b06064 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt @@ -10,10 +10,16 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.repository.ProxyRepository import zed.rainxch.core.domain.repository.ThemesRepository import zed.rainxch.core.domain.utils.BrowserHelper +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.failed_to_save_proxy_settings +import zed.rainxch.githubstore.core.presentation.res.invalid_proxy_port +import zed.rainxch.githubstore.core.presentation.res.proxy_host_required import zed.rainxch.profile.domain.repository.ProfileRepository class ProfileViewModel( @@ -208,7 +214,18 @@ class ProfileViewModel( else -> return } viewModelScope.launch { - proxyRepository.setProxyConfig(config) + runCatching { + proxyRepository.setProxyConfig(config) + }.onSuccess { + _events.send(ProfileEvent.OnProxySaved) + }.onFailure { error -> + _events.send( + ProfileEvent.OnProxySaveError( + error.message ?: getString(Res.string.failed_to_save_proxy_settings) + ) + ) + } + } } } @@ -237,8 +254,19 @@ class ProfileViewModel( val currentState = _state.value val port = currentState.proxyPort.toIntOrNull() ?.takeIf { it in 1..65535 } - ?: return - val host = currentState.proxyHost.trim().takeIf { it.isNotBlank() } ?: return + ?: run { + viewModelScope.launch { + _events.send(ProfileEvent.OnProxySaveError(getString(Res.string.invalid_proxy_port))) + } + return + } + val host = currentState.proxyHost.trim().takeIf { it.isNotBlank() } ?: run { + viewModelScope.launch { + _events.send(ProfileEvent.OnProxySaveError(getString(Res.string.proxy_host_required))) + } + return + } + val username = currentState.proxyUsername.takeIf { it.isNotBlank() } val password = currentState.proxyPassword.takeIf { it.isNotBlank() } @@ -257,7 +285,7 @@ class ProfileViewModel( }.onFailure { error -> _events.send( ProfileEvent.OnProxySaveError( - error.message ?: "Failed to save proxy settings" + error.message ?: getString(Res.string.failed_to_save_proxy_settings) ) ) } diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt index 772c5830..f5a06b39 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt @@ -5,8 +5,6 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -26,6 +24,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -34,11 +33,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.* @@ -131,15 +130,24 @@ private fun ProxyTypeCard( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { ProxyType.entries.forEach { type -> - ProxyTypeChip( - label = when (type) { - ProxyType.NONE -> stringResource(Res.string.proxy_none) - ProxyType.SYSTEM -> stringResource(Res.string.proxy_system) - ProxyType.HTTP -> stringResource(Res.string.proxy_http) - ProxyType.SOCKS -> stringResource(Res.string.proxy_socks) - }, - isSelected = selectedType == type, + FilterChip( + selected = selectedType == type, onClick = { onTypeSelected(type) }, + label = { + Text( + text = when (type) { + ProxyType.NONE -> stringResource(Res.string.proxy_none) + ProxyType.SYSTEM -> stringResource(Res.string.proxy_system) + ProxyType.HTTP -> stringResource(Res.string.proxy_http) + ProxyType.SOCKS -> stringResource(Res.string.proxy_socks) + }, + fontWeight = if (selectedType == type) FontWeight.Bold else FontWeight.Normal, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, modifier = Modifier.weight(1f) ) } @@ -148,40 +156,6 @@ private fun ProxyTypeCard( } } -@Composable -private fun ProxyTypeChip( - label: String, - isSelected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - Column( - modifier = modifier - .clip(RoundedCornerShape(12.dp)) - .background( - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - } - ) - .clickable(onClick = onClick) - .padding(vertical = 10.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = label, - style = MaterialTheme.typography.labelLarge, - color = if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal - ) - } -} - @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ProxyDetailsCard( @@ -268,7 +242,12 @@ private fun ProxyDetailsCard( } else { Icons.Default.Visibility }, - contentDescription = null, + contentDescription = if (state.isProxyPasswordVisible) { + stringResource(Res.string.proxy_hide_password) + } else { + stringResource(Res.string.proxy_show_password) + }, + modifier = Modifier.size(20.dp) ) } From 5362bc9d9d01c1a627a0725269d47ffbaf774884 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Thu, 26 Feb 2026 14:14:43 +0500 Subject: [PATCH 5/5] feat(app): Remove in-app uninstallation and refine downgrade handling This commit removes the ability to uninstall applications directly from the app's interface across Android and Desktop platforms. It also refines the downgrade process by replacing the automatic uninstall prompt with a manual instruction message. - **feat(core)**: Removed `uninstall` method from the `Installer` interface and its Android/Desktop implementations. - **feat(app)**: Removed `UninstallApp` and `OnUninstallApp` actions from `DetailsViewModel` and `AppsViewModel`. - **ui(details)**: Simplified `SmartInstallButton` to only show an "Open" button when the same version is installed, removing the split "Uninstall/Open" layout. - **ui(details)**: Replaced the `ShowDowngradeWarning` dialog in `DetailsRoot` with a simple snackbar message instructing the user to uninstall manually. - **ui(apps)**: Removed the uninstall icon button from the installed apps list. - **fix(network)**: Hardened `GitHubClientProvider` initialization by adding a 1.5s timeout and `runCatching` when fetching proxy configurations to prevent potential startup hangs. - **i18n**: Updated strings across all supported languages to reflect the removal of uninstallation features and add new proxy-related error/status messages. - **chore(android)**: Removed `REQUEST_DELETE_PACKAGES` permission from `AndroidManifest.xml`. - **chore**: Deleted `feature/settings/CLAUDE.md`. --- .../src/androidMain/AndroidManifest.xml | 1 - .../core/data/services/AndroidInstaller.kt | 14 --- .../zed/rainxch/core/data/di/SharedModule.kt | 46 ++++---- .../core/data/services/DesktopInstaller.kt | 5 - .../rainxch/core/domain/system/Installer.kt | 2 - .../composeResources/values-bn/strings-bn.xml | 13 +- .../composeResources/values-es/strings-es.xml | 17 +-- .../composeResources/values-fr/strings-fr.xml | 13 +- .../composeResources/values-hi/strings-hi.xml | 14 ++- .../composeResources/values-it/strings-it.xml | 13 +- .../composeResources/values-ja/strings-ja.xml | 13 +- .../composeResources/values-kr/strings-kr.xml | 13 +- .../composeResources/values-pl/strings-pl.xml | 13 +- .../composeResources/values-ru/strings-ru.xml | 13 +- .../composeResources/values-tr/strings-tr.xml | 13 +- .../values-zh-rCN/strings-zh-rCN.xml | 13 +- .../composeResources/values/strings.xml | 15 +-- .../rainxch/apps/presentation/AppsAction.kt | 1 - .../zed/rainxch/apps/presentation/AppsRoot.kt | 20 ---- .../apps/presentation/AppsViewModel.kt | 20 ---- .../details/presentation/DetailsAction.kt | 1 - .../details/presentation/DetailsEvent.kt | 5 - .../details/presentation/DetailsRoot.kt | 47 -------- .../details/presentation/DetailsViewModel.kt | 28 +---- .../components/SmartInstallButton.kt | 111 +++++------------- feature/settings/CLAUDE.md | 50 -------- 26 files changed, 147 insertions(+), 367 deletions(-) delete mode 100644 feature/settings/CLAUDE.md diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 2c6cc402..9c3aeb7d 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -11,7 +11,6 @@ - { - // Load saved proxy config SYNCHRONOUSLY before creating the client provider - // so the very first HTTP client uses the correct proxy. This is critical for - // users in regions where direct GitHub access is blocked (e.g. China). - runBlocking { - val config = get().getProxyConfig().first() - when (config) { - is ProxyConfig.None -> ProxyManager.setNoProxy() - is ProxyConfig.System -> ProxyManager.setSystemProxy() - is ProxyConfig.Http -> ProxyManager.setHttpProxy( - host = config.host, - port = config.port, - username = config.username, - password = config.password - ) - is ProxyConfig.Socks -> ProxyManager.setSocksProxy( - host = config.host, - port = config.port, - username = config.username, - password = config.password - ) - } + val config = runBlocking { + runCatching { + withTimeout(1_500L) { + get().getProxyConfig().first() + } + }.getOrDefault(ProxyConfig.None) + } + + when (config) { + is ProxyConfig.None -> ProxyManager.setNoProxy() + is ProxyConfig.System -> ProxyManager.setSystemProxy() + is ProxyConfig.Http -> ProxyManager.setHttpProxy( + host = config.host, + port = config.port, + username = config.username, + password = config.password + ) + + is ProxyConfig.Socks -> ProxyManager.setSocksProxy( + host = config.host, + port = config.port, + username = config.username, + password = config.password + ) } GitHubClientProvider( diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt index d2e910c1..164eef17 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt @@ -56,11 +56,6 @@ class DesktopInstaller( } - override fun uninstall(packageName: String) { - // Desktop doesn't have a unified uninstall mechanism - Logger.d { "Uninstall not supported on desktop for: $packageName" } - } - override fun openApp(packageName: String): Boolean { // Desktop apps are launched differently per platform Logger.d { "Open app not supported on desktop for: $packageName" } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt index ceee0a8d..2fa394a0 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt @@ -28,7 +28,5 @@ interface Installer { fun getApkInfoExtractor(): InstallerInfoExtractor - fun uninstall(packageName: String) - fun openApp(packageName: String): Boolean } \ 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 ca8572a1..85d28bc1 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -350,15 +350,11 @@ ইনস্টল মুলতুবি - - আনইনস্টল + খুলুন - ডাউনগ্রেডের জন্য আনইনস্টল প্রয়োজন - সংস্করণ %1$s ইনস্টল করতে বর্তমান সংস্করণ (%2$s) প্রথমে আনইনস্টল করতে হবে। অ্যাপের ডেটা মুছে যাবে। - প্রথমে আনইনস্টল করুন + সংস্করণ %1$s ইনস্টল করা যাচ্ছে না কারণ একটি নতুন সংস্করণ (%2$s) ইতিমধ্যে ইনস্টল করা আছে। অনুগ্রহ করে প্রথমে বর্তমান সংস্করণটি ম্যানুয়ালি আনইনস্টল করুন। %1$s ইনস্টল করুন %1$s খুলতে ব্যর্থ - %1$s আনইনস্টল করতে ব্যর্থ সর্বশেষ @@ -386,5 +382,10 @@ আপনার ডিভাইসের প্রক্সি সেটিংস ব্যবহার করে পোর্ট ১–৬৫৫৩৫ এর মধ্যে হতে হবে সরাসরি সংযোগ, কোনো প্রক্সি নেই + প্রক্সি সেটিংস সংরক্ষণ করতে ব্যর্থ হয়েছে + প্রক্সি হোস্ট প্রয়োজন + অবৈধ প্রক্সি পোর্ট + পাসওয়ার্ড দেখান + পাসওয়ার্ড লুকান 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 1d2b8400..1e85c169 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -315,15 +315,11 @@ Inspeccionar con AppManager Verificar permisos, rastreadores y seguridad - - Desinstalar + Abrir - La degradación requiere desinstalar - Instalar la versión %1$s requiere desinstalar la versión actual (%2$s) primero. Los datos de la app se perderán. - Desinstalar primero + No se puede instalar la versión %1$s porque ya hay una versión más reciente (%2$s) instalada. Por favor, desinstala la versión actual manualmente primero. Instalar %1$s Error al abrir %1$s - Error al desinstalar %1$s Última @@ -346,10 +342,15 @@ Puerto Nombre de usuario (opcional) Contraseña (opcional) - Guardar Proxy + Guardar proxy Configuración de proxy guardada - Usa la configuración proxy del dispositivo + Usa la configuración de proxy del dispositivo El puerto debe ser 1–65535 Conexión directa, sin proxy + No se pudieron guardar los ajustes del proxy + Se requiere el host del proxy + Puerto de proxy no válido + Mostrar contraseña + Ocultar contraseña \ 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 55f7ee06..09330e25 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -315,15 +315,11 @@ Inspecter avec AppManager Vérifier les permissions, trackers et sécurité - - Désinstaller + Ouvrir - La rétrogradation nécessite la désinstallation - L\'installation de la version %1$s nécessite la désinstallation de la version actuelle (%2$s). Les données de l\'application seront perdues. - Désinstaller d\'abord + Impossible d\'installer la version %1$s car une version plus récente (%2$s) est déjà installée. Veuillez d\'abord désinstaller manuellement la version actuelle. Installer %1$s Impossible d\'ouvrir %1$s - Impossible de désinstaller %1$s Dernière @@ -351,5 +347,10 @@ Utilise les paramètres proxy de l\'appareil Le port doit être entre 1 et 65535 Connexion directe, pas de proxy + Échec de l'enregistrement des paramètres du proxy + L’hôte du proxy est requis + Port proxy invalide + Afficher le mot de passe + Masquer le mot de passe \ 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 0f6d8cca..d45230c5 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -350,15 +350,11 @@ इंस्टॉल लंबित - - अनइंस्टॉल + खोलें - डाउनग्रेड के लिए अनइंस्टॉल आवश्यक - संस्करण %1$s इंस्टॉल करने के लिए पहले वर्तमान संस्करण (%2$s) को अनइंस्टॉल करना होगा। ऐप डेटा खो जाएगा। - पहले अनइंस्टॉल करें + संस्करण %1$s इंस्टॉल नहीं किया जा सकता क्योंकि एक नया संस्करण (%2$s) पहले से इंस्टॉल है। कृपया पहले वर्तमान संस्करण को मैन्युअल रूप से अनइंस्टॉल करें। %1$s इंस्टॉल करें %1$s खोलने में विफल - %1$s अनइंस्टॉल करने में विफल नवीनतम @@ -386,4 +382,10 @@ आपके डिवाइस की प्रॉक्सी सेटिंग का उपयोग करता है पोर्ट 1–65535 के बीच होना चाहिए सीधा कनेक्शन, कोई प्रॉक्सी नहीं + प्रॉक्सी सेटिंग्स सहेजने में विफल + प्रॉक्सी होस्ट आवश्यक है + अमान्य प्रॉक्सी पोर्ट + पासवर्ड दिखाएँ + पासवर्ड छुपाएँ + 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 4ce2b8bf..88381c51 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -346,15 +346,11 @@ Installazione in sospeso - - Disinstalla + Apri - Il downgrade richiede la disinstallazione - L\'installazione della versione %1$s richiede la disinstallazione della versione corrente (%2$s). I dati dell\'app verranno persi. - Disinstalla prima + Impossibile installare la versione %1$s perché una versione più recente (%2$s) è già installata. Disinstalla manualmente la versione corrente prima di procedere. Installa %1$s Impossibile aprire %1$s - Impossibile disinstallare %1$s Ultima @@ -387,5 +383,10 @@ Usa le impostazioni proxy del dispositivo La porta deve essere 1–65535 Connessione diretta, nessun proxy + Impossibile salvare le impostazioni del proxy + L'host del proxy è obbligatorio + Porta proxy non valida + Mostra password + Nascondi password \ 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 7a0acb4c..2d14564e 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -315,15 +315,11 @@ AppManagerで検査 権限、トラッカー、セキュリティを確認 - - アンインストール + 開く - ダウングレードにはアンインストールが必要 - バージョン%1$sのインストールには、現在のバージョン(%2$s)のアンインストールが必要です。アプリデータは失われます。 - 先にアンインストール + より新しいバージョン(%2$s)がすでにインストールされているため、バージョン%1$sをインストールできません。まず現在のバージョンを手動でアンインストールしてください。 %1$sをインストール %1$sを開けませんでした - %1$sのアンインストールに失敗しました 最新 @@ -351,5 +347,10 @@ デバイスのプロキシ設定を使用します ポートは1〜65535の範囲で指定してください 直接接続、プロキシなし + プロキシ設定の保存に失敗しました + プロキシホストは必須です + 無効なプロキシポート + パスワードを表示 + パスワードを非表示 \ 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 294cbb28..ee6e7872 100644 --- a/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml +++ b/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml @@ -348,15 +348,11 @@ 설치 대기 중 - - 제거 + 열기 - 다운그레이드를 위해 제거가 필요합니다 - 버전 %1$s을(를) 설치하려면 현재 버전(%2$s)을 먼저 제거해야 합니다. 앱 데이터가 삭제됩니다. - 먼저 제거 + 더 최신 버전(%2$s)이 이미 설치되어 있어 버전 %1$s을(를) 설치할 수 없습니다. 현재 버전을 먼저 수동으로 제거해 주세요. %1$s 설치 %1$s 열기 실패 - %1$s 제거 실패 최신 @@ -384,5 +380,10 @@ 기기의 프록시 설정을 사용합니다 포트는 1–65535 사이여야 합니다 직접 연결, 프록시 없음 + 프록시 설정을 저장하지 못했습니다 + 프록시 호스트가 필요합니다 + 잘못된 프록시 포트 + 비밀번호 표시 + 비밀번호 숨기기 \ 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 1ce9edbc..d266bd52 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -313,15 +313,11 @@ Oczekuje na instalację - - Odinstaluj + Otwórz - Obniżenie wersji wymaga odinstalowania - Instalacja wersji %1$s wymaga odinstalowania bieżącej wersji (%2$s). Dane aplikacji zostaną utracone. - Najpierw odinstaluj + Nie można zainstalować wersji %1$s, ponieważ nowsza wersja (%2$s) jest już zainstalowana. Najpierw ręcznie odinstaluj bieżącą wersję. Zainstaluj %1$s Nie udało się otworzyć %1$s - Nie udało się odinstalować %1$s Najnowsza @@ -349,5 +345,10 @@ Używa ustawień proxy urządzenia Port musi być z zakresu 1–65535 Połączenie bezpośrednie, bez proxy + Nie udało się zapisać ustawień proxy + Host proxy jest wymagany + Nieprawidłowy port proxy + Pokaż hasło + Ukryj hasło \ 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 b88c9bcc..f74c0baa 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -315,15 +315,11 @@ Ожидает установки - - Удалить + Открыть - Для понижения версии требуется удаление - Для установки версии %1$s необходимо сначала удалить текущую версию (%2$s). Данные приложения будут потеряны. - Сначала удалить + Невозможно установить версию %1$s, так как более новая версия (%2$s) уже установлена. Пожалуйста, сначала удалите текущую версию вручную. Установить %1$s Не удалось открыть %1$s - Не удалось удалить %1$s Последняя @@ -351,5 +347,10 @@ Использует прокси-настройки устройства Порт должен быть 1–65535 Прямое подключение, без прокси + Не удалось сохранить настройки прокси + Требуется хост прокси + Недопустимый порт прокси + Показать пароль + Скрыть пароль \ 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 b7553bbd..655a4645 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -347,15 +347,11 @@ Kurulum bekleniyor - - Kaldır + - Sürüm düşürme kaldırma gerektirir - %1$s sürümünü yüklemek için önce mevcut sürümü (%2$s) kaldırmanız gerekir. Uygulama verileri kaybolacaktır. - Önce kaldır + Daha yeni bir sürüm (%2$s) zaten yüklü olduğu için %1$s sürümü yüklenemiyor. Lütfen önce mevcut sürümü manuel olarak kaldırın. %1$s yükle %1$s açılamadı - %1$s kaldırılamadı En son @@ -383,5 +379,10 @@ Cihazınızın proxy ayarlarını kullanır Port 1–65535 arası olmalı Doğrudan bağlantı, proxy yok + Proxy ayarları kaydedilemedi + Proxy ana bilgisayarı gerekli + Geçersiz proxy portu + Şifreyi göster + Şifreyi gizle 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 0904b5f2..75f9027b 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 @@ -316,15 +316,11 @@ 使用 AppManager 检查 检查权限、追踪器和安全性 - - 卸载 + 打开 - 降级需要先卸载 - 安装版本 %1$s 需要先卸载当前版本(%2$s)。应用数据将丢失。 - 先卸载 + 无法安装版本 %1$s,因为已安装了更新的版本(%2$s)。请先手动卸载当前版本。 安装 %1$s 无法打开 %1$s - 无法卸载 %1$s 最新 @@ -352,5 +348,10 @@ 使用设备的代理设置 端口必须为 1–65535 直连,不使用代理 + 无法保存代理设置 + 必须填写代理主机 + 无效的代理端口 + 显示密码 + 隐藏密码 \ 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 211db65d..71f968e3 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -136,9 +136,14 @@ Password (optional) Save Proxy Proxy settings saved - Uses your device\'s proxy settings + Uses your device's proxy settings Port must be 1–65535 Direct connection, no proxy + Failed to save proxy settings + Proxy host is required + Invalid proxy port + Show password + Hide password Logged out successfully, redirecting... @@ -188,15 +193,11 @@ Installing Pending install - - Uninstall + Open - Downgrade requires uninstall - Installing version %1$s requires uninstalling the current version (%2$s) first. Your app data will be lost. - Uninstall first + Cannot install version %1$s because a newer version (%2$s) is already installed. Please uninstall the current version manually first. Install %1$s Failed to open %1$s - Failed to uninstall %1$s Open in Obtainium diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt index 73327800..1e25b4ce 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt @@ -12,6 +12,5 @@ sealed interface AppsAction { data object OnCancelUpdateAll : AppsAction data object OnCheckAllForUpdates : AppsAction data object OnRefresh : AppsAction - data class OnUninstallApp(val app: InstalledApp) : AppsAction data class OnNavigateToRepo(val repoId: Long) : AppsAction } \ No newline at end of file diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt index 1c0ba136..46ca4691 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt @@ -27,7 +27,6 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Update -import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -302,7 +301,6 @@ fun AppsScreen( onOpenClick = { onAction(AppsAction.OnOpenApp(appItem.installedApp)) }, onUpdateClick = { onAction(AppsAction.OnUpdateApp(appItem.installedApp)) }, onCancelClick = { onAction(AppsAction.OnCancelUpdate(appItem.installedApp.packageName)) }, - onUninstallClick = { onAction(AppsAction.OnUninstallApp(appItem.installedApp)) }, onRepoClick = { onAction(AppsAction.OnNavigateToRepo(appItem.installedApp.repoId)) }, modifier = Modifier.liquefiable(liquidState) ) @@ -376,7 +374,6 @@ fun AppItemCard( onOpenClick: () -> Unit, onUpdateClick: () -> Unit, onCancelClick: () -> Unit, - onUninstallClick: () -> Unit, onRepoClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -556,23 +553,6 @@ fun AppItemCard( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { - // Uninstall icon button (shown when not pending and not actively updating) - if (!app.isPendingInstall && - appItem.updateState !is UpdateState.Downloading && - appItem.updateState !is UpdateState.Installing && - appItem.updateState !is UpdateState.CheckingUpdate - ) { - IconButton( - onClick = onUninstallClick - ) { - Icon( - imageVector = Icons.Outlined.DeleteOutline, - contentDescription = stringResource(Res.string.uninstall), - tint = MaterialTheme.colorScheme.error - ) - } - } - Button( onClick = onOpenClick, modifier = Modifier.weight(1f), diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt index 00c92a66..c3ceba56 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt @@ -188,10 +188,6 @@ class AppsViewModel( refresh() } - is AppsAction.OnUninstallApp -> { - uninstallApp(action.app) - } - is AppsAction.OnNavigateToRepo -> { viewModelScope.launch { _events.send(AppsEvent.NavigateToRepo(action.repoId)) @@ -200,22 +196,6 @@ class AppsViewModel( } } - private fun uninstallApp(app: InstalledApp) { - viewModelScope.launch { - try { - installer.uninstall(app.packageName) - logger.debug("Requested uninstall for ${app.packageName}") - } catch (e: Exception) { - logger.error("Failed to request uninstall for ${app.packageName}: ${e.message}") - _events.send( - AppsEvent.ShowError( - getString(Res.string.failed_to_uninstall, app.appName) - ) - ) - } - } - } - private fun openApp(app: InstalledApp) { viewModelScope.launch { try { diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt index 10064610..cf32d2c1 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt @@ -28,7 +28,6 @@ sealed interface DetailsAction { data object OnToggleFavorite : DetailsAction data object CheckForUpdates : DetailsAction data object UpdateApp : DetailsAction - data object UninstallApp : DetailsAction data object OpenApp : DetailsAction data class OnMessage(val messageText: StringResource) : DetailsAction diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsEvent.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsEvent.kt index e940dfd4..c559f81a 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsEvent.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsEvent.kt @@ -4,9 +4,4 @@ sealed interface DetailsEvent { data class OnOpenRepositoryInApp(val repositoryId: Long) : DetailsEvent data class InstallTrackingFailed(val message: String) : DetailsEvent data class OnMessage(val message: String) : DetailsEvent - data class ShowDowngradeWarning( - val packageName: String, - val currentVersion: String, - val targetVersion: String - ) : DetailsEvent } \ No newline at end of file diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt index d2ef6fb1..1ad4155d 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt @@ -20,7 +20,6 @@ import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -32,16 +31,13 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow @@ -81,9 +77,6 @@ fun DetailsRoot( val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() - var downgradeWarning by remember { - mutableStateOf(null) - } ObserveAsEvents(viewModel.events) { event -> when (event) { @@ -100,49 +93,9 @@ fun DetailsRoot( snackbarHostState.showSnackbar(event.message) } } - - is DetailsEvent.ShowDowngradeWarning -> { - downgradeWarning = event - } } } - downgradeWarning?.let { warning -> - AlertDialog( - onDismissRequest = { downgradeWarning = null }, - title = { - Text(text = stringResource(Res.string.downgrade_requires_uninstall)) - }, - text = { - Text( - text = stringResource( - Res.string.downgrade_warning_message, - warning.targetVersion, - warning.currentVersion - ) - ) - }, - confirmButton = { - TextButton( - onClick = { - downgradeWarning = null - viewModel.onAction(DetailsAction.UninstallApp) - } - ) { - Text( - text = stringResource(Res.string.uninstall_first), - color = MaterialTheme.colorScheme.error - ) - } - }, - dismissButton = { - TextButton(onClick = { downgradeWarning = null }) { - Text(text = stringResource(Res.string.cancel)) - } - } - ) - } - DetailsScreen( state = state, snackbarHostState = snackbarHostState, diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index 7028d87b..7aafb74e 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -330,10 +330,12 @@ class DetailsViewModel( if (isDowngrade) { viewModelScope.launch { _events.send( - DetailsEvent.ShowDowngradeWarning( - packageName = installedApp.packageName, - currentVersion = installedApp.installedVersion, - targetVersion = release.tagName + DetailsEvent.OnMessage( + getString( + Res.string.downgrade_warning_message, + release.tagName, + installedApp.installedVersion + ) ) ) } @@ -470,24 +472,6 @@ class DetailsViewModel( } } - DetailsAction.UninstallApp -> { - val installedApp = _state.value.installedApp ?: return - logger.debug("Uninstalling app: ${installedApp.packageName}") - viewModelScope.launch { - try { - installer.uninstall(installedApp.packageName) - } catch (e: Exception) { - logger.error("Failed to request uninstall for ${installedApp.packageName}: ${e.message}") - _events.send( - DetailsEvent.OnMessage( - getString(Res.string.failed_to_uninstall, installedApp.packageName) - ) - ) - } - } - - } - DetailsAction.OpenApp -> { val installedApp = _state.value.installedApp ?: return val launched = installer.openApp(installedApp.packageName) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt index d24603da..53be6573 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt @@ -17,7 +17,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Update import androidx.compose.material3.CardDefaults @@ -77,92 +76,38 @@ fun SmartInstallButton( val isActiveDownload = state.isDownloading || state.downloadStage != DownloadStage.IDLE - // When same version is installed, show Open + Uninstall (Play Store style) + // When same version is installed, show Open button if (isSameVersionInstalled && !isActiveDownload) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + ElevatedCard( + modifier = modifier + .height(52.dp) + .clickable { onAction(DetailsAction.OpenApp) } + .liquefiable(liquidState), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.primary + ), + shape = CircleShape ) { - // Uninstall button - ElevatedCard( - onClick = { onAction(DetailsAction.UninstallApp) }, - modifier = Modifier - .weight(1f) - .height(52.dp) - .liquefiable(liquidState), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ), - shape = RoundedCornerShape( - topStart = 24.dp, - bottomStart = 24.dp, - topEnd = 6.dp, - bottomEnd = 6.dp - ) - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onErrorContainer - ) - Text( - text = stringResource(Res.string.uninstall), - color = MaterialTheme.colorScheme.onErrorContainer, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium - ) - } - } - } - - // Open button - ElevatedCard( - modifier = Modifier - .weight(1f) - .height(52.dp) - .clickable { onAction(DetailsAction.OpenApp) } - .liquefiable(liquidState), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.primary - ), - shape = RoundedCornerShape( - topStart = 6.dp, - bottomStart = 6.dp, - topEnd = 24.dp, - bottomEnd = 24.dp - ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - Text( - text = stringResource(Res.string.open_app), - color = MaterialTheme.colorScheme.onPrimary, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium - ) - } + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + Text( + text = stringResource(Res.string.open_app), + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) } } } diff --git a/feature/settings/CLAUDE.md b/feature/settings/CLAUDE.md deleted file mode 100644 index 991b9190..00000000 --- a/feature/settings/CLAUDE.md +++ /dev/null @@ -1,50 +0,0 @@ -# CLAUDE.md - Settings Feature - -## Purpose - -App settings and preferences. Organized into three sections: **Account** (login status, logout), **Appearance** (theme colors, dark mode, AMOLED, font), and **About** (version info, links). - -## Module Structure - -``` -feature/settings/ -├── domain/ -│ └── repository/SettingsRepository.kt # Login state, logout, version -├── data/ -│ ├── di/SharedModule.kt # Koin: settingsModule -│ └── repository/SettingsRepositoryImpl.kt -└── presentation/ - ├── SettingsViewModel.kt # Settings state, theme changes, logout - ├── SettingsState.kt # isLoggedIn, theme, darkMode, amoled, font, version - ├── SettingsAction.kt # Theme change, toggle dark/amoled, logout, etc. - ├── SettingsEvent.kt # One-off events - ├── SettingsRoot.kt # Main composable (scrollable settings list) - └── components/ - ├── LogoutDialog.kt # Confirmation dialog for logout - └── sections/ - ├── Account.kt # Login status + logout button - ├── Appearance.kt # Theme picker, dark mode, AMOLED, font - └── About.kt # Version, links, credits -``` - -## Key Interfaces - -```kotlin -interface SettingsRepository { - val isUserLoggedIn: Flow - suspend fun logout() - fun getVersionName(): String -} -``` - -## Navigation - -Route: `GithubStoreGraph.SettingsScreen` (data object, no params) - -## Implementation Notes - -- Theme settings persist via `ThemesRepository` from core/domain (DataStore-backed) -- `SettingsViewModel` also depends on `ThemesRepository` for reading/writing appearance preferences -- Logout clears the token via `TokenStore` and resets auth state -- App theme (colors, dark mode, AMOLED, font) is applied app-wide through `MainViewModel` which observes `ThemesRepository` -- Version name is read from build config at runtime