From bb1f59d1f4bdf549ca59195360d5dc003bb324e7 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 10 Mar 2026 00:27:47 +0300 Subject: [PATCH] android settings: split menu into telegram-like folder pages --- android/CHANGELOG.md | 11 + .../messenger/ui/settings/SettingsScreen.kt | 848 ++++++++---------- 2 files changed, 377 insertions(+), 482 deletions(-) diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 1e72c66..fb97034 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -786,3 +786,14 @@ - restart realtime socket on new active account token, - force refresh chats for both `archived=false` and `archived=true` right after switch. - Fixed navigation behavior on account switch to avoid noisy `popBackStack ... not found` and stale restored stack state. + +### Step 115 - Settings UI restructured into Telegram-like folders +- Reworked Settings into a menu-first screen with Telegram-style grouped rows. +- Added per-item folder pages (subscreens) for: + - Account + - Chat settings + - Privacy + - Notifications + - Devices + - Data/Chat folders/Power/Language placeholders +- Kept theme logic intact and moved appearance controls into `Chat settings` folder. 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 cfcd4c4..6c50297 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,11 +1,13 @@ package ru.daemonlord.messenger.ui.settings import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -19,18 +21,25 @@ import androidx.compose.foundation.shape.CircleShape 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.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.Email +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 -import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults @@ -49,22 +58,34 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment 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.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage -import kotlinx.coroutines.flow.collectLatest import ru.daemonlord.messenger.domain.settings.model.AppThemeMode +import ru.daemonlord.messenger.ui.account.AccountUiState import ru.daemonlord.messenger.ui.account.AccountViewModel +private enum class SettingsFolder(val title: String) { + Account("Аккаунт"), + Chat("Настройки чатов"), + Privacy("Конфиденциальность"), + Notifications("Уведомления"), + Data("Данные и память"), + Folders("Папки с чатами"), + Devices("Устройства"), + Power("Энергосбережение"), + Language("Язык"), +} + @Composable fun SettingsRoute( onBackToChats: () -> Unit, @@ -95,84 +116,13 @@ fun SettingsScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val profile = state.profile - val scrollState = rememberScrollState() - val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 - - var showAddAccountDialog by remember { mutableStateOf(false) } - var addEmail by remember { mutableStateOf("") } - var addPassword by remember { mutableStateOf("") } - - var privacyPm by remember(profile?.privacyPrivateMessages) { mutableStateOf(profile?.privacyPrivateMessages ?: "everyone") } - var privacyLastSeen by remember(profile?.privacyLastSeen) { mutableStateOf(profile?.privacyLastSeen ?: "everyone") } - var privacyAvatar by remember(profile?.privacyAvatar) { mutableStateOf(profile?.privacyAvatar ?: "everyone") } - var privacyGroupInvites by remember(profile?.privacyGroupInvites) { mutableStateOf(profile?.privacyGroupInvites ?: "everyone") } - var twoFactorCode by remember { mutableStateOf("") } - var recoveryRegenerateCode by remember { mutableStateOf("") } + val isTablet = LocalConfiguration.current.screenWidthDp >= 840 + var folder by rememberSaveable { mutableStateOf(null) } LaunchedEffect(Unit) { viewModel.refresh() onMainBarVisibilityChanged(true) } - LaunchedEffect(scrollState) { - var prevOffset = 0 - snapshotFlow { scrollState.value } - .collectLatest { offset -> - when { - offset == 0 -> onMainBarVisibilityChanged(true) - offset > prevOffset -> onMainBarVisibilityChanged(false) - offset < prevOffset -> onMainBarVisibilityChanged(true) - } - prevOffset = offset - } - } - - if (showAddAccountDialog) { - AlertDialog( - onDismissRequest = { if (!state.isAddingAccount) showAddAccountDialog = false }, - title = { Text("Add account") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = addEmail, - onValueChange = { addEmail = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text("Email") }, - ) - OutlinedTextField( - value = addPassword, - onValueChange = { addPassword = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text("Password") }, - ) - if (state.isAddingAccount) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) - } - } - }, - confirmButton = { - Button( - onClick = { - viewModel.addAccount(addEmail, addPassword) { success -> - if (success) { - showAddAccountDialog = false - addEmail = "" - addPassword = "" - onSwitchAccount() - } - } - }, - enabled = !state.isAddingAccount, - ) { Text("Sign in") } - }, - dismissButton = { - TextButton(onClick = { if (!state.isAddingAccount) showAddAccountDialog = false }) { - Text("Cancel") - } - }, - ) - } Box( modifier = Modifier @@ -184,455 +134,389 @@ fun SettingsScreen( Column( modifier = Modifier .fillMaxWidth() - .then(if (isTabletLayout) Modifier.widthIn(max = 720.dp) else Modifier) - .verticalScroll(scrollState) - .padding(horizontal = 16.dp, vertical = 12.dp) - .padding(bottom = 96.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier), ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text("Settings", style = MaterialTheme.typography.headlineSmall) - if (state.isLoading) { - CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) - } - } - - Surface( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(22.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (!profile?.avatarUrl.isNullOrBlank()) { - AsyncImage( - model = profile?.avatarUrl, - contentDescription = "Profile avatar", - modifier = Modifier - .size(58.dp) - .clip(CircleShape), - ) - } else { - Box( - modifier = Modifier - .size(58.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { - Text( - text = profile?.name?.firstOrNull()?.uppercase() ?: "?", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - } - } - Column(modifier = Modifier.weight(1f)) { - Text( - text = profile?.name ?: "Loading...", - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = listOfNotNull( - profile?.email, - profile?.username?.takeIf { it.isNotBlank() }?.let { "@$it" }, - ).joinToString(" • "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - IconButton(onClick = onOpenProfile) { - Icon(Icons.Filled.Person, contentDescription = "Profile") - } - } - } - - SettingsSectionCard { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text("Accounts", style = MaterialTheme.typography.titleSmall) - OutlinedButton(onClick = { showAddAccountDialog = true }) { - Icon(Icons.Filled.Add, contentDescription = null) - Text("Add account", modifier = Modifier.padding(start = 6.dp)) - } - } - state.storedAccounts.forEach { account -> - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) - .padding(horizontal = 10.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (!account.avatarUrl.isNullOrBlank()) { - AsyncImage( - model = account.avatarUrl, - contentDescription = null, - modifier = Modifier.size(34.dp).clip(CircleShape), - ) - } else { - Box( - modifier = Modifier - .size(34.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { - Text( - text = account.title.firstOrNull()?.uppercase() ?: "?", - style = MaterialTheme.typography.labelMedium, - ) - } - } - Column(modifier = Modifier.weight(1f)) { - Text( - text = account.title, - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = account.subtitle, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (account.isActive) { - Text("Active", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelSmall) - } else { - OutlinedButton( - onClick = { - viewModel.switchStoredAccount(account.userId) { switched -> - if (switched) onSwitchAccount() - } - }, - ) { - Text("Switch") - } - } - OutlinedButton(onClick = { viewModel.removeStoredAccount(account.userId) }) { - Icon(Icons.Filled.DeleteOutline, contentDescription = null) - } - } - } - } - - SettingsSectionCard { - Text("Appearance", style = MaterialTheme.typography.titleSmall) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ThemeButton( - text = "Light", - selected = state.appThemeMode == AppThemeMode.LIGHT, - ) { viewModel.setThemeMode(AppThemeMode.LIGHT) } - ThemeButton( - text = "Dark", - selected = state.appThemeMode == AppThemeMode.DARK, - ) { viewModel.setThemeMode(AppThemeMode.DARK) } - ThemeButton( - text = "System", - selected = state.appThemeMode == AppThemeMode.SYSTEM, - ) { viewModel.setThemeMode(AppThemeMode.SYSTEM) } - } - } - - SettingsSectionCard { - Text("Notifications", style = MaterialTheme.typography.titleSmall) - SettingsToggleRow( - icon = Icons.Filled.Notifications, - title = "Enable notifications", - checked = state.notificationsEnabled, - onCheckedChange = viewModel::setGlobalNotificationsEnabled, + if (folder == null) { + SettingsHome( + state = state, + name = profile?.name.orEmpty(), + email = profile?.email.orEmpty(), + username = profile?.username.orEmpty(), + avatarUrl = profile?.avatarUrl, + onOpenProfile = onOpenProfile, + onOpenFolder = { folder = it }, ) - SettingsToggleRow( - icon = Icons.Filled.Visibility, - title = "Show message preview", - checked = state.notificationsPreviewEnabled, - onCheckedChange = viewModel::setNotificationPreviewEnabled, + } else { + SettingsFolderView( + state = state, + folder = folder ?: SettingsFolder.Account, + onBack = { folder = null }, + onBackToChats = onBackToChats, + onSwitchAccount = onSwitchAccount, + onLogout = onLogout, + onOpenProfile = onOpenProfile, + viewModel = viewModel, ) } - - SettingsSectionCard { - Text("Privacy", style = MaterialTheme.typography.titleSmall) - PrivacyDropdownField( - label = "Private messages", - value = privacyPm, - onValueChange = { privacyPm = it }, - ) - PrivacyDropdownField( - label = "Last seen", - value = privacyLastSeen, - onValueChange = { privacyLastSeen = it }, - ) - PrivacyDropdownField( - label = "Avatar", - value = privacyAvatar, - onValueChange = { privacyAvatar = it }, - ) - PrivacyDropdownField( - label = "Group invites", - value = privacyGroupInvites, - onValueChange = { privacyGroupInvites = it }, - ) - Button( - onClick = { - viewModel.updatePrivacy( - privateMessages = privacyPm, - lastSeen = privacyLastSeen, - avatar = privacyAvatar, - groupInvites = privacyGroupInvites, - ) - }, - enabled = !state.isSaving, - modifier = Modifier.fillMaxWidth(), - ) { - Icon(Icons.Filled.Lock, contentDescription = null) - Text("Save privacy", modifier = Modifier.padding(start = 6.dp)) - } - } - - SettingsSectionCard { - Text("Blocked users", style = MaterialTheme.typography.titleSmall) - state.blockedUsers.forEach { blocked -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(blocked.name, style = MaterialTheme.typography.bodyMedium) - if (!blocked.username.isNullOrBlank()) { - Text("@${blocked.username}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - OutlinedButton(onClick = { viewModel.unblockUser(blocked.id) }) { - Text("Unblock") - } - } - } - } - - SettingsSectionCard { - Text("Sessions & Security", style = MaterialTheme.typography.titleSmall) - state.sessions.forEach { session -> - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon(Icons.Filled.Devices, contentDescription = null) - Column(modifier = Modifier.weight(1f)) { - Text(session.userAgent ?: "Unknown device", style = MaterialTheme.typography.bodyMedium) - Text(session.ipAddress ?: "Unknown IP", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - OutlinedButton( - onClick = { viewModel.revokeSession(session.jti) }, - enabled = !state.isSaving && session.current != true, - ) { - Text("Revoke") - } - } - } - OutlinedButton( - onClick = viewModel::revokeAllSessions, - enabled = !state.isSaving, - modifier = Modifier.fillMaxWidth(), - ) { - Icon(Icons.Filled.Security, contentDescription = null) - Text("Revoke all sessions", modifier = Modifier.padding(start = 6.dp)) - } - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = viewModel::setupTwoFactor, enabled = !state.isSaving) { - Text("Setup 2FA") - } - OutlinedButton(onClick = viewModel::refreshRecoveryStatus, enabled = !state.isSaving) { - Text("Recovery status") - } - } - OutlinedTextField( - value = twoFactorCode, - onValueChange = { twoFactorCode = it }, - label = { Text("2FA code") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { viewModel.enableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { - Text("Enable") - } - OutlinedButton(onClick = { viewModel.disableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { - Text("Disable") - } - } - OutlinedTextField( - value = recoveryRegenerateCode, - onValueChange = { recoveryRegenerateCode = it }, - label = { Text("Code for recovery regeneration") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - OutlinedButton( - onClick = { viewModel.regenerateRecoveryCodes(recoveryRegenerateCode) }, - enabled = recoveryRegenerateCode.isNotBlank() && !state.isSaving, - modifier = Modifier.fillMaxWidth(), - ) { - Text("Regenerate recovery codes") - } - if (state.recoveryCodesRemaining != null) { - Text("Recovery codes left: ${state.recoveryCodesRemaining}", style = MaterialTheme.typography.bodySmall) - } - state.recoveryCodes.forEach { code -> - Text(code, style = MaterialTheme.typography.bodySmall) - } - } - - if (!state.message.isNullOrBlank()) { - Text(state.message.orEmpty(), color = MaterialTheme.colorScheme.primary) - } - if (!state.errorMessage.isNullOrBlank()) { - Text(state.errorMessage.orEmpty(), color = MaterialTheme.colorScheme.error) - } - - Button(onClick = onLogout, modifier = Modifier.fillMaxWidth()) { - Text("Logout") - } } } } @Composable -private fun SettingsSectionCard(content: @Composable ColumnScope.() -> Unit) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(22.dp), - modifier = Modifier.fillMaxWidth(), +private fun SettingsHome( + state: AccountUiState, + name: String, + email: String, + username: String, + avatarUrl: String?, + onOpenProfile: () -> Unit, + onOpenFolder: (SettingsFolder) -> Unit, +) { + val scroll = rememberScrollState() + val active = state.storedAccounts.firstOrNull { it.isActive } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scroll) + .padding(horizontal = 16.dp, vertical = 12.dp) + .padding(bottom = 96.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - content = content, + ProfileHeader(name.ifBlank { "User" }, email, username, avatarUrl, onOpenProfile) + + SettingsCard { + Text("АККАУНТЫ", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium) + SettingsShortcut( + title = active?.title ?: "No active account", + subtitle = active?.subtitle ?: "Add account", + onClick = { onOpenFolder(SettingsFolder.Account) }, + ) + } + + SettingsCard { + SettingsRow(Icons.Filled.AccountCircle, "Аккаунт", "Номер, имя пользователя, о себе") { onOpenFolder(SettingsFolder.Account) } + SettingsRow(Icons.Filled.Chat, "Настройки чатов", "Обои, ночной режим, анимации") { onOpenFolder(SettingsFolder.Chat) } + SettingsRow(Icons.Filled.Lock, "Конфиденциальность", "Время захода, устройства, ключи доступа") { onOpenFolder(SettingsFolder.Privacy) } + SettingsRow(Icons.Filled.Notifications, "Уведомления", "Звуки, счётчик сообщений") { onOpenFolder(SettingsFolder.Notifications) } + SettingsRow(Icons.Filled.Storage, "Данные и память", "Настройки загрузки медиафайлов") { onOpenFolder(SettingsFolder.Data) } + SettingsRow(Icons.Filled.Folder, "Папки с чатами", "Сортировка чатов по папкам") { onOpenFolder(SettingsFolder.Folders) } + SettingsRow(Icons.Filled.Devices, "Устройства", "Управление активными сеансами") { onOpenFolder(SettingsFolder.Devices) } + SettingsRow(Icons.Filled.BatterySaver, "Энергосбережение", "Экономия энергии при низком заряде") { onOpenFolder(SettingsFolder.Power) } + SettingsRow(Icons.Filled.Language, "Язык", "Русский", divider = false) { onOpenFolder(SettingsFolder.Language) } + } + } +} + +@Composable +private fun SettingsFolderView( + state: AccountUiState, + folder: SettingsFolder, + onBack: () -> Unit, + onBackToChats: () -> Unit, + onSwitchAccount: () -> Unit, + onLogout: () -> Unit, + onOpenProfile: () -> Unit, + viewModel: AccountViewModel, +) { + val scroll = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scroll) + .padding(horizontal = 16.dp, vertical = 12.dp) + .padding(bottom = 96.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } + Text(folder.title, style = MaterialTheme.typography.headlineSmall) + Spacer(modifier = Modifier.weight(1f)) + if (state.isLoading || state.isSaving) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + } + + when (folder) { + SettingsFolder.Account -> AccountFolder(state, onSwitchAccount, onOpenProfile, onLogout, viewModel) + SettingsFolder.Chat -> ChatFolder(state, viewModel) + SettingsFolder.Privacy -> PrivacyFolder(state, viewModel) + SettingsFolder.Notifications -> NotificationsFolder(state, viewModel) + SettingsFolder.Devices -> DevicesFolder(state, viewModel) + SettingsFolder.Data -> PlaceholderFolder("Раздел будет расширен в следующем шаге.") + SettingsFolder.Folders -> PlaceholderFolder("Управление папками добавим следующей итерацией.") + SettingsFolder.Power -> PlaceholderFolder("Параметры энергосбережения добавим отдельным шагом.") + SettingsFolder.Language -> PlaceholderFolder("Смена языка будет вынесена в отдельный экран.") + } + + if (!state.message.isNullOrBlank()) Text(state.message.orEmpty(), color = MaterialTheme.colorScheme.primary) + if (!state.errorMessage.isNullOrBlank()) Text(state.errorMessage.orEmpty(), color = MaterialTheme.colorScheme.error) + + OutlinedButton(onClick = onBackToChats, modifier = Modifier.fillMaxWidth()) { Text("Back to chats") } + } +} + +@Composable +private fun AccountFolder( + state: AccountUiState, + onSwitchAccount: () -> Unit, + onOpenProfile: () -> Unit, + onLogout: () -> Unit, + viewModel: AccountViewModel, +) { + var showDialog by remember { mutableStateOf(false) } + var addEmail by remember { mutableStateOf("") } + var addPassword by remember { mutableStateOf("") } + + if (showDialog) { + AlertDialog( + onDismissRequest = { if (!state.isAddingAccount) showDialog = false }, + title = { Text("Add account") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(value = addEmail, onValueChange = { addEmail = it }, label = { Text("Email") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = addPassword, onValueChange = { addPassword = it }, label = { Text("Password") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + }, + confirmButton = { + Button(onClick = { + viewModel.addAccount(addEmail, addPassword) { ok -> + if (ok) { + showDialog = false + addEmail = "" + addPassword = "" + onSwitchAccount() + } + } + }, enabled = !state.isAddingAccount) { Text("Sign in") } + }, + dismissButton = { TextButton(onClick = { if (!state.isAddingAccount) showDialog = false }) { Text("Cancel") } }, ) } + + SettingsCard { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Accounts", style = MaterialTheme.typography.titleSmall) + OutlinedButton(onClick = { showDialog = true }) { + Icon(Icons.Filled.Add, contentDescription = null) + Text("Add account", modifier = Modifier.padding(start = 6.dp)) + } + } + + state.storedAccounts.forEach { account -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(account.title.firstOrNull()?.uppercase() ?: "?", modifier = Modifier + .size(30.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(top = 6.dp), maxLines = 1) + Column(modifier = Modifier.weight(1f)) { + Text(account.title) + Text(account.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (account.isActive) { + Text("Active", color = MaterialTheme.colorScheme.primary) + } else { + OutlinedButton(onClick = { viewModel.switchStoredAccount(account.userId) { if (it) onSwitchAccount() } }) { Text("Switch") } + } + OutlinedButton(onClick = { viewModel.removeStoredAccount(account.userId) }) { Icon(Icons.Filled.DeleteOutline, contentDescription = null) } + } + } + } + + SettingsCard { + OutlinedButton(onClick = onOpenProfile, modifier = Modifier.fillMaxWidth()) { Text("Open profile") } + Button(onClick = onLogout, modifier = Modifier.fillMaxWidth()) { Text("Logout") } + } } @Composable -private fun SettingsActionRow( - icon: androidx.compose.ui.graphics.vector.ImageVector, - title: String, - subtitle: String, - onClick: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) - .padding(horizontal = 10.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) - Column(modifier = Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.bodyLarge) - Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - OutlinedButton(onClick = onClick) { - Text("Open") +private fun ChatFolder(state: AccountUiState, viewModel: AccountViewModel) { + SettingsCard { + Text("Appearance", style = MaterialTheme.typography.titleSmall) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ThemeButton("Light", state.appThemeMode == AppThemeMode.LIGHT) { viewModel.setThemeMode(AppThemeMode.LIGHT) } + ThemeButton("Dark", state.appThemeMode == AppThemeMode.DARK) { viewModel.setThemeMode(AppThemeMode.DARK) } + ThemeButton("System", state.appThemeMode == AppThemeMode.SYSTEM) { viewModel.setThemeMode(AppThemeMode.SYSTEM) } } } } @Composable -private fun SettingsToggleRow( - icon: androidx.compose.ui.graphics.vector.ImageVector, - title: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { +private fun NotificationsFolder(state: AccountUiState, viewModel: AccountViewModel) { + SettingsCard { + SettingsToggle(Icons.Filled.Notifications, "Enable notifications", state.notificationsEnabled, viewModel::setGlobalNotificationsEnabled) + SettingsToggle(Icons.Filled.Visibility, "Show message preview", state.notificationsPreviewEnabled, viewModel::setNotificationPreviewEnabled) + } +} + +@Composable +private fun PrivacyFolder(state: AccountUiState, viewModel: AccountViewModel) { + var pm by remember(state.profile?.privacyPrivateMessages) { mutableStateOf(state.profile?.privacyPrivateMessages ?: "everyone") } + var lastSeen by remember(state.profile?.privacyLastSeen) { mutableStateOf(state.profile?.privacyLastSeen ?: "everyone") } + var avatar by remember(state.profile?.privacyAvatar) { mutableStateOf(state.profile?.privacyAvatar ?: "everyone") } + var invites by remember(state.profile?.privacyGroupInvites) { mutableStateOf(state.profile?.privacyGroupInvites ?: "everyone") } + + SettingsCard { + PrivacyDropdown("Private messages", pm) { pm = it } + PrivacyDropdown("Last seen", lastSeen) { lastSeen = it } + PrivacyDropdown("Avatar", avatar) { avatar = it } + PrivacyDropdown("Group invites", invites) { invites = it } + Button(onClick = { viewModel.updatePrivacy(pm, lastSeen, avatar, invites) }, enabled = !state.isSaving, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Filled.Lock, contentDescription = null) + Text("Save privacy", modifier = Modifier.padding(start = 6.dp)) + } + } +} + +@Composable +private fun DevicesFolder(state: AccountUiState, viewModel: AccountViewModel) { + var twoFactorCode by remember { mutableStateOf("") } + var recoveryCode by remember { mutableStateOf("") } + + SettingsCard { + Text("Sessions & Security", style = MaterialTheme.typography.titleSmall) + state.sessions.forEach { s -> + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon(Icons.Filled.Devices, contentDescription = null) + Column(modifier = Modifier.weight(1f)) { + Text(s.userAgent ?: "Unknown device") + Text(s.ipAddress ?: "Unknown IP", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + OutlinedButton(onClick = { viewModel.revokeSession(s.jti) }, enabled = !state.isSaving && s.current != true) { Text("Revoke") } + } + } + OutlinedButton(onClick = viewModel::revokeAllSessions, enabled = !state.isSaving, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Filled.Security, contentDescription = null) + Text("Revoke all sessions", modifier = Modifier.padding(start = 6.dp)) + } + } + + SettingsCard { + OutlinedTextField(value = twoFactorCode, onValueChange = { twoFactorCode = it }, label = { Text("2FA code") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { viewModel.enableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { Text("Enable") } + OutlinedButton(onClick = { viewModel.disableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { Text("Disable") } + } + OutlinedTextField(value = recoveryCode, onValueChange = { recoveryCode = it }, label = { Text("Code for recovery regeneration") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedButton(onClick = { viewModel.regenerateRecoveryCodes(recoveryCode) }, enabled = recoveryCode.isNotBlank() && !state.isSaving, modifier = Modifier.fillMaxWidth()) { + Text("Regenerate recovery codes") + } + } +} + +@Composable +private fun PlaceholderFolder(text: String) { + SettingsCard { Text(text) } +} + +@Composable +private fun ProfileHeader(name: String, email: String, username: String, avatarUrl: String?, onOpenProfile: () -> Unit) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (!avatarUrl.isNullOrBlank()) { + AsyncImage(model = avatarUrl, contentDescription = null, modifier = Modifier.size(84.dp).clip(CircleShape)) + } else { + Box(modifier = Modifier.size(84.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) { + Text(name.firstOrNull()?.uppercase() ?: "?") + } + } + Text(name, style = MaterialTheme.typography.headlineSmall) + Text(listOfNotNull(email.takeIf { it.isNotBlank() }, username.takeIf { it.isNotBlank() }?.let { "@$it" }).joinToString(" • "), color = MaterialTheme.colorScheme.onSurfaceVariant) + TextButton(onClick = onOpenProfile) { Text("Open profile") } + } +} + +@Composable +private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) { + Surface(color = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(22.dp), modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), content = content) + } +} + +@Composable +private fun SettingsShortcut(title: String, subtitle: String, onClick: () -> Unit) { Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) - .padding(horizontal = 10.dp, vertical = 10.dp), + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).clickable(onClick = onClick).padding(horizontal = 8.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(title.firstOrNull()?.uppercase() ?: "A", modifier = Modifier.size(30.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer).padding(top = 6.dp), maxLines = 1) + Column(modifier = Modifier.weight(1f)) { + Text(title) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + } +} + +@Composable +private fun SettingsRow(icon: ImageVector, title: String, subtitle: String, divider: Boolean = true, onClick: () -> Unit) { + Column(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 6.dp), + ) { + Box(modifier = Modifier.size(34.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text(title) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + } + if (divider) { + Spacer(modifier = Modifier.fillMaxWidth().padding(start = 50.dp).size(width = 1.dp, height = 0.5.dp).background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.7f))) + } + } +} + +@Composable +private fun SettingsToggle(icon: ImageVector, title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(MaterialTheme.colorScheme.surfaceContainerHighest).padding(horizontal = 10.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) - Text(title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Text(title, modifier = Modifier.weight(1f)) Switch(checked = checked, onCheckedChange = onCheckedChange) } } @Composable -private fun ThemeButton( - text: String, - selected: Boolean, - onClick: () -> Unit, -) { - if (selected) { - Button(onClick = onClick) { Text(text) } - } else { - OutlinedButton(onClick = onClick) { Text(text) } - } +private fun ThemeButton(text: String, selected: Boolean, onClick: () -> Unit) { + if (selected) Button(onClick = onClick) { Text(text) } else OutlinedButton(onClick = onClick) { Text(text) } } @Composable @OptIn(ExperimentalMaterial3Api::class) -private fun PrivacyDropdownField( - label: String, - value: String, - onValueChange: (String) -> Unit, -) { +private fun PrivacyDropdown(label: String, value: String, onChange: (String) -> Unit) { val options = listOf("everyone", "contacts", "nobody") var expanded by remember { mutableStateOf(false) } - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - ) { + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = !expanded }) { OutlinedTextField( value = value, onValueChange = {}, readOnly = true, label = { Text(label) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - modifier = Modifier - .menuAnchor() - .fillMaxWidth(), + modifier = Modifier.menuAnchor().fillMaxWidth(), singleLine = true, ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { options.forEach { option -> - DropdownMenuItem( - text = { Text(option) }, - onClick = { - onValueChange(option) - expanded = false - }, - ) + DropdownMenuItem(text = { Text(option) }, onClick = { onChange(option); expanded = false }) } } }