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,
- 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.

View File

@@ -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<SettingsFolder?>(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),
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 },
)
} 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,
SettingsFolderView(
state = state,
folder = folder ?: SettingsFolder.Account,
onBack = { folder = null },
onBackToChats = onBackToChats,
onSwitchAccount = onSwitchAccount,
onLogout = onLogout,
onOpenProfile = onOpenProfile,
viewModel = viewModel,
)
}
}
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,
@Composable
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),
) {
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 = { showAddAccountDialog = true }) {
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(horizontal = 10.dp, vertical = 10.dp),
.padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
horizontalArrangement = Arrangement.spacedBy(8.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)
Text(account.title.firstOrNull()?.uppercase() ?: "?", modifier = Modifier
.size(30.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = account.title.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.labelMedium,
)
}
}
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(top = 6.dp), maxLines = 1)
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,
)
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, style = MaterialTheme.typography.labelSmall)
Text("Active", color = MaterialTheme.colorScheme.primary)
} 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)
OutlinedButton(onClick = { viewModel.switchStoredAccount(account.userId) { if (it) onSwitchAccount() } }) { Text("Switch") }
}
OutlinedButton(onClick = { viewModel.removeStoredAccount(account.userId) }) { Icon(Icons.Filled.DeleteOutline, contentDescription = null) }
}
}
}
SettingsSectionCard {
SettingsCard {
OutlinedButton(onClick = onOpenProfile, modifier = Modifier.fillMaxWidth()) { Text("Open profile") }
Button(onClick = onLogout, modifier = Modifier.fillMaxWidth()) { Text("Logout") }
}
}
@Composable
private fun ChatFolder(state: AccountUiState, viewModel: AccountViewModel) {
SettingsCard {
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) }
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) }
}
}
}
SettingsSectionCard {
Text("Notifications", style = MaterialTheme.typography.titleSmall)
SettingsToggleRow(
icon = Icons.Filled.Notifications,
title = "Enable notifications",
checked = state.notificationsEnabled,
onCheckedChange = viewModel::setGlobalNotificationsEnabled,
)
SettingsToggleRow(
icon = Icons.Filled.Visibility,
title = "Show message preview",
checked = state.notificationsPreviewEnabled,
onCheckedChange = viewModel::setNotificationPreviewEnabled,
)
@Composable
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)
}
}
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(),
) {
@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))
}
}
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 {
@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 { session ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
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(session.userAgent ?: "Unknown device", style = MaterialTheme.typography.bodyMedium)
Text(session.ipAddress ?: "Unknown IP", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(s.userAgent ?: "Unknown device")
Text(s.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.revokeSession(s.jti) }, enabled = !state.isSaving && s.current != true) { Text("Revoke") }
}
}
}
OutlinedButton(
onClick = viewModel::revokeAllSessions,
enabled = !state.isSaving,
modifier = Modifier.fillMaxWidth(),
) {
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)) {
OutlinedButton(onClick = viewModel::setupTwoFactor, enabled = !state.isSaving) {
Text("Setup 2FA")
Button(onClick = { viewModel.enableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { Text("Enable") }
OutlinedButton(onClick = { viewModel.disableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { Text("Disable") }
}
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(),
) {
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")
}
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(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
content = content,
)
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 SettingsActionRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit,
) {
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)).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(
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),
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 6.dp),
) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
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, style = MaterialTheme.typography.bodyLarge)
Text(title)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
OutlinedButton(onClick = onClick) {
Text("Open")
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 SettingsToggleRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
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),
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 })
}
}
}