android: implement profile settings privacy sessions and 2fa ui
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user