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. - 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`. - 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. - 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 android.provider.MediaStore
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size 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.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
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.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.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow 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.Alignment
import androidx.compose.ui.Modifier 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.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 kotlinx.coroutines.flow.collectLatest
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.flow.collectLatest
import ru.daemonlord.messenger.ui.account.AccountViewModel import ru.daemonlord.messenger.ui.account.AccountViewModel
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
private val ProfileBg = Color(0xFF0D0F12)
private val ProfileCard = Color(0xFF171A1F)
private val ProfileMuted = Color(0xFFA2A7B3)
@Composable @Composable
fun ProfileRoute( fun ProfileRoute(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
@@ -69,7 +85,6 @@ fun ProfileRoute(
} }
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun ProfileScreen( fun ProfileScreen(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
@@ -82,6 +97,8 @@ fun ProfileScreen(
var username by remember(profile?.username) { mutableStateOf(profile?.username.orEmpty()) } var username by remember(profile?.username) { mutableStateOf(profile?.username.orEmpty()) }
var bio by remember(profile?.bio) { mutableStateOf(profile?.bio.orEmpty()) } var bio by remember(profile?.bio) { mutableStateOf(profile?.bio.orEmpty()) }
var avatarUrl by remember(profile?.avatarUrl) { mutableStateOf(profile?.avatarUrl.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() val scrollState = rememberScrollState()
LaunchedEffect(Unit) { 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 isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
val pickAvatarLauncher = rememberLauncherForActivityResult( val pickAvatarLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(), contract = ActivityResultContracts.GetContent(),
@@ -119,58 +136,177 @@ fun ProfileScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing) .background(ProfileBg)
.navigationBarsPadding(), .windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter, contentAlignment = Alignment.TopCenter,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 720.dp) else Modifier) .then(if (isTabletLayout) Modifier.widthIn(max = 720.dp) else Modifier)
.verticalScroll(scrollState)
.padding(bottom = 96.dp), .padding(bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
TopAppBar( Box(
title = { Text("Profile") },
)
Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(scrollState) .height(305.dp)
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), .background(
verticalArrangement = Arrangement.spacedBy(12.dp), Brush.verticalGradient(
colors = listOf(Color(0xFF9F4649), Color(0xFFB85C61), Color(0xFF9D4A4C)),
),
),
) { ) {
if (!avatarUrl.isBlank()) { Column(
Box( modifier = Modifier
modifier = Modifier.fillMaxWidth(), .fillMaxSize()
contentAlignment = Alignment.Center, .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( AsyncImage(
model = avatarUrl, model = avatarUrl,
contentDescription = "Avatar", contentDescription = "Avatar",
modifier = Modifier modifier = Modifier
.size(180.dp) .size(108.dp)
.aspectRatio(1f)
.clip(CircleShape) .clip(CircleShape)
.border( .border(1.dp, Color(0x88FFFFFF), CircleShape),
width = 1.dp, )
color = MaterialTheme.colorScheme.outlineVariant, } else {
shape = CircleShape, 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( Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Button(onClick = { pickAvatarLauncher.launch("image/*") }, enabled = !state.isSaving) { HeroActionButton(
Text("Upload avatar") label = "Choose photo",
icon = Icons.Filled.AddAPhoto,
modifier = Modifier.weight(1f),
) {
pickAvatarLauncher.launch("image/*")
} }
if (state.isSaving) { HeroActionButton(
CircularProgressIndicator(modifier = Modifier.padding(4.dp)) 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( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
@@ -209,29 +345,35 @@ fun ProfileScreen(
) { ) {
Text("Save profile") Text("Save profile")
} }
if (state.isSaving) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
}
}
}
}
if (!state.message.isNullOrBlank()) { if (!state.message.isNullOrBlank()) {
Text( Text(
text = state.message!!, text = state.message.orEmpty(),
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 4.dp),
) )
} }
if (!state.errorMessage.isNullOrBlank()) { if (!state.errorMessage.isNullOrBlank()) {
Text( Text(
text = state.errorMessage!!, text = state.errorMessage.orEmpty(),
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 4.dp),
) )
} }
OutlinedButton(
onClick = onOpenSettings,
modifier = Modifier.fillMaxWidth(),
) {
Text("Open settings")
}
OutlinedButton( OutlinedButton(
onClick = onBackToChats, onClick = onBackToChats,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp),
) { ) {
Text("Back to chats") Text("Back to chats")
} }
@@ -240,6 +382,50 @@ fun ProfileScreen(
} }
} }
@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 = onClick,
modifier = Modifier.fillMaxWidth(),
) {
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? { private fun Uri.toSquareJpeg(context: Context): ByteArray? {
val bitmap = runCatching { val bitmap = runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {

View File

@@ -1,45 +1,72 @@
package ru.daemonlord.messenger.ui.settings 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.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.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState 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.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.Button
import androidx.compose.material3.CircularProgressIndicator 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.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.appcompat.app.AppCompatDelegate
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.graphics.Brush
import androidx.compose.ui.graphics.Color
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.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 kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import ru.daemonlord.messenger.ui.account.AccountViewModel import ru.daemonlord.messenger.ui.account.AccountViewModel
private val SettingsBackground = Color(0xFF0D0F12)
private val SettingsCard = Color(0xFF1A1D23)
private val SettingsMuted = Color(0xFF9EA3B0)
@Composable @Composable
fun SettingsRoute( fun SettingsRoute(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
@@ -58,7 +85,6 @@ fun SettingsRoute(
} }
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun SettingsScreen( fun SettingsScreen(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
onOpenProfile: () -> Unit, onOpenProfile: () -> Unit,
@@ -68,20 +94,12 @@ fun SettingsScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val profile = state.profile 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 scrollState = rememberScrollState()
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
var nightMode by remember { mutableIntStateOf(AppCompatDelegate.getDefaultNightMode()) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.refresh() viewModel.refresh()
viewModel.refreshRecoveryStatus()
onMainBarVisibilityChanged(true) onMainBarVisibilityChanged(true)
} }
LaunchedEffect(scrollState) { LaunchedEffect(scrollState) {
@@ -100,6 +118,7 @@ fun SettingsScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(SettingsBackground)
.windowInsetsPadding(WindowInsets.safeDrawing), .windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter, contentAlignment = Alignment.TopCenter,
) { ) {
@@ -107,229 +126,142 @@ fun SettingsScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 720.dp) else Modifier) .then(if (isTabletLayout) Modifier.widthIn(max = 720.dp) else Modifier)
.verticalScroll(scrollState)
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(bottom = 96.dp), .padding(bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
TopAppBar( Row(
title = { Text("Settings") }, modifier = Modifier.fillMaxWidth(),
) verticalAlignment = Alignment.CenterVertically,
Column( horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Settings", style = MaterialTheme.typography.headlineSmall, color = Color.White)
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
}
}
Surface(
color = SettingsCard,
shape = RoundedCornerShape(22.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(scrollState) .padding(14.dp),
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), verticalAlignment = Alignment.CenterVertically,
verticalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Text("Appearance", style = MaterialTheme.typography.titleMedium) 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,
)
}
}
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)
}
}
}
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") {}
}
SettingsSectionCard {
Text("Appearance", style = MaterialTheme.typography.titleSmall, color = Color.White)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton( ThemeButton(
onClick = { text = "Light",
selected = nightMode == AppCompatDelegate.MODE_NIGHT_NO,
) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
nightMode = AppCompatDelegate.MODE_NIGHT_NO nightMode = AppCompatDelegate.MODE_NIGHT_NO
}, }
) { Text("Light") } ThemeButton(
OutlinedButton( text = "Dark",
onClick = { selected = nightMode == AppCompatDelegate.MODE_NIGHT_YES,
) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
nightMode = AppCompatDelegate.MODE_NIGHT_YES nightMode = AppCompatDelegate.MODE_NIGHT_YES
}, }
) { Text("Dark") } ThemeButton(
OutlinedButton( text = "System",
onClick = { selected = nightMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
nightMode = 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")
}
}
}
}
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 ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(user.name)
if (!user.username.isNullOrBlank()) {
Text("@${user.username}", style = MaterialTheme.typography.bodySmall)
}
}
OutlinedButton(onClick = { viewModel.unblockUser(user.id) }) {
Text("Unblock")
}
} }
} }
} }
Text("2FA", style = MaterialTheme.typography.titleMedium) SettingsSectionCard {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { SettingsRow(Icons.Filled.Shield, "Revoke all sessions", "Sign out from all other devices") {
OutlinedButton(onClick = viewModel::setupTwoFactor, enabled = !state.isSaving) { viewModel.revokeAllSessions()
Text("Setup")
} }
OutlinedButton(onClick = viewModel::refreshRecoveryStatus, enabled = !state.isSaving) { SettingsRow(Icons.Filled.Star, "Premium", "Placeholder section") {}
Text("Refresh status") SettingsRow(Icons.AutoMirrored.Filled.HelpOutline, "Help", "FAQ and support") {}
}
}
if (!state.twoFactorSecret.isNullOrBlank()) {
Text("Secret: ${state.twoFactorSecret}")
if (!state.twoFactorOtpAuthUrl.isNullOrBlank()) {
Text("OTP URI: ${state.twoFactorOtpAuthUrl}", style = MaterialTheme.typography.bodySmall)
}
}
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")
}
OutlinedButton(
onClick = { viewModel.disableTwoFactor(twoFactorCode) },
enabled = !state.isSaving && twoFactorCode.isNotBlank(),
) {
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()) { if (!state.message.isNullOrBlank()) {
Text(state.message!!, color = MaterialTheme.colorScheme.primary) Text(state.message.orEmpty(), color = MaterialTheme.colorScheme.primary)
} }
if (!state.errorMessage.isNullOrBlank()) { if (!state.errorMessage.isNullOrBlank()) {
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error) Text(state.errorMessage.orEmpty(), color = MaterialTheme.colorScheme.error)
} }
Spacer(modifier = Modifier.padding(top = 4.dp))
OutlinedButton( OutlinedButton(
onClick = onOpenProfile, onClick = onBackToChats,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text("Open profile") Text("Back to chats")
} }
Button( Button(
onClick = onLogout, onClick = onLogout,
@@ -337,16 +269,71 @@ fun SettingsScreen(
) { ) {
Text("Logout") Text("Logout")
} }
Row( }
}
}
@Composable
private fun SettingsSectionCard(content: @Composable ColumnScope.() -> Unit) {
Surface(
color = SettingsCard,
shape = RoundedCornerShape(22.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) { ) {
OutlinedButton(onClick = onBackToChats) { Column(
Text("Back to chats") 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) }
} }
} }