From cdf785966839bb92d10b999d55ff0797df59809f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 23:54:47 +0300 Subject: [PATCH] android: align settings/profile with app theme and add real settings controls --- android/CHANGELOG.md | 9 + .../ru/daemonlord/messenger/MainActivity.kt | 15 + .../repository/DataStoreThemeRepository.kt | 42 ++ .../messenger/di/RepositoryModule.kt | 8 + .../domain/settings/model/AppThemeMode.kt | 8 + .../settings/repository/ThemeRepository.kt | 11 + .../messenger/ui/account/AccountUiState.kt | 5 + .../messenger/ui/account/AccountViewModel.kt | 74 ++++ .../messenger/ui/profile/ProfileScreen.kt | 45 +- .../messenger/ui/settings/SettingsScreen.kt | 390 ++++++++++++++---- 10 files changed, 497 insertions(+), 110 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/settings/repository/DataStoreThemeRepository.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/settings/model/AppThemeMode.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/settings/repository/ThemeRepository.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index c45d05d..6c42c32 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -747,3 +747,12 @@ - shows saved accounts, - allows switch/remove, - triggers auth recheck + chats reload on switch. + +### Step 111 - Real Settings + persistent theme + add-account UX +- Implemented persistent app theme storage via DataStore (`ThemeRepository`) and applied theme mode on app start in `MainActivity`. +- Reworked `SettingsScreen` to contain only working settings and actions: + - multi-account list (`Switch`/`Remove`) + `Add account` dialog with email/password sign-in, + - appearance (`Light`/`Dark`/`System`) wired to persisted theme, + - notifications (`global` + `preview`) wired to `NotificationSettingsRepository`, + - privacy update, blocked users management, sessions revoke/revoke-all, 2FA controls. +- Updated `ProfileScreen` to follow current app theme colors instead of forced dark palette. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt b/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt index 0e62f3a..d696436 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt @@ -12,13 +12,20 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.foundation.layout.fillMaxSize import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.runBlocking import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras +import ru.daemonlord.messenger.domain.settings.model.AppThemeMode +import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository import ru.daemonlord.messenger.ui.navigation.MessengerNavHost import ru.daemonlord.messenger.ui.theme.MessengerTheme +import javax.inject.Inject @AndroidEntryPoint class MainActivity : AppCompatActivity() { + @Inject + lateinit var themeRepository: ThemeRepository private var pendingInviteToken by mutableStateOf(null) private var pendingVerifyEmailToken by mutableStateOf(null) private var pendingResetPasswordToken by mutableStateOf(null) @@ -26,6 +33,14 @@ class MainActivity : AppCompatActivity() { private var pendingNotificationMessageId by mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { + val savedThemeMode = runBlocking { themeRepository.getThemeMode() } + AppCompatDelegate.setDefaultNightMode( + when (savedThemeMode) { + AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO + AppThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES + AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + ) super.onCreate(savedInstanceState) pendingInviteToken = intent.extractInviteToken() pendingVerifyEmailToken = intent.extractVerifyEmailToken() diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/settings/repository/DataStoreThemeRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/settings/repository/DataStoreThemeRepository.kt new file mode 100644 index 0000000..f339edf --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/settings/repository/DataStoreThemeRepository.kt @@ -0,0 +1,42 @@ +package ru.daemonlord.messenger.data.settings.repository + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import ru.daemonlord.messenger.domain.settings.model.AppThemeMode +import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DataStoreThemeRepository @Inject constructor( + private val dataStore: DataStore, +) : ThemeRepository { + + override fun observeThemeMode(): Flow { + return dataStore.data.map { prefs -> + prefs[THEME_MODE_KEY] + ?.let { raw -> runCatching { AppThemeMode.valueOf(raw) }.getOrNull() } + ?: AppThemeMode.SYSTEM + } + } + + override suspend fun getThemeMode(): AppThemeMode { + return observeThemeMode().first() + } + + override suspend fun setThemeMode(mode: AppThemeMode) { + dataStore.edit { prefs -> + prefs[THEME_MODE_KEY] = mode.name + } + } + + private companion object { + val THEME_MODE_KEY = stringPreferencesKey("app_theme_mode") + } +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt index ebe5681..cd450b1 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt @@ -12,6 +12,7 @@ import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository import ru.daemonlord.messenger.data.search.repository.NetworkSearchRepository +import ru.daemonlord.messenger.data.settings.repository.DataStoreThemeRepository import ru.daemonlord.messenger.data.user.repository.NetworkAccountRepository import ru.daemonlord.messenger.domain.account.repository.AccountRepository import ru.daemonlord.messenger.domain.auth.repository.AuthRepository @@ -22,6 +23,7 @@ import ru.daemonlord.messenger.domain.media.repository.MediaRepository import ru.daemonlord.messenger.domain.message.repository.MessageRepository import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository import ru.daemonlord.messenger.domain.search.repository.SearchRepository +import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository import javax.inject.Singleton @Module @@ -81,4 +83,10 @@ abstract class RepositoryModule { abstract fun bindSearchRepository( repository: NetworkSearchRepository, ): SearchRepository + + @Binds + @Singleton + abstract fun bindThemeRepository( + repository: DataStoreThemeRepository, + ): ThemeRepository } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/settings/model/AppThemeMode.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/settings/model/AppThemeMode.kt new file mode 100644 index 0000000..5134a0b --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/settings/model/AppThemeMode.kt @@ -0,0 +1,8 @@ +package ru.daemonlord.messenger.domain.settings.model + +enum class AppThemeMode { + LIGHT, + DARK, + SYSTEM, +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/settings/repository/ThemeRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/settings/repository/ThemeRepository.kt new file mode 100644 index 0000000..f1fd0a5 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/settings/repository/ThemeRepository.kt @@ -0,0 +1,11 @@ +package ru.daemonlord.messenger.domain.settings.repository + +import kotlinx.coroutines.flow.Flow +import ru.daemonlord.messenger.domain.settings.model.AppThemeMode + +interface ThemeRepository { + fun observeThemeMode(): Flow + suspend fun getThemeMode(): AppThemeMode + suspend fun setThemeMode(mode: AppThemeMode) +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt index 1431f98..831bd63 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt @@ -3,6 +3,7 @@ package ru.daemonlord.messenger.ui.account import ru.daemonlord.messenger.domain.account.model.UserSearchItem import ru.daemonlord.messenger.domain.auth.model.AuthSession import ru.daemonlord.messenger.domain.auth.model.AuthUser +import ru.daemonlord.messenger.domain.settings.model.AppThemeMode data class AccountUiState( val isLoading: Boolean = false, @@ -16,6 +17,10 @@ data class AccountUiState( val recoveryCodesRemaining: Int? = null, val activeUserId: Long? = null, val storedAccounts: List = emptyList(), + val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM, + val notificationsEnabled: Boolean = true, + val notificationsPreviewEnabled: Boolean = true, + val isAddingAccount: Boolean = false, val message: String? = null, val errorMessage: String? = null, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt index ad61272..6705861 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt @@ -2,6 +2,7 @@ package ru.daemonlord.messenger.ui.account import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.appcompat.app.AppCompatDelegate import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -9,15 +10,22 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import ru.daemonlord.messenger.core.token.TokenRepository +import ru.daemonlord.messenger.domain.auth.usecase.LoginUseCase import ru.daemonlord.messenger.domain.account.repository.AccountRepository import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository +import ru.daemonlord.messenger.domain.settings.model.AppThemeMode +import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository import javax.inject.Inject @HiltViewModel class AccountViewModel @Inject constructor( private val accountRepository: AccountRepository, private val tokenRepository: TokenRepository, + private val loginUseCase: LoginUseCase, + private val notificationSettingsRepository: NotificationSettingsRepository, + private val themeRepository: ThemeRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(AccountUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -34,6 +42,8 @@ class AccountViewModel @Inject constructor( val blocked = accountRepository.listBlockedUsers() val activeUserId = tokenRepository.getActiveUserId() val storedAccounts = tokenRepository.getAccounts() + val notificationSettings = notificationSettingsRepository.getSettings() + val appThemeMode = themeRepository.getThemeMode() _uiState.update { state -> state.copy( isLoading = false, @@ -53,6 +63,9 @@ class AccountViewModel @Inject constructor( isActive = activeUserId == account.userId, ) }, + notificationsEnabled = notificationSettings.globalEnabled, + notificationsPreviewEnabled = notificationSettings.previewEnabled, + appThemeMode = appThemeMode, errorMessage = listOf(me, sessions, blocked) .filterIsInstance() .firstOrNull() @@ -63,6 +76,67 @@ class AccountViewModel @Inject constructor( } } + fun setThemeMode(mode: AppThemeMode) { + viewModelScope.launch { + themeRepository.setThemeMode(mode) + AppCompatDelegate.setDefaultNightMode( + when (mode) { + AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO + AppThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES + AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + ) + _uiState.update { it.copy(appThemeMode = mode) } + } + } + + fun setGlobalNotificationsEnabled(enabled: Boolean) { + viewModelScope.launch { + notificationSettingsRepository.setGlobalEnabled(enabled) + _uiState.update { it.copy(notificationsEnabled = enabled) } + } + } + + fun setNotificationPreviewEnabled(enabled: Boolean) { + viewModelScope.launch { + notificationSettingsRepository.setPreviewEnabled(enabled) + _uiState.update { it.copy(notificationsPreviewEnabled = enabled) } + } + } + + fun addAccount(email: String, password: String, onDone: (Boolean) -> Unit = {}) { + val normalizedEmail = email.trim() + if (normalizedEmail.isBlank() || password.isBlank()) { + _uiState.update { it.copy(errorMessage = "Email and password are required.") } + onDone(false) + return + } + viewModelScope.launch { + _uiState.update { it.copy(isAddingAccount = true, errorMessage = null, message = null) } + when (val result = loginUseCase(normalizedEmail, password)) { + is AppResult.Success -> { + _uiState.update { + it.copy( + isAddingAccount = false, + message = "Account added.", + ) + } + refresh() + onDone(true) + } + is AppResult.Error -> { + _uiState.update { + it.copy( + isAddingAccount = false, + errorMessage = result.reason.toUiMessage(), + ) + } + onDone(false) + } + } + } + } + fun switchStoredAccount(userId: Long, onSwitched: (Boolean) -> Unit = {}) { viewModelScope.launch { val switched = tokenRepository.switchAccount(userId) 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 29b554b..c28971a 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 @@ -43,7 +43,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -65,10 +64,6 @@ import kotlinx.coroutines.flow.collectLatest import ru.daemonlord.messenger.ui.account.AccountViewModel import java.io.ByteArrayOutputStream -private val ProfileBg = Color(0xFF0D0F12) -private val ProfileCard = Color(0xFF171A1F) -private val ProfileMuted = Color(0xFFA2A7B3) - @Composable fun ProfileRoute( onBackToChats: () -> Unit, @@ -97,7 +92,7 @@ fun ProfileScreen( 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()) } - var selectedTab by remember { mutableIntStateOf(0) } + var selectedTab by remember { mutableStateOf(0) } var editMode by remember { mutableStateOf(false) } val scrollState = rememberScrollState() @@ -136,7 +131,7 @@ fun ProfileScreen( Box( modifier = Modifier .fillMaxSize() - .background(ProfileBg) + .background(MaterialTheme.colorScheme.background) .windowInsetsPadding(WindowInsets.safeDrawing), contentAlignment = Alignment.TopCenter, ) { @@ -153,7 +148,11 @@ fun ProfileScreen( .height(305.dp) .background( Brush.verticalGradient( - colors = listOf(Color(0xFF9F4649), Color(0xFFB85C61), Color(0xFF9D4A4C)), + colors = listOf( + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.tertiaryContainer, + ), ), ), ) { @@ -172,20 +171,20 @@ fun ProfileScreen( modifier = Modifier .size(108.dp) .clip(CircleShape) - .border(1.dp, Color(0x88FFFFFF), CircleShape), + .border(1.dp, MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f), CircleShape), ) } else { Box( modifier = Modifier .size(108.dp) .clip(CircleShape) - .background(Color(0x55FFFFFF)), + .background(MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)), contentAlignment = Alignment.Center, ) { Text( text = name.firstOrNull()?.uppercase() ?: "?", style = MaterialTheme.typography.headlineMedium, - color = Color.White, + color = MaterialTheme.colorScheme.onPrimaryContainer, ) } } @@ -193,12 +192,12 @@ fun ProfileScreen( Text( text = if (name.isBlank()) "User" else name, style = MaterialTheme.typography.headlineSmall, - color = Color.White, + color = MaterialTheme.colorScheme.onPrimaryContainer, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text("online", color = Color(0xE6FFFFFF), style = MaterialTheme.typography.bodyLarge) + Text("online", color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), style = MaterialTheme.typography.bodyLarge) Row( modifier = Modifier.fillMaxWidth(), @@ -234,7 +233,7 @@ fun ProfileScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Surface( - color = ProfileCard, + color = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(22.dp), modifier = Modifier .fillMaxWidth() @@ -254,7 +253,7 @@ fun ProfileScreen( } Surface( - color = ProfileCard, + color = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(18.dp), modifier = Modifier.fillMaxWidth(), ) { @@ -271,7 +270,7 @@ fun ProfileScreen( } Surface( - color = ProfileCard, + color = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(22.dp), modifier = Modifier.fillMaxWidth(), ) { @@ -282,10 +281,10 @@ fun ProfileScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text("No posts yet", color = Color.White, style = MaterialTheme.typography.titleLarge) + Text("No posts yet", style = MaterialTheme.typography.titleLarge) Text( "Publish something in your profile.", - color = ProfileMuted, + color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, ) Button(onClick = {}, modifier = Modifier.padding(top = 6.dp)) { @@ -296,7 +295,7 @@ fun ProfileScreen( if (editMode) { Surface( - color = ProfileCard, + color = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(22.dp), modifier = Modifier.fillMaxWidth(), ) { @@ -306,7 +305,7 @@ fun ProfileScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Text("Edit profile", color = Color.White, style = MaterialTheme.typography.titleMedium) + Text("Edit profile", style = MaterialTheme.typography.titleMedium) OutlinedTextField( value = name, onValueChange = { name = it }, @@ -390,7 +389,7 @@ private fun HeroActionButton( onClick: () -> Unit, ) { Surface( - color = Color(0x40FFFFFF), + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.8f), shape = RoundedCornerShape(16.dp), modifier = modifier, ) { @@ -407,8 +406,8 @@ private fun HeroActionButton( @Composable private fun ProfileInfoRow(label: String, value: String) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(text = value, style = MaterialTheme.typography.titleLarge, color = Color.White) - Text(text = label, style = MaterialTheme.typography.bodyMedium, color = ProfileMuted) + Text(text = value, style = MaterialTheme.typography.titleLarge) + Text(text = label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } } 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 2cc343f..81a156f 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 @@ -1,17 +1,14 @@ package ru.daemonlord.messenger.ui.settings -import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.WindowInsets 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.safeDrawing import androidx.compose.foundation.layout.size @@ -22,37 +19,37 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.HelpOutline -import androidx.compose.material.icons.filled.BatterySaver -import androidx.compose.material.icons.filled.ChatBubbleOutline -import androidx.compose.material.icons.filled.DataUsage +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.DeleteOutline import androidx.compose.material.icons.filled.Devices -import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Shield -import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -61,12 +58,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import kotlinx.coroutines.flow.collectLatest +import ru.daemonlord.messenger.domain.settings.model.AppThemeMode import ru.daemonlord.messenger.ui.account.AccountViewModel -private val SettingsBackground = Color(0xFF0D0F12) -private val SettingsCard = Color(0xFF1A1D23) -private val SettingsMuted = Color(0xFF9EA3B0) - @Composable fun SettingsRoute( onBackToChats: () -> Unit, @@ -99,7 +93,19 @@ fun SettingsScreen( val profile = state.profile val scrollState = rememberScrollState() val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 - var nightMode by remember { mutableIntStateOf(AppCompatDelegate.getDefaultNightMode()) } + + var showAddAccountDialog by remember { mutableStateOf(false) } + var addEmail by remember { mutableStateOf("") } + var addPassword by remember { mutableStateOf("") } + + 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 blockUserIdInput by remember { mutableStateOf("") } + var twoFactorCode by remember { mutableStateOf("") } + var recoveryRegenerateCode by remember { mutableStateOf("") } LaunchedEffect(Unit) { viewModel.refresh() @@ -118,10 +124,58 @@ fun SettingsScreen( } } + if (showAddAccountDialog) { + AlertDialog( + onDismissRequest = { if (!state.isAddingAccount) showAddAccountDialog = false }, + title = { Text("Add account") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = addEmail, + onValueChange = { addEmail = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Email") }, + ) + OutlinedTextField( + value = addPassword, + onValueChange = { addPassword = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Password") }, + ) + if (state.isAddingAccount) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + } + }, + confirmButton = { + Button( + onClick = { + viewModel.addAccount(addEmail, addPassword) { success -> + if (success) { + showAddAccountDialog = false + addEmail = "" + addPassword = "" + onSwitchAccount() + } + } + }, + enabled = !state.isAddingAccount, + ) { Text("Sign in") } + }, + dismissButton = { + TextButton(onClick = { if (!state.isAddingAccount) showAddAccountDialog = false }) { + Text("Cancel") + } + }, + ) + } + Box( modifier = Modifier .fillMaxSize() - .background(SettingsBackground) + .background(MaterialTheme.colorScheme.background) .windowInsetsPadding(WindowInsets.safeDrawing), contentAlignment = Alignment.TopCenter, ) { @@ -139,14 +193,14 @@ fun SettingsScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - Text("Settings", style = MaterialTheme.typography.headlineSmall, color = Color.White) + Text("Settings", style = MaterialTheme.typography.headlineSmall) if (state.isLoading) { CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) } } Surface( - color = SettingsCard, + color = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(22.dp), modifier = Modifier.fillMaxWidth(), ) { @@ -170,13 +224,12 @@ fun SettingsScreen( modifier = Modifier .size(58.dp) .clip(CircleShape) - .background(Color(0xFF6650A4)), + .background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center, ) { Text( text = profile?.name?.firstOrNull()?.uppercase() ?: "?", style = MaterialTheme.typography.titleMedium, - color = Color.White, fontWeight = FontWeight.SemiBold, ) } @@ -185,7 +238,6 @@ fun SettingsScreen( Text( text = profile?.name ?: "Loading...", style = MaterialTheme.typography.titleMedium, - color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -195,25 +247,35 @@ fun SettingsScreen( profile?.username?.takeIf { it.isNotBlank() }?.let { "@$it" }, ).joinToString(" • "), style = MaterialTheme.typography.bodySmall, - color = SettingsMuted, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } IconButton(onClick = onOpenProfile) { - Icon(Icons.Filled.Person, contentDescription = "Profile", tint = Color.White) + Icon(Icons.Filled.Person, contentDescription = "Profile") } } } SettingsSectionCard { - Text("Accounts", style = MaterialTheme.typography.titleSmall, color = Color.White) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Accounts", style = MaterialTheme.typography.titleSmall) + OutlinedButton(onClick = { showAddAccountDialog = true }) { + Icon(Icons.Filled.Add, contentDescription = null) + Text("Add account", modifier = Modifier.padding(start = 6.dp)) + } + } state.storedAccounts.forEach { account -> Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(14.dp)) - .background(Color(0x141F232B)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) .padding(horizontal = 10.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -229,12 +291,11 @@ fun SettingsScreen( modifier = Modifier .size(34.dp) .clip(CircleShape) - .background(Color(0xFF2A2F38)), + .background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center, ) { Text( text = account.title.firstOrNull()?.uppercase() ?: "?", - color = Color.White, style = MaterialTheme.typography.labelMedium, ) } @@ -242,19 +303,18 @@ fun SettingsScreen( Column(modifier = Modifier.weight(1f)) { Text( text = account.title, - color = Color.White, style = MaterialTheme.typography.bodyMedium, ) Text( text = account.subtitle, - color = SettingsMuted, + color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } if (account.isActive) { - Text("Active", color = Color(0xFF9EDB9D), style = MaterialTheme.typography.labelSmall) + Text("Active", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelSmall) } else { OutlinedButton( onClick = { @@ -267,57 +327,205 @@ fun SettingsScreen( } } OutlinedButton(onClick = { viewModel.removeStoredAccount(account.userId) }) { - Text("Remove") + Icon(Icons.Filled.DeleteOutline, contentDescription = null) } } } } SettingsSectionCard { - SettingsRow(Icons.Filled.Person, "Account", "Name, username, bio") { onOpenProfile() } - SettingsRow(Icons.Filled.ChatBubbleOutline, "Chat settings", "Wallpaper, media, interactions") {} - SettingsRow(Icons.Filled.Lock, "Privacy", "Last seen, messages, avatar") {} - SettingsRow(Icons.Filled.Notifications, "Notifications", "Sounds and counters") {} - SettingsRow(Icons.Filled.DataUsage, "Data and storage", "Media auto-download") {} - SettingsRow(Icons.Filled.Devices, "Devices", "${state.sessions.size} active sessions") {} - SettingsRow(Icons.Filled.BatterySaver, "Power saving", "Animation and media limits") {} - SettingsRow(Icons.Filled.Language, "Language", "English") {} - } - - SettingsSectionCard { - Text("Appearance", style = MaterialTheme.typography.titleSmall, color = Color.White) - Spacer(Modifier.height(4.dp)) + Text("Appearance", style = MaterialTheme.typography.titleSmall) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { ThemeButton( text = "Light", - selected = nightMode == AppCompatDelegate.MODE_NIGHT_NO, - ) { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) - nightMode = AppCompatDelegate.MODE_NIGHT_NO - } + selected = state.appThemeMode == AppThemeMode.LIGHT, + ) { viewModel.setThemeMode(AppThemeMode.LIGHT) } ThemeButton( text = "Dark", - selected = nightMode == AppCompatDelegate.MODE_NIGHT_YES, - ) { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - nightMode = AppCompatDelegate.MODE_NIGHT_YES - } + selected = state.appThemeMode == AppThemeMode.DARK, + ) { viewModel.setThemeMode(AppThemeMode.DARK) } ThemeButton( text = "System", - selected = nightMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, + selected = state.appThemeMode == AppThemeMode.SYSTEM, + ) { viewModel.setThemeMode(AppThemeMode.SYSTEM) } + } + } + + SettingsSectionCard { + Text("Notifications", style = MaterialTheme.typography.titleSmall) + SettingsToggleRow( + icon = Icons.Filled.Notifications, + title = "Enable notifications", + checked = state.notificationsEnabled, + onCheckedChange = viewModel::setGlobalNotificationsEnabled, + ) + SettingsToggleRow( + icon = Icons.Filled.Visibility, + title = "Show message preview", + checked = state.notificationsPreviewEnabled, + onCheckedChange = viewModel::setNotificationPreviewEnabled, + ) + } + + SettingsSectionCard { + Text("Privacy", style = MaterialTheme.typography.titleSmall) + OutlinedTextField( + value = privacyPm, + onValueChange = { privacyPm = it }, + label = { Text("Private messages") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = privacyLastSeen, + onValueChange = { privacyLastSeen = it }, + label = { Text("Last seen") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = privacyAvatar, + onValueChange = { privacyAvatar = it }, + label = { Text("Avatar") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = privacyGroupInvites, + onValueChange = { privacyGroupInvites = it }, + label = { Text("Group invites") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Button( + onClick = { + viewModel.updatePrivacy( + privateMessages = privacyPm, + lastSeen = privacyLastSeen, + avatar = privacyAvatar, + groupInvites = privacyGroupInvites, + ) + }, + enabled = !state.isSaving, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Lock, contentDescription = null) + Text("Save privacy", modifier = Modifier.padding(start = 6.dp)) + } + + OutlinedTextField( + value = blockUserIdInput, + onValueChange = { blockUserIdInput = it.filter(Char::isDigit) }, + label = { Text("User ID to block") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedButton( + onClick = { + blockUserIdInput.toLongOrNull()?.let(viewModel::blockUser) + blockUserIdInput = "" + }, + enabled = blockUserIdInput.isNotBlank() && !state.isSaving, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Block user") + } + state.blockedUsers.forEach { blocked -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - nightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + Column(modifier = Modifier.weight(1f)) { + Text(blocked.name, style = MaterialTheme.typography.bodyMedium) + if (!blocked.username.isNullOrBlank()) { + Text("@${blocked.username}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + OutlinedButton(onClick = { viewModel.unblockUser(blocked.id) }) { + Text("Unblock") + } } } } SettingsSectionCard { - SettingsRow(Icons.Filled.Shield, "Revoke all sessions", "Sign out from all other devices") { - viewModel.revokeAllSessions() + Text("Sessions & Security", style = MaterialTheme.typography.titleSmall) + state.sessions.forEach { session -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(Icons.Filled.Devices, contentDescription = null) + Column(modifier = Modifier.weight(1f)) { + Text(session.userAgent ?: "Unknown device", style = MaterialTheme.typography.bodyMedium) + Text(session.ipAddress ?: "Unknown IP", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + OutlinedButton( + onClick = { viewModel.revokeSession(session.jti) }, + enabled = !state.isSaving && session.current != true, + ) { + Text("Revoke") + } + } } - SettingsRow(Icons.Filled.Star, "Premium", "Placeholder section") {} - SettingsRow(Icons.AutoMirrored.Filled.HelpOutline, "Help", "FAQ and support") {} + OutlinedButton( + onClick = viewModel::revokeAllSessions, + enabled = !state.isSaving, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Security, contentDescription = null) + Text("Revoke all sessions", modifier = Modifier.padding(start = 6.dp)) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = viewModel::setupTwoFactor, enabled = !state.isSaving) { + Text("Setup 2FA") + } + OutlinedButton(onClick = viewModel::refreshRecoveryStatus, enabled = !state.isSaving) { + Text("Recovery status") + } + } + OutlinedTextField( + value = twoFactorCode, + onValueChange = { twoFactorCode = it }, + label = { Text("2FA code") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { viewModel.enableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { + Text("Enable") + } + OutlinedButton(onClick = { viewModel.disableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { + Text("Disable") + } + } + OutlinedTextField( + value = recoveryRegenerateCode, + onValueChange = { recoveryRegenerateCode = it }, + label = { Text("Code for recovery regeneration") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedButton( + onClick = { viewModel.regenerateRecoveryCodes(recoveryRegenerateCode) }, + enabled = recoveryRegenerateCode.isNotBlank() && !state.isSaving, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Regenerate recovery codes") + } + if (state.recoveryCodesRemaining != null) { + Text("Recovery codes left: ${state.recoveryCodesRemaining}", style = MaterialTheme.typography.bodySmall) + } + state.recoveryCodes.forEach { code -> + Text(code, style = MaterialTheme.typography.bodySmall) + } + } + + SettingsSectionCard { + SettingsActionRow(Icons.Filled.Email, "Profile", "Open profile") { onOpenProfile() } } if (!state.message.isNullOrBlank()) { @@ -327,16 +535,10 @@ fun SettingsScreen( Text(state.errorMessage.orEmpty(), color = MaterialTheme.colorScheme.error) } - OutlinedButton( - onClick = onBackToChats, - modifier = Modifier.fillMaxWidth(), - ) { + OutlinedButton(onClick = onBackToChats, modifier = Modifier.fillMaxWidth()) { Text("Back to chats") } - Button( - onClick = onLogout, - modifier = Modifier.fillMaxWidth(), - ) { + Button(onClick = onLogout, modifier = Modifier.fillMaxWidth()) { Text("Logout") } } @@ -346,7 +548,7 @@ fun SettingsScreen( @Composable private fun SettingsSectionCard(content: @Composable ColumnScope.() -> Unit) { Surface( - color = SettingsCard, + color = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(22.dp), modifier = Modifier.fillMaxWidth(), ) { @@ -354,14 +556,14 @@ private fun SettingsSectionCard(content: @Composable ColumnScope.() -> Unit) { modifier = Modifier .fillMaxWidth() .padding(horizontal = 14.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), content = content, ) } } @Composable -private fun SettingsRow( +private fun SettingsActionRow( icon: androidx.compose.ui.graphics.vector.ImageVector, title: String, subtitle: String, @@ -371,23 +573,15 @@ private fun SettingsRow( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(14.dp)) - .background(Color(0x141F232B)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) .padding(horizontal = 10.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Box( - modifier = Modifier - .size(34.dp) - .clip(CircleShape) - .background(Color(0xFF2A2F38)), - contentAlignment = Alignment.Center, - ) { - Icon(icon, contentDescription = null, tint = Color(0xFFB38BFF)) - } + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) Column(modifier = Modifier.weight(1f)) { - Text(title, color = Color.White, style = MaterialTheme.typography.bodyLarge) - Text(subtitle, color = SettingsMuted, style = MaterialTheme.typography.bodySmall) + Text(title, style = MaterialTheme.typography.bodyLarge) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } OutlinedButton(onClick = onClick) { Text("Open") @@ -395,6 +589,28 @@ private fun SettingsRow( } } +@Composable +private fun SettingsToggleRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(horizontal = 10.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text(title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + @Composable private fun ThemeButton( text: String,