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 @@ - + bottomNavigationHeight = with(density) { coordinates.size.height.toDp() } + } ) } } 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/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt index ef2c3345..55e686ed 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt @@ -180,20 +180,6 @@ class AndroidInstaller( } } - override fun uninstall(packageName: String) { - Logger.d { "Requesting uninstall for: $packageName" } - val intent = Intent(Intent.ACTION_DELETE).apply { - data = "package:$packageName".toUri() - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - try { - context.startActivity(intent) - } catch (e: Exception) { - Logger.w { "Failed to start uninstall for $packageName: ${e.message}" } - } - - } - override fun openApp(packageName: String): Boolean { val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName) return if (launchIntent != null) { 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..40f778eb 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,9 @@ 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 kotlinx.coroutines.withTimeout import org.koin.dsl.module import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.data_source.impl.DefaultTokenStore @@ -21,13 +24,16 @@ 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 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 +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 +90,12 @@ val coreModule = module { ) } + single { + ProxyRepositoryImpl( + preferences = get() + ) + } + single { SyncInstalledAppsUseCase( packageMonitor = get(), @@ -96,6 +108,32 @@ val coreModule = module { val networkModule = module { single { + 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( 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 new file mode 100644 index 00000000..1531dca8 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt @@ -0,0 +1,132 @@ +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" -> { + 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) { + // Persist first so config survives crashes, then apply in-memory + 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) + } + } + } + } + applyToProxyManager(config) + } + + 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/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/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/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 bc09ee53..85d28bc1 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 @@ চেহারা সম্পর্কে + নেটওয়ার্ক থিমের রঙ @@ -349,15 +350,11 @@ ইনস্টল মুলতুবি - - আনইনস্টল + খুলুন - ডাউনগ্রেডের জন্য আনইনস্টল প্রয়োজন - সংস্করণ %1$s ইনস্টল করতে বর্তমান সংস্করণ (%2$s) প্রথমে আনইনস্টল করতে হবে। অ্যাপের ডেটা মুছে যাবে। - প্রথমে আনইনস্টল করুন + সংস্করণ %1$s ইনস্টল করা যাচ্ছে না কারণ একটি নতুন সংস্করণ (%2$s) ইতিমধ্যে ইনস্টল করা আছে। অনুগ্রহ করে প্রথমে বর্তমান সংস্করণটি ম্যানুয়ালি আনইনস্টল করুন। %1$s ইনস্টল করুন %1$s খুলতে ব্যর্থ - %1$s আনইনস্টল করতে ব্যর্থ সর্বশেষ @@ -370,4 +367,25 @@ %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..1e85c169 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 @@ -314,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 @@ -335,4 +332,25 @@ 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 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 bdd14de5..09330e25 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 @@ -314,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 @@ -335,4 +332,25 @@ 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 + É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 9a8d3b05..d45230c5 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 @@ उपस्थिति के बारे में + नेटवर्क थीम रंग @@ -349,15 +350,11 @@ इंस्टॉल लंबित - - अनइंस्टॉल + खोलें - डाउनग्रेड के लिए अनइंस्टॉल आवश्यक - संस्करण %1$s इंस्टॉल करने के लिए पहले वर्तमान संस्करण (%2$s) को अनइंस्टॉल करना होगा। ऐप डेटा खो जाएगा। - पहले अनइंस्टॉल करें + संस्करण %1$s इंस्टॉल नहीं किया जा सकता क्योंकि एक नया संस्करण (%2$s) पहले से इंस्टॉल है। कृपया पहले वर्तमान संस्करण को मैन्युअल रूप से अनइंस्टॉल करें। %1$s इंस्टॉल करें %1$s खोलने में विफल - %1$s अनइंस्टॉल करने में विफल नवीनतम @@ -369,4 +366,26 @@ %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..88381c51 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 @@ -345,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 @@ -371,4 +368,25 @@ %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 + 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 9bfa17a0..2d14564e 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 ブラックテーマ @@ -314,15 +315,11 @@ AppManagerで検査 権限、トラッカー、セキュリティを確認 - - アンインストール + 開く - ダウングレードにはアンインストールが必要 - バージョン%1$sのインストールには、現在のバージョン(%2$s)のアンインストールが必要です。アプリデータは失われます。 - 先にアンインストール + より新しいバージョン(%2$s)がすでにインストールされているため、バージョン%1$sをインストールできません。まず現在のバージョンを手動でアンインストールしてください。 %1$sをインストール %1$sを開けませんでした - %1$sのアンインストールに失敗しました 最新 @@ -335,4 +332,25 @@ %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..ee6e7872 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 @@ 외관 정보 + 네트워크 테마 색상 @@ -347,15 +348,11 @@ 설치 대기 중 - - 제거 + 열기 - 다운그레이드를 위해 제거가 필요합니다 - 버전 %1$s을(를) 설치하려면 현재 버전(%2$s)을 먼저 제거해야 합니다. 앱 데이터가 삭제됩니다. - 먼저 제거 + 더 최신 버전(%2$s)이 이미 설치되어 있어 버전 %1$s을(를) 설치할 수 없습니다. 현재 버전을 먼저 수동으로 제거해 주세요. %1$s 설치 %1$s 열기 실패 - %1$s 제거 실패 최신 @@ -368,4 +365,25 @@ %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..d266bd52 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 @@ -312,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 @@ -333,4 +330,25 @@ %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 + 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 024ecb30..f74c0baa 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 чёрная тема @@ -314,15 +315,11 @@ Ожидает установки - - Удалить + Открыть - Для понижения версии требуется удаление - Для установки версии %1$s необходимо сначала удалить текущую версию (%2$s). Данные приложения будут потеряны. - Сначала удалить + Невозможно установить версию %1$s, так как более новая версия (%2$s) уже установлена. Пожалуйста, сначала удалите текущую версию вручную. Установить %1$s Не удалось открыть %1$s - Не удалось удалить %1$s Последняя @@ -335,4 +332,25 @@ %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..655a4645 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 @@ -346,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 @@ -367,4 +364,25 @@ %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 + 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 04b000ad..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 @@ -93,6 +93,7 @@ 外观 关于 + 网络 主题颜色 AMOLED 黑色主题 @@ -315,15 +316,11 @@ 使用 AppManager 检查 检查权限、追踪器和安全性 - - 卸载 + 打开 - 降级需要先卸载 - 安装版本 %1$s 需要先卸载当前版本(%2$s)。应用数据将丢失。 - 先卸载 + 无法安装版本 %1$s,因为已安装了更新的版本(%2$s)。请先手动卸载当前版本。 安装 %1$s 无法打开 %1$s - 无法卸载 %1$s 最新 @@ -336,4 +333,25 @@ %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 7f90bf06..71f968e3 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,27 @@ Logout + + Proxy Type + None + System + HTTP + SOCKS + Host + Port + Username (optional) + Password (optional) + Save Proxy + Proxy settings saved + 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... @@ -171,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/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/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/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..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 @@ -13,4 +13,11 @@ 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 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 e77a358c..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 @@ -3,4 +3,6 @@ package zed.rainxch.profile.presentation 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 e1e719ea..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,12 +35,14 @@ 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 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 +70,18 @@ fun ProfileRoot( snackbarState.showSnackbar(event.message) } } + + ProfileEvent.OnProxySaved -> { + coroutineScope.launch { + snackbarState.showSnackbar(getString(Res.string.proxy_saved)) + } + } + + is ProfileEvent.OnProxySaveError -> { + coroutineScope.launch { + snackbarState.showSnackbar(event.message) + } + } } } @@ -106,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) @@ -140,6 +159,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..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 @@ -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,24 @@ 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 = "", + val isProxyPasswordVisible: Boolean = false, +) + +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..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,14 +10,23 @@ 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( 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 +38,7 @@ class ProfileViewModel( loadCurrentTheme() collectIsUserLoggedIn() loadVersionName() + loadProxyConfig() hasLoadedInitialData = true } @@ -95,6 +105,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 +204,94 @@ 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 { + 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) + ) + ) + } + + } + } + } + + 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.OnProxyPasswordVisibilityToggle -> { + _state.update { it.copy(isProxyPasswordVisible = !it.isProxyPasswordVisible) } + } + + ProfileAction.OnProxySave -> { + val currentState = _state.value + val port = currentState.proxyPort.toIntOrNull() + ?.takeIf { it in 1..65535 } + ?: 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() } + + 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 { + 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) + ) + ) + } + } + } } } -} \ 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..f5a06b39 --- /dev/null +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt @@ -0,0 +1,276 @@ +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.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.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.FilterChip +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.* +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)) + } + ) + + // 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(), + 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 -> + 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) + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +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( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + shape = RoundedCornerShape(20.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Host + Port row + 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)) }, + placeholder = { Text("127.0.0.1") }, + 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)) }, + 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)) }, + label = { Text(stringResource(Res.string.proxy_username)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + 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 = 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 = if (state.isProxyPasswordVisible) { + stringResource(Res.string.proxy_hide_password) + } else { + stringResource(Res.string.proxy_show_password) + }, + + modifier = Modifier.size(20.dp) + ) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) + + // Save button + FilledTonalButton( + onClick = { onAction(ProfileAction.OnProxySave) }, + enabled = isFormValid, + modifier = Modifier.align(Alignment.End) + ) { + Icon( + imageVector = Icons.Default.Save, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(Res.string.proxy_save)) + } + } + } +} 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