android: redesign settings and profile screens to telegram-like layout
Some checks failed
Android CI / android (push) Failing after 4m55s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 23:37:48 +03:00
parent 15e80262e0
commit 19471ac736
3 changed files with 530 additions and 345 deletions

View File

@@ -719,3 +719,15 @@
- Removed fallback read-pointer advancement in `ChatViewModel.acknowledgeLatestMessages(...)` that previously moved `lastReadMessageId` by latest loaded message id.
- Read pointer is now advanced only via `onVisibleIncomingMessageId(...)` from visible incoming rows in `ChatScreen`.
- This prevents read acknowledgements from overshooting beyond what user actually saw during refresh/recompose scenarios.
### Step 109 - Telegram-like Settings/Profile visual refresh
- Redesigned `SettingsScreen` to Telegram-inspired dark card layout:
- profile header card with avatar/name/email/username,
- grouped settings rows with material icons,
- appearance controls (Light/Dark/System),
- quick security/help sections and preserved logout/back actions.
- Redesigned `ProfileScreen` to Telegram-inspired structure:
- gradient hero header with centered avatar, status, and action buttons,
- primary profile info card,
- tab-like section (`Posts/Archived/Gifts`) with placeholder content,
- inline edit card (name/username/bio/avatar URL) with existing save/upload behavior preserved.

View File

