android settings: split menu into telegram-like folder pages
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-10 00:27:47 +03:00
parent 4bab551f0e
commit bb1f59d1f4
2 changed files with 377 additions and 482 deletions

View File

@@ -786,3 +786,14 @@
- restart realtime socket on new active account token, - restart realtime socket on new active account token,
- force refresh chats for both `archived=false` and `archived=true` right after switch. - 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. - 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.

View File

@@ -1,11 +1,13 @@
package ru.daemonlord.messenger.ui.settings package ru.daemonlord.messenger.ui.settings
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.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.DeleteOutline
import androidx.compose.material.icons.filled.Devices 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.Lock
import androidx.compose.material.icons.filled.Notifications 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.Security
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
@@ -49,22 +58,34 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.flow.collectLatest
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.ui.account.AccountUiState
import ru.daemonlord.messenger.ui.account.AccountViewModel import ru.daemonlord.messenger.ui.account.AccountViewModel
private enum class SettingsFolder(val title: String) {
Account("Аккаунт"),
Chat("Настройки чатов"),
Privacy("Конфиденциальность"),
Notifications("Уведомления"),
Data("Данные и память"),
Folders("Папки с чатами"),
Devices("Устройства"),
Power("Энергосбережение"),
Language("Язык"),
}
@Composable @Composable
fun SettingsRoute( fun SettingsRoute(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
@@ -95,84 +116,13 @@ fun SettingsScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val profile = state.profile val profile = state.profile
val scrollState = rememberScrollState() val isTablet = LocalConfiguration.current.screenWidthDp >= 840
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 var folder by rememberSaveable { mutableStateOf<SettingsFolder?>(null) }
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("") }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.refresh() viewModel.refresh()
onMainBarVisibilityChanged(true) 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( Box(
modifier = Modifier modifier = Modifier
@@ -184,455 +134,389 @@ fun SettingsScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 720.dp) else Modifier) .then(if (isTablet) 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),
) { ) {
Row( if (folder == null) {
modifier = Modifier.fillMaxWidth(), SettingsHome(
verticalAlignment = Alignment.CenterVertically, state = state,
horizontalArrangement = Arrangement.SpaceBetween, name = profile?.name.orEmpty(),
) { email = profile?.email.orEmpty(),
Text("Settings", style = MaterialTheme.typography.headlineSmall) username = profile?.username.orEmpty(),
if (state.isLoading) { avatarUrl = profile?.avatarUrl,
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) onOpenProfile = onOpenProfile,
} onOpenFolder = { folder = it },
}
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,
) )
SettingsToggleRow( } else {
icon = Icons.Filled.Visibility, SettingsFolderView(
title = "Show message preview", state = state,
checked = state.notificationsPreviewEnabled, folder = folder ?: SettingsFolder.Account,
onCheckedChange = viewModel::setNotificationPreviewEnabled, 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 @Composable
private fun SettingsSectionCard(content: @Composable ColumnScope.() -> Unit) { private fun SettingsHome(
Surface( state: AccountUiState,
color = MaterialTheme.colorScheme.surfaceContainer, name: String,
shape = RoundedCornerShape(22.dp), email: String,
modifier = Modifier.fillMaxWidth(), 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( ProfileHeader(name.ifBlank { "User" }, email, username, avatarUrl, onOpenProfile)
modifier = Modifier
.fillMaxWidth() SettingsCard {
.padding(horizontal = 14.dp, vertical = 12.dp), Text("АККАУНТЫ", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium)
verticalArrangement = Arrangement.spacedBy(8.dp), SettingsShortcut(
content = content, 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 @Composable
private fun SettingsActionRow( private fun ChatFolder(state: AccountUiState, viewModel: AccountViewModel) {
icon: androidx.compose.ui.graphics.vector.ImageVector, SettingsCard {
title: String, Text("Appearance", style = MaterialTheme.typography.titleSmall)
subtitle: String, Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
onClick: () -> Unit, ThemeButton("Light", state.appThemeMode == AppThemeMode.LIGHT) { viewModel.setThemeMode(AppThemeMode.LIGHT) }
) { ThemeButton("Dark", state.appThemeMode == AppThemeMode.DARK) { viewModel.setThemeMode(AppThemeMode.DARK) }
Row( ThemeButton("System", state.appThemeMode == AppThemeMode.SYSTEM) { viewModel.setThemeMode(AppThemeMode.SYSTEM) }
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")
} }
} }
} }
@Composable @Composable
private fun SettingsToggleRow( private fun NotificationsFolder(state: AccountUiState, viewModel: AccountViewModel) {
icon: androidx.compose.ui.graphics.vector.ImageVector, SettingsCard {
title: String, SettingsToggle(Icons.Filled.Notifications, "Enable notifications", state.notificationsEnabled, viewModel::setGlobalNotificationsEnabled)
checked: Boolean, SettingsToggle(Icons.Filled.Visibility, "Show message preview", state.notificationsPreviewEnabled, viewModel::setNotificationPreviewEnabled)
onCheckedChange: (Boolean) -> Unit, }
) { }
@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( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).clickable(onClick = onClick).padding(horizontal = 8.dp, vertical = 10.dp),
.fillMaxWidth() verticalAlignment = Alignment.CenterVertically,
.clip(RoundedCornerShape(14.dp)) horizontalArrangement = Arrangement.spacedBy(10.dp),
.background(MaterialTheme.colorScheme.surfaceContainerHighest) ) {
.padding(horizontal = 10.dp, vertical = 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, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) 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) Switch(checked = checked, onCheckedChange = onCheckedChange)
} }
} }
@Composable @Composable
private fun ThemeButton( private fun ThemeButton(text: String, selected: Boolean, onClick: () -> Unit) {
text: String, if (selected) Button(onClick = onClick) { Text(text) } else OutlinedButton(onClick = onClick) { Text(text) }
selected: Boolean,
onClick: () -> Unit,
) {
if (selected) {
Button(onClick = onClick) { Text(text) }
} else {
OutlinedButton(onClick = onClick) { Text(text) }
}
} }
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
private fun PrivacyDropdownField( private fun PrivacyDropdown(label: String, value: String, onChange: (String) -> Unit) {
label: String,
value: String,
onValueChange: (String) -> Unit,
) {
val options = listOf("everyone", "contacts", "nobody") val options = listOf("everyone", "contacts", "nobody")
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox( ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = !expanded }) {
expanded = expanded,
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField( OutlinedTextField(
value = value, value = value,
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = { Text(label) }, label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier modifier = Modifier.menuAnchor().fillMaxWidth(),
.menuAnchor()
.fillMaxWidth(),
singleLine = true, singleLine = true,
) )
ExposedDropdownMenu( DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { option -> options.forEach { option ->
DropdownMenuItem( DropdownMenuItem(text = { Text(option) }, onClick = { onChange(option); expanded = false })
text = { Text(option) },
onClick = {
onValueChange(option)
expanded = false
},
)
} }
} }
} }