android: implement profile settings privacy sessions and 2fa ui
This commit is contained in:
@@ -1,28 +1,53 @@
|
|||||||
package ru.daemonlord.messenger.ui.profile
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
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.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
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
|
@Composable
|
||||||
fun ProfileRoute(
|
fun ProfileRoute(
|
||||||
onBackToChats: () -> Unit,
|
onBackToChats: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
viewModel: AccountViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
ProfileScreen(
|
ProfileScreen(
|
||||||
onBackToChats = onBackToChats,
|
onBackToChats = onBackToChats,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
viewModel = viewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +55,33 @@ fun ProfileRoute(
|
|||||||
fun ProfileScreen(
|
fun ProfileScreen(
|
||||||
onBackToChats: () -> Unit,
|
onBackToChats: () -> Unit,
|
||||||
onOpenSettings: () -> 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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -42,11 +93,76 @@ fun ProfileScreen(
|
|||||||
text = "Profile",
|
text = "Profile",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
)
|
)
|
||||||
Text(
|
if (!avatarUrl.isBlank()) {
|
||||||
text = "Profile editing screen placeholder. Telegram-like account editor will be implemented in a dedicated step.",
|
AsyncImage(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
model = avatarUrl,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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(
|
OutlinedButton(
|
||||||
onClick = onOpenSettings,
|
onClick = onOpenSettings,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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.Arrangement
|
||||||
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.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
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.windowInsetsPadding
|
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.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
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.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
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
|
@Composable
|
||||||
fun SettingsRoute(
|
fun SettingsRoute(
|
||||||
onBackToChats: () -> Unit,
|
onBackToChats: () -> Unit,
|
||||||
onOpenProfile: () -> Unit,
|
onOpenProfile: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
|
viewModel: AccountViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBackToChats = onBackToChats,
|
onBackToChats = onBackToChats,
|
||||||
onOpenProfile = onOpenProfile,
|
onOpenProfile = onOpenProfile,
|
||||||
onLogout = onLogout,
|
onLogout = onLogout,
|
||||||
|
viewModel = viewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,11 +51,29 @@ fun SettingsScreen(
|
|||||||
onBackToChats: () -> Unit,
|
onBackToChats: () -> Unit,
|
||||||
onOpenProfile: () -> Unit,
|
onOpenProfile: () -> Unit,
|
||||||
onLogout: () -> 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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
|
.verticalScroll(scrollState)
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
@@ -48,11 +81,182 @@ fun SettingsScreen(
|
|||||||
text = "Settings",
|
text = "Settings",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
)
|
)
|
||||||
Text(
|
if (state.isLoading) {
|
||||||
text = "Core account actions are here. More Telegram-like settings will be added in next iterations.",
|
CircularProgressIndicator()
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
}
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
|
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(
|
OutlinedButton(
|
||||||
onClick = onOpenProfile,
|
onClick = onOpenProfile,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -76,4 +280,3 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user