android: redesign settings and profile screens to telegram-like layout
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
OutlinedButton(onClick = onBackToChats) {
|
|
||||||
Text("Back to chats")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user