android: persist language settings and realtime/ui sync updates
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,7 @@ import ru.daemonlord.messenger.domain.account.model.UserSearchItem
|
||||
import ru.daemonlord.messenger.domain.account.model.AccountNotification
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
|
||||
data class AccountUiState(
|
||||
@@ -19,6 +20,7 @@ data class AccountUiState(
|
||||
val activeUserId: Long? = null,
|
||||
val storedAccounts: List<StoredAccountUi> = emptyList(),
|
||||
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
|
||||
val appLanguage: AppLanguage = AppLanguage.SYSTEM,
|
||||
val notificationsEnabled: Boolean = true,
|
||||
val notificationsPreviewEnabled: Boolean = true,
|
||||
val notificationsHistory: List<AccountNotification> = emptyList(),
|
||||
|
||||
@@ -3,6 +3,7 @@ package ru.daemonlord.messenger.ui.account
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -18,7 +19,9 @@ import ru.daemonlord.messenger.domain.common.AppError
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
|
||||
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
|
||||
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -30,6 +33,7 @@ class AccountViewModel @Inject constructor(
|
||||
private val chatRepository: ChatRepository,
|
||||
private val realtimeManager: RealtimeManager,
|
||||
private val notificationSettingsRepository: NotificationSettingsRepository,
|
||||
private val languageRepository: LanguageRepository,
|
||||
private val themeRepository: ThemeRepository,
|
||||
private val pushTokenSyncManager: PushTokenSyncManager,
|
||||
) : ViewModel() {
|
||||
@@ -51,6 +55,7 @@ class AccountViewModel @Inject constructor(
|
||||
val storedAccounts = tokenRepository.getAccounts()
|
||||
val notificationSettings = notificationSettingsRepository.getSettings()
|
||||
val appThemeMode = themeRepository.getThemeMode()
|
||||
val appLanguage = languageRepository.getLanguage()
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
@@ -74,6 +79,7 @@ class AccountViewModel @Inject constructor(
|
||||
notificationsEnabled = notificationSettings.globalEnabled,
|
||||
notificationsPreviewEnabled = notificationSettings.previewEnabled,
|
||||
appThemeMode = appThemeMode,
|
||||
appLanguage = appLanguage,
|
||||
errorMessage = listOf(me, sessions, blocked, notifications)
|
||||
.filterIsInstance<AppResult.Error>()
|
||||
.firstOrNull()
|
||||
@@ -98,6 +104,16 @@ class AccountViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setLanguage(language: AppLanguage) {
|
||||
viewModelScope.launch {
|
||||
languageRepository.setLanguage(language)
|
||||
val locales = language.tag?.let { LocaleListCompat.forLanguageTags(it) }
|
||||
?: LocaleListCompat.getEmptyLocaleList()
|
||||
AppCompatDelegate.setApplicationLocales(locales)
|
||||
_uiState.update { it.copy(appLanguage = language) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setGlobalNotificationsEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
notificationSettingsRepository.setGlobalEnabled(enabled)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ data class MessageUiState(
|
||||
val inlineSearchMatches: List<Long> = emptyList(),
|
||||
val highlightedMessageId: Long? = null,
|
||||
val actionState: MessageActionState = MessageActionState(),
|
||||
val chatDeletedNonce: Long = 0L,
|
||||
)
|
||||
|
||||
data class ForwardTargetUiModel(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -10,12 +10,14 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -39,6 +41,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -49,19 +52,29 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import ru.daemonlord.messenger.ui.account.AccountViewModel
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.max
|
||||
|
||||
@Composable
|
||||
fun ProfileRoute(
|
||||
@@ -86,6 +99,7 @@ fun ProfileScreen(
|
||||
var bio by remember(profile?.bio) { mutableStateOf(profile?.bio.orEmpty()) }
|
||||
var avatarUrl by remember(profile?.avatarUrl) { mutableStateOf(profile?.avatarUrl.orEmpty()) }
|
||||
var editMode by remember { mutableStateOf(false) }
|
||||
var pendingAvatarBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -110,14 +124,7 @@ fun ProfileScreen(
|
||||
val pickAvatarLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent(),
|
||||
) { uri ->
|
||||
val bytes = uri?.toSquareJpeg(context) ?: return@rememberLauncherForActivityResult
|
||||
viewModel.uploadAvatar(
|
||||
fileName = "avatar.jpg",
|
||||
mimeType = "image/jpeg",
|
||||
bytes = bytes,
|
||||
) { uploadedUrl ->
|
||||
avatarUrl = uploadedUrl
|
||||
}
|
||||
pendingAvatarBitmap = uri?.toBitmap(context)
|
||||
}
|
||||
|
||||
Box(
|
||||
@@ -134,80 +141,82 @@ fun ProfileScreen(
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = 96.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(305.dp)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
MaterialTheme.colorScheme.secondaryContainer,
|
||||
MaterialTheme.colorScheme.tertiaryContainer,
|
||||
if (!editMode) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(305.dp)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
MaterialTheme.colorScheme.secondaryContainer,
|
||||
MaterialTheme.colorScheme.tertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
if (avatarUrl.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = avatarUrl,
|
||||
contentDescription = "Avatar",
|
||||
modifier = Modifier
|
||||
.size(108.dp)
|
||||
.clip(CircleShape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f), CircleShape),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(108.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = name.firstOrNull()?.uppercase() ?: "?",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = if (name.isBlank()) "User" else name,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text("online", color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), style = MaterialTheme.typography.bodyLarge)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
HeroActionButton(
|
||||
label = "Choose photo",
|
||||
icon = Icons.Filled.AddAPhoto,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
pickAvatarLauncher.launch("image/*")
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
if (avatarUrl.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = avatarUrl,
|
||||
contentDescription = "Avatar",
|
||||
modifier = Modifier
|
||||
.size(108.dp)
|
||||
.clip(CircleShape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f), CircleShape),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(108.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = name.firstOrNull()?.uppercase() ?: "?",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
HeroActionButton(
|
||||
label = if (editMode) "Editing" else "Edit",
|
||||
icon = Icons.Filled.Edit,
|
||||
modifier = Modifier.weight(1f),
|
||||
|
||||
Text(
|
||||
text = if (name.isBlank()) "User" else name,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text("online", color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), style = MaterialTheme.typography.bodyLarge)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
editMode = !editMode
|
||||
HeroActionButton(
|
||||
label = "Choose photo",
|
||||
icon = Icons.Filled.AddAPhoto,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
pickAvatarLauncher.launch("image/*")
|
||||
}
|
||||
HeroActionButton(
|
||||
label = "Edit",
|
||||
icon = Icons.Filled.Edit,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
editMode = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,23 +226,25 @@ fun ProfileScreen(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.offset(y = (-22).dp),
|
||||
) {
|
||||
Column(
|
||||
if (!editMode) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
.offset(y = (-22).dp),
|
||||
) {
|
||||
ProfileInfoRow("Email", profile?.email.orEmpty())
|
||||
ProfileInfoRow("Bio", bio.ifBlank { "Not set" })
|
||||
ProfileInfoRow("Username", if (username.isBlank()) "Not set" else "@$username")
|
||||
ProfileInfoRow("Name", name.ifBlank { "Not set" })
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
ProfileInfoRow("Email", profile?.email.orEmpty())
|
||||
ProfileInfoRow("Bio", bio.ifBlank { "Not set" })
|
||||
ProfileInfoRow("Username", if (username.isBlank()) "Not set" else "@$username")
|
||||
ProfileInfoRow("Name", name.ifBlank { "Not set" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +252,9 @@ fun ProfileScreen(
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -250,6 +263,13 @@ fun ProfileScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("Edit profile", style = MaterialTheme.typography.titleMedium)
|
||||
HeroActionButton(
|
||||
label = "Choose photo",
|
||||
icon = Icons.Filled.AddAPhoto,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
pickAvatarLauncher.launch("image/*")
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
@@ -274,19 +294,25 @@ fun ProfileScreen(
|
||||
label = { Text("Avatar URL") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.updateProfile(
|
||||
name = name,
|
||||
username = username,
|
||||
bio = bio.ifBlank { null },
|
||||
avatarUrl = avatarUrl.ifBlank { null },
|
||||
)
|
||||
},
|
||||
enabled = !state.isSaving && name.isNotBlank() && username.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Save profile")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = { editMode = false }, modifier = Modifier.weight(1f)) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.updateProfile(
|
||||
name = name,
|
||||
username = username,
|
||||
bio = bio.ifBlank { null },
|
||||
avatarUrl = avatarUrl.ifBlank { null },
|
||||
)
|
||||
editMode = false
|
||||
},
|
||||
enabled = !state.isSaving && name.isNotBlank() && username.isNotBlank(),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
if (state.isSaving) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
|
||||
@@ -314,6 +340,22 @@ fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingAvatarBitmap?.let { bitmap ->
|
||||
AvatarCropDialog(
|
||||
bitmap = bitmap,
|
||||
onDismiss = { pendingAvatarBitmap = null },
|
||||
onConfirm = { croppedBytes ->
|
||||
viewModel.uploadAvatar(
|
||||
fileName = "avatar.jpg",
|
||||
mimeType = "image/jpeg",
|
||||
bytes = croppedBytes,
|
||||
) { uploadedUrl ->
|
||||
avatarUrl = uploadedUrl
|
||||
}
|
||||
pendingAvatarBitmap = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -346,8 +388,120 @@ private fun ProfileInfoRow(label: String, value: String) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun Uri.toSquareJpeg(context: Context): ByteArray? {
|
||||
val bitmap = runCatching {
|
||||
@Composable
|
||||
private fun AvatarCropDialog(
|
||||
bitmap: Bitmap,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (ByteArray) -> Unit,
|
||||
) {
|
||||
var scale by remember(bitmap) { mutableStateOf(1f) }
|
||||
var offset by remember(bitmap) { mutableStateOf(Offset.Zero) }
|
||||
var viewportSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
fun clampOffset(raw: Offset, currentScale: Float, viewportPx: Float): Offset {
|
||||
val baseScale = max(viewportPx / bitmap.width.toFloat(), viewportPx / bitmap.height.toFloat())
|
||||
val displayedWidth = bitmap.width * baseScale * currentScale
|
||||
val displayedHeight = bitmap.height * baseScale * currentScale
|
||||
val maxOffsetX = max(0f, (displayedWidth - viewportPx) / 2f)
|
||||
val maxOffsetY = max(0f, (displayedHeight - viewportPx) / 2f)
|
||||
return Offset(
|
||||
x = raw.x.coerceIn(-maxOffsetX, maxOffsetX),
|
||||
y = raw.y.coerceIn(-maxOffsetY, maxOffsetY),
|
||||
)
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("Crop avatar", style = MaterialTheme.typography.titleMedium)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.clipToBounds()
|
||||
.onSizeChanged { viewportSize = it }
|
||||
.pointerInput(bitmap, viewportSize, scale, offset) {
|
||||
detectTransformGestures { _, pan, zoom, _ ->
|
||||
val viewport = viewportSize.width.toFloat().coerceAtLeast(1f)
|
||||
val newScale = (scale * zoom).coerceIn(1f, 4f)
|
||||
scale = newScale
|
||||
offset = clampOffset(offset + pan, newScale, viewport)
|
||||
}
|
||||
},
|
||||
) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = "Avatar crop preview",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = offset.x
|
||||
translationY = offset.y
|
||||
},
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val viewportPx = viewportSize.width.toFloat()
|
||||
if (viewportPx <= 1f) return@Button
|
||||
val baseScale = max(viewportPx / bitmap.width.toFloat(), viewportPx / bitmap.height.toFloat())
|
||||
val fullScale = baseScale * scale
|
||||
val centerX = viewportPx / 2f + offset.x
|
||||
val centerY = viewportPx / 2f + offset.y
|
||||
val left = ((0f - centerX) / fullScale + bitmap.width / 2f)
|
||||
val top = ((0f - centerY) / fullScale + bitmap.height / 2f)
|
||||
val side = (viewportPx / fullScale).coerceAtMost(minOf(bitmap.width, bitmap.height).toFloat())
|
||||
|
||||
val safeLeft = left.coerceIn(0f, bitmap.width - side)
|
||||
val safeTop = top.coerceIn(0f, bitmap.height - side)
|
||||
val cropBitmap = Bitmap.createBitmap(
|
||||
bitmap,
|
||||
safeLeft.toInt(),
|
||||
safeTop.toInt(),
|
||||
side.toInt().coerceAtLeast(1),
|
||||
side.toInt().coerceAtLeast(1),
|
||||
)
|
||||
val output = ByteArrayOutputStream()
|
||||
val ok = cropBitmap.compress(Bitmap.CompressFormat.JPEG, 92, output)
|
||||
if (ok) onConfirm(output.toByteArray())
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Use")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Use two fingers to zoom and move.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Uri.toBitmap(context: Context): Bitmap? {
|
||||
return runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val src = ImageDecoder.createSource(context.contentResolver, this)
|
||||
ImageDecoder.decodeBitmap(src)
|
||||
@@ -355,18 +509,5 @@ private fun Uri.toSquareJpeg(context: Context): ByteArray? {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaStore.Images.Media.getBitmap(context.contentResolver, this)
|
||||
}
|
||||
}.getOrNull() ?: return null
|
||||
|
||||
val square = bitmap.centerCropSquare()
|
||||
val output = ByteArrayOutputStream()
|
||||
val compressed = square.compress(Bitmap.CompressFormat.JPEG, 92, output)
|
||||
if (!compressed) return null
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
private fun Bitmap.centerCropSquare(): Bitmap {
|
||||
val side = minOf(width, height)
|
||||
val left = (width - side) / 2
|
||||
val top = (height - side) / 2
|
||||
return Bitmap.createBitmap(this, left, top, side, side)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user