From cdb45abb21457d96a3a9084d4699c8fbea92168a Mon Sep 17 00:00:00 2001 From: benya Date: Wed, 11 Mar 2026 04:52:03 +0300 Subject: [PATCH] android: persist language settings and realtime/ui sync updates --- .../ru/daemonlord/messenger/MainActivity.kt | 12 + .../repository/DataStoreLanguageRepository.kt | 44 ++ .../messenger/di/RepositoryModule.kt | 8 + .../usecase/HandleRealtimeEventsUseCase.kt | 22 +- .../domain/settings/model/AppLanguage.kt | 15 + .../settings/repository/LanguageRepository.kt | 11 + .../push/MessengerFirebaseMessagingService.kt | 7 + .../messenger/push/PushPayloadParser.kt | 3 +- .../messenger/ui/account/AccountUiState.kt | 2 + .../messenger/ui/account/AccountViewModel.kt | 16 + .../messenger/ui/auth/AuthUiState.kt | 2 +- .../messenger/ui/auth/AuthViewModel.kt | 22 + .../messenger/ui/auth/LoginScreen.kt | 3 +- .../messenger/ui/chat/ChatViewModel.kt | 59 ++- .../messenger/ui/chat/MessageUiState.kt | 1 + .../messenger/ui/chats/ChatListViewModel.kt | 10 +- .../messenger/ui/navigation/AppNavGraph.kt | 64 ++- .../messenger/ui/profile/ProfileScreen.kt | 381 ++++++++++++------ .../messenger/ui/settings/SettingsScreen.kt | 288 ++++++++----- .../messenger/ui/theme/MessengerTheme.kt | 17 +- .../RealtimePipelineIntegrationTest.kt | 157 ++++++++ web/tsconfig.tsbuildinfo | 2 +- 22 files changed, 909 insertions(+), 237 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/settings/repository/DataStoreLanguageRepository.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/settings/model/AppLanguage.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/settings/repository/LanguageRepository.kt create mode 100644 android/app/src/test/java/ru/daemonlord/messenger/integration/RealtimePipelineIntegrationTest.kt diff --git a/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt b/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt index 77d4e86..59ff81f 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/MainActivity.kt @@ -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() diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/settings/repository/DataStoreLanguageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/settings/repository/DataStoreLanguageRepository.kt new file mode 100644 index 0000000..2440c42 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/settings/repository/DataStoreLanguageRepository.kt @@ -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, +) : LanguageRepository { + + override fun observeLanguage(): Flow { + 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") + } +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt index cd450b1..ca0c662 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt @@ -12,6 +12,7 @@ import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository import ru.daemonlord.messenger.data.search.repository.NetworkSearchRepository +import ru.daemonlord.messenger.data.settings.repository.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 } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt index 84e245e..6b90310 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt @@ -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, ) ) } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/settings/model/AppLanguage.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/settings/model/AppLanguage.kt new file mode 100644 index 0000000..ec2ac7c --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/settings/model/AppLanguage.kt @@ -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 + } + } +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/settings/repository/LanguageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/settings/repository/LanguageRepository.kt new file mode 100644 index 0000000..7b1d112 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/settings/repository/LanguageRepository.kt @@ -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 + suspend fun getLanguage(): AppLanguage + suspend fun setLanguage(language: AppLanguage) +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/push/MessengerFirebaseMessagingService.kt b/android/app/src/main/java/ru/daemonlord/messenger/push/MessengerFirebaseMessagingService.kt index 4789b1d..99d1f23 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/push/MessengerFirebaseMessagingService.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/push/MessengerFirebaseMessagingService.kt @@ -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) } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/push/PushPayloadParser.kt b/android/app/src/main/java/ru/daemonlord/messenger/push/PushPayloadParser.kt index a07cda7..69b7bbb 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/push/PushPayloadParser.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/push/PushPayloadParser.kt @@ -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, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt index 333a195..849c0f8 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt @@ -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 = emptyList(), val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM, + val appLanguage: AppLanguage = AppLanguage.SYSTEM, val notificationsEnabled: Boolean = true, val notificationsPreviewEnabled: Boolean = true, val notificationsHistory: List = emptyList(), diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt index 800daf3..1175e62 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt @@ -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() .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) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthUiState.kt index d6d6865..3f41d15 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthUiState.kt @@ -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, ) - diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt index 1337851..bc75d91 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt @@ -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) } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt index 86c3c78..ac9cb9a 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt @@ -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), ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt index 6d894ef..3e4549a 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -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() } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt index 39daa52..74987da 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt @@ -38,6 +38,7 @@ data class MessageUiState( val inlineSearchMatches: List = emptyList(), val highlightedMessageId: Long? = null, val actionState: MessageActionState = MessageActionState(), + val chatDeletedNonce: Long = 0L, ) data class ForwardTargetUiModel( diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt index b1132c1..a8e83eb 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt @@ -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() } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt index 14eda4b..8059cbe 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt @@ -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( diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt index 53a4214..4cc576c 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt @@ -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(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() } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt index a272066..dbe13ec 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt @@ -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 +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/theme/MessengerTheme.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/theme/MessengerTheme.kt index db79329..6ba524b 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/theme/MessengerTheme.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/theme/MessengerTheme.kt @@ -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, ) } diff --git a/android/app/src/test/java/ru/daemonlord/messenger/integration/RealtimePipelineIntegrationTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/integration/RealtimePipelineIntegrationTest.kt new file mode 100644 index 0000000..e26fd46 --- /dev/null +++ b/android/app/src/test/java/ru/daemonlord/messenger/integration/RealtimePipelineIntegrationTest.kt @@ -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(extraBufferCapacity = 8) + override val events: Flow = 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> = kotlinx.coroutines.flow.flowOf(emptyList()) + override fun observeChat(chatId: Long): Flow = kotlinx.coroutines.flow.flowOf(null) + override suspend fun refreshChats(archived: Boolean): AppResult = AppResult.Success(Unit) + override suspend fun refreshChat(chatId: Long): AppResult = AppResult.Success(Unit) + override suspend fun createInviteLink(chatId: Long): AppResult { + return AppResult.Error(ru.daemonlord.messenger.domain.common.AppError.Unknown(null)) + } + override suspend fun joinByInvite(token: String): AppResult { + 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 = 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 { + 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 + } +} + diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index 96dc05e..8fb2842 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/components/settingspanel.tsx","./src/components/toastviewport.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/audioplayerstore.ts","./src/store/authstore.ts","./src/store/chatstore.ts","./src/store/uistore.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/preferences.ts","./src/utils/webnotifications.ts","./src/utils/ws.ts"],"version":"5.9.2"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/avatarcropmodal.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/mediaviewer.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/components/settingspanel.tsx","./src/components/toastviewport.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/audioplayerstore.ts","./src/store/authstore.ts","./src/store/chatstore.ts","./src/store/uistore.ts","./src/utils/firebasepush.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/preferences.ts","./src/utils/webnotifications.ts","./src/utils/ws.ts"],"version":"5.9.2"} \ No newline at end of file