android: implement profile settings privacy sessions and 2fa ui

This commit is contained in:
Codex
2026-03-09 16:05:39 +03:00
parent 9ad8372d45
commit 5368515112
2 changed files with 352 additions and 9 deletions

View File

@@ -1,28 +1,53 @@
package ru.daemonlord.messenger.ui.profile
import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import ru.daemonlord.messenger.ui.account.AccountViewModel
import java.io.ByteArrayOutputStream
@Composable
fun ProfileRoute(
onBackToChats: () -> Unit,
onOpenSettings: () -> Unit,
viewModel: AccountViewModel = hiltViewModel(),
) {
ProfileScreen(
onBackToChats = onBackToChats,
onOpenSettings = onOpenSettings,
viewModel = viewModel,
)
}
@@ -30,7 +55,33 @@ fun ProfileRoute(
fun ProfileScreen(
onBackToChats: () -> Unit,
onOpenSettings: () -> Unit,
viewModel: AccountViewModel,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val profile = state.profile
var name by remember(profile?.name) { mutableStateOf(profile?.name.orEmpty()) }
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()) }
LaunchedEffect(Unit) {
viewModel.refresh()
}
val context = androidx.compose.ui.platform.LocalContext.current
val pickAvatarLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
) { uri ->
val bytes = uri?.toSquareJpeg(context) ?: return@rememberLauncherForActivityResult
viewModel.uploadAvatar(
fileName = "avatar.jpg",
mimeType = "image/jpeg",
bytes = bytes,
) { uploadedUrl ->
avatarUrl = uploadedUrl
}
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -42,11 +93,76 @@ fun ProfileScreen(
text = "Profile",
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = "Profile editing screen placeholder. Telegram-like account editor will be implemented in a dedicated step.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
if (!avatarUrl.isBlank()) {
AsyncImage(
model = avatarUrl,
contentDescription = "Avatar",
modifier = Modifier.fillMaxWidth(),
)
}
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(),
)
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.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(),
@@ -62,3 +178,27 @@ fun ProfileScreen(
}
}
private fun Uri.toSquareJpeg(context: Context): ByteArray? {
val bitmap = runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val src = ImageDecoder.createSource(context.contentResolver, this)
ImageDecoder.decodeBitmap(src)
} else {
@Suppress("DEPRECATION")
MediaStore.Images.Media.getBitmap(context.contentResolver, this)
}
}.getOrNull() ?: return null
val square = bitmap.centerCropSquare()
val output = ByteArrayOutputStream()
val compressed = square.compress(Bitmap.CompressFormat.JPEG, 92, output)
if (!compressed) return null
return output.toByteArray()
}
private fun Bitmap.centerCropSquare(): Bitmap {
val side = minOf(width, height)
val left = (width - side) / 2
val top = (height - side) / 2
return Bitmap.createBitmap(this, left, top, side, side)
}

View File

@@ -3,31 +3,46 @@ package ru.daemonlord.messenger.ui.settings
import androidx.compose.foundation.layout.Arrangement
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.daemonlord.messenger.ui.account.AccountViewModel
@Composable
fun SettingsRoute(
onBackToChats: () -> Unit,
onOpenProfile: () -> Unit,
onLogout: () -> Unit,
viewModel: AccountViewModel = hiltViewModel(),
) {
SettingsScreen(
onBackToChats = onBackToChats,
onOpenProfile = onOpenProfile,
onLogout = onLogout,
viewModel = viewModel,
)
}
@@ -36,11 +51,29 @@ fun SettingsScreen(
onBackToChats: () -> Unit,
onOpenProfile: () -> Unit,
onLogout: () -> Unit,
viewModel: AccountViewModel,
) {
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("") }
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
viewModel.refresh()
viewModel.refreshRecoveryStatus()
}
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
@@ -48,11 +81,182 @@ fun SettingsScreen(
text = "Settings",
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = "Core account actions are here. More Telegram-like settings will be added in next iterations.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
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)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = viewModel::setupTwoFactor, enabled = !state.isSaving) {
Text("Setup")
}
OutlinedButton(onClick = viewModel::refreshRecoveryStatus, enabled = !state.isSaving) {
Text("Refresh status")
}
}
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()) {
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(),
@@ -76,4 +280,3 @@ fun SettingsScreen(
}
}
}