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, - shows saved accounts,
- allows switch/remove, - allows switch/remove,
- triggers auth recheck + chats reload on switch. - 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.ui.Modifier
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras 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.navigation.MessengerNavHost
import ru.daemonlord.messenger.ui.theme.MessengerTheme import ru.daemonlord.messenger.ui.theme.MessengerTheme
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@Inject
lateinit var themeRepository: ThemeRepository
private var pendingInviteToken by mutableStateOf<String?>(null) private var pendingInviteToken by mutableStateOf<String?>(null)
private var pendingVerifyEmailToken by mutableStateOf<String?>(null) private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
private var pendingResetPasswordToken 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) private var pendingNotificationMessageId by mutableStateOf<Long?>(null)
override fun onCreate(savedInstanceState: Bundle?) { 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) super.onCreate(savedInstanceState)
pendingInviteToken = intent.extractInviteToken() pendingInviteToken = intent.extractInviteToken()
pendingVerifyEmailToken = intent.extractVerifyEmailToken() 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.message.repository.NetworkMessageRepository
import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository
import ru.daemonlord.messenger.data.search.repository.NetworkSearchRepository 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.data.user.repository.NetworkAccountRepository
import ru.daemonlord.messenger.domain.account.repository.AccountRepository import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository 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.message.repository.MessageRepository
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import ru.daemonlord.messenger.domain.search.repository.SearchRepository import ru.daemonlord.messenger.domain.search.repository.SearchRepository
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -81,4 +83,10 @@ abstract class RepositoryModule {
abstract fun bindSearchRepository( abstract fun bindSearchRepository(
repository: NetworkSearchRepository, repository: NetworkSearchRepository,
): SearchRepository ): 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.account.model.UserSearchItem
import ru.daemonlord.messenger.domain.auth.model.AuthSession import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthUser import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
data class AccountUiState( data class AccountUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -16,6 +17,10 @@ data class AccountUiState(
val recoveryCodesRemaining: Int? = null, val recoveryCodesRemaining: Int? = null,
val activeUserId: Long? = null, val activeUserId: Long? = null,
val storedAccounts: List<StoredAccountUi> = emptyList(), 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 message: String? = null,
val errorMessage: 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.appcompat.app.AppCompatDelegate
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -9,15 +10,22 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.daemonlord.messenger.core.token.TokenRepository 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.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult 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 import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AccountViewModel @Inject constructor( class AccountViewModel @Inject constructor(
private val accountRepository: AccountRepository, private val accountRepository: AccountRepository,
private val tokenRepository: TokenRepository, private val tokenRepository: TokenRepository,
private val loginUseCase: LoginUseCase,
private val notificationSettingsRepository: NotificationSettingsRepository,
private val themeRepository: ThemeRepository,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(AccountUiState()) private val _uiState = MutableStateFlow(AccountUiState())
val uiState: StateFlow<AccountUiState> = _uiState.asStateFlow() val uiState: StateFlow<AccountUiState> = _uiState.asStateFlow()
@@ -34,6 +42,8 @@ class AccountViewModel @Inject constructor(
val blocked = accountRepository.listBlockedUsers() val blocked = accountRepository.listBlockedUsers()
val activeUserId = tokenRepository.getActiveUserId() val activeUserId = tokenRepository.getActiveUserId()
val storedAccounts = tokenRepository.getAccounts() val storedAccounts = tokenRepository.getAccounts()
val notificationSettings = notificationSettingsRepository.getSettings()
val appThemeMode = themeRepository.getThemeMode()
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
isLoading = false, isLoading = false,
@@ -53,6 +63,9 @@ class AccountViewModel @Inject constructor(
isActive = activeUserId == account.userId, isActive = activeUserId == account.userId,
) )
}, },
notificationsEnabled = notificationSettings.globalEnabled,
notificationsPreviewEnabled = notificationSettings.previewEnabled,
appThemeMode = appThemeMode,
errorMessage = listOf(me, sessions, blocked) errorMessage = listOf(me, sessions, blocked)
.filterIsInstance<AppResult.Error>() .filterIsInstance<AppResult.Error>()
.firstOrNull() .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 = {}) { fun switchStoredAccount(userId: Long, onSwitched: (Boolean) -> Unit = {}) {
viewModelScope.launch { viewModelScope.launch {
val switched = tokenRepository.switchAccount(userId) 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -65,10 +64,6 @@ import kotlinx.coroutines.flow.collectLatest
import ru.daemonlord.messenger.ui.account.AccountViewModel import ru.daemonlord.messenger.ui.account.AccountViewModel
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
private val ProfileBg = Color(0xFF0D0F12)
private val ProfileCard = Color(0xFF171A1F)
private val ProfileMuted = Color(0xFFA2A7B3)
@Composable @Composable
fun ProfileRoute( fun ProfileRoute(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
@@ -97,7 +92,7 @@ fun ProfileScreen(
var username by remember(profile?.username) { mutableStateOf(profile?.username.orEmpty()) } var username by remember(profile?.username) { mutableStateOf(profile?.username.orEmpty()) }
var bio by remember(profile?.bio) { mutableStateOf(profile?.bio.orEmpty()) } var bio by remember(profile?.bio) { mutableStateOf(profile?.bio.orEmpty()) }
var avatarUrl by remember(profile?.avatarUrl) { mutableStateOf(profile?.avatarUrl.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) } var editMode by remember { mutableStateOf(false) }
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
@@ -136,7 +131,7 @@ fun ProfileScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(ProfileBg) .background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.safeDrawing), .windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter, contentAlignment = Alignment.TopCenter,
) { ) {
@@ -153,7 +148,11 @@ fun ProfileScreen(
.height(305.dp) .height(305.dp)
.background( .background(
Brush.verticalGradient( 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 modifier = Modifier
.size(108.dp) .size(108.dp)
.clip(CircleShape) .clip(CircleShape)
.border(1.dp, Color(0x88FFFFFF), CircleShape), .border(1.dp, MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f), CircleShape),
) )
} else { } else {
Box( Box(
modifier = Modifier modifier = Modifier
.size(108.dp) .size(108.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0x55FFFFFF)), .background(MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = name.firstOrNull()?.uppercase() ?: "?", text = name.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
color = Color.White, color = MaterialTheme.colorScheme.onPrimaryContainer,
) )
} }
} }
@@ -193,12 +192,12 @@ fun ProfileScreen(
Text( Text(
text = if (name.isBlank()) "User" else name, text = if (name.isBlank()) "User" else name,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
color = Color.White, color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -234,7 +233,7 @@ fun ProfileScreen(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Surface( Surface(
color = ProfileCard, color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(22.dp),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -254,7 +253,7 @@ fun ProfileScreen(
} }
Surface( Surface(
color = ProfileCard, color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(18.dp), shape = RoundedCornerShape(18.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -271,7 +270,7 @@ fun ProfileScreen(
} }
Surface( Surface(
color = ProfileCard, color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(22.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -282,10 +281,10 @@ fun ProfileScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp), 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( Text(
"Publish something in your profile.", "Publish something in your profile.",
color = ProfileMuted, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
Button(onClick = {}, modifier = Modifier.padding(top = 6.dp)) { Button(onClick = {}, modifier = Modifier.padding(top = 6.dp)) {
@@ -296,7 +295,7 @@ fun ProfileScreen(
if (editMode) { if (editMode) {
Surface( Surface(
color = ProfileCard, color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(22.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -306,7 +305,7 @@ fun ProfileScreen(
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text("Edit profile", color = Color.White, style = MaterialTheme.typography.titleMedium) Text("Edit profile", style = MaterialTheme.typography.titleMedium)
OutlinedTextField( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
@@ -390,7 +389,7 @@ private fun HeroActionButton(
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
Surface( Surface(
color = Color(0x40FFFFFF), color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.8f),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
modifier = modifier, modifier = modifier,
) { ) {
@@ -407,8 +406,8 @@ private fun HeroActionButton(
@Composable @Composable
private fun ProfileInfoRow(label: String, value: String) { private fun ProfileInfoRow(label: String, value: String) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = value, style = MaterialTheme.typography.titleLarge, color = Color.White) Text(text = value, style = MaterialTheme.typography.titleLarge)
Text(text = label, style = MaterialTheme.typography.bodyMedium, color = ProfileMuted) Text(text = label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }

View File

@@ -1,17 +1,14 @@
package ru.daemonlord.messenger.ui.settings package ru.daemonlord.messenger.ui.settings
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size 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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.HelpOutline import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BatterySaver import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material.icons.filled.DataUsage
import androidx.compose.material.icons.filled.Devices 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.Lock
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -61,12 +58,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.ui.account.AccountViewModel import ru.daemonlord.messenger.ui.account.AccountViewModel
private val SettingsBackground = Color(0xFF0D0F12)
private val SettingsCard = Color(0xFF1A1D23)
private val SettingsMuted = Color(0xFF9EA3B0)
@Composable @Composable
fun SettingsRoute( fun SettingsRoute(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
@@ -99,7 +93,19 @@ fun SettingsScreen(
val profile = state.profile val profile = state.profile
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 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) { LaunchedEffect(Unit) {
viewModel.refresh() 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(SettingsBackground) .background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.safeDrawing), .windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter, contentAlignment = Alignment.TopCenter,
) { ) {
@@ -139,14 +193,14 @@ fun SettingsScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Text("Settings", style = MaterialTheme.typography.headlineSmall, color = Color.White) Text("Settings", style = MaterialTheme.typography.headlineSmall)
if (state.isLoading) { if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
} }
} }
Surface( Surface(
color = SettingsCard, color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(22.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -170,13 +224,12 @@ fun SettingsScreen(
modifier = Modifier modifier = Modifier
.size(58.dp) .size(58.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFF6650A4)), .background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = profile?.name?.firstOrNull()?.uppercase() ?: "?", text = profile?.name?.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = Color.White,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
} }
@@ -185,7 +238,6 @@ fun SettingsScreen(
Text( Text(
text = profile?.name ?: "Loading...", text = profile?.name ?: "Loading...",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = Color.White,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -195,25 +247,35 @@ fun SettingsScreen(
profile?.username?.takeIf { it.isNotBlank() }?.let { "@$it" }, profile?.username?.takeIf { it.isNotBlank() }?.let { "@$it" },
).joinToString(""), ).joinToString(""),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = SettingsMuted, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
IconButton(onClick = onOpenProfile) { IconButton(onClick = onOpenProfile) {
Icon(Icons.Filled.Person, contentDescription = "Profile", tint = Color.White) Icon(Icons.Filled.Person, contentDescription = "Profile")
} }
} }
} }
SettingsSectionCard { 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 -> state.storedAccounts.forEach { account ->
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(14.dp)) .clip(RoundedCornerShape(14.dp))
.background(Color(0x141F232B)) .background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(horizontal = 10.dp, vertical = 10.dp), .padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
@@ -229,12 +291,11 @@ fun SettingsScreen(
modifier = Modifier modifier = Modifier
.size(34.dp) .size(34.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFF2A2F38)), .background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = account.title.firstOrNull()?.uppercase() ?: "?", text = account.title.firstOrNull()?.uppercase() ?: "?",
color = Color.White,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
) )
} }
@@ -242,19 +303,18 @@ fun SettingsScreen(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = account.title, text = account.title,
color = Color.White,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
Text( Text(
text = account.subtitle, text = account.subtitle,
color = SettingsMuted, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
if (account.isActive) { if (account.isActive) {
Text("Active", color = Color(0xFF9EDB9D), style = MaterialTheme.typography.labelSmall) Text("Active", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelSmall)
} else { } else {
OutlinedButton( OutlinedButton(
onClick = { onClick = {
@@ -267,57 +327,205 @@ fun SettingsScreen(
} }
} }
OutlinedButton(onClick = { viewModel.removeStoredAccount(account.userId) }) { OutlinedButton(onClick = { viewModel.removeStoredAccount(account.userId) }) {
Text("Remove") Icon(Icons.Filled.DeleteOutline, contentDescription = null)
} }
} }
} }
} }
SettingsSectionCard { SettingsSectionCard {
SettingsRow(Icons.Filled.Person, "Account", "Name, username, bio") { onOpenProfile() } Text("Appearance", style = MaterialTheme.typography.titleSmall)
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))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ThemeButton( ThemeButton(
text = "Light", text = "Light",
selected = nightMode == AppCompatDelegate.MODE_NIGHT_NO, selected = state.appThemeMode == AppThemeMode.LIGHT,
) { ) { viewModel.setThemeMode(AppThemeMode.LIGHT) }
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
nightMode = AppCompatDelegate.MODE_NIGHT_NO
}
ThemeButton( ThemeButton(
text = "Dark", text = "Dark",
selected = nightMode == AppCompatDelegate.MODE_NIGHT_YES, selected = state.appThemeMode == AppThemeMode.DARK,
) { ) { viewModel.setThemeMode(AppThemeMode.DARK) }
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
nightMode = AppCompatDelegate.MODE_NIGHT_YES
}
ThemeButton( ThemeButton(
text = "System", 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) Column(modifier = Modifier.weight(1f)) {
nightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM 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 { SettingsSectionCard {
SettingsRow(Icons.Filled.Shield, "Revoke all sessions", "Sign out from all other devices") { Text("Sessions & Security", style = MaterialTheme.typography.titleSmall)
viewModel.revokeAllSessions() 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") {} OutlinedButton(
SettingsRow(Icons.AutoMirrored.Filled.HelpOutline, "Help", "FAQ and support") {} 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()) { if (!state.message.isNullOrBlank()) {
@@ -327,16 +535,10 @@ fun SettingsScreen(
Text(state.errorMessage.orEmpty(), color = MaterialTheme.colorScheme.error) Text(state.errorMessage.orEmpty(), color = MaterialTheme.colorScheme.error)
} }
OutlinedButton( OutlinedButton(onClick = onBackToChats, modifier = Modifier.fillMaxWidth()) {
onClick = onBackToChats,
modifier = Modifier.fillMaxWidth(),
) {
Text("Back to chats") Text("Back to chats")
} }
Button( Button(onClick = onLogout, modifier = Modifier.fillMaxWidth()) {
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
) {
Text("Logout") Text("Logout")
} }
} }
@@ -346,7 +548,7 @@ fun SettingsScreen(
@Composable @Composable
private fun SettingsSectionCard(content: @Composable ColumnScope.() -> Unit) { private fun SettingsSectionCard(content: @Composable ColumnScope.() -> Unit) {
Surface( Surface(
color = SettingsCard, color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(22.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -354,14 +556,14 @@ private fun SettingsSectionCard(content: @Composable ColumnScope.() -> Unit) {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp), .padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
content = content, content = content,
) )
} }
} }
@Composable @Composable
private fun SettingsRow( private fun SettingsActionRow(
icon: androidx.compose.ui.graphics.vector.ImageVector, icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String, title: String,
subtitle: String, subtitle: String,
@@ -371,23 +573,15 @@ private fun SettingsRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(14.dp)) .clip(RoundedCornerShape(14.dp))
.background(Color(0x141F232B)) .background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(horizontal = 10.dp, vertical = 10.dp), .padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Box( Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
modifier = Modifier
.size(34.dp)
.clip(CircleShape)
.background(Color(0xFF2A2F38)),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = Color(0xFFB38BFF))
}
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text(title, color = Color.White, style = MaterialTheme.typography.bodyLarge) Text(title, style = MaterialTheme.typography.bodyLarge)
Text(subtitle, color = SettingsMuted, style = MaterialTheme.typography.bodySmall) Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
OutlinedButton(onClick = onClick) { OutlinedButton(onClick = onClick) {
Text("Open") 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 @Composable
private fun ThemeButton( private fun ThemeButton(
text: String, text: String,