From 53303a5a5b5c07f89e571d9b5e86c8ca46d0d5f4 Mon Sep 17 00:00:00 2001 From: benya Date: Mon, 6 Apr 2026 02:24:50 +0300 Subject: [PATCH] feat: implement data and power settings sections --- .../messenger/ui/settings/SettingsScreen.kt | 288 +++++++++++++++++- .../app/src/main/res/values-ru/strings.xml | 20 ++ android/app/src/main/res/values/strings.xml | 20 ++ 3 files changed, 315 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt index 5724761..ea77e7a 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt @@ -1,5 +1,9 @@ package ru.daemonlord.messenger.ui.settings +import android.content.Intent +import android.os.Build +import android.os.PowerManager +import android.provider.Settings import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -22,14 +26,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.BatterySaver -import androidx.compose.material.icons.filled.Chat import androidx.compose.material.icons.filled.DeleteOutline import androidx.compose.material.icons.filled.Devices -import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notifications @@ -58,7 +61,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -66,6 +71,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -77,6 +83,10 @@ import ru.daemonlord.messenger.domain.settings.model.AppLanguage import ru.daemonlord.messenger.domain.settings.model.AppThemeMode import ru.daemonlord.messenger.ui.account.AccountUiState import ru.daemonlord.messenger.ui.account.AccountViewModel +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext private enum class SettingsFolder { Account, @@ -84,7 +94,6 @@ private enum class SettingsFolder { Privacy, Notifications, Data, - Folders, Devices, Power, Language, @@ -200,15 +209,41 @@ private fun SettingsHome( SettingsCard { SettingsRow(Icons.Filled.AccountCircle, stringResource(id = R.string.settings_folder_account), stringResource(id = R.string.settings_account_subtitle)) { onOpenFolder(SettingsFolder.Account) } - SettingsRow(Icons.Filled.Chat, stringResource(id = R.string.settings_folder_chat), stringResource(id = R.string.settings_chat_subtitle)) { onOpenFolder(SettingsFolder.Chat) } + SettingsRow(Icons.AutoMirrored.Filled.Chat, stringResource(id = R.string.settings_folder_chat), stringResource(id = R.string.settings_chat_subtitle)) { onOpenFolder(SettingsFolder.Chat) } SettingsRow(Icons.Filled.Lock, stringResource(id = R.string.settings_folder_privacy), stringResource(id = R.string.settings_privacy_subtitle)) { onOpenFolder(SettingsFolder.Privacy) } SettingsRow(Icons.Filled.Notifications, stringResource(id = R.string.settings_folder_notifications), stringResource(id = R.string.settings_notifications_subtitle)) { onOpenFolder(SettingsFolder.Notifications) } SettingsRow(Icons.Filled.Storage, stringResource(id = R.string.settings_folder_data), stringResource(id = R.string.settings_data_subtitle)) { onOpenFolder(SettingsFolder.Data) } - SettingsRow(Icons.Filled.Folder, stringResource(id = R.string.settings_folder_folders), stringResource(id = R.string.settings_folders_subtitle)) { onOpenFolder(SettingsFolder.Folders) } SettingsRow(Icons.Filled.Devices, stringResource(id = R.string.settings_folder_devices), stringResource(id = R.string.settings_devices_subtitle)) { onOpenFolder(SettingsFolder.Devices) } SettingsRow(Icons.Filled.BatterySaver, stringResource(id = R.string.settings_folder_power), stringResource(id = R.string.settings_power_subtitle)) { onOpenFolder(SettingsFolder.Power) } SettingsRow(Icons.Filled.Language, stringResource(id = R.string.settings_folder_language), languageLabel(state.appLanguage), divider = false) { onOpenFolder(SettingsFolder.Language) } } + + SettingsCard { + Text( + text = stringResource(id = R.string.settings_preferences_header), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelMedium, + ) + SettingsToggle( + icon = Icons.Filled.Notifications, + title = stringResource(id = R.string.settings_enable_notifications), + checked = state.notificationsEnabled, + onCheckedChange = {}, + enabled = false, + ) + SettingsToggle( + icon = Icons.Filled.Visibility, + title = stringResource(id = R.string.settings_show_preview), + checked = state.notificationsPreviewEnabled, + onCheckedChange = {}, + enabled = false, + ) + SettingsShortcut( + title = stringResource(id = R.string.settings_folder_language), + subtitle = languageLabel(state.appLanguage), + onClick = { onOpenFolder(SettingsFolder.Language) }, + ) + } } } @@ -247,10 +282,9 @@ private fun SettingsFolderView( SettingsFolder.Chat -> ChatFolder(state, viewModel) SettingsFolder.Privacy -> PrivacyFolder(state, viewModel) SettingsFolder.Notifications -> NotificationsFolder(state, viewModel) + SettingsFolder.Data -> DataFolder() SettingsFolder.Devices -> DevicesFolder(state, viewModel) - SettingsFolder.Data -> PlaceholderFolder(stringResource(id = R.string.settings_placeholder_data)) - SettingsFolder.Folders -> PlaceholderFolder(stringResource(id = R.string.settings_placeholder_folders)) - SettingsFolder.Power -> PlaceholderFolder(stringResource(id = R.string.settings_placeholder_power)) + SettingsFolder.Power -> PowerFolder() SettingsFolder.Language -> LanguageFolder(state, viewModel) } @@ -326,7 +360,6 @@ private fun folderTitle(folder: SettingsFolder): String = when (folder) { SettingsFolder.Privacy -> stringResource(id = R.string.settings_folder_privacy) SettingsFolder.Notifications -> stringResource(id = R.string.settings_folder_notifications) SettingsFolder.Data -> stringResource(id = R.string.settings_folder_data) - SettingsFolder.Folders -> stringResource(id = R.string.settings_folder_folders) SettingsFolder.Devices -> stringResource(id = R.string.settings_folder_devices) SettingsFolder.Power -> stringResource(id = R.string.settings_folder_power) SettingsFolder.Language -> stringResource(id = R.string.settings_folder_language) @@ -517,8 +550,231 @@ private fun DevicesFolder(state: AccountUiState, viewModel: AccountViewModel) { } @Composable -private fun PlaceholderFolder(text: String) { - SettingsCard { Text(text) } +private fun DataFolder() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var cacheStats by remember { mutableStateOf(null) } + var isClearing by remember { mutableStateOf(false) } + + suspend fun refreshStats() { + cacheStats = readSettingsCacheStats(context) + } + + LaunchedEffect(Unit) { + refreshStats() + } + + SettingsCard { + Text(stringResource(id = R.string.settings_data_usage_title), style = MaterialTheme.typography.titleSmall) + val stats = cacheStats + if (stats == null) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + SettingsMetricRow( + title = stringResource(id = R.string.settings_data_images_cache), + value = formatSettingsBytes(stats.coilImagesBytes), + ) + SettingsMetricRow( + title = stringResource(id = R.string.settings_data_media_cache), + value = formatSettingsBytes(stats.exoMediaBytes), + ) + SettingsMetricRow( + title = stringResource(id = R.string.settings_data_temp_captures), + value = formatSettingsBytes(stats.capturesBytes), + ) + SettingsMetricRow( + title = stringResource(id = R.string.settings_data_total_cache), + value = formatSettingsBytes(stats.totalBytes), + ) + } + } + + SettingsCard { + Text(stringResource(id = R.string.settings_data_actions_title), style = MaterialTheme.typography.titleSmall) + OutlinedButton( + onClick = { + scope.launch { + isClearing = true + withContext(Dispatchers.IO) { + clearSettingsCacheDirectories(context) + } + refreshStats() + isClearing = false + } + }, + enabled = !isClearing, + modifier = Modifier.fillMaxWidth(), + ) { + if (isClearing) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Filled.DeleteOutline, contentDescription = null) + } + Text( + text = stringResource(id = R.string.settings_data_clear_cache), + modifier = Modifier.padding(start = 6.dp), + ) + } + Text( + text = stringResource(id = R.string.settings_data_clear_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun PowerFolder() { + val context = LocalContext.current + val powerManager = remember { + context.getSystemService(PowerManager::class.java) + } + val powerSnapshot = produceState(initialValue = readPowerState(context, powerManager)) { + value = readPowerState(context, powerManager) + } + + SettingsCard { + Text(stringResource(id = R.string.settings_power_status_title), style = MaterialTheme.typography.titleSmall) + SettingsMetricRow( + title = stringResource(id = R.string.settings_power_saver_enabled), + value = if (powerSnapshot.value.isPowerSaveMode) { + context.getString(R.string.settings_status_enabled) + } else { + context.getString(R.string.settings_status_disabled) + }, + ) + SettingsMetricRow( + title = stringResource(id = R.string.settings_power_optimizations), + value = if (powerSnapshot.value.ignoresOptimizations) { + context.getString(R.string.settings_power_not_optimized) + } else { + context.getString(R.string.settings_power_optimized) + }, + ) + Text( + text = stringResource(id = R.string.settings_power_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + SettingsCard { + Text(stringResource(id = R.string.settings_power_actions_title), style = MaterialTheme.typography.titleSmall) + OutlinedButton( + onClick = { + context.startActivity(Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.BatterySaver, contentDescription = null) + Text( + text = stringResource(id = R.string.settings_power_open_battery_saver), + modifier = Modifier.padding(start = 6.dp), + ) + } + OutlinedButton( + onClick = { + val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS + } else { + Settings.ACTION_SETTINGS + } + context.startActivity(Intent(action).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Security, contentDescription = null) + Text( + text = stringResource(id = R.string.settings_power_open_optimization_settings), + modifier = Modifier.padding(start = 6.dp), + ) + } + } +} + +private data class SettingsCacheStats( + val coilImagesBytes: Long, + val exoMediaBytes: Long, + val capturesBytes: Long, +) { + val totalBytes: Long = coilImagesBytes + exoMediaBytes + capturesBytes +} + +private data class PowerSnapshot( + val isPowerSaveMode: Boolean, + val ignoresOptimizations: Boolean, +) + +@Composable +private fun SettingsMetricRow(title: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +private suspend fun readSettingsCacheStats(context: android.content.Context): SettingsCacheStats = withContext(Dispatchers.IO) { + SettingsCacheStats( + coilImagesBytes = File(context.cacheDir, "coil_images").directorySize(), + exoMediaBytes = File(context.cacheDir, "exo_media_cache").directorySize(), + capturesBytes = File(context.cacheDir, "captures").directorySize(), + ) +} + +private suspend fun clearSettingsCacheDirectories(context: android.content.Context) = withContext(Dispatchers.IO) { + listOf( + File(context.cacheDir, "coil_images"), + File(context.cacheDir, "exo_media_cache"), + File(context.cacheDir, "captures"), + ).forEach { file -> + if (file.exists()) { + file.deleteRecursively() + file.mkdirs() + } + } +} + +private fun File.directorySize(): Long { + if (!exists()) return 0L + if (isFile) return length() + return walkTopDown() + .filter { it.isFile } + .sumOf { it.length() } +} + +private fun formatSettingsBytes(value: Long): String { + if (value < 1024) return "$value B" + val kb = value / 1024.0 + if (kb < 1024) return String.format("%.1f KB", kb) + val mb = kb / 1024.0 + if (mb < 1024) return String.format("%.1f MB", mb) + val gb = mb / 1024.0 + return String.format("%.1f GB", gb) +} + +private fun readPowerState(context: android.content.Context, powerManager: PowerManager?): PowerSnapshot { + val isSaver = powerManager?.isPowerSaveMode == true + val ignores = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + powerManager?.isIgnoringBatteryOptimizations(context.packageName) == true + } else { + true + } + return PowerSnapshot( + isPowerSaveMode = isSaver, + ignoresOptimizations = ignores, + ) } @Composable @@ -600,7 +856,13 @@ private fun SettingsRow(icon: ImageVector, title: String, subtitle: String, divi } @Composable -private fun SettingsToggle(icon: ImageVector, title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { +private fun SettingsToggle( + icon: ImageVector, + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, +) { Row( modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(MaterialTheme.colorScheme.surfaceContainerHighest).padding(horizontal = 10.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, @@ -608,7 +870,7 @@ private fun SettingsToggle(icon: ImageVector, title: String, checked: Boolean, o ) { Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) Text(title, modifier = Modifier.weight(1f)) - Switch(checked = checked, onCheckedChange = onCheckedChange) + Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) } } diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 77380f1..865972b 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -260,6 +260,26 @@ Сортировка чатов по папкам Управление активными сессиями Экономия энергии при низком заряде + Быстрые настройки + Кэшированные медиа + Кэш изображений + Кэш видео и аудио + Временные записи + Всего кэша + Действия с данными + Очистить кэш медиа + Это удалит временные копии медиа и освободит место. Сами чаты и файлы на сервере не пострадают. + Состояние энергосбережения + Режим энергосбережения + Оптимизация батареи + Без ограничений + Оптимизируется системой + Используйте системные настройки, если уведомления или синхронизация в фоне становятся слишком агрессивно ограничены. + Действия + Открыть настройки энергосбережения + Открыть настройки оптимизации + Включено + Выключено Этот раздел будет расширен на следующем шаге. Управление папками чатов будет добавлено на следующей итерации. Настройки энергосбережения будут добавлены отдельным этапом. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 8b729d9..15516f1 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -260,6 +260,26 @@ Sort chats by folders Manage active sessions Save power on low battery + Quick preferences + Cached media + Image cache + Video and audio cache + Temporary captures + Total cached data + Data actions + Clear cached media + This clears temporary media copies and frees local space. Chats and server files stay intact. + Power status + Battery saver + Battery optimization + Not restricted + Optimized by the system + Use system settings if message delivery or background sync become aggressive under battery restrictions. + Power actions + Open battery saver settings + Open optimization settings + Enabled + Disabled This section will be expanded in the next step. Chat folders management will be added in next iteration. Power saving settings will be added in a separate step.