diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/ui/activity/MainActivity.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/activity/MainActivity.kt index 24d6394..8a9b3f1 100755 --- a/app/src/main/java/ru/risdeveau/pixeldragon/ui/activity/MainActivity.kt +++ b/app/src/main/java/ru/risdeveau/pixeldragon/ui/activity/MainActivity.kt @@ -47,6 +47,7 @@ import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import de.connect2x.trixnity.client.CryptoDriverModule @@ -69,8 +70,14 @@ import kotlinx.coroutines.launch import ru.risdeveau.pixeldragon.R import ru.risdeveau.pixeldragon.client import ru.risdeveau.pixeldragon.ui.item.Avatar +import ru.risdeveau.pixeldragon.ui.layout.AccountSettingsScreen +import ru.risdeveau.pixeldragon.ui.layout.AppearanceSettingsScreen +import ru.risdeveau.pixeldragon.ui.layout.DevicesSettingsScreen import ru.risdeveau.pixeldragon.ui.layout.Room import ru.risdeveau.pixeldragon.ui.layout.RoomList +import ru.risdeveau.pixeldragon.ui.layout.SettingsMainScreen +import ru.risdeveau.pixeldragon.ui.layout.SettingsRoutes +import ru.risdeveau.pixeldragon.ui.layout.settingsTitleForRoute import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme import ru.risdeveau.pixeldragon.util.getMediaStore import ru.risdeveau.pixeldragon.util.getRoomStore @@ -130,6 +137,32 @@ class MainActivity : ComponentActivity() { text = "Not implemented" ) } + + navigation( + route = SettingsRoutes.Graph, + startDestination = SettingsRoutes.Main, + ) { + composable(SettingsRoutes.Main) { + SettingsMainScreen( + modifier = Modifier.padding(innerPadding), + onCategoryClick = { category -> + navController.navigate(category.route) + }, + ) + } + + composable(SettingsRoutes.Account) { + AccountSettingsScreen(Modifier.padding(innerPadding)) + } + + composable(SettingsRoutes.Devices) { + DevicesSettingsScreen(Modifier.padding(innerPadding)) + } + + composable(SettingsRoutes.Appearance) { + AppearanceSettingsScreen(Modifier.padding(innerPadding)) + } + } } } } @@ -176,6 +209,7 @@ private fun PixelDragonTopBar( val route = backStackEntry?.destination?.route val rid = backStackEntry?.arguments?.getString("rid") val isRoomLikeScreen = route == "room/{rid}" || route == "space/{rid}" + val isSettingsScreen = route == SettingsRoutes.Graph || route?.startsWith("settings/") == true val roomsFlow = remember(client) { client!!.room.getAll().flattenValues().map { it.toList() } @@ -190,20 +224,42 @@ private fun PixelDragonTopBar( } } - if (isRoomLikeScreen) { - RoomTopBar( - room = currentRoom, - fallbackTitle = rid ?: stringResource(R.string.app_name), - onBack = { navController.popBackStack() }, - ) - } else { - HomeTopBar(syncState = syncState) + when { + isRoomLikeScreen -> { + RoomTopBar( + room = currentRoom, + fallbackTitle = rid ?: stringResource(R.string.app_name), + onBack = { navController.popBackStack() }, + ) + } + + isSettingsScreen -> { + RoomTopBar( + room = null, + fallbackTitle = settingsTitleForRoute(route), + onBack = { navController.popBackStack() }, + ) + } + + else -> { + HomeTopBar( + syncState = syncState, + onAvatarClick = { + navController.navigate(SettingsRoutes.Graph) { + launchSingleTop = true + } + }, + ) + } } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun HomeTopBar(syncState: SyncState) { +private fun HomeTopBar( + syncState: SyncState, + onAvatarClick: () -> Unit, +) { CenterAlignedTopAppBar( colors = topAppBarColors( containerColor = MaterialTheme.colorScheme.primaryContainer, @@ -227,12 +283,16 @@ private fun HomeTopBar(syncState: SyncState) { userAvatar = profile?.avatarUrl } - Avatar( - Modifier.size(32.dp).clip(CircleShape), - userAvatar, - userName, - MaterialTheme.colorScheme.background - ) + IconButton(onClick = onAvatarClick) { + Avatar( + Modifier + .size(32.dp) + .clip(CircleShape), + userAvatar, + userName, + MaterialTheme.colorScheme.background + ) + } } ) } diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Settings.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Settings.kt new file mode 100755 index 0000000..7bfc06b --- /dev/null +++ b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Settings.kt @@ -0,0 +1,1286 @@ +/* + * Created by sweetbread + * Copyright (c) 2026. All rights reserved. + */ + +package ru.risdeveau.pixeldragon.ui.layout + +import android.content.Context +import android.net.Uri +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import de.connect2x.trixnity.client.MatrixClient +import de.connect2x.trixnity.client.store.KeySignatureTrustLevel +import de.connect2x.trixnity.client.store.KeyStore +import de.connect2x.trixnity.clientserverapi.client.SyncState +import de.connect2x.trixnity.clientserverapi.client.UIA +import de.connect2x.trixnity.clientserverapi.model.authentication.IdentifierType +import de.connect2x.trixnity.clientserverapi.model.device.Device +import de.connect2x.trixnity.clientserverapi.model.media.Media +import de.connect2x.trixnity.clientserverapi.model.uia.AuthenticationRequest +import de.connect2x.trixnity.clientserverapi.model.user.ProfileField +import de.connect2x.trixnity.clientserverapi.model.user.avatarUrl +import de.connect2x.trixnity.clientserverapi.model.user.displayName +import io.ktor.http.ContentType +import io.ktor.utils.io.ByteReadChannel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import ru.risdeveau.pixeldragon.client +import ru.risdeveau.pixeldragon.ui.item.Avatar +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +object SettingsRoutes { + const val Graph = "settings" + + const val Main = "settings/main" + const val Account = "settings/account" + const val Devices = "settings/devices" + const val Appearance = "settings/appearance" +} + +data class SettingsCategory( + val route: String, + val title: String, + val subtitle: String, +) + +private val settingsCategories = listOf( + SettingsCategory( + route = SettingsRoutes.Account, + title = "Account", + subtitle = "Name, avatar, password and Matrix account", + ), + SettingsCategory( + route = SettingsRoutes.Devices, + title = "Devices", + subtitle = "Sessions, verification status and logout", + ), + SettingsCategory( + route = SettingsRoutes.Appearance, + title = "Appearance", + subtitle = "Theme and message layout", + ), +) + +private data class MatrixSession( + val device: Device, + val verification: KeySignatureTrustLevel?, +) + +private data class PendingAuthentication( + val title: String, + val description: String, + val uia: UIA, + val onSuccess: suspend () -> Unit, +) + +fun settingsTitleForRoute(route: String?): String { + return when (route) { + SettingsRoutes.Main, + SettingsRoutes.Graph -> "Settings" + SettingsRoutes.Account -> "Account" + SettingsRoutes.Devices -> "Devices" + SettingsRoutes.Appearance -> "Appearance" + else -> "Settings" + } +} + +@Composable +fun SettingsMainScreen( + modifier: Modifier = Modifier, + onCategoryClick: (SettingsCategory) -> Unit, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + ) { + item { + SettingsHeader( + title = "Settings", + subtitle = "Manage your PixelDragon account and app preferences.", + ) + } + + items(settingsCategories) { category -> + SettingsCategoryItem( + category = category, + onClick = { onCategoryClick(category) }, + ) + HorizontalDivider(modifier = Modifier.padding(start = 16.dp)) + } + } +} + +@Composable +fun AccountSettingsScreen(modifier: Modifier = Modifier) { + val matrixClient = client!! + val profile by matrixClient.profile.collectAsState() + val scope = rememberCoroutineScope() + val context = LocalContext.current + + val userName = profile?.displayName ?: matrixClient.userId.localpart ?: matrixClient.userId.toString() + val userAvatar = profile?.avatarUrl + + var isSavingProfile by remember { mutableStateOf(false) } + var isChangingPassword by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + var editNameDialogOpen by remember { mutableStateOf(false) } + var editAvatarDialogOpen by remember { mutableStateOf(false) } + var changePasswordDialogOpen by remember { mutableStateOf(false) } + var pendingAuthentication by remember { mutableStateOf(null) } + + fun updateProfileField( + profileField: ProfileField, + successMessage: String, + ) { + scope.launch { + isSavingProfile = true + message = null + matrixClient.setProfileField(profileField) + .onSuccess { message = successMessage } + .onFailure { message = it.toSettingsError() } + isSavingProfile = false + } + } + + val avatarPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + isSavingProfile = true + message = null + runCatching { + matrixClient.uploadAvatar(context, uri) + }.onSuccess { mxcUrl -> + matrixClient.setProfileField(ProfileField.AvatarUrl(mxcUrl)).getOrThrow() + message = "Avatar updated" + }.onFailure { + message = it.toSettingsError() + } + isSavingProfile = false + } + } + + LazyColumn( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + SettingsHeader( + title = "Account", + subtitle = "Your visible Matrix profile in PixelDragon.", + ) + } + + item { + Card( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + modifier = Modifier + .size(72.dp) + .clip(CircleShape), + url = userAvatar, + fallbackName = userName, + fallbackColor = MaterialTheme.colorScheme.primaryContainer, + ) + + Spacer(Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = userName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleLarge, + ) + + Spacer(Modifier.height(4.dp)) + + Text( + text = matrixClient.userId.toString(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } + + message?.let { status -> + item { + SettingsStatusMessage(status) + } + } + + item { + SettingsSection(title = "Profile") { + SettingsActionRow( + title = "Display name", + value = userName, + actionLabel = "Edit", + enabled = !isSavingProfile, + onClick = { editNameDialogOpen = true }, + ) + SettingsDivider() + SettingsActionRow( + title = "Avatar", + value = if (userAvatar.isNullOrBlank()) "Not set" else userAvatar, + actionLabel = "Edit", + enabled = !isSavingProfile, + onClick = { editAvatarDialogOpen = true }, + ) + SettingsDivider() + SettingsInfoRow( + title = "User ID", + value = matrixClient.userId.toString(), + ) + } + } + + item { + SettingsSection(title = "Security") { + SettingsActionRow( + title = "Password", + value = "Change your Matrix account password", + actionLabel = "Change", + enabled = !isChangingPassword, + onClick = { changePasswordDialogOpen = true }, + ) + } + } + } + + if (editNameDialogOpen) { + EditTextDialog( + title = "Edit display name", + label = "Display name", + initialValue = userName, + confirmText = "Save", + onDismiss = { editNameDialogOpen = false }, + onConfirm = { newName -> + editNameDialogOpen = false + updateProfileField( + profileField = ProfileField.DisplayName(newName.trim().ifBlank { null }), + successMessage = "Display name updated", + ) + }, + ) + } + + if (editAvatarDialogOpen) { + EditAvatarDialog( + currentAvatarUrl = userAvatar, + onDismiss = { editAvatarDialogOpen = false }, + onPickImage = { + editAvatarDialogOpen = false + avatarPicker.launch("image/*") + }, + onSaveMxc = { mxcUrl -> + editAvatarDialogOpen = false + updateProfileField( + profileField = ProfileField.AvatarUrl(mxcUrl.trim().ifBlank { null }), + successMessage = "Avatar updated", + ) + }, + onRemove = { + editAvatarDialogOpen = false + updateProfileField( + profileField = ProfileField.AvatarUrl(null), + successMessage = "Avatar removed", + ) + }, + ) + } + + if (changePasswordDialogOpen) { + ChangePasswordDialog( + isBusy = isChangingPassword, + onDismiss = { changePasswordDialogOpen = false }, + onChangePassword = { newPassword, logoutDevices -> + scope.launch { + isChangingPassword = true + message = null + val result = matrixClient.api.authentication + .changePassword(newPassword = newPassword, logoutDevices = logoutDevices) + result.onSuccess { uia -> + uia.handleUia( + onNeedsAuthentication = { step -> + pendingAuthentication = PendingAuthentication( + title = "Confirm password change", + description = "Enter your current password to confirm this change.", + uia = step, + onSuccess = { + message = "Password changed" + changePasswordDialogOpen = false + }, + ) + }, + onSuccess = { + message = "Password changed" + changePasswordDialogOpen = false + }, + ) + }.onFailure { + message = it.toSettingsError() + } + isChangingPassword = false + } + }, + ) + } + + pendingAuthentication?.let { pending -> + PasswordAuthenticationDialog( + title = pending.title, + description = pending.description, + isBusy = isChangingPassword, + onDismiss = { pendingAuthentication = null }, + onConfirm = { password -> + scope.launch { + isChangingPassword = true + message = null + val authRequest = AuthenticationRequest.Password( + identifier = IdentifierType.User(matrixClient.userId.toString()), + password = password, + ) + val result = when (val uia = pending.uia) { + is UIA.Step -> uia.authenticate(authRequest) + is UIA.Error -> uia.authenticate(authRequest) + is UIA.Success -> Result.success(uia) + } + result.onSuccess { nextUia -> + nextUia.handleUia( + onNeedsAuthentication = { nextStep -> + pendingAuthentication = pending.copy(uia = nextStep) + message = "Additional authentication is required" + }, + onSuccess = { + pending.onSuccess() + pendingAuthentication = null + }, + ) + }.onFailure { + message = it.toSettingsError() + } + isChangingPassword = false + } + }, + ) + } +} + +@Composable +fun DevicesSettingsScreen(modifier: Modifier = Modifier) { + val matrixClient = client!! + val syncState by matrixClient.api.sync.currentSyncState.collectAsState(initial = SyncState.STOPPED) + val scope = rememberCoroutineScope() + val manufacturer = Build.MANUFACTURER.replaceFirstChar { it.titlecase() } + val deviceName = listOf(manufacturer, Build.MODEL) + .filter { it.isNotBlank() } + .joinToString(" ") + + var sessions by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var isClosingDeviceId by remember { mutableStateOf(null) } + var message by remember { mutableStateOf(null) } + var pendingAuthentication by remember { mutableStateOf(null) } + + suspend fun loadSessions() { + isLoading = true + message = null + runCatching { + matrixClient.loadSessionsWithVerification() + }.onSuccess { loadedSessions -> + sessions = loadedSessions + }.onFailure { + message = it.toSettingsError() + } + isLoading = false + } + + fun closeSession(device: Device) { + scope.launch { + isClosingDeviceId = device.deviceId + message = null + matrixClient.api.device.deleteDevice(device.deviceId) + .onSuccess { uia -> + uia.handleUia( + onNeedsAuthentication = { step -> + pendingAuthentication = PendingAuthentication( + title = "Close session", + description = "Enter your current password to close ${device.displayName ?: device.deviceId}.", + uia = step, + onSuccess = { + message = "Session closed" + loadSessions() + }, + ) + }, + onSuccess = { + message = "Session closed" + loadSessions() + }, + ) + }.onFailure { + message = it.toSettingsError() + } + isClosingDeviceId = null + } + } + + LaunchedEffect(matrixClient) { + loadSessions() + } + + LazyColumn( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + SettingsHeader( + title = "Devices", + subtitle = "All Matrix sessions connected to your account.", + ) + } + + message?.let { status -> + item { + SettingsStatusMessage(status) + } + } + + item { + SettingsSection(title = "Current device") { + SettingsInfoRow( + title = "Device", + value = deviceName.ifBlank { "Android device" }, + ) + SettingsDivider() + SettingsInfoRow( + title = "Device ID", + value = matrixClient.deviceId, + ) + SettingsDivider() + SettingsInfoRow( + title = "Android", + value = "${Build.VERSION.RELEASE} · API ${Build.VERSION.SDK_INT}", + ) + SettingsDivider() + SettingsInfoRow( + title = "Sync", + value = syncState.toSettingsStatus(), + ) + } + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Sessions", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + ) + TextButton( + enabled = !isLoading, + onClick = { + scope.launch { loadSessions() } + }, + ) { + Text("Refresh") + } + } + } + + when { + isLoading -> { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + } + } + } + + sessions.isEmpty() -> { + item { + SettingsStatusMessage("No active sessions returned by the homeserver") + } + } + + else -> { + items( + items = sessions, + key = { it.device.deviceId }, + ) { session -> + SessionCard( + session = session, + currentDeviceId = matrixClient.deviceId, + isClosing = isClosingDeviceId == session.device.deviceId, + onClose = { closeSession(session.device) }, + ) + } + } + } + } + + pendingAuthentication?.let { pending -> + PasswordAuthenticationDialog( + title = pending.title, + description = pending.description, + isBusy = isClosingDeviceId != null, + onDismiss = { + pendingAuthentication = null + isClosingDeviceId = null + }, + onConfirm = { password -> + scope.launch { + val authRequest = AuthenticationRequest.Password( + identifier = IdentifierType.User(matrixClient.userId.toString()), + password = password, + ) + val result = when (val uia = pending.uia) { + is UIA.Step -> uia.authenticate(authRequest) + is UIA.Error -> uia.authenticate(authRequest) + is UIA.Success -> Result.success(uia) + } + result.onSuccess { nextUia -> + nextUia.handleUia( + onNeedsAuthentication = { nextStep -> + pendingAuthentication = pending.copy(uia = nextStep) + message = "Additional authentication is required" + }, + onSuccess = { + pending.onSuccess() + pendingAuthentication = null + }, + ) + }.onFailure { + message = it.toSettingsError() + } + isClosingDeviceId = null + } + }, + ) + } +} + +@Composable +fun AppearanceSettingsScreen(modifier: Modifier = Modifier) { + LazyColumn( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + SettingsHeader( + title = "Appearance", + subtitle = "Visual preferences that can later be wired to persistence.", + ) + } + + item { + SettingsSection(title = "Theme") { + SettingsInfoRow( + title = "Color mode", + value = "System default", + ) + SettingsDivider() + SettingsInfoRow( + title = "Accent", + value = "PixelDragon", + ) + } + } + + item { + SettingsSection(title = "Messages") { + SettingsInfoRow( + title = "Layout", + value = "Chat bubbles", + ) + SettingsDivider() + SettingsInfoRow( + title = "HTML messages", + value = "Rich rendering", + ) + } + } + } +} + +@Composable +private fun SettingsCategoryItem( + category: SettingsCategory, + onClick: () -> Unit, +) { + ListItem( + modifier = Modifier.clickable(onClick = onClick), + headlineContent = { + Text( + text = category.title, + style = MaterialTheme.typography.titleMedium, + ) + }, + supportingContent = { + Text(category.subtitle) + }, + trailingContent = { + Text( + text = "›", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) +} + +@Composable +private fun SettingsHeader( + title: String, + subtitle: String, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 20.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + ) + + Spacer(Modifier.height(6.dp)) + + Text( + text = subtitle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun SettingsSection( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp), + text = title, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + ) + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Column(content = content) + } + } +} + +@Composable +private fun SettingsInfoRow( + title: String, + value: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = title, + style = MaterialTheme.typography.bodyLarge, + ) + + Spacer(Modifier.width(16.dp)) + + Text( + modifier = Modifier.weight(1f), + text = value, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun SettingsActionRow( + title: String, + value: String, + actionLabel: String, + enabled: Boolean = true, + destructive: Boolean = false, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = value, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } + + Spacer(Modifier.width(12.dp)) + + TextButton( + enabled = enabled, + onClick = onClick, + ) { + Text( + text = actionLabel, + color = if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun SettingsDivider() { + HorizontalDivider(modifier = Modifier.padding(start = 16.dp)) +} + +@Composable +private fun SettingsStatusMessage(message: String) { + Surface( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + modifier = Modifier.padding(16.dp), + text = message, + color = MaterialTheme.colorScheme.onSecondaryContainer, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun SessionCard( + session: MatrixSession, + currentDeviceId: String, + isClosing: Boolean, + onClose: () -> Unit, +) { + val device = session.device + val isCurrent = device.deviceId == currentDeviceId + + Surface( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.displayName ?: "Unnamed session", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = device.deviceId, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(Modifier.width(12.dp)) + + if (isCurrent) { + Surface( + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Text( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + text = "Current", + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + + Spacer(Modifier.height(12.dp)) + + SettingsTinyInfoRow( + title = "Verification", + value = session.verification.toVerificationStatus(), + ) + SettingsTinyInfoRow( + title = "Last seen", + value = device.lastSeenTs?.formatLastSeen() ?: "Unknown", + ) + SettingsTinyInfoRow( + title = "Last IP", + value = device.lastSeenIp ?: "Unknown", + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedButton( + enabled = !isCurrent && !isClosing, + onClick = onClose, + ) { + if (isClosing) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + } + Text(if (isCurrent) "Current session" else "Close session") + } + } + } +} + +@Composable +private fun SettingsTinyInfoRow( + title: String, + value: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = title, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.width(12.dp)) + Text( + modifier = Modifier.weight(1.4f), + text = value, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun EditTextDialog( + title: String, + label: String, + initialValue: String, + confirmText: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var value by remember(initialValue) { mutableStateOf(initialValue) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = value, + onValueChange = { value = it }, + label = { Text(label) }, + singleLine = true, + ) + }, + confirmButton = { + Button(onClick = { onConfirm(value) }) { + Text(confirmText) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + +@Composable +private fun EditAvatarDialog( + currentAvatarUrl: String?, + onDismiss: () -> Unit, + onPickImage: () -> Unit, + onSaveMxc: (String) -> Unit, + onRemove: () -> Unit, +) { + var avatarUrl by remember(currentAvatarUrl) { mutableStateOf(currentAvatarUrl.orEmpty()) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Edit avatar") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Pick an image to upload it to Matrix, or paste an existing mxc:// URI.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = avatarUrl, + onValueChange = { avatarUrl = it }, + label = { Text("Matrix avatar URI") }, + placeholder = { Text("mxc://server/name") }, + singleLine = true, + ) + } + }, + confirmButton = { + Button(onClick = { onSaveMxc(avatarUrl) }) { + Text("Save") + } + }, + dismissButton = { + Row { + TextButton(onClick = onPickImage) { + Text("Pick image") + } + TextButton(onClick = onRemove) { + Text("Remove") + } + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + }, + ) +} + +@Composable +private fun ChangePasswordDialog( + isBusy: Boolean, + onDismiss: () -> Unit, + onChangePassword: (newPassword: String, logoutDevices: Boolean) -> Unit, +) { + var newPassword by remember { mutableStateOf("") } + var repeatPassword by remember { mutableStateOf("") } + var logoutDevices by remember { mutableStateOf(false) } + val passwordsMatch = newPassword.isNotBlank() && newPassword == repeatPassword + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Change password") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = newPassword, + onValueChange = { newPassword = it }, + label = { Text("New password") }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true, + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = repeatPassword, + onValueChange = { repeatPassword = it }, + label = { Text("Repeat new password") }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true, + isError = repeatPassword.isNotBlank() && newPassword != repeatPassword, + supportingText = { + if (repeatPassword.isNotBlank() && newPassword != repeatPassword) { + Text("Passwords do not match") + } + }, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = logoutDevices, + onCheckedChange = { logoutDevices = it }, + ) + Spacer(Modifier.width(8.dp)) + Text("Log out other devices") + } + } + }, + confirmButton = { + Button( + enabled = passwordsMatch && !isBusy, + onClick = { onChangePassword(newPassword, logoutDevices) }, + ) { + if (isBusy) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + } + Text("Change") + } + }, + dismissButton = { + TextButton( + enabled = !isBusy, + onClick = onDismiss, + ) { + Text("Cancel") + } + }, + ) +} + +@Composable +private fun PasswordAuthenticationDialog( + title: String, + description: String, + isBusy: Boolean, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var password by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = description, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = password, + onValueChange = { password = it }, + label = { Text("Current password") }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true, + ) + } + }, + confirmButton = { + Button( + enabled = password.isNotBlank() && !isBusy, + onClick = { onConfirm(password) }, + ) { + if (isBusy) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + } + Text("Confirm") + } + }, + dismissButton = { + TextButton( + enabled = !isBusy, + onClick = onDismiss, + ) { + Text("Cancel") + } + }, + ) +} + +private suspend fun MatrixClient.uploadAvatar( + context: Context, + uri: Uri, +): String { + val contentResolver = context.contentResolver + val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: error("Could not read selected image") + val contentType = contentResolver.getType(uri)?.let { ContentType.parse(it) } + val media = Media( + content = ByteReadChannel(bytes), + contentLength = bytes.size.toLong(), + contentType = contentType, + contentDisposition = null, + ) + return api.media.upload(media).getOrThrow().contentUri +} + +private suspend fun MatrixClient.loadSessionsWithVerification(): List { + val keyStore: KeyStore = di.get() + val deviceKeys = withTimeoutOrNull(1_500) { + keyStore.getDeviceKeys(userId).collectAsFirstOrNull() + } + return api.device.getDevices() + .getOrThrow() + .sortedWith( + compareByDescending { it.deviceId == deviceId } + .thenByDescending { it.lastSeenTs ?: 0L } + .thenBy { it.displayName ?: it.deviceId } + ) + .map { device -> + MatrixSession( + device = device, + verification = deviceKeys?.get(device.deviceId)?.trustLevel, + ) + } +} + +private suspend fun Flow.collectAsFirstOrNull(): T? { + return firstOrNull() +} + +private suspend fun UIA.handleUia( + onNeedsAuthentication: suspend (UIA) -> Unit, + onSuccess: suspend () -> Unit, +) { + when (this) { + is UIA.Success -> onSuccess() + is UIA.Step -> onNeedsAuthentication(this) + is UIA.Error -> onNeedsAuthentication(this) + } +} + +private fun KeySignatureTrustLevel?.toVerificationStatus(): String { + return when (this) { + is KeySignatureTrustLevel.Valid -> if (verified) "Verified" else "Valid, not verified" + is KeySignatureTrustLevel.CrossSigned -> if (verified) "Cross-signed and verified" else "Cross-signed" + is KeySignatureTrustLevel.NotCrossSigned -> "Not cross-signed" + is KeySignatureTrustLevel.NotAllDeviceKeysCrossSigned -> { + if (verified) "User verified, not all devices signed" else "Not all devices signed" + } + is KeySignatureTrustLevel.Blocked -> "Blocked" + is KeySignatureTrustLevel.Invalid -> "Invalid: $reason" + null -> "Unknown" + } +} + +private fun Long.formatLastSeen(): String { + return Instant.ofEpochMilli(this) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("dd MMM yyyy, HH:mm")) +} + +private fun Throwable.toSettingsError(): String { + return message?.takeIf { it.isNotBlank() } ?: this::class.simpleName ?: "Unknown error" +} + +private fun SyncState.toSettingsStatus(): String { + return when (this) { + SyncState.INITIAL_SYNC -> "Initial sync" + SyncState.STARTED -> "Syncing" + SyncState.TIMEOUT -> "No network connection" + SyncState.ERROR -> "Sync error" + SyncState.RUNNING -> "Running" + SyncState.STOPPED -> "Stopped" + } +}