android: persist language settings and realtime/ui sync updates
Some checks failed
Android CI / android (push) Failing after 5m7s
Android Release / release (push) Failing after 5m5s
CI / test (push) Failing after 2m49s

This commit is contained in:
2026-03-11 04:52:03 +03:00
parent cd7fb878b3
commit cdb45abb21
22 changed files with 909 additions and 237 deletions

View File

@@ -13,10 +13,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.fillMaxSize
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
@@ -28,6 +30,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var themeRepository: ThemeRepository
@Inject
lateinit var languageRepository: LanguageRepository
@Inject
lateinit var notificationDispatcher: NotificationDispatcher
@@ -51,6 +56,13 @@ class MainActivity : AppCompatActivity() {
AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
)
val savedLanguageTag = if (this::languageRepository.isInitialized) {
runBlocking { languageRepository.getLanguage().tag }
} else {
null
}
val locales = savedLanguageTag?.let { LocaleListCompat.forLanguageTags(it) } ?: LocaleListCompat.getEmptyLocaleList()
AppCompatDelegate.setApplicationLocales(locales)
pendingInviteToken = intent.extractInviteToken()
pendingVerifyEmailToken = intent.extractVerifyEmailToken()
pendingResetPasswordToken = intent.extractResetPasswordToken()

View File

@@ -0,0 +1,44 @@
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 javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
@Singleton
class DataStoreLanguageRepository @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : LanguageRepository {
override fun observeLanguage(): Flow<AppLanguage> {
return dataStore.data.map { prefs ->
AppLanguage.fromTag(prefs[LANGUAGE_TAG_KEY])
}
}
override suspend fun getLanguage(): AppLanguage {
return observeLanguage().first()
}
override suspend fun setLanguage(language: AppLanguage) {
dataStore.edit { prefs ->
if (language.tag == null) {
prefs.remove(LANGUAGE_TAG_KEY)
} else {
prefs[LANGUAGE_TAG_KEY] = language.tag
}
}
}
private companion object {
val LANGUAGE_TAG_KEY = stringPreferencesKey("app_language_tag")
}
}

View File

@@ -12,6 +12,7 @@ import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository
import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository
import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository
import ru.daemonlord.messenger.data.search.repository.NetworkSearchRepository
import ru.daemonlord.messenger.data.settings.repository.DataStoreLanguageRepository
import ru.daemonlord.messenger.data.settings.repository.DataStoreThemeRepository
import ru.daemonlord.messenger.data.user.repository.NetworkAccountRepository
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
@@ -23,6 +24,7 @@ import ru.daemonlord.messenger.domain.media.repository.MediaRepository
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import javax.inject.Singleton
@@ -89,4 +91,10 @@ abstract class RepositoryModule {
abstract fun bindThemeRepository(
repository: DataStoreThemeRepository,
): ThemeRepository
@Binds
@Singleton
abstract fun bindLanguageRepository(
repository: DataStoreLanguageRepository,
): LanguageRepository
}

View File

@@ -10,6 +10,7 @@ import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.ChatNotificationPayload
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
@@ -28,6 +29,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
private val messageDao: MessageDao,
private val notificationDispatcher: NotificationDispatcher,
private val activeChatTracker: ActiveChatTracker,
private val tokenRepository: TokenRepository,
private val notificationSettingsRepository: NotificationSettingsRepository,
private val shouldShowMessageNotificationUseCase: ShouldShowMessageNotificationUseCase,
) {
@@ -83,10 +85,26 @@ class HandleRealtimeEventsUseCase @Inject constructor(
} else {
chatDao.incrementUnread(chatId = event.chatId)
}
val activeUserId = tokenRepository.getActiveUserId()
val myUsername = activeUserId?.let { userId ->
tokenRepository.getAccounts()
.firstOrNull { it.userId == userId }
?.username
?.trim()
?.removePrefix("@")
?.lowercase()
}
val isMentionByText = if (myUsername.isNullOrBlank()) {
false
} else {
Regex("(^|\\W)@${Regex.escape(myUsername)}(\\W|$)", RegexOption.IGNORE_CASE)
.containsMatchIn(event.text.orEmpty())
}
val isMention = event.isMention || isMentionByText
val muted = chatDao.isChatMuted(event.chatId) == true
val shouldNotify = shouldShowMessageNotificationUseCase(
chatId = event.chatId,
isMention = event.isMention,
isMention = isMention,
serverMuted = muted,
)
if (activeChatId != event.chatId && shouldNotify) {
@@ -110,7 +128,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
messageId = event.messageId,
title = title,
body = body,
isMention = event.isMention,
isMention = isMention,
)
)
}

View File

