android: align settings/profile with app theme and add real settings controls
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.settings.model
|
||||||
|
|
||||||
|
enum class AppThemeMode {
|
||||||
|
LIGHT,
|
||||||
|
DARK,
|
||||||
|
SYSTEM,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user