@@ -8,16 +8,17 @@ import android.os.Build
import android.provider.MediaStore
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
@@ -25,34 +26,49 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
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.filled.AddAPhoto
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
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 kotlinx.coroutines.flow.collectLatest
import coil.compose.AsyncImage
import kotlinx.coroutines.flow.collectLatest
import ru.daemonlord.messenger.ui.account.AccountViewModel
import java.io.ByteArrayOutputStream
private val ProfileBg = Color(0xFF0D0F12)
private val ProfileCard = Color(0xFF171A1F)
private val ProfileMuted = Color(0xFFA2A7B3)
@Composable
fun ProfileRoute(
onBackToChats: () -> Unit,
@@ -69,7 +85,6 @@ fun ProfileRoute(
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun ProfileScreen(
onBackToChats: () -> Unit,
onOpenSettings: () -> Unit,
@@ -82,6 +97,8 @@ fun ProfileScreen(
var username by remember(profile?.username) { mutableStateOf(profile?.username.orEmpty()) }
var bio by remember(profile?.bio) { mutableStateOf(profile?.bio.orEmpty()) }
var avatarUrl by remember(profile?.avatarUrl) { mutableStateOf(profile?.avatarUrl.orEmpty()) }
var selectedTab by remember { mutableIntStateOf(0) }
var editMode by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
@@ -101,7 +118,7 @@ fun ProfileScreen(
}
}
val context = androidx.compose.ui.platform.LocalContext.current
val context = LocalContext.current
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
val pickAvatarLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
@@ -119,127 +136,296 @@ fun ProfileScreen(
Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.navigationBarsPadding(),
.background(ProfileBg)
.windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 720.dp) else Modifier)
.padding(bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TopAppBar(
title = { Text("Profile") },
)
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
.padding(bottom = 96.dp),
) {
if (!avatarUrl.isBlank()) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = avatarUrl,
contentDescription = "Avatar",
modifier = Modifier
.size(180.dp)
.aspectRatio(1f)
.clip(CircleShape)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
shape = CircleShape,
modifier = Modifier
.fillMaxWidth()
.height(305.dp)
.background(
Brush.verticalGradient(
colors = listOf(Color(0xFF9F4649), Color(0xFFB85C61), Color(0xFF9D4A4C)),
),
)
),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Spacer(modifier = Modifier.height(6.dp))
if (avatarUrl.isNotBlank()) {
AsyncImage(
model = avatarUrl,
contentDescription = "Avatar",
modifier = Modifier
.size(108.dp)
.clip(CircleShape)
.border(1.dp, Color(0x88FFFFFF), CircleShape),
)
} else {
Box(
modifier = Modifier
.size(108.dp)
.clip(CircleShape)
.background(Color(0x55FFFFFF)),
contentAlignment = Alignment.Center,
) {
Text(
text = name.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
)
}
}
Text(
text = if (name.isBlank()) "User" else name,
style = MaterialTheme.typography.headlineSmall,
color = Color.White,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text("online", color = Color(0xE6FFFFFF), style = MaterialTheme.typography.bodyLarge)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
HeroActionButton(
label = "Choose photo",
icon = Icons.Filled.AddAPhoto,
modifier = Modifier.weight(1f),
) {
pickAvatarLauncher.launch("image/*")
}
HeroActionButton(
label = if (editMode) "Editing" else "Edit",
icon = Icons.Filled.Edit,
modifier = Modifier.weight(1f),
) {
editMode = !editMode
}
HeroActionButton(
label = "Settings",
icon = Icons.Filled.Settings,
modifier = Modifier.weight(1f),
) {
onOpenSettings()
}
}
}
}
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Surface(
color = ProfileCard,
shape = RoundedCornerShape(22.dp),
modifier = Modifier
.fillMaxWidth()
.padding(top = (-22).dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
ProfileInfoRow("Email", profile?.email.orEmpty())
ProfileInfoRow("Bio", bio.ifBlank { "Not set" })
ProfileInfoRow("Username", if (username.isBlank()) "Not set" else "@$username")
ProfileInfoRow("Name", name.ifBlank { "Not set" })
}
}
Surface(
color = ProfileCard,
shape = RoundedCornerShape(18.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ProfileTab("Posts", selectedTab == 0, modifier = Modifier.weight(1f)) { selectedTab = 0 }
ProfileTab("Archived", selectedTab == 1, modifier = Modifier.weight(1f)) { selectedTab = 1 }
ProfileTab("Gifts", selectedTab == 2, modifier = Modifier.weight(1f)) { selectedTab = 2 }
}
}
Surface(
color = ProfileCard,
shape = RoundedCornerShape(22.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(18.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("No posts yet", color = Color.White, style = MaterialTheme.typography.titleLarge)
Text(
"Publish something in your profile.",
color = ProfileMuted,
style = MaterialTheme.typography.bodyMedium,
)
Button(onClick = {}, modifier = Modifier.padding(top = 6.dp)) {
Text("Add")
}
}
}
if (editMode) {
Surface(
color = ProfileCard,
shape = RoundedCornerShape(22.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Edit profile", color = Color.White, style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = bio,
onValueChange = { bio = it },
label = { Text("Bio") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = avatarUrl,
onValueChange = { avatarUrl = it },
label = { Text("Avatar URL") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
viewModel.updateProfile(
name = name,
username = username,
bio = bio.ifBlank { null },
avatarUrl = avatarUrl.ifBlank { null },
)
},
enabled = !state.isSaving && name.isNotBlank() && username.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Save profile")
}
if (state.isSaving) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
}
}
}
}
if (!state.message.isNullOrBlank()) {
Text(
text = state.message.orEmpty(),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
if (!state.errorMessage.isNullOrBlank()) {
Text(
text = state.errorMessage.orEmpty(),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
OutlinedButton(
onClick = onBackToChats,
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp),
) {
Text("Back to chats")
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(onClick = { pickAvatarLauncher.launch("image/*") }, enabled = !state.isSaving) {
Text("Upload avatar")
}
if (state.isSaving) {
CircularProgressIndicator(modifier = Modifier.padding(4.dp))
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = bio,
onValueChange = { bio = it },
label = { Text("Bio") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = avatarUrl,
onValueChange = { avatarUrl = it },
label = { Text("Avatar URL") },
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun HeroActionButton(
label: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Surface(
color = Color(0x40FFFFFF),
shape = RoundedCornerShape(16.dp),
modifier = modifier,
) {
Button(
onClick = {
viewModel.updateProfile(
name = name,
username = username,
bio = bio.ifBlank { null },
avatarUrl = avatarUrl.ifBlank { null },
)
},
enabled = !state.isSaving && name.isNotBlank() && username.isNotBlank(),
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
) {
Text("Save profile")
}
if (!state.message.isNullOrBlank()) {
Text(
text = state.message!!,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall,
)
}
if (!state.errorMessage.isNullOrBlank()) {
Text(
text = state.errorMessage!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
OutlinedButton(
onClick = onOpenSettings,
modifier = Modifier.fillMaxWidth(),
) {
Text("Open settings")
}
OutlinedButton(
onClick = onBackToChats,
modifier = Modifier.fillMaxWidth(),
) {
Text("Back to chats")
}
}
Icon(icon, contentDescription = null)
Text(label, modifier = Modifier.padding(start = 6.dp))
}
}
}
@Composable
private fun ProfileInfoRow(label: String, value: String) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = value, style = MaterialTheme.typography.titleLarge, color = Color.White)
Text(text = label, style = MaterialTheme.typography.bodyMedium, color = ProfileMuted)
}
}
@Composable
private fun ProfileTab(
label: String,
selected: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
if (selected) {
Button(onClick = onClick, modifier = modifier) { Text(label) }
} else {
OutlinedButton(onClick = onClick, modifier = modifier) { Text(label) }
}
}
private fun Uri.toSquareJpeg(context: Context): ByteArray? {
val bitmap = runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {

View File

@@ -1,45 +1,72 @@
package ru.daemonlord.messenger.ui.settings
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.background
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
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
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.HelpOutline
import androidx.compose.material.icons.filled.BatterySaver
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material.icons.filled.DataUsage
import androidx.compose.material.icons.filled.Devices
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.Shield
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
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.ui.account.AccountViewModel
private val SettingsBackground = Color(0xFF0D0F12)
private val SettingsCard = Color(0xFF1A1D23)
private val SettingsMuted = Color(0xFF9EA3B0)
@Composable
fun SettingsRoute(
onBackToChats: () -> Unit,
@@ -58,7 +85,6 @@ fun SettingsRoute(
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun SettingsScreen(
onBackToChats: () -> Unit,
onOpenProfile: () -> Unit,
@@ -68,20 +94,12 @@ fun SettingsScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val profile = state.profile
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("") }
var blockUserIdInput by remember { mutableStateOf("") }
var nightMode by remember { mutableIntStateOf(AppCompatDelegate.getDefaultNightMode()) }
val scrollState = rememberScrollState()
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
var nightMode by remember { mutableIntStateOf(AppCompatDelegate.getDefaultNightMode()) }
LaunchedEffect(Unit) {
viewModel.refresh()
viewModel.refreshRecoveryStatus()
onMainBarVisibilityChanged(true)
}
LaunchedEffect(scrollState) {
@@ -100,6 +118,7 @@ fun SettingsScreen(
Box(
modifier = Modifier
.fillMaxSize()
.background(SettingsBackground)
.windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter,
) {
@@ -107,246 +126,214 @@ fun SettingsScreen(
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(12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
TopAppBar(
title = { Text("Settings") },
)
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Appearance", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
nightMode = AppCompatDelegate.MODE_NIGHT_NO
},
) { Text("Light") }
OutlinedButton(
onClick = {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
nightMode = AppCompatDelegate.MODE_NIGHT_YES
},
) { Text("Dark") }
OutlinedButton(
onClick = {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
nightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
},
) { Text("System") }
}
Text(
text = when (nightMode) {
AppCompatDelegate.MODE_NIGHT_YES -> "Current theme: Dark"
AppCompatDelegate.MODE_NIGHT_NO -> "Current theme: Light"
else -> "Current theme: System"
},
style = MaterialTheme.typography.bodySmall,
)
if (state.isLoading) {
CircularProgressIndicator()
}
Text("Sessions", style = MaterialTheme.typography.titleMedium)
if (state.sessions.isEmpty()) {
Text("No active sessions", color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
state.sessions.forEach { session ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(session.userAgent ?: "Unknown device", style = MaterialTheme.typography.bodyMedium)
Text(session.ipAddress ?: "Unknown IP", style = MaterialTheme.typography.bodySmall)
}
OutlinedButton(
onClick = { viewModel.revokeSession(session.jti) },
enabled = !state.isSaving && session.current != true,
) {
Text("Revoke")
}
Text("Settings", style = MaterialTheme.typography.headlineSmall, color = Color.White)
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
}
}
}
OutlinedButton(
onClick = viewModel::revokeAllSessions,
enabled = !state.isSaving,
modifier = Modifier.fillMaxWidth(),
) {
Text("Revoke all sessions")
}
Text("Privacy", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = privacyPm,
onValueChange = { privacyPm = it },
label = { Text("PM privacy (everyone/contacts/nobody)") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = privacyLastSeen,
onValueChange = { privacyLastSeen = it },
label = { Text("Last seen privacy") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = privacyAvatar,
onValueChange = { privacyAvatar = it },
label = { Text("Avatar privacy") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = privacyGroupInvites,
onValueChange = { privacyGroupInvites = it },
label = { Text("Group invites privacy") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
viewModel.updatePrivacy(
privateMessages = privacyPm,
lastSeen = privacyLastSeen,
avatar = privacyAvatar,
groupInvites = privacyGroupInvites,
)
},
enabled = !state.isSaving,
modifier = Modifier.fillMaxWidth(),
) {
Text("Save privacy")
}
Text("Blocked users", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = blockUserIdInput,
onValueChange = { blockUserIdInput = it.filter { ch -> ch.isDigit() } },
label = { Text("User ID to block") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = {
blockUserIdInput.toLongOrNull()?.let { viewModel.blockUser(it) }
blockUserIdInput = ""
},
enabled = !state.isSaving && blockUserIdInput.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Block user by ID")
}
if (state.blockedUsers.isEmpty()) {
Text("Blocked list is empty", color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
state.blockedUsers.forEach { user ->
Surface(
color = SettingsCard,
shape = RoundedCornerShape(22.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(user.name)
if (!user.username.isNullOrBlank()) {
Text("@${user.username}", style = MaterialTheme.typography.bodySmall)
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(Color(0xFF6650A4)),
contentAlignment = Alignment.Center,
) {
Text(
text = profile?.name?.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.titleMedium,
color = Color.White,
fontWeight = FontWeight.SemiBold,
)
}
}
OutlinedButton(onClick = { viewModel.unblockUser(user.id) }) {
Text("Unblock")
Column(modifier = Modifier.weight(1f)) {
Text(
text = profile?.name ?: "Loading...",
style = MaterialTheme.typography.titleMedium,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = listOfNotNull(
profile?.email,
profile?.username?.takeIf { it.isNotBlank() }?.let { "@$it" },
).joinToString(""),
style = MaterialTheme.typography.bodySmall,
color = SettingsMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onOpenProfile) {
Icon(Icons.Filled.Person, contentDescription = "Profile", tint = Color.White)
}
}
}
}
Text("2FA", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = viewModel::setupTwoFactor, enabled = !state.isSaving) {
Text("Setup")
SettingsSectionCard {
SettingsRow(Icons.Filled.Person, "Account", "Name, username, bio") { onOpenProfile() }
SettingsRow(Icons.Filled.ChatBubbleOutline, "Chat settings", "Wallpaper, media, interactions") {}
SettingsRow(Icons.Filled.Lock, "Privacy", "Last seen, messages, avatar") {}
SettingsRow(Icons.Filled.Notifications, "Notifications", "Sounds and counters") {}
SettingsRow(Icons.Filled.DataUsage, "Data and storage", "Media auto-download") {}
SettingsRow(Icons.Filled.Devices, "Devices", "${state.sessions.size} active sessions") {}
SettingsRow(Icons.Filled.BatterySaver, "Power saving", "Animation and media limits") {}
SettingsRow(Icons.Filled.Language, "Language", "English") {}
}
OutlinedButton(onClick = viewModel::refreshRecoveryStatus, enabled = !state.isSaving) {
Text("Refresh status")
SettingsSectionCard {
Text("Appearance", style = MaterialTheme.typography.titleSmall, color = Color.White)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ThemeButton(
text = "Light",
selected = nightMode == AppCompatDelegate.MODE_NIGHT_NO,
) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
nightMode = AppCompatDelegate.MODE_NIGHT_NO
}
ThemeButton(
text = "Dark",
selected = nightMode == AppCompatDelegate.MODE_NIGHT_YES,
) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
nightMode = AppCompatDelegate.MODE_NIGHT_YES
}
ThemeButton(
text = "System",
selected = nightMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
nightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
}
}
}
if (!state.twoFactorSecret.isNullOrBlank()) {
Text("Secret: ${state.twoFactorSecret}")
if (!state.twoFactorOtpAuthUrl.isNullOrBlank()) {
Text("OTP URI: ${state.twoFactorOtpAuthUrl}", style = MaterialTheme.typography.bodySmall)
SettingsSectionCard {
SettingsRow(Icons.Filled.Shield, "Revoke all sessions", "Sign out from all other devices") {
viewModel.revokeAllSessions()
}
SettingsRow(Icons.Filled.Star, "Premium", "Placeholder section") {}
SettingsRow(Icons.AutoMirrored.Filled.HelpOutline, "Help", "FAQ and support") {}
}
}
Text("Recovery codes left: ${state.recoveryCodesRemaining ?: "-"}")
OutlinedTextField(
value = twoFactorCode,
onValueChange = { twoFactorCode = it },
label = { Text("2FA code") },
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { viewModel.enableTwoFactor(twoFactorCode) },
enabled = !state.isSaving && twoFactorCode.isNotBlank(),
) {
Text("Enable 2FA")
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 = { viewModel.disableTwoFactor(twoFactorCode) },
enabled = !state.isSaving && twoFactorCode.isNotBlank(),
onClick = onBackToChats,
modifier = Modifier.fillMaxWidth(),
) {
Text("Disable 2FA")
}
}
OutlinedTextField(
value = recoveryRegenerateCode,
onValueChange = { recoveryRegenerateCode = it },
label = { Text("Code to regenerate recovery codes") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = { viewModel.regenerateRecoveryCodes(recoveryRegenerateCode) },
enabled = !state.isSaving && recoveryRegenerateCode.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Regenerate recovery codes")
}
if (state.recoveryCodes.isNotEmpty()) {
Text("New recovery codes:", style = MaterialTheme.typography.bodyMedium)
state.recoveryCodes.forEach { code -> Text(code, style = MaterialTheme.typography.bodySmall) }
}
if (!state.message.isNullOrBlank()) {
Text(state.message!!, color = MaterialTheme.colorScheme.primary)
}
if (!state.errorMessage.isNullOrBlank()) {
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error)
}
Spacer(modifier = Modifier.padding(top = 4.dp))
OutlinedButton(
onClick = onOpenProfile,
modifier = Modifier.fillMaxWidth(),
) {
Text("Open profile")
}
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
) {
Text("Logout")
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onBackToChats) {
Text("Back to chats")
}
}
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
) {
Text("Logout")
}
}
}
}
@Composable
private fun SettingsSectionCard(content: @Composable ColumnScope.() -> Unit) {
Surface(
color = SettingsCard,
shape = RoundedCornerShape(22.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
content = content,
)
}
}
@Composable
private fun SettingsRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(Color(0x141F232B))
.padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(34.dp)
.clip(CircleShape)
.background(Color(0xFF2A2F38)),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = Color(0xFFB38BFF))
}
Column(modifier = Modifier.weight(1f)) {
Text(title, color = Color.White, style = MaterialTheme.typography.bodyLarge)
Text(subtitle, color = SettingsMuted, style = MaterialTheme.typography.bodySmall)
}
OutlinedButton(onClick = onClick) {
Text("Open")
}
}
}
@Composable
private fun ThemeButton(
text: String,
selected: Boolean,
onClick: () -> Unit,
) {
if (selected) {
Button(onClick = onClick) { Text(text) }
} else {
OutlinedButton(onClick = onClick) { Text(text) }
}
}