android: align settings/profile with app theme and add real settings controls
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 23:54:47 +03:00
parent daddbfd2a0
commit cdf7859668
10 changed files with 497 additions and 110 deletions

View File

@@ -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.

View File

@@ -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<String?>(null)
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
@@ -26,6 +33,14 @@ class MainActivity : AppCompatActivity() {
private var pendingNotificationMessageId by mutableStateOf<Long?>(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()

View File

@@ -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<Preferences>,
) : ThemeRepository {
override fun observeThemeMode(): Flow<AppThemeMode> {
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")
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,8 @@
package ru.daemonlord.messenger.domain.settings.model
enum class AppThemeMode {
LIGHT,
DARK,
SYSTEM,
}

View File

@@ -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<AppThemeMode>
suspend fun getThemeMode(): AppThemeMode
suspend fun setThemeMode(mode: AppThemeMode)
}

View File

@@ -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<StoredAccountUi> = 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,
)

View File

@@ -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<AccountUiState> = _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<AppResult.Error>()
.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)

View File

@@ -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)
}
}

View File

@@ -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,