@@ -0,0 +1,15 @@
package ru.daemonlord.messenger.domain.settings.model
enum class AppLanguage(val tag: String?) {
SYSTEM(null),
RUSSIAN("ru"),
ENGLISH("en");
companion object {
fun fromTag(tag: String?): AppLanguage {
if (tag.isNullOrBlank()) return SYSTEM
return entries.firstOrNull { it.tag.equals(tag, ignoreCase = true) } ?: 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.AppLanguage
interface LanguageRepository {
fun observeLanguage(): Flow<AppLanguage>
suspend fun getLanguage(): AppLanguage
suspend fun setLanguage(language: AppLanguage)
}

View File

@@ -4,6 +4,7 @@ import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import javax.inject.Inject
@@ -16,8 +17,14 @@ class MessengerFirebaseMessagingService : FirebaseMessagingService() {
@Inject
lateinit var pushTokenSyncManager: PushTokenSyncManager
@Inject
lateinit var activeChatTracker: ActiveChatTracker
override fun onMessageReceived(message: RemoteMessage) {
val payload = PushPayloadParser.parse(message) ?: return
if (activeChatTracker.activeChatId.value == payload.chatId) {
return
}
notificationDispatcher.showChatMessage(payload)
}

View File

@@ -29,7 +29,8 @@ object PushPayloadParser {
?: data["text"]
?: "Open chat"
val isMention = data["is_mention"]?.equals("true", ignoreCase = true) == true ||
data["mention"]?.equals("true", ignoreCase = true) == true
data["mention"]?.equals("true", ignoreCase = true) == true ||
data["type"]?.equals("mention", ignoreCase = true) == true
return ChatNotificationPayload(
chatId = chatId,

View File

@@ -4,6 +4,7 @@ import ru.daemonlord.messenger.domain.account.model.UserSearchItem
import ru.daemonlord.messenger.domain.account.model.AccountNotification
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
data class AccountUiState(
@@ -19,6 +20,7 @@ data class AccountUiState(
val activeUserId: Long? = null,
val storedAccounts: List<StoredAccountUi> = emptyList(),
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
val appLanguage: AppLanguage = AppLanguage.SYSTEM,
val notificationsEnabled: Boolean = true,
val notificationsPreviewEnabled: Boolean = true,
val notificationsHistory: List<AccountNotification> = emptyList(),

View File

@@ -3,6 +3,7 @@ package ru.daemonlord.messenger.ui.account
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -18,7 +19,9 @@ import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import javax.inject.Inject
@@ -30,6 +33,7 @@ class AccountViewModel @Inject constructor(
private val chatRepository: ChatRepository,
private val realtimeManager: RealtimeManager,
private val notificationSettingsRepository: NotificationSettingsRepository,
private val languageRepository: LanguageRepository,
private val themeRepository: ThemeRepository,
private val pushTokenSyncManager: PushTokenSyncManager,
) : ViewModel() {
@@ -51,6 +55,7 @@ class AccountViewModel @Inject constructor(
val storedAccounts = tokenRepository.getAccounts()
val notificationSettings = notificationSettingsRepository.getSettings()
val appThemeMode = themeRepository.getThemeMode()
val appLanguage = languageRepository.getLanguage()
_uiState.update { state ->
state.copy(
isLoading = false,
@@ -74,6 +79,7 @@ class AccountViewModel @Inject constructor(
notificationsEnabled = notificationSettings.globalEnabled,
notificationsPreviewEnabled = notificationSettings.previewEnabled,
appThemeMode = appThemeMode,
appLanguage = appLanguage,
errorMessage = listOf(me, sessions, blocked, notifications)
.filterIsInstance<AppResult.Error>()
.firstOrNull()
@@ -98,6 +104,16 @@ class AccountViewModel @Inject constructor(
}
}
fun setLanguage(language: AppLanguage) {
viewModelScope.launch {
languageRepository.setLanguage(language)
val locales = language.tag?.let { LocaleListCompat.forLanguageTags(it) }
?: LocaleListCompat.getEmptyLocaleList()
AppCompatDelegate.setApplicationLocales(locales)
_uiState.update { it.copy(appLanguage = language) }
}
}
fun setGlobalNotificationsEnabled(enabled: Boolean) {
viewModelScope.launch {
notificationSettingsRepository.setGlobalEnabled(enabled)

View File

@@ -19,7 +19,7 @@ data class AuthUiState(
val isCheckingSession: Boolean = true,
val isLoading: Boolean = false,
val isAuthenticated: Boolean = false,
val authCompletedNonce: Long = 0L,
val successMessage: String? = null,
val errorMessage: String? = null,
)

View File

@@ -193,6 +193,7 @@ class AuthViewModel @Inject constructor(
it.copy(
isLoading = false,
isAuthenticated = true,
authCompletedNonce = System.currentTimeMillis(),
errorMessage = null,
successMessage = null,
)
@@ -253,6 +254,7 @@ class AuthViewModel @Inject constructor(
it.copy(
isLoading = false,
isAuthenticated = true,
authCompletedNonce = System.currentTimeMillis(),
errorMessage = null,
)
}
@@ -297,6 +299,26 @@ class AuthViewModel @Inject constructor(
restoreSession()
}
fun startAddAccountFlow() {
_uiState.update {
it.copy(
step = AuthStep.EMAIL,
email = "",
name = "",
username = "",
password = "",
otpCode = "",
recoveryCode = "",
useRecoveryCode = false,
isCheckingSession = false,
isLoading = false,
isAuthenticated = false,
successMessage = null,
errorMessage = null,
)
}
}
private fun restoreSession() {
viewModelScope.launch {
_uiState.update { it.copy(isCheckingSession = true) }

View File

@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
@Composable
fun LoginScreen(
state: AuthUiState,
headerTitle: String = "Messenger Login",
onEmailChanged: (String) -> Unit,
onNameChanged: (String) -> Unit,
onUsernameChanged: (String) -> Unit,
@@ -57,7 +58,7 @@ fun LoginScreen(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Messenger Login",
text = headerTitle,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 14.dp),
)

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatUseCase
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
import ru.daemonlord.messenger.domain.common.AppError
@@ -57,6 +58,7 @@ class ChatViewModel @Inject constructor(
private val forwardMessageBulkUseCase: ForwardMessageBulkUseCase,
private val listMessageReactionsUseCase: ListMessageReactionsUseCase,
private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase,
private val chatRepository: ChatRepository,
private val observeChatUseCase: ObserveChatUseCase,
private val observeChatsUseCase: ObserveChatsUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
@@ -446,6 +448,62 @@ class ChatViewModel @Inject constructor(
}
}
fun onToggleChatNotifications() {
viewModelScope.launch {
when (val current = chatRepository.getChatNotifications(chatId = chatId)) {
is AppResult.Success -> {
when (val updated = chatRepository.updateChatNotifications(chatId = chatId, muted = !current.data.muted)) {
is AppResult.Success -> _uiState.update {
it.copy(errorMessage = if (updated.data.muted) "Notifications muted." else "Notifications enabled.")
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = updated.reason.toUiMessage()) }
}
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = current.reason.toUiMessage()) }
}
}
}
fun onClearHistory() {
viewModelScope.launch {
when (val result = chatRepository.clearChat(chatId = chatId)) {
is AppResult.Success -> _uiState.update {
it.copy(
selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
actionState = it.actionState.clearSelection(),
errorMessage = "Chat history cleared.",
)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun onDeleteOrLeaveChat() {
viewModelScope.launch {
val type = uiState.value.chatType.lowercase()
val result = when (type) {
"group", "channel" -> chatRepository.leaveChat(chatId = chatId)
else -> chatRepository.removeChat(chatId = chatId, forAll = false)
}
when (result) {
is AppResult.Success -> _uiState.update {
it.copy(
selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
actionState = it.actionState.clearSelection(),
errorMessage = null,
chatDeletedNonce = it.chatDeletedNonce + 1L,
)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun onSendClick() {
val text = uiState.value.inputText.trim()
if (text.isBlank()) return
@@ -792,7 +850,6 @@ class ChatViewModel @Inject constructor(
override fun onCleared() {
activeChatTracker.clearActiveChat(chatId)
handleRealtimeEventsUseCase.stop()
super.onCleared()
}

View File

@@ -38,6 +38,7 @@ data class MessageUiState(
val inlineSearchMatches: List<Long> = emptyList(),
val highlightedMessageId: Long? = null,
val actionState: MessageActionState = MessageActionState(),
val chatDeletedNonce: Long = 0L,
)
data class ForwardTargetUiModel(

View File

@@ -346,6 +346,15 @@ class ChatListViewModel @Inject constructor(
}
}
fun deleteChatForAll(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.removeChat(chatId = chatId, forAll = true)) {
is AppResult.Success -> _uiState.update { it.copy(managementMessage = "Чат удален для всех.") }
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun toggleChatMute(chatId: Long) {
viewModelScope.launch {
when (val current = chatRepository.getChatNotifications(chatId = chatId)) {
@@ -619,7 +628,6 @@ class ChatListViewModel @Inject constructor(
}
override fun onCleared() {
handleRealtimeEventsUseCase.stop()
super.onCleared()
}
}

View File

@@ -41,6 +41,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -57,6 +58,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.ui.auth.AuthViewModel
import ru.daemonlord.messenger.ui.auth.LoginScreen
import ru.daemonlord.messenger.ui.auth.reset.ResetPasswordRoute
@@ -71,6 +73,7 @@ private object Routes {
const val Startup = "startup"
const val AuthGraph = "auth_graph"
const val Login = "login"
const val AddAccountLogin = "add_account_login"
const val VerifyEmail = "verify_email"
const val ResetPassword = "reset_password"
const val Chats = "chats"
@@ -181,6 +184,7 @@ fun MessengerNavHost(
composable(route = Routes.Login) {
LoginScreen(
state = uiState,
headerTitle = "Messenger Login",
onEmailChanged = viewModel::onEmailChanged,
onNameChanged = viewModel::onNameChanged,
onUsernameChanged = viewModel::onUsernameChanged,
@@ -195,6 +199,49 @@ fun MessengerNavHost(
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) },
)
}
composable(route = Routes.AddAccountLogin) { entry ->
val addAccountViewModel: AuthViewModel = hiltViewModel(entry)
val addAccountState by addAccountViewModel.uiState.collectAsState()
val lastCompletedNonce = remember { mutableStateOf(0L) }
LaunchedEffect(Unit) {
addAccountViewModel.startAddAccountFlow()
}
LaunchedEffect(addAccountState.authCompletedNonce) {
val nonce = addAccountState.authCompletedNonce
if (nonce == 0L || nonce == lastCompletedNonce.value) return@LaunchedEffect
lastCompletedNonce.value = nonce
viewModel.recheckSession()
navController.navigate(Routes.Chats) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = false
}
launchSingleTop = true
restoreState = false
}
}
LoginScreen(
state = addAccountState,
headerTitle = "Add account",
onEmailChanged = addAccountViewModel::onEmailChanged,
onNameChanged = addAccountViewModel::onNameChanged,
onUsernameChanged = addAccountViewModel::onUsernameChanged,
onPasswordChanged = addAccountViewModel::onPasswordChanged,
onOtpCodeChanged = addAccountViewModel::onOtpCodeChanged,
onRecoveryCodeChanged = addAccountViewModel::onRecoveryCodeChanged,
onToggleRecoveryCodeMode = addAccountViewModel::toggleRecoveryCodeMode,
onContinueEmail = addAccountViewModel::continueWithEmail,
onSubmitStep = addAccountViewModel::submitAuthStep,
onBackToEmail = {
if (addAccountState.step == ru.daemonlord.messenger.ui.auth.AuthStep.EMAIL) {
navController.popBackStack()
} else {
addAccountViewModel.backToEmailStep()
}
},
onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) },
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) },
)
}
}
composable(
@@ -248,6 +295,7 @@ fun MessengerNavHost(
composable(route = Routes.Settings) {
SettingsRoute(
onOpenProfile = { navController.navigate(Routes.Profile) },
onAddAccount = { navController.navigate(Routes.AddAccountLogin) },
onSwitchAccount = {
viewModel.recheckSession()
navController.navigate(Routes.Chats) {
@@ -329,9 +377,9 @@ private fun MainBottomBar(
NavigationBarItem(
selected = currentRoute == Routes.Chats,
onClick = { onNavigate(Routes.Chats) },
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = "Chats") },
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = stringResource(id = R.string.nav_chats)) },
label = {
androidx.compose.material3.Text("Chats", maxLines = 1, overflow = TextOverflow.Ellipsis)
androidx.compose.material3.Text(stringResource(id = R.string.nav_chats), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
@@ -345,9 +393,9 @@ private fun MainBottomBar(
NavigationBarItem(
selected = currentRoute == Routes.Contacts,
onClick = { onNavigate(Routes.Contacts) },
icon = { Icon(Icons.Filled.Contacts, contentDescription = "Contacts") },
icon = { Icon(Icons.Filled.Contacts, contentDescription = stringResource(id = R.string.nav_contacts)) },
label = {
androidx.compose.material3.Text("Contacts", maxLines = 1, overflow = TextOverflow.Ellipsis)
androidx.compose.material3.Text(stringResource(id = R.string.nav_contacts), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
@@ -361,9 +409,9 @@ private fun MainBottomBar(
NavigationBarItem(
selected = currentRoute == Routes.Settings,
onClick = { onNavigate(Routes.Settings) },
icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") },
icon = { Icon(Icons.Filled.Settings, contentDescription = stringResource(id = R.string.nav_settings)) },
label = {
androidx.compose.material3.Text("Settings", maxLines = 1, overflow = TextOverflow.Ellipsis)
androidx.compose.material3.Text(stringResource(id = R.string.nav_settings), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
@@ -377,9 +425,9 @@ private fun MainBottomBar(
NavigationBarItem(
selected = currentRoute == Routes.Profile,
onClick = { onNavigate(Routes.Profile) },
icon = { Icon(Icons.Filled.Person, contentDescription = "Profile") },
icon = { Icon(Icons.Filled.Person, contentDescription = stringResource(id = R.string.nav_profile)) },
label = {
androidx.compose.material3.Text("Profile", maxLines = 1, overflow = TextOverflow.Ellipsis)
androidx.compose.material3.Text(stringResource(id = R.string.nav_profile), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(

View File

@@ -10,12 +10,14 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -39,6 +41,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -49,19 +52,29 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.window.Dialog
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import kotlinx.coroutines.flow.collectLatest
import ru.daemonlord.messenger.ui.account.AccountViewModel
import java.io.ByteArrayOutputStream
import kotlin.math.max
@Composable
fun ProfileRoute(
@@ -86,6 +99,7 @@ fun ProfileScreen(
var bio by remember(profile?.bio) { mutableStateOf(profile?.bio.orEmpty()) }
var avatarUrl by remember(profile?.avatarUrl) { mutableStateOf(profile?.avatarUrl.orEmpty()) }
var editMode by remember { mutableStateOf(false) }
var pendingAvatarBitmap by remember { mutableStateOf<Bitmap?>(null) }
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
@@ -110,14 +124,7 @@ fun ProfileScreen(
val pickAvatarLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
) { uri ->
val bytes = uri?.toSquareJpeg(context) ?: return@rememberLauncherForActivityResult
viewModel.uploadAvatar(
fileName = "avatar.jpg",
mimeType = "image/jpeg",
bytes = bytes,
) { uploadedUrl ->
avatarUrl = uploadedUrl
}
pendingAvatarBitmap = uri?.toBitmap(context)
}
Box(
@@ -134,80 +141,82 @@ fun ProfileScreen(
.verticalScroll(scrollState)
.padding(bottom = 96.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(305.dp)
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.secondaryContainer,
MaterialTheme.colorScheme.tertiaryContainer,
if (!editMode) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(305.dp)
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.secondaryContainer,
MaterialTheme.colorScheme.tertiaryContainer,
),
),
),
),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Spacer(modifier = Modifier.height(6.dp))
if (avatarUrl.isNotBlank()) {
AsyncImage(
model = avatarUrl,
contentDescription = "Avatar",
modifier = Modifier
.size(108.dp)
.clip(CircleShape)
.border(1.dp, MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f), CircleShape),
)
} else {
Box(
modifier = Modifier
.size(108.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center,
) {
Text(
text = name.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Text(
text = if (name.isBlank()) "User" else name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text("online", color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), style = MaterialTheme.typography.bodyLarge)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
HeroActionButton(
label = "Choose photo",
icon = Icons.Filled.AddAPhoto,
modifier = Modifier.weight(1f),
) {
pickAvatarLauncher.launch("image/*")
Spacer(modifier = Modifier.height(6.dp))
if (avatarUrl.isNotBlank()) {
AsyncImage(
model = avatarUrl,
contentDescription = "Avatar",
modifier = Modifier
.size(108.dp)
.clip(CircleShape)
.border(1.dp, MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f), CircleShape),
)
} else {
Box(
modifier = Modifier
.size(108.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center,
) {
Text(
text = name.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
HeroActionButton(
label = if (editMode) "Editing" else "Edit",
icon = Icons.Filled.Edit,
modifier = Modifier.weight(1f),
Text(
text = if (name.isBlank()) "User" else name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text("online", color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), style = MaterialTheme.typography.bodyLarge)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
editMode = !editMode
HeroActionButton(
label = "Choose photo",
icon = Icons.Filled.AddAPhoto,
modifier = Modifier.weight(1f),
) {
pickAvatarLauncher.launch("image/*")
}
HeroActionButton(
label = "Edit",
icon = Icons.Filled.Edit,
modifier = Modifier.weight(1f),
) {
editMode = true
}
}
}
}
@@ -217,23 +226,25 @@ fun ProfileScreen(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(22.dp),
modifier = Modifier
.fillMaxWidth()
.offset(y = (-22).dp),
) {
Column(
if (!editMode) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(22.dp),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
.offset(y = (-22).dp),
) {
ProfileInfoRow("Email", profile?.email.orEmpty())
ProfileInfoRow("Bio", bio.ifBlank { "Not set" })
ProfileInfoRow("Username", if (username.isBlank()) "Not set" else "@$username")
ProfileInfoRow("Name", name.ifBlank { "Not set" })
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
ProfileInfoRow("Email", profile?.email.orEmpty())
ProfileInfoRow("Bio", bio.ifBlank { "Not set" })
ProfileInfoRow("Username", if (username.isBlank()) "Not set" else "@$username")
ProfileInfoRow("Name", name.ifBlank { "Not set" })
}
}
}
@@ -241,7 +252,9 @@ fun ProfileScreen(
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(22.dp),
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
) {
Column(
modifier = Modifier
@@ -250,6 +263,13 @@ fun ProfileScreen(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Edit profile", style = MaterialTheme.typography.titleMedium)
HeroActionButton(
label = "Choose photo",
icon = Icons.Filled.AddAPhoto,
modifier = Modifier.fillMaxWidth(),
) {
pickAvatarLauncher.launch("image/*")
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
@@ -274,19 +294,25 @@ fun ProfileScreen(
label = { Text("Avatar URL") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
viewModel.updateProfile(
name = name,
username = username,
bio = bio.ifBlank { null },
avatarUrl = avatarUrl.ifBlank { null },
)
},
enabled = !state.isSaving && name.isNotBlank() && username.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Save profile")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { editMode = false }, modifier = Modifier.weight(1f)) {
Text("Cancel")
}
Button(
onClick = {
viewModel.updateProfile(
name = name,
username = username,
bio = bio.ifBlank { null },
avatarUrl = avatarUrl.ifBlank { null },
)
editMode = false
},
enabled = !state.isSaving && name.isNotBlank() && username.isNotBlank(),
modifier = Modifier.weight(1f),
) {
Text("Save")
}
}
if (state.isSaving) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
@@ -314,6 +340,22 @@ fun ProfileScreen(
}
}
}
pendingAvatarBitmap?.let { bitmap ->
AvatarCropDialog(
bitmap = bitmap,
onDismiss = { pendingAvatarBitmap = null },
onConfirm = { croppedBytes ->
viewModel.uploadAvatar(
fileName = "avatar.jpg",
mimeType = "image/jpeg",
bytes = croppedBytes,
) { uploadedUrl ->
avatarUrl = uploadedUrl
}
pendingAvatarBitmap = null
},
)
}
}
@Composable
@@ -346,8 +388,120 @@ private fun ProfileInfoRow(label: String, value: String) {
}
}
private fun Uri.toSquareJpeg(context: Context): ByteArray? {
val bitmap = runCatching {
@Composable
private fun AvatarCropDialog(
bitmap: Bitmap,
onDismiss: () -> Unit,
onConfirm: (ByteArray) -> Unit,
) {
var scale by remember(bitmap) { mutableStateOf(1f) }
var offset by remember(bitmap) { mutableStateOf(Offset.Zero) }
var viewportSize by remember { mutableStateOf(IntSize.Zero) }
fun clampOffset(raw: Offset, currentScale: Float, viewportPx: Float): Offset {
val baseScale = max(viewportPx / bitmap.width.toFloat(), viewportPx / bitmap.height.toFloat())
val displayedWidth = bitmap.width * baseScale * currentScale
val displayedHeight = bitmap.height * baseScale * currentScale
val maxOffsetX = max(0f, (displayedWidth - viewportPx) / 2f)
val maxOffsetY = max(0f, (displayedHeight - viewportPx) / 2f)
return Offset(
x = raw.x.coerceIn(-maxOffsetX, maxOffsetX),
y = raw.y.coerceIn(-maxOffsetY, maxOffsetY),
)
}
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Crop avatar", style = MaterialTheme.typography.titleMedium)
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.clipToBounds()
.onSizeChanged { viewportSize = it }
.pointerInput(bitmap, viewportSize, scale, offset) {
detectTransformGestures { _, pan, zoom, _ ->
val viewport = viewportSize.width.toFloat().coerceAtLeast(1f)
val newScale = (scale * zoom).coerceIn(1f, 4f)
scale = newScale
offset = clampOffset(offset + pan, newScale, viewport)
}
},
) {
androidx.compose.foundation.Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "Avatar crop preview",
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
},
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = onDismiss,
modifier = Modifier.weight(1f),
) {
Text("Cancel")
}
Button(
onClick = {
val viewportPx = viewportSize.width.toFloat()
if (viewportPx <= 1f) return@Button
val baseScale = max(viewportPx / bitmap.width.toFloat(), viewportPx / bitmap.height.toFloat())
val fullScale = baseScale * scale
val centerX = viewportPx / 2f + offset.x
val centerY = viewportPx / 2f + offset.y
val left = ((0f - centerX) / fullScale + bitmap.width / 2f)
val top = ((0f - centerY) / fullScale + bitmap.height / 2f)
val side = (viewportPx / fullScale).coerceAtMost(minOf(bitmap.width, bitmap.height).toFloat())
val safeLeft = left.coerceIn(0f, bitmap.width - side)
val safeTop = top.coerceIn(0f, bitmap.height - side)
val cropBitmap = Bitmap.createBitmap(
bitmap,
safeLeft.toInt(),
safeTop.toInt(),
side.toInt().coerceAtLeast(1),
side.toInt().coerceAtLeast(1),
)
val output = ByteArrayOutputStream()
val ok = cropBitmap.compress(Bitmap.CompressFormat.JPEG, 92, output)
if (ok) onConfirm(output.toByteArray())
},
modifier = Modifier.weight(1f),
) {
Text("Use")
}
}
Text(
text = "Use two fingers to zoom and move.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
private fun Uri.toBitmap(context: Context): Bitmap? {
return runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val src = ImageDecoder.createSource(context.contentResolver, this)
ImageDecoder.decodeBitmap(src)
@@ -355,18 +509,5 @@ private fun Uri.toSquareJpeg(context: Context): ByteArray? {
@Suppress("DEPRECATION")
MediaStore.Images.Media.getBitmap(context.contentResolver, this)
}
}.getOrNull() ?: return null
val square = bitmap.centerCropSquare()
val output = ByteArrayOutputStream()
val compressed = square.compress(Bitmap.CompressFormat.JPEG, 92, output)
if (!compressed) return null
return output.toByteArray()
}
private fun Bitmap.centerCropSquare(): Bitmap {
val side = minOf(width, height)
val left = (width - side) / 2
val top = (height - side) / 2
return Bitmap.createBitmap(this, left, top, side, side)
}.getOrNull()
}

View File

@@ -49,6 +49,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -65,30 +66,34 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.ui.account.AccountUiState
import ru.daemonlord.messenger.ui.account.AccountViewModel
private enum class SettingsFolder(val title: String) {
Account("Аккаунт"),
Chat("Настройки чатов"),
Privacy("Конфиденциальность"),
Notifications("Уведомления"),
Data("Данные и память"),
Folders("Папки с чатами"),
Devices("Устройства"),
Power("Энергосбережение"),
Language("Язык"),
private enum class SettingsFolder {
Account,
Chat,
Privacy,
Notifications,
Data,
Folders,
Devices,
Power,
Language,
}
@Composable
fun SettingsRoute(
onOpenProfile: () -> Unit,
onAddAccount: () -> Unit,
onSwitchAccount: () -> Unit,
onLogout: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit,
@@ -96,6 +101,7 @@ fun SettingsRoute(
) {
SettingsScreen(
onOpenProfile = onOpenProfile,
onAddAccount = onAddAccount,
onSwitchAccount = onSwitchAccount,
onLogout = onLogout,
onMainBarVisibilityChanged = onMainBarVisibilityChanged,
@@ -106,6 +112,7 @@ fun SettingsRoute(
@Composable
fun SettingsScreen(
onOpenProfile: () -> Unit,
onAddAccount: () -> Unit,
onSwitchAccount: () -> Unit,
onLogout: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit,
@@ -148,6 +155,7 @@ fun SettingsScreen(
state = state,
folder = folder ?: SettingsFolder.Account,
onBack = { folder = null },
onAddAccount = onAddAccount,
onSwitchAccount = onSwitchAccount,
onLogout = onLogout,
onOpenProfile = onOpenProfile,
@@ -179,27 +187,27 @@ private fun SettingsHome(
.padding(bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
ProfileHeader(name.ifBlank { "User" }, email, username, avatarUrl, onOpenProfile)
ProfileHeader(name.ifBlank { stringResource(id = R.string.settings_user_fallback) }, email, username, avatarUrl, onOpenProfile)
SettingsCard {
Text("АККАУНТЫ", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium)
Text(stringResource(id = R.string.settings_accounts_header), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium)
SettingsShortcut(
title = active?.title ?: "No active account",
subtitle = active?.subtitle ?: "Add account",
title = active?.title ?: stringResource(id = R.string.settings_no_active_account),
subtitle = active?.subtitle ?: stringResource(id = R.string.settings_add_account),
onClick = { onOpenFolder(SettingsFolder.Account) },
)
}
SettingsCard {
SettingsRow(Icons.Filled.AccountCircle, "Аккаунт", "Номер, имя пользователя, о себе") { onOpenFolder(SettingsFolder.Account) }
SettingsRow(Icons.Filled.Chat, "Настройки чатов", "Обои, ночной режим, анимации") { onOpenFolder(SettingsFolder.Chat) }
SettingsRow(Icons.Filled.Lock, "Конфиденциальность", "Время захода, устройства, ключи доступа") { onOpenFolder(SettingsFolder.Privacy) }
SettingsRow(Icons.Filled.Notifications, "Уведомления", "Звуки, счётчик сообщений") { onOpenFolder(SettingsFolder.Notifications) }
SettingsRow(Icons.Filled.Storage, "Данные и память", "Настройки загрузки медиафайлов") { onOpenFolder(SettingsFolder.Data) }
SettingsRow(Icons.Filled.Folder, "Папки с чатами", "Сортировка чатов по папкам") { onOpenFolder(SettingsFolder.Folders) }
SettingsRow(Icons.Filled.Devices, "Устройства", "Управление активными сеансами") { onOpenFolder(SettingsFolder.Devices) }
SettingsRow(Icons.Filled.BatterySaver, "Энергосбережение", "Экономия энергии при низком заряде") { onOpenFolder(SettingsFolder.Power) }
SettingsRow(Icons.Filled.Language, "Язык", "Русский", divider = false) { onOpenFolder(SettingsFolder.Language) }
SettingsRow(Icons.Filled.AccountCircle, stringResource(id = R.string.settings_folder_account), stringResource(id = R.string.settings_account_subtitle)) { onOpenFolder(SettingsFolder.Account) }
SettingsRow(Icons.Filled.Chat, stringResource(id = R.string.settings_folder_chat), stringResource(id = R.string.settings_chat_subtitle)) { onOpenFolder(SettingsFolder.Chat) }
SettingsRow(Icons.Filled.Lock, stringResource(id = R.string.settings_folder_privacy), stringResource(id = R.string.settings_privacy_subtitle)) { onOpenFolder(SettingsFolder.Privacy) }
SettingsRow(Icons.Filled.Notifications, stringResource(id = R.string.settings_folder_notifications), stringResource(id = R.string.settings_notifications_subtitle)) { onOpenFolder(SettingsFolder.Notifications) }
SettingsRow(Icons.Filled.Storage, stringResource(id = R.string.settings_folder_data), stringResource(id = R.string.settings_data_subtitle)) { onOpenFolder(SettingsFolder.Data) }
SettingsRow(Icons.Filled.Folder, stringResource(id = R.string.settings_folder_folders), stringResource(id = R.string.settings_folders_subtitle)) { onOpenFolder(SettingsFolder.Folders) }
SettingsRow(Icons.Filled.Devices, stringResource(id = R.string.settings_folder_devices), stringResource(id = R.string.settings_devices_subtitle)) { onOpenFolder(SettingsFolder.Devices) }
SettingsRow(Icons.Filled.BatterySaver, stringResource(id = R.string.settings_folder_power), stringResource(id = R.string.settings_power_subtitle)) { onOpenFolder(SettingsFolder.Power) }
SettingsRow(Icons.Filled.Language, stringResource(id = R.string.settings_folder_language), languageLabel(state.appLanguage), divider = false) { onOpenFolder(SettingsFolder.Language) }
}
}
}
@@ -209,6 +217,7 @@ private fun SettingsFolderView(
state: AccountUiState,
folder: SettingsFolder,
onBack: () -> Unit,
onAddAccount: () -> Unit,
onSwitchAccount: () -> Unit,
onLogout: () -> Unit,
onOpenProfile: () -> Unit,
@@ -226,7 +235,7 @@ private fun SettingsFolderView(
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
Text(folder.title, style = MaterialTheme.typography.headlineSmall)
Text(folderTitle(folder), style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.weight(1f))
if (state.isLoading || state.isSaving) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
@@ -234,15 +243,15 @@ private fun SettingsFolderView(
}
when (folder) {
SettingsFolder.Account -> AccountFolder(state, onSwitchAccount, onOpenProfile, onLogout, viewModel)
SettingsFolder.Account -> AccountFolder(state, onAddAccount, onSwitchAccount, onOpenProfile, onLogout, viewModel)
SettingsFolder.Chat -> ChatFolder(state, viewModel)
SettingsFolder.Privacy -> PrivacyFolder(state, viewModel)
SettingsFolder.Notifications -> NotificationsFolder(state, viewModel)
SettingsFolder.Devices -> DevicesFolder(state, viewModel)
SettingsFolder.Data -> PlaceholderFolder("Раздел будет расширен в следующем шаге.")
SettingsFolder.Folders -> PlaceholderFolder("Управление папками добавим следующей итерацией.")
SettingsFolder.Power -> PlaceholderFolder("Параметры энергосбережения добавим отдельным шагом.")
SettingsFolder.Language -> PlaceholderFolder("Смена языка будет вынесена в отдельный экран.")
SettingsFolder.Data -> PlaceholderFolder(stringResource(id = R.string.settings_placeholder_data))
SettingsFolder.Folders -> PlaceholderFolder(stringResource(id = R.string.settings_placeholder_folders))
SettingsFolder.Power -> PlaceholderFolder(stringResource(id = R.string.settings_placeholder_power))
SettingsFolder.Language -> LanguageFolder(state, viewModel)
}
if (!state.message.isNullOrBlank()) Text(state.message.orEmpty(), color = MaterialTheme.colorScheme.primary)
@@ -250,50 +259,94 @@ private fun SettingsFolderView(
}
}
@Composable
private fun LanguageFolder(state: AccountUiState, viewModel: AccountViewModel) {
SettingsCard {
Text(stringResource(id = R.string.settings_language_title), style = MaterialTheme.typography.titleSmall)
LanguageOptionRow(
title = stringResource(id = R.string.language_system),
subtitle = stringResource(id = R.string.settings_language_system_subtitle),
selected = state.appLanguage == AppLanguage.SYSTEM,
onClick = { viewModel.setLanguage(AppLanguage.SYSTEM) },
)
LanguageOptionRow(
title = stringResource(id = R.string.language_russian),
subtitle = "Russian",
selected = state.appLanguage == AppLanguage.RUSSIAN,
onClick = { viewModel.setLanguage(AppLanguage.RUSSIAN) },
)
LanguageOptionRow(
title = stringResource(id = R.string.language_english),
subtitle = stringResource(id = R.string.settings_language_english_subtitle),
selected = state.appLanguage == AppLanguage.ENGLISH,
onClick = { viewModel.setLanguage(AppLanguage.ENGLISH) },
)
}
}
@Composable
private fun LanguageOptionRow(
title: String,
subtitle: String,
selected: Boolean,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
RadioButton(selected = selected, onClick = onClick)
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun languageLabel(language: AppLanguage): String = when (language) {
AppLanguage.SYSTEM -> stringResource(id = R.string.language_system)
AppLanguage.RUSSIAN -> stringResource(id = R.string.language_russian)
AppLanguage.ENGLISH -> stringResource(id = R.string.language_english)
}
@Composable
private fun folderTitle(folder: SettingsFolder): String = when (folder) {
SettingsFolder.Account -> stringResource(id = R.string.settings_folder_account)
SettingsFolder.Chat -> stringResource(id = R.string.settings_folder_chat)
SettingsFolder.Privacy -> stringResource(id = R.string.settings_folder_privacy)
SettingsFolder.Notifications -> stringResource(id = R.string.settings_folder_notifications)
SettingsFolder.Data -> stringResource(id = R.string.settings_folder_data)
SettingsFolder.Folders -> stringResource(id = R.string.settings_folder_folders)
SettingsFolder.Devices -> stringResource(id = R.string.settings_folder_devices)
SettingsFolder.Power -> stringResource(id = R.string.settings_folder_power)
SettingsFolder.Language -> stringResource(id = R.string.settings_folder_language)
}
@Composable
private fun AccountFolder(
state: AccountUiState,
onAddAccount: () -> Unit,
onSwitchAccount: () -> Unit,
onOpenProfile: () -> Unit,
onLogout: () -> Unit,
viewModel: AccountViewModel,
) {
var showDialog by remember { mutableStateOf(false) }
var addEmail by remember { mutableStateOf("") }
var addPassword by remember { mutableStateOf("") }
if (showDialog) {
AlertDialog(
onDismissRequest = { if (!state.isAddingAccount) showDialog = false },
title = { Text("Add account") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = addEmail, onValueChange = { addEmail = it }, label = { Text("Email") }, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = addPassword, onValueChange = { addPassword = it }, label = { Text("Password") }, singleLine = true, modifier = Modifier.fillMaxWidth())
}
},
confirmButton = {
Button(onClick = {
viewModel.addAccount(addEmail, addPassword) { ok ->
if (ok) {
showDialog = false
addEmail = ""
addPassword = ""
onSwitchAccount()
}
}
}, enabled = !state.isAddingAccount) { Text("Sign in") }
},
dismissButton = { TextButton(onClick = { if (!state.isAddingAccount) showDialog = false }) { Text("Cancel") } },
)
}
SettingsCard {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("Accounts", style = MaterialTheme.typography.titleSmall)
OutlinedButton(onClick = { showDialog = true }) {
Text(stringResource(id = R.string.settings_accounts), style = MaterialTheme.typography.titleSmall)
OutlinedButton(onClick = onAddAccount) {
Icon(Icons.Filled.Add, contentDescription = null)
Text("Add account", modifier = Modifier.padding(start = 6.dp))
Text(stringResource(id = R.string.settings_add_account), modifier = Modifier.padding(start = 6.dp))
}
}
@@ -307,19 +360,27 @@ private fun AccountFolder(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(account.title.firstOrNull()?.uppercase() ?: "?", modifier = Modifier
.size(30.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(top = 6.dp), maxLines = 1)
Box(
modifier = Modifier
.size(30.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = account.title.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(account.title)
Text(account.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(account.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (account.isActive) {
Text("Active", color = MaterialTheme.colorScheme.primary)
Text(stringResource(id = R.string.settings_active), color = MaterialTheme.colorScheme.primary)
} else {
OutlinedButton(onClick = { viewModel.switchStoredAccount(account.userId) { if (it) onSwitchAccount() } }) { Text("Switch") }
OutlinedButton(onClick = { viewModel.switchStoredAccount(account.userId) { if (it) onSwitchAccount() } }) { Text(stringResource(id = R.string.settings_switch)) }
}
OutlinedButton(onClick = { viewModel.removeStoredAccount(account.userId) }) { Icon(Icons.Filled.DeleteOutline, contentDescription = null) }
}
@@ -327,19 +388,19 @@ private fun AccountFolder(
}
SettingsCard {
OutlinedButton(onClick = onOpenProfile, modifier = Modifier.fillMaxWidth()) { Text("Open profile") }
Button(onClick = onLogout, modifier = Modifier.fillMaxWidth()) { Text("Logout") }
OutlinedButton(onClick = onOpenProfile, modifier = Modifier.fillMaxWidth()) { Text(stringResource(id = R.string.settings_open_profile)) }
Button(onClick = onLogout, modifier = Modifier.fillMaxWidth()) { Text(stringResource(id = R.string.settings_logout)) }
}
}
@Composable
private fun ChatFolder(state: AccountUiState, viewModel: AccountViewModel) {
SettingsCard {
Text("Appearance", style = MaterialTheme.typography.titleSmall)
Text(stringResource(id = R.string.settings_appearance), style = MaterialTheme.typography.titleSmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ThemeButton("Light", state.appThemeMode == AppThemeMode.LIGHT) { viewModel.setThemeMode(AppThemeMode.LIGHT) }
ThemeButton("Dark", state.appThemeMode == AppThemeMode.DARK) { viewModel.setThemeMode(AppThemeMode.DARK) }
ThemeButton("System", state.appThemeMode == AppThemeMode.SYSTEM) { viewModel.setThemeMode(AppThemeMode.SYSTEM) }
ThemeButton(stringResource(id = R.string.theme_light), state.appThemeMode == AppThemeMode.LIGHT) { viewModel.setThemeMode(AppThemeMode.LIGHT) }
ThemeButton(stringResource(id = R.string.theme_dark), state.appThemeMode == AppThemeMode.DARK) { viewModel.setThemeMode(AppThemeMode.DARK) }
ThemeButton(stringResource(id = R.string.theme_system), state.appThemeMode == AppThemeMode.SYSTEM) { viewModel.setThemeMode(AppThemeMode.SYSTEM) }
}
}
}
@@ -347,20 +408,20 @@ private fun ChatFolder(state: AccountUiState, viewModel: AccountViewModel) {
@Composable
private fun NotificationsFolder(state: AccountUiState, viewModel: AccountViewModel) {
SettingsCard {
SettingsToggle(Icons.Filled.Notifications, "Enable notifications", state.notificationsEnabled, viewModel::setGlobalNotificationsEnabled)
SettingsToggle(Icons.Filled.Visibility, "Show message preview", state.notificationsPreviewEnabled, viewModel::setNotificationPreviewEnabled)
SettingsToggle(Icons.Filled.Notifications, stringResource(id = R.string.settings_enable_notifications), state.notificationsEnabled, viewModel::setGlobalNotificationsEnabled)
SettingsToggle(Icons.Filled.Visibility, stringResource(id = R.string.settings_show_preview), state.notificationsPreviewEnabled, viewModel::setNotificationPreviewEnabled)
OutlinedButton(
onClick = viewModel::refresh,
enabled = !state.isLoading && !state.isSaving,
modifier = Modifier.fillMaxWidth(),
) {
Text("Refresh notification history")
Text(stringResource(id = R.string.settings_refresh_notifications))
}
}
SettingsCard {
Text("Recent notifications", style = MaterialTheme.typography.titleSmall)
Text(stringResource(id = R.string.settings_recent_notifications), style = MaterialTheme.typography.titleSmall)
if (state.notificationsHistory.isEmpty()) {
Text("No server notifications yet.", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(id = R.string.settings_no_notifications_yet), color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
state.notificationsHistory.take(20).forEach { notification ->
Column(
@@ -403,13 +464,13 @@ private fun PrivacyFolder(state: AccountUiState, viewModel: AccountViewModel) {
var invites by remember(state.profile?.privacyGroupInvites) { mutableStateOf(state.profile?.privacyGroupInvites ?: "everyone") }
SettingsCard {
PrivacyDropdown("Private messages", pm) { pm = it }
PrivacyDropdown("Last seen", lastSeen) { lastSeen = it }
PrivacyDropdown("Avatar", avatar) { avatar = it }
PrivacyDropdown("Group invites", invites) { invites = it }
PrivacyDropdown(stringResource(id = R.string.privacy_private_messages), pm) { pm = it }
PrivacyDropdown(stringResource(id = R.string.privacy_last_seen), lastSeen) { lastSeen = it }
PrivacyDropdown(stringResource(id = R.string.privacy_avatar), avatar) { avatar = it }
PrivacyDropdown(stringResource(id = R.string.privacy_group_invites), invites) { invites = it }
Button(onClick = { viewModel.updatePrivacy(pm, lastSeen, avatar, invites) }, enabled = !state.isSaving, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Lock, contentDescription = null)
Text("Save privacy", modifier = Modifier.padding(start = 6.dp))
Text(stringResource(id = R.string.privacy_save), modifier = Modifier.padding(start = 6.dp))
}
}
}
@@ -420,32 +481,32 @@ private fun DevicesFolder(state: AccountUiState, viewModel: AccountViewModel) {
var recoveryCode by remember { mutableStateOf("") }
SettingsCard {
Text("Sessions & Security", style = MaterialTheme.typography.titleSmall)
Text(stringResource(id = R.string.settings_sessions_security), style = MaterialTheme.typography.titleSmall)
state.sessions.forEach { s ->
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(Icons.Filled.Devices, contentDescription = null)
Column(modifier = Modifier.weight(1f)) {
Text(s.userAgent ?: "Unknown device")
Text(s.ipAddress ?: "Unknown IP", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(s.userAgent ?: stringResource(id = R.string.settings_unknown_device))
Text(s.ipAddress ?: stringResource(id = R.string.settings_unknown_ip), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
OutlinedButton(onClick = { viewModel.revokeSession(s.jti) }, enabled = !state.isSaving && s.current != true) { Text("Revoke") }
OutlinedButton(onClick = { viewModel.revokeSession(s.jti) }, enabled = !state.isSaving && s.current != true) { Text(stringResource(id = R.string.settings_revoke)) }
}
}
OutlinedButton(onClick = viewModel::revokeAllSessions, enabled = !state.isSaving, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Security, contentDescription = null)
Text("Revoke all sessions", modifier = Modifier.padding(start = 6.dp))
Text(stringResource(id = R.string.settings_revoke_all), modifier = Modifier.padding(start = 6.dp))
}
}
SettingsCard {
OutlinedTextField(value = twoFactorCode, onValueChange = { twoFactorCode = it }, label = { Text("2FA code") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(value = twoFactorCode, onValueChange = { twoFactorCode = it }, label = { Text(stringResource(id = R.string.settings_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") }
Button(onClick = { viewModel.enableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { Text(stringResource(id = R.string.settings_enable)) }
OutlinedButton(onClick = { viewModel.disableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { Text(stringResource(id = R.string.settings_disable)) }
}
OutlinedTextField(value = recoveryCode, onValueChange = { recoveryCode = it }, label = { Text("Code for recovery regeneration") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(value = recoveryCode, onValueChange = { recoveryCode = it }, label = { Text(stringResource(id = R.string.settings_recovery_regen_code)) }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedButton(onClick = { viewModel.regenerateRecoveryCodes(recoveryCode) }, enabled = recoveryCode.isNotBlank() && !state.isSaving, modifier = Modifier.fillMaxWidth()) {
Text("Regenerate recovery codes")
Text(stringResource(id = R.string.settings_regenerate_recovery_codes))
}
}
}
@@ -467,7 +528,7 @@ private fun ProfileHeader(name: String, email: String, username: String, avatarU
}
Text(name, style = MaterialTheme.typography.headlineSmall)
Text(listOfNotNull(email.takeIf { it.isNotBlank() }, username.takeIf { it.isNotBlank() }?.let { "@$it" }).joinToString(""), color = MaterialTheme.colorScheme.onSurfaceVariant)
TextButton(onClick = onOpenProfile) { Text("Open profile") }
TextButton(onClick = onOpenProfile) { Text(stringResource(id = R.string.settings_open_profile)) }
}
}
@@ -485,9 +546,25 @@ private fun SettingsShortcut(title: String, subtitle: String, onClick: () -> Uni
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(title.firstOrNull()?.uppercase() ?: "A", modifier = Modifier.size(30.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer).padding(top = 6.dp), maxLines = 1)
Box(
modifier = Modifier
.size(30.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = title.firstOrNull()?.uppercase() ?: "A",
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(title)
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
@@ -552,8 +629,19 @@ private fun PrivacyDropdown(label: String, value: String, onChange: (String) ->
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { option ->
DropdownMenuItem(text = { Text(option) }, onClick = { onChange(option); expanded = false })
DropdownMenuItem(
text = { Text(privacyOptionLabel(option)) },
onClick = { onChange(option); expanded = false },
)
}
}
}
}
@Composable
private fun privacyOptionLabel(value: String): String = when (value.lowercase()) {
"everyone" -> stringResource(id = R.string.privacy_everyone)
"contacts" -> stringResource(id = R.string.privacy_contacts)
"nobody" -> stringResource(id = R.string.privacy_nobody)
else -> value
}

View File

@@ -1,11 +1,16 @@
package ru.daemonlord.messenger.ui.theme
import android.app.Activity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColors = lightColorScheme()
private val DarkColors = darkColorScheme()
@@ -17,8 +22,18 @@ fun MessengerTheme(content: @Composable () -> Unit) {
AppCompatDelegate.MODE_NIGHT_NO -> false
else -> isSystemInDarkTheme()
}
val colorScheme = if (darkTheme) DarkColors else LightColors
val view = LocalView.current
SideEffect {
val window = (view.context as? Activity)?.window ?: return@SideEffect
window.statusBarColor = colorScheme.surface.toArgb()
window.navigationBarColor = colorScheme.surface.toArgb()
val controller = WindowCompat.getInsetsController(window, view)
controller.isAppearanceLightStatusBars = !darkTheme
controller.isAppearanceLightNavigationBars = !darkTheme
}
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
colorScheme = colorScheme,
content = content,
)
}

View File

@@ -0,0 +1,157 @@
package ru.daemonlord.messenger.integration
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.notifications.model.ChatNotificationOverride
import ru.daemonlord.messenger.domain.notifications.model.NotificationSettings
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import ru.daemonlord.messenger.domain.notifications.usecase.ShouldShowMessageNotificationUseCase
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import java.time.Instant
@RunWith(RobolectricTestRunner::class)
@OptIn(ExperimentalCoroutinesApi::class)
class RealtimePipelineIntegrationTest {
private lateinit var db: MessengerDatabase
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
MessengerDatabase::class.java,
).allowMainThreadQueries().build()
}
@After
fun tearDown() {
db.close()
}
@Test
fun receiveMessageEvent_updatesRoomState() = runTest {
db.chatDao().upsertChats(
listOf(
ChatEntity(
id = 77L,
publicId = "chat-77",
type = "private",
title = null,
displayTitle = "Integration chat",
handle = null,
avatarUrl = null,
archived = false,
pinned = false,
muted = false,
unreadCount = 0,
unreadMentionsCount = 0,
counterpartUserId = null,
counterpartName = null,
counterpartUsername = null,
counterpartAvatarUrl = null,
counterpartIsOnline = false,
counterpartLastSeenAt = null,
lastMessageText = null,
lastMessageType = null,
lastMessageCreatedAt = null,
pinnedMessageId = null,
myRole = "member",
updatedSortAt = Instant.now().toString(),
)
)
)
val realtimeManager = FakeRealtimeManager()
val useCase = HandleRealtimeEventsUseCase(
realtimeManager = realtimeManager,
chatRepository = NoOpChatRepository(),
chatDao = db.chatDao(),
messageDao = db.messageDao(),
notificationDispatcher = NotificationDispatcher(ApplicationProvider.getApplicationContext()),
activeChatTracker = ActiveChatTracker(),
shouldShowMessageNotificationUseCase = ShouldShowMessageNotificationUseCase(
notificationSettingsRepository = AllowAllNotificationSettingsRepository(),
),
)
useCase.start()
realtimeManager.emit(
RealtimeEvent.ReceiveMessage(
chatId = 77L,
messageId = 9001L,
senderId = 5L,
replyToMessageId = null,
text = "integration hello",
type = "text",
createdAt = Instant.now().toString(),
isMention = false,
)
)
val chat = db.chatDao().observeChatById(77L).first()
assertEquals(1, chat?.unreadCount)
assertEquals("integration hello", chat?.lastMessageText)
useCase.stop()
}
private class FakeRealtimeManager : RealtimeManager {
private val stream = MutableSharedFlow<RealtimeEvent>(extraBufferCapacity = 8)
override val events: Flow<RealtimeEvent> = stream
override fun connect() = Unit
override fun disconnect() = Unit
fun emit(event: RealtimeEvent) {
stream.tryEmit(event)
}
}
private class NoOpChatRepository : ChatRepository {
override fun observeChats(archived: Boolean): Flow<List<ChatItem>> = kotlinx.coroutines.flow.flowOf(emptyList())
override fun observeChat(chatId: Long): Flow<ChatItem?> = kotlinx.coroutines.flow.flowOf(null)
override suspend fun refreshChats(archived: Boolean): AppResult<Unit> = AppResult.Success(Unit)
override suspend fun refreshChat(chatId: Long): AppResult<Unit> = AppResult.Success(Unit)
override suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink> {
return AppResult.Error(ru.daemonlord.messenger.domain.common.AppError.Unknown(null))
}
override suspend fun joinByInvite(token: String): AppResult<ChatItem> {
return AppResult.Error(ru.daemonlord.messenger.domain.common.AppError.Unknown(null))
}
override suspend fun deleteChat(chatId: Long) = Unit
}
private class AllowAllNotificationSettingsRepository : NotificationSettingsRepository {
override fun observeSettings(): Flow<NotificationSettings> = kotlinx.coroutines.flow.flowOf(NotificationSettings())
override suspend fun getSettings(): NotificationSettings = NotificationSettings()
override suspend fun setGlobalEnabled(enabled: Boolean) = Unit
override suspend fun setPreviewEnabled(enabled: Boolean) = Unit
override fun observeChatOverride(chatId: Long): Flow<ChatNotificationOverride> {
return kotlinx.coroutines.flow.flowOf(ChatNotificationOverride.DEFAULT)
}
override suspend fun getChatOverride(chatId: Long): ChatNotificationOverride = ChatNotificationOverride.DEFAULT
override suspend fun setChatOverride(chatId: Long, mode: ChatNotificationOverride) = Unit
override suspend fun clearChatOverride(chatId: Long) = Unit
override suspend fun clearChatOverrides() = Unit
}
}