diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index f63b290..82c1a36 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt index 11c300d..29b554b 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt @@ -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) { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt index d5a7aff..7cdff04 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt @@ -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) } + } +}