android settings: split menu into telegram-like folder pages
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user