diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt index 0eebff3..c2d310c 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt @@ -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) +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt index e6a35e8..b898b27 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt @@ -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( } } } -