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.compose.foundation.layout.fillMaxSize
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras 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.model.AppThemeMode
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
@@ -28,6 +30,9 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var themeRepository: ThemeRepository lateinit var themeRepository: ThemeRepository
@Inject
lateinit var languageRepository: LanguageRepository
@Inject @Inject
lateinit var notificationDispatcher: NotificationDispatcher lateinit var notificationDispatcher: NotificationDispatcher
@@ -51,6 +56,13 @@ class MainActivity : AppCompatActivity() {
AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM 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() pendingInviteToken = intent.extractInviteToken()
pendingVerifyEmailToken = intent.extractVerifyEmailToken() pendingVerifyEmailToken = intent.extractVerifyEmailToken()
pendingResetPasswordToken = intent.extractResetPasswordToken() 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.message.repository.NetworkMessageRepository
import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository
import ru.daemonlord.messenger.data.search.repository.NetworkSearchRepository import ru.daemonlord.messenger.data.search.repository.NetworkSearchRepository
import ru.daemonlord.messenger.data.settings.repository.DataStoreLanguageRepository
import ru.daemonlord.messenger.data.settings.repository.DataStoreThemeRepository import ru.daemonlord.messenger.data.settings.repository.DataStoreThemeRepository
import ru.daemonlord.messenger.data.user.repository.NetworkAccountRepository import ru.daemonlord.messenger.data.user.repository.NetworkAccountRepository
import ru.daemonlord.messenger.domain.account.repository.AccountRepository import ru.daemonlord.messenger.domain.account.repository.AccountRepository
@@ -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.message.repository.MessageRepository
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import ru.daemonlord.messenger.domain.search.repository.SearchRepository import ru.daemonlord.messenger.domain.search.repository.SearchRepository
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import javax.inject.Singleton import javax.inject.Singleton
@@ -89,4 +91,10 @@ abstract class RepositoryModule {
abstract fun bindThemeRepository( abstract fun bindThemeRepository(
repository: DataStoreThemeRepository, repository: DataStoreThemeRepository,
): ThemeRepository ): 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.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.ChatNotificationPayload import ru.daemonlord.messenger.core.notifications.ChatNotificationPayload
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher 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.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
@@ -28,6 +29,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
private val messageDao: MessageDao, private val messageDao: MessageDao,
private val notificationDispatcher: NotificationDispatcher, private val notificationDispatcher: NotificationDispatcher,
private val activeChatTracker: ActiveChatTracker, private val activeChatTracker: ActiveChatTracker,
private val tokenRepository: TokenRepository,
private val notificationSettingsRepository: NotificationSettingsRepository, private val notificationSettingsRepository: NotificationSettingsRepository,
private val shouldShowMessageNotificationUseCase: ShouldShowMessageNotificationUseCase, private val shouldShowMessageNotificationUseCase: ShouldShowMessageNotificationUseCase,
) { ) {
@@ -83,10 +85,26 @@ class HandleRealtimeEventsUseCase @Inject constructor(
} else { } else {
chatDao.incrementUnread(chatId = event.chatId) 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 muted = chatDao.isChatMuted(event.chatId) == true
val shouldNotify = shouldShowMessageNotificationUseCase( val shouldNotify = shouldShowMessageNotificationUseCase(
chatId = event.chatId, chatId = event.chatId,
isMention = event.isMention, isMention = isMention,
serverMuted = muted, serverMuted = muted,
) )
if (activeChatId != event.chatId && shouldNotify) { if (activeChatId != event.chatId && shouldNotify) {
@@ -110,7 +128,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
messageId = event.messageId, messageId = event.messageId,
title = title, title = title,
body = body, 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.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import javax.inject.Inject import javax.inject.Inject
@@ -16,8 +17,14 @@ class MessengerFirebaseMessagingService : FirebaseMessagingService() {
@Inject @Inject
lateinit var pushTokenSyncManager: PushTokenSyncManager lateinit var pushTokenSyncManager: PushTokenSyncManager
@Inject
lateinit var activeChatTracker: ActiveChatTracker
override fun onMessageReceived(message: RemoteMessage) { override fun onMessageReceived(message: RemoteMessage) {
val payload = PushPayloadParser.parse(message) ?: return val payload = PushPayloadParser.parse(message) ?: return
if (activeChatTracker.activeChatId.value == payload.chatId) {
return
}
notificationDispatcher.showChatMessage(payload) notificationDispatcher.showChatMessage(payload)
} }

View File

@@ -29,7 +29,8 @@ object PushPayloadParser {
?: data["text"] ?: data["text"]
?: "Open chat" ?: "Open chat"
val isMention = data["is_mention"]?.equals("true", ignoreCase = true) == true || 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( return ChatNotificationPayload(
chatId = chatId, 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.account.model.AccountNotification
import ru.daemonlord.messenger.domain.auth.model.AuthSession import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthUser import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
data class AccountUiState( data class AccountUiState(
@@ -19,6 +20,7 @@ data class AccountUiState(
val activeUserId: Long? = null, val activeUserId: Long? = null,
val storedAccounts: List<StoredAccountUi> = emptyList(), val storedAccounts: List<StoredAccountUi> = emptyList(),
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM, val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
val appLanguage: AppLanguage = AppLanguage.SYSTEM,
val notificationsEnabled: Boolean = true, val notificationsEnabled: Boolean = true,
val notificationsPreviewEnabled: Boolean = true, val notificationsPreviewEnabled: Boolean = true,
val notificationsHistory: List<AccountNotification> = emptyList(), val notificationsHistory: List<AccountNotification> = emptyList(),

View File

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

View File

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

View File

@@ -193,6 +193,7 @@ class AuthViewModel @Inject constructor(
it.copy( it.copy(
isLoading = false, isLoading = false,
isAuthenticated = true, isAuthenticated = true,
authCompletedNonce = System.currentTimeMillis(),
errorMessage = null, errorMessage = null,
successMessage = null, successMessage = null,
) )
@@ -253,6 +254,7 @@ class AuthViewModel @Inject constructor(
it.copy( it.copy(
isLoading = false, isLoading = false,
isAuthenticated = true, isAuthenticated = true,
authCompletedNonce = System.currentTimeMillis(),
errorMessage = null, errorMessage = null,
) )
} }
@@ -297,6 +299,26 @@ class AuthViewModel @Inject constructor(
restoreSession() 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() { private fun restoreSession() {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isCheckingSession = true) } _uiState.update { it.copy(isCheckingSession = true) }

View File

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

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher 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.ObserveChatUseCase
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppError
@@ -57,6 +58,7 @@ class ChatViewModel @Inject constructor(
private val forwardMessageBulkUseCase: ForwardMessageBulkUseCase, private val forwardMessageBulkUseCase: ForwardMessageBulkUseCase,
private val listMessageReactionsUseCase: ListMessageReactionsUseCase, private val listMessageReactionsUseCase: ListMessageReactionsUseCase,
private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase, private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase,
private val chatRepository: ChatRepository,
private val observeChatUseCase: ObserveChatUseCase, private val observeChatUseCase: ObserveChatUseCase,
private val observeChatsUseCase: ObserveChatsUseCase, private val observeChatsUseCase: ObserveChatsUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase, 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() { fun onSendClick() {
val text = uiState.value.inputText.trim() val text = uiState.value.inputText.trim()
if (text.isBlank()) return if (text.isBlank()) return
@@ -792,7 +850,6 @@ class ChatViewModel @Inject constructor(
override fun onCleared() { override fun onCleared() {
activeChatTracker.clearActiveChat(chatId) activeChatTracker.clearActiveChat(chatId)
handleRealtimeEventsUseCase.stop()
super.onCleared() super.onCleared()
} }

View File

@@ -38,6 +38,7 @@ data class MessageUiState(
val inlineSearchMatches: List<Long> = emptyList(), val inlineSearchMatches: List<Long> = emptyList(),
val highlightedMessageId: Long? = null, val highlightedMessageId: Long? = null,
val actionState: MessageActionState = MessageActionState(), val actionState: MessageActionState = MessageActionState(),
val chatDeletedNonce: Long = 0L,
) )
data class ForwardTargetUiModel( 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) { fun toggleChatMute(chatId: Long) {
viewModelScope.launch { viewModelScope.launch {
when (val current = chatRepository.getChatNotifications(chatId = chatId)) { when (val current = chatRepository.getChatNotifications(chatId = chatId)) {
@@ -619,7 +628,6 @@ class ChatListViewModel @Inject constructor(
} }
override fun onCleared() { override fun onCleared() {
handleRealtimeEventsUseCase.stop()
super.onCleared() super.onCleared()
} }
} }

View File

@@ -41,6 +41,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -57,6 +58,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.ui.auth.AuthViewModel import ru.daemonlord.messenger.ui.auth.AuthViewModel
import ru.daemonlord.messenger.ui.auth.LoginScreen import ru.daemonlord.messenger.ui.auth.LoginScreen
import ru.daemonlord.messenger.ui.auth.reset.ResetPasswordRoute import ru.daemonlord.messenger.ui.auth.reset.ResetPasswordRoute
@@ -71,6 +73,7 @@ private object Routes {
const val Startup = "startup" const val Startup = "startup"
const val AuthGraph = "auth_graph" const val AuthGraph = "auth_graph"
const val Login = "login" const val Login = "login"
const val AddAccountLogin = "add_account_login"
const val VerifyEmail = "verify_email" const val VerifyEmail = "verify_email"
const val ResetPassword = "reset_password" const val ResetPassword = "reset_password"
const val Chats = "chats" const val Chats = "chats"
@@ -181,6 +184,7 @@ fun MessengerNavHost(
composable(route = Routes.Login) { composable(route = Routes.Login) {
LoginScreen( LoginScreen(
state = uiState, state = uiState,
headerTitle = "Messenger Login",
onEmailChanged = viewModel::onEmailChanged, onEmailChanged = viewModel::onEmailChanged,
onNameChanged = viewModel::onNameChanged, onNameChanged = viewModel::onNameChanged,
onUsernameChanged = viewModel::onUsernameChanged, onUsernameChanged = viewModel::onUsernameChanged,
@@ -195,6 +199,49 @@ fun MessengerNavHost(
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) }, 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( composable(
@@ -248,6 +295,7 @@ fun MessengerNavHost(
composable(route = Routes.Settings) { composable(route = Routes.Settings) {
SettingsRoute( SettingsRoute(
onOpenProfile = { navController.navigate(Routes.Profile) }, onOpenProfile = { navController.navigate(Routes.Profile) },
onAddAccount = { navController.navigate(Routes.AddAccountLogin) },
onSwitchAccount = { onSwitchAccount = {
viewModel.recheckSession() viewModel.recheckSession()
navController.navigate(Routes.Chats) { navController.navigate(Routes.Chats) {
@@ -329,9 +377,9 @@ private fun MainBottomBar(
NavigationBarItem( NavigationBarItem(
selected = currentRoute == Routes.Chats, selected = currentRoute == Routes.Chats,
onClick = { onNavigate(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 = { 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, alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors( colors = NavigationBarItemDefaults.colors(
@@ -345,9 +393,9 @@ private fun MainBottomBar(
NavigationBarItem( NavigationBarItem(
selected = currentRoute == Routes.Contacts, selected = currentRoute == Routes.Contacts,
onClick = { onNavigate(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 = { 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, alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors( colors = NavigationBarItemDefaults.colors(
@@ -361,9 +409,9 @@ private fun MainBottomBar(
NavigationBarItem( NavigationBarItem(
selected = currentRoute == Routes.Settings, selected = currentRoute == Routes.Settings,
onClick = { onNavigate(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 = { 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, alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors( colors = NavigationBarItemDefaults.colors(
@@ -377,9 +425,9 @@ private fun MainBottomBar(
NavigationBarItem( NavigationBarItem(
selected = currentRoute == Routes.Profile, selected = currentRoute == Routes.Profile,
onClick = { onNavigate(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 = { 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, alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors( colors = NavigationBarItemDefaults.colors(

View File

@@ -10,12 +10,14 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -39,6 +41,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -49,19 +52,29 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp 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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import ru.daemonlord.messenger.ui.account.AccountViewModel import ru.daemonlord.messenger.ui.account.AccountViewModel
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import kotlin.math.max
@Composable @Composable
fun ProfileRoute( fun ProfileRoute(
@@ -86,6 +99,7 @@ fun ProfileScreen(
var bio by remember(profile?.bio) { mutableStateOf(profile?.bio.orEmpty()) } var bio by remember(profile?.bio) { mutableStateOf(profile?.bio.orEmpty()) }
var avatarUrl by remember(profile?.avatarUrl) { mutableStateOf(profile?.avatarUrl.orEmpty()) } var avatarUrl by remember(profile?.avatarUrl) { mutableStateOf(profile?.avatarUrl.orEmpty()) }
var editMode by remember { mutableStateOf(false) } var editMode by remember { mutableStateOf(false) }
var pendingAvatarBitmap by remember { mutableStateOf<Bitmap?>(null) }
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -110,14 +124,7 @@ fun ProfileScreen(
val pickAvatarLauncher = rememberLauncherForActivityResult( val pickAvatarLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(), contract = ActivityResultContracts.GetContent(),
) { uri -> ) { uri ->
val bytes = uri?.toSquareJpeg(context) ?: return@rememberLauncherForActivityResult pendingAvatarBitmap = uri?.toBitmap(context)
viewModel.uploadAvatar(
fileName = "avatar.jpg",
mimeType = "image/jpeg",
bytes = bytes,
) { uploadedUrl ->
avatarUrl = uploadedUrl
}
} }
Box( Box(
@@ -134,80 +141,82 @@ fun ProfileScreen(
.verticalScroll(scrollState) .verticalScroll(scrollState)
.padding(bottom = 96.dp), .padding(bottom = 96.dp),
) { ) {
Box( if (!editMode) {
modifier = Modifier Box(
.fillMaxWidth() modifier = Modifier
.height(305.dp) .fillMaxWidth()
.background( .height(305.dp)
Brush.verticalGradient( .background(
colors = listOf( Brush.verticalGradient(
MaterialTheme.colorScheme.primaryContainer, colors = listOf(
MaterialTheme.colorScheme.secondaryContainer, MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.tertiaryContainer, 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)) Column(
if (avatarUrl.isNotBlank()) { modifier = Modifier
AsyncImage( .fillMaxSize()
model = avatarUrl, .padding(horizontal = 16.dp, vertical = 14.dp),
contentDescription = "Avatar", horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier verticalArrangement = Arrangement.spacedBy(10.dp),
.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),
) { ) {
HeroActionButton( Spacer(modifier = Modifier.height(6.dp))
label = "Choose photo", if (avatarUrl.isNotBlank()) {
icon = Icons.Filled.AddAPhoto, AsyncImage(
modifier = Modifier.weight(1f), model = avatarUrl,
) { contentDescription = "Avatar",
pickAvatarLauncher.launch("image/*") 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", Text(
icon = Icons.Filled.Edit, text = if (name.isBlank()) "User" else name,
modifier = Modifier.weight(1f), 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), modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Surface( if (!editMode) {
color = MaterialTheme.colorScheme.surfaceContainer, Surface(
shape = RoundedCornerShape(22.dp), color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier shape = RoundedCornerShape(22.dp),
.fillMaxWidth()
.offset(y = (-22).dp),
) {
Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .offset(y = (-22).dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
ProfileInfoRow("Email", profile?.email.orEmpty()) Column(
ProfileInfoRow("Bio", bio.ifBlank { "Not set" }) modifier = Modifier
ProfileInfoRow("Username", if (username.isBlank()) "Not set" else "@$username") .fillMaxWidth()
ProfileInfoRow("Name", name.ifBlank { "Not set" }) .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( Surface(
color = MaterialTheme.colorScheme.surfaceContainer, color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(22.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -250,6 +263,13 @@ fun ProfileScreen(
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text("Edit profile", style = MaterialTheme.typography.titleMedium) Text("Edit profile", style = MaterialTheme.typography.titleMedium)
HeroActionButton(
label = "Choose photo",
icon = Icons.Filled.AddAPhoto,
modifier = Modifier.fillMaxWidth(),
) {
pickAvatarLauncher.launch("image/*")
}
OutlinedTextField( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
@@ -274,19 +294,25 @@ fun ProfileScreen(
label = { Text("Avatar URL") }, label = { Text("Avatar URL") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Button( Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
onClick = { TextButton(onClick = { editMode = false }, modifier = Modifier.weight(1f)) {
viewModel.updateProfile( Text("Cancel")
name = name, }
username = username, Button(
bio = bio.ifBlank { null }, onClick = {
avatarUrl = avatarUrl.ifBlank { null }, viewModel.updateProfile(
) name = name,
}, username = username,
enabled = !state.isSaving && name.isNotBlank() && username.isNotBlank(), bio = bio.ifBlank { null },
modifier = Modifier.fillMaxWidth(), avatarUrl = avatarUrl.ifBlank { null },
) { )
Text("Save profile") editMode = false
},
enabled = !state.isSaving && name.isNotBlank() && username.isNotBlank(),
modifier = Modifier.weight(1f),
) {
Text("Save")
}
} }
if (state.isSaving) { if (state.isSaving) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) 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 @Composable
@@ -346,8 +388,120 @@ private fun ProfileInfoRow(label: String, value: String) {
} }
} }
private fun Uri.toSquareJpeg(context: Context): ByteArray? { @Composable
val bitmap = runCatching { 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val src = ImageDecoder.createSource(context.contentResolver, this) val src = ImageDecoder.createSource(context.contentResolver, this)
ImageDecoder.decodeBitmap(src) ImageDecoder.decodeBitmap(src)
@@ -355,18 +509,5 @@ private fun Uri.toSquareJpeg(context: Context): ByteArray? {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
MediaStore.Images.Media.getBitmap(context.contentResolver, this) MediaStore.Images.Media.getBitmap(context.contentResolver, this)
} }
}.getOrNull() ?: return null }.getOrNull()
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)
} }

View File

@@ -49,6 +49,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -65,30 +66,34 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage import coil.compose.AsyncImage
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.ui.account.AccountUiState import ru.daemonlord.messenger.ui.account.AccountUiState
import ru.daemonlord.messenger.ui.account.AccountViewModel import ru.daemonlord.messenger.ui.account.AccountViewModel
private enum class SettingsFolder(val title: String) { private enum class SettingsFolder {
Account("Аккаунт"), Account,
Chat("Настройки чатов"), Chat,
Privacy("Конфиденциальность"), Privacy,
Notifications("Уведомления"), Notifications,
Data("Данные и память"), Data,
Folders("Папки с чатами"), Folders,
Devices("Устройства"), Devices,
Power("Энергосбережение"), Power,
Language("Язык"), Language,
} }
@Composable @Composable
fun SettingsRoute( fun SettingsRoute(
onOpenProfile: () -> Unit, onOpenProfile: () -> Unit,
onAddAccount: () -> Unit,
onSwitchAccount: () -> Unit, onSwitchAccount: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit, onMainBarVisibilityChanged: (Boolean) -> Unit,
@@ -96,6 +101,7 @@ fun SettingsRoute(
) { ) {
SettingsScreen( SettingsScreen(
onOpenProfile = onOpenProfile, onOpenProfile = onOpenProfile,
onAddAccount = onAddAccount,
onSwitchAccount = onSwitchAccount, onSwitchAccount = onSwitchAccount,
onLogout = onLogout, onLogout = onLogout,
onMainBarVisibilityChanged = onMainBarVisibilityChanged, onMainBarVisibilityChanged = onMainBarVisibilityChanged,
@@ -106,6 +112,7 @@ fun SettingsRoute(
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
onOpenProfile: () -> Unit, onOpenProfile: () -> Unit,
onAddAccount: () -> Unit,
onSwitchAccount: () -> Unit, onSwitchAccount: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit, onMainBarVisibilityChanged: (Boolean) -> Unit,
@@ -148,6 +155,7 @@ fun SettingsScreen(
state = state, state = state,
folder = folder ?: SettingsFolder.Account, folder = folder ?: SettingsFolder.Account,
onBack = { folder = null }, onBack = { folder = null },
onAddAccount = onAddAccount,
onSwitchAccount = onSwitchAccount, onSwitchAccount = onSwitchAccount,
onLogout = onLogout, onLogout = onLogout,
onOpenProfile = onOpenProfile, onOpenProfile = onOpenProfile,
@@ -179,27 +187,27 @@ private fun SettingsHome(
.padding(bottom = 96.dp), .padding(bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(12.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 { 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( SettingsShortcut(
title = active?.title ?: "No active account", title = active?.title ?: stringResource(id = R.string.settings_no_active_account),
subtitle = active?.subtitle ?: "Add account", subtitle = active?.subtitle ?: stringResource(id = R.string.settings_add_account),
onClick = { onOpenFolder(SettingsFolder.Account) }, onClick = { onOpenFolder(SettingsFolder.Account) },
) )
} }
SettingsCard { SettingsCard {
SettingsRow(Icons.Filled.AccountCircle, "Аккаунт", "Номер, имя пользователя, о себе") { onOpenFolder(SettingsFolder.Account) } 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, "Настройки чатов", "Обои, ночной режим, анимации") { onOpenFolder(SettingsFolder.Chat) } 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, "Конфиденциальность", "Время захода, устройства, ключи доступа") { onOpenFolder(SettingsFolder.Privacy) } 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, "Уведомления", "Звуки, счётчик сообщений") { onOpenFolder(SettingsFolder.Notifications) } 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, "Данные и память", "Настройки загрузки медиафайлов") { onOpenFolder(SettingsFolder.Data) } 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, "Папки с чатами", "Сортировка чатов по папкам") { onOpenFolder(SettingsFolder.Folders) } 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, "Устройства", "Управление активными сеансами") { onOpenFolder(SettingsFolder.Devices) } 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, "Энергосбережение", "Экономия энергии при низком заряде") { onOpenFolder(SettingsFolder.Power) } 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, "Язык", "Русский", divider = false) { onOpenFolder(SettingsFolder.Language) } 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, state: AccountUiState,
folder: SettingsFolder, folder: SettingsFolder,
onBack: () -> Unit, onBack: () -> Unit,
onAddAccount: () -> Unit,
onSwitchAccount: () -> Unit, onSwitchAccount: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onOpenProfile: () -> Unit, onOpenProfile: () -> Unit,
@@ -226,7 +235,7 @@ private fun SettingsFolderView(
) { ) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } 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)) Spacer(modifier = Modifier.weight(1f))
if (state.isLoading || state.isSaving) { if (state.isLoading || state.isSaving) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
@@ -234,15 +243,15 @@ private fun SettingsFolderView(
} }
when (folder) { 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.Chat -> ChatFolder(state, viewModel)
SettingsFolder.Privacy -> PrivacyFolder(state, viewModel) SettingsFolder.Privacy -> PrivacyFolder(state, viewModel)
SettingsFolder.Notifications -> NotificationsFolder(state, viewModel) SettingsFolder.Notifications -> NotificationsFolder(state, viewModel)
SettingsFolder.Devices -> DevicesFolder(state, viewModel) SettingsFolder.Devices -> DevicesFolder(state, viewModel)
SettingsFolder.Data -> PlaceholderFolder("Раздел будет расширен в следующем шаге.") SettingsFolder.Data -> PlaceholderFolder(stringResource(id = R.string.settings_placeholder_data))
SettingsFolder.Folders -> PlaceholderFolder("Управление папками добавим следующей итерацией.") SettingsFolder.Folders -> PlaceholderFolder(stringResource(id = R.string.settings_placeholder_folders))
SettingsFolder.Power -> PlaceholderFolder("Параметры энергосбережения добавим отдельным шагом.") SettingsFolder.Power -> PlaceholderFolder(stringResource(id = R.string.settings_placeholder_power))
SettingsFolder.Language -> PlaceholderFolder("Смена языка будет вынесена в отдельный экран.") SettingsFolder.Language -> LanguageFolder(state, viewModel)
} }
if (!state.message.isNullOrBlank()) Text(state.message.orEmpty(), color = MaterialTheme.colorScheme.primary) 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 @Composable
private fun AccountFolder( private fun AccountFolder(
state: AccountUiState, state: AccountUiState,
onAddAccount: () -> Unit,
onSwitchAccount: () -> Unit, onSwitchAccount: () -> Unit,
onOpenProfile: () -> Unit, onOpenProfile: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
viewModel: AccountViewModel, 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 { SettingsCard {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("Accounts", style = MaterialTheme.typography.titleSmall) Text(stringResource(id = R.string.settings_accounts), style = MaterialTheme.typography.titleSmall)
OutlinedButton(onClick = { showDialog = true }) { OutlinedButton(onClick = onAddAccount) {
Icon(Icons.Filled.Add, contentDescription = null) 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, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text(account.title.firstOrNull()?.uppercase() ?: "?", modifier = Modifier Box(
.size(30.dp) modifier = Modifier
.clip(CircleShape) .size(30.dp)
.background(MaterialTheme.colorScheme.primaryContainer) .clip(CircleShape)
.padding(top = 6.dp), maxLines = 1) .background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = account.title.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
)
}
Column(modifier = Modifier.weight(1f)) { 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) Text(account.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
if (account.isActive) { if (account.isActive) {
Text("Active", color = MaterialTheme.colorScheme.primary) Text(stringResource(id = R.string.settings_active), color = MaterialTheme.colorScheme.primary)
} else { } 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) } OutlinedButton(onClick = { viewModel.removeStoredAccount(account.userId) }) { Icon(Icons.Filled.DeleteOutline, contentDescription = null) }
} }
@@ -327,19 +388,19 @@ private fun AccountFolder(
} }
SettingsCard { SettingsCard {
OutlinedButton(onClick = onOpenProfile, modifier = Modifier.fillMaxWidth()) { Text("Open profile") } OutlinedButton(onClick = onOpenProfile, modifier = Modifier.fillMaxWidth()) { Text(stringResource(id = R.string.settings_open_profile)) }
Button(onClick = onLogout, modifier = Modifier.fillMaxWidth()) { Text("Logout") } Button(onClick = onLogout, modifier = Modifier.fillMaxWidth()) { Text(stringResource(id = R.string.settings_logout)) }
} }
} }
@Composable @Composable
private fun ChatFolder(state: AccountUiState, viewModel: AccountViewModel) { private fun ChatFolder(state: AccountUiState, viewModel: AccountViewModel) {
SettingsCard { SettingsCard {
Text("Appearance", style = MaterialTheme.typography.titleSmall) Text(stringResource(id = R.string.settings_appearance), style = MaterialTheme.typography.titleSmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ThemeButton("Light", state.appThemeMode == AppThemeMode.LIGHT) { viewModel.setThemeMode(AppThemeMode.LIGHT) } ThemeButton(stringResource(id = R.string.theme_light), state.appThemeMode == AppThemeMode.LIGHT) { viewModel.setThemeMode(AppThemeMode.LIGHT) }
ThemeButton("Dark", state.appThemeMode == AppThemeMode.DARK) { viewModel.setThemeMode(AppThemeMode.DARK) } ThemeButton(stringResource(id = R.string.theme_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_system), state.appThemeMode == AppThemeMode.SYSTEM) { viewModel.setThemeMode(AppThemeMode.SYSTEM) }
} }
} }
} }
@@ -347,20 +408,20 @@ private fun ChatFolder(state: AccountUiState, viewModel: AccountViewModel) {
@Composable @Composable
private fun NotificationsFolder(state: AccountUiState, viewModel: AccountViewModel) { private fun NotificationsFolder(state: AccountUiState, viewModel: AccountViewModel) {
SettingsCard { SettingsCard {
SettingsToggle(Icons.Filled.Notifications, "Enable notifications", state.notificationsEnabled, viewModel::setGlobalNotificationsEnabled) SettingsToggle(Icons.Filled.Notifications, stringResource(id = R.string.settings_enable_notifications), state.notificationsEnabled, viewModel::setGlobalNotificationsEnabled)
SettingsToggle(Icons.Filled.Visibility, "Show message preview", state.notificationsPreviewEnabled, viewModel::setNotificationPreviewEnabled) SettingsToggle(Icons.Filled.Visibility, stringResource(id = R.string.settings_show_preview), state.notificationsPreviewEnabled, viewModel::setNotificationPreviewEnabled)
OutlinedButton( OutlinedButton(
onClick = viewModel::refresh, onClick = viewModel::refresh,
enabled = !state.isLoading && !state.isSaving, enabled = !state.isLoading && !state.isSaving,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text("Refresh notification history") Text(stringResource(id = R.string.settings_refresh_notifications))
} }
} }
SettingsCard { SettingsCard {
Text("Recent notifications", style = MaterialTheme.typography.titleSmall) Text(stringResource(id = R.string.settings_recent_notifications), style = MaterialTheme.typography.titleSmall)
if (state.notificationsHistory.isEmpty()) { 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 { } else {
state.notificationsHistory.take(20).forEach { notification -> state.notificationsHistory.take(20).forEach { notification ->
Column( Column(
@@ -403,13 +464,13 @@ private fun PrivacyFolder(state: AccountUiState, viewModel: AccountViewModel) {
var invites by remember(state.profile?.privacyGroupInvites) { mutableStateOf(state.profile?.privacyGroupInvites ?: "everyone") } var invites by remember(state.profile?.privacyGroupInvites) { mutableStateOf(state.profile?.privacyGroupInvites ?: "everyone") }
SettingsCard { SettingsCard {
PrivacyDropdown("Private messages", pm) { pm = it } PrivacyDropdown(stringResource(id = R.string.privacy_private_messages), pm) { pm = it }
PrivacyDropdown("Last seen", lastSeen) { lastSeen = it } PrivacyDropdown(stringResource(id = R.string.privacy_last_seen), lastSeen) { lastSeen = it }
PrivacyDropdown("Avatar", avatar) { avatar = it } PrivacyDropdown(stringResource(id = R.string.privacy_avatar), avatar) { avatar = it }
PrivacyDropdown("Group invites", invites) { invites = 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()) { Button(onClick = { viewModel.updatePrivacy(pm, lastSeen, avatar, invites) }, enabled = !state.isSaving, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Lock, contentDescription = null) 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("") } var recoveryCode by remember { mutableStateOf("") }
SettingsCard { SettingsCard {
Text("Sessions & Security", style = MaterialTheme.typography.titleSmall) Text(stringResource(id = R.string.settings_sessions_security), style = MaterialTheme.typography.titleSmall)
state.sessions.forEach { s -> state.sessions.forEach { s ->
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(Icons.Filled.Devices, contentDescription = null) Icon(Icons.Filled.Devices, contentDescription = null)
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text(s.userAgent ?: "Unknown device") Text(s.userAgent ?: stringResource(id = R.string.settings_unknown_device))
Text(s.ipAddress ?: "Unknown IP", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) 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()) { OutlinedButton(onClick = viewModel::revokeAllSessions, enabled = !state.isSaving, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Security, contentDescription = null) 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 { 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)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.enableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { Text("Enable") } 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("Disable") } 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()) { 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(name, style = MaterialTheme.typography.headlineSmall)
Text(listOfNotNull(email.takeIf { it.isNotBlank() }, username.takeIf { it.isNotBlank() }?.let { "@$it" }).joinToString(""), color = MaterialTheme.colorScheme.onSurfaceVariant) 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, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp), 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)) { 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) Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) 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 }) { DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { option -> 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 package ru.daemonlord.messenger.ui.theme
import android.app.Activity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable 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 LightColors = lightColorScheme()
private val DarkColors = darkColorScheme() private val DarkColors = darkColorScheme()
@@ -17,8 +22,18 @@ fun MessengerTheme(content: @Composable () -> Unit) {
AppCompatDelegate.MODE_NIGHT_NO -> false AppCompatDelegate.MODE_NIGHT_NO -> false
else -> isSystemInDarkTheme() 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( MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors, colorScheme = colorScheme,
content = content, 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
}
}

View File

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