From 61fd7b7f2ec84a0b08f4e7f99d34e1de1176fc5b Mon Sep 17 00:00:00 2001 From: YaAndreyIgorevich Date: Sun, 8 Mar 2026 01:23:53 +0700 Subject: [PATCH] [feat] - multisessions implemented - update SessionManager - add account switch logic --- .../messenger/data/local/SessionManager.kt | 161 ++++++++++++-- .../data/repository/AuthRepositoryImpl.kt | 16 +- .../accounts/AccountSwitcherScreen.kt | 183 ++++++++++++++++ .../accounts/AccountSwitcherViewModel.kt | 57 +++++ .../messenger/presentation/main/HomeScreen.kt | 63 ++++-- .../presentation/main/HomeViewModel.kt | 17 +- .../presentation/navigation/AppNavGraph.kt | 31 ++- .../presentation/profile/ProfileViewModel.kt | 10 +- .../presentation/settings/SettingsScreen.kt | 204 +++++++++++++++++- 9 files changed, 691 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/org/yobble/messenger/presentation/accounts/AccountSwitcherScreen.kt create mode 100644 app/src/main/java/org/yobble/messenger/presentation/accounts/AccountSwitcherViewModel.kt diff --git a/app/src/main/java/org/yobble/messenger/data/local/SessionManager.kt b/app/src/main/java/org/yobble/messenger/data/local/SessionManager.kt index 849fea3..fb8dfa6 100644 --- a/app/src/main/java/org/yobble/messenger/data/local/SessionManager.kt +++ b/app/src/main/java/org/yobble/messenger/data/local/SessionManager.kt @@ -5,56 +5,177 @@ import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject import javax.inject.Singleton import androidx.core.content.edit +data class AccountInfo( + val accountId: String, + val userId: String, + val login: String? = null, + val displayName: String? = null, + val avatarFileId: String? = null, + val bio: String? = null +) + @Singleton class SessionManager @Inject constructor( - @ApplicationContext context: Context + @ApplicationContext private val context: Context ) { private val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() - private val prefs: SharedPreferences = EncryptedSharedPreferences.create( - context, - "yobble_session_prefs", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + private val accountsPrefs: SharedPreferences = context.getSharedPreferences( + "yobble_accounts", Context.MODE_PRIVATE ) + private val _activeAccountId = MutableStateFlow(accountsPrefs.getString(KEY_ACTIVE_ACCOUNT, null)) + val activeAccountId: StateFlow = _activeAccountId.asStateFlow() + + private val sessionCaches = mutableMapOf() + private val localPrefs: SharedPreferences = context.getSharedPreferences( "yobble_local_prefs", Context.MODE_PRIVATE ) + private fun getSessionPrefs(accountId: String): SharedPreferences { + return sessionCaches.getOrPut(accountId) { + EncryptedSharedPreferences.create( + context, + "yobble_session_$accountId", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + } + + private val activePrefs: SharedPreferences? + get() { + val id = _activeAccountId.value ?: return null + return getSessionPrefs(id) + } + var accessToken: String? - get() = prefs.getString(KEY_ACCESS_TOKEN, null) - set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) } + get() = activePrefs?.getString(KEY_ACCESS_TOKEN, null) + set(value) = activePrefs?.edit { putString(KEY_ACCESS_TOKEN, value) } ?: Unit var refreshToken: String? - get() = prefs.getString(KEY_REFRESH_TOKEN, null) - set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) } + get() = activePrefs?.getString(KEY_REFRESH_TOKEN, null) + set(value) = activePrefs?.edit { putString(KEY_REFRESH_TOKEN, value) } ?: Unit var userId: String? - get() = prefs.getString(KEY_USER_ID, null) - set(value) = prefs.edit { putString(KEY_USER_ID, value) } + get() = activePrefs?.getString(KEY_USER_ID, null) + set(value) = activePrefs?.edit { putString(KEY_USER_ID, value) } ?: Unit val isLoggedIn: Boolean get() = accessToken != null fun saveSession(accessToken: String, refreshToken: String, userId: String) { + val accountId = _activeAccountId.value ?: userId + if (_activeAccountId.value == null) { + setActiveAccount(accountId) + } + val prefs = getSessionPrefs(accountId) prefs.edit { putString(KEY_ACCESS_TOKEN, accessToken) - .putString(KEY_REFRESH_TOKEN, refreshToken) - .putString(KEY_USER_ID, userId) + putString(KEY_REFRESH_TOKEN, refreshToken) + putString(KEY_USER_ID, userId) } + addAccountToList(accountId, userId) } fun clearSession() { - prefs.edit { clear() } - localPrefs.edit { clear() } + val accountId = _activeAccountId.value ?: return + removeAccount(accountId) + } + + fun getAccounts(): List { + val ids = accountsPrefs.getStringSet(KEY_ACCOUNT_IDS, emptySet()) ?: emptySet() + return ids.mapNotNull { id -> + try { + val prefs = getSessionPrefs(id) + val uid = prefs.getString(KEY_USER_ID, null) ?: return@mapNotNull null + AccountInfo( + accountId = id, + userId = uid, + login = prefs.getString(KEY_LOGIN, null), + displayName = prefs.getString(KEY_DISPLAY_NAME, null), + avatarFileId = prefs.getString(KEY_AVATAR_FILE_ID, null), + bio = prefs.getString(KEY_BIO, null) + ) + } catch (_: Exception) { + null + } + } + } + + fun switchAccount(accountId: String) { + val ids = accountsPrefs.getStringSet(KEY_ACCOUNT_IDS, emptySet()) ?: emptySet() + if (accountId !in ids) return + setActiveAccount(accountId) + } + + fun prepareNewAccountLogin() { + _activeAccountId.value = null + accountsPrefs.edit { remove(KEY_ACTIVE_ACCOUNT) } + } + + fun removeAccount(accountId: String) { + val ids = (accountsPrefs.getStringSet(KEY_ACCOUNT_IDS, emptySet()) ?: emptySet()).toMutableSet() + ids.remove(accountId) + accountsPrefs.edit { + putStringSet(KEY_ACCOUNT_IDS, ids) + } + + try { + getSessionPrefs(accountId).edit { clear() } + } catch (_: Exception) {} + sessionCaches.remove(accountId) + + if (_activeAccountId.value == accountId) { + val nextId = ids.firstOrNull() + if (nextId != null) { + setActiveAccount(nextId) + } else { + _activeAccountId.value = null + accountsPrefs.edit { remove(KEY_ACTIVE_ACCOUNT) } + localPrefs.edit { clear() } + } + } + } + + fun updateAccountMeta( + login: String? = null, + displayName: String? = null, + avatarFileId: String? = null, + bio: String? = null + ) { + val prefs = activePrefs ?: return + prefs.edit { + if (login != null) putString(KEY_LOGIN, login) + if (displayName != null) putString(KEY_DISPLAY_NAME, displayName) + if (avatarFileId != null) putString(KEY_AVATAR_FILE_ID, avatarFileId) + // bio can be empty string to clear it + if (bio != null) putString(KEY_BIO, bio) + } + } + + private fun setActiveAccount(accountId: String) { + _activeAccountId.value = accountId + accountsPrefs.edit { putString(KEY_ACTIVE_ACCOUNT, accountId) } + } + + private fun addAccountToList(accountId: String, userId: String) { + val ids = (accountsPrefs.getStringSet(KEY_ACCOUNT_IDS, emptySet()) ?: emptySet()).toMutableSet() + ids.add(accountId) + accountsPrefs.edit { + putStringSet(KEY_ACCOUNT_IDS, ids) + } } fun saveLastReadMessageId(chatId: String, messageId: String) { @@ -81,5 +202,11 @@ class SessionManager @Inject constructor( private const val KEY_ACCESS_TOKEN = "access_token" private const val KEY_REFRESH_TOKEN = "refresh_token" private const val KEY_USER_ID = "user_id" + private const val KEY_LOGIN = "login" + private const val KEY_DISPLAY_NAME = "display_name" + private const val KEY_AVATAR_FILE_ID = "avatar_file_id" + private const val KEY_BIO = "bio" + private const val KEY_ACTIVE_ACCOUNT = "active_account_id" + private const val KEY_ACCOUNT_IDS = "account_ids" } } diff --git a/app/src/main/java/org/yobble/messenger/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/org/yobble/messenger/data/repository/AuthRepositoryImpl.kt index 8821ab0..bd453c6 100644 --- a/app/src/main/java/org/yobble/messenger/data/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/org/yobble/messenger/data/repository/AuthRepositoryImpl.kt @@ -22,11 +22,13 @@ class AuthRepositoryImpl @Inject constructor( authApi.login(LoginRequestDto(login, password)) } if (result is NetworkResult.Success) { + val data = result.data.data sessionManager.saveSession( - accessToken = result.data.data.accessToken, - refreshToken = result.data.data.refreshToken, - userId = result.data.data.userId + accessToken = data.accessToken, + refreshToken = data.refreshToken, + userId = data.userId ) + sessionManager.updateAccountMeta(login = login, displayName = null) pushTokenManager.registerPushToken() } return result @@ -43,11 +45,13 @@ class AuthRepositoryImpl @Inject constructor( authApi.verifyCode(VerifyCodeRequestDto(login, otp)) } if (result is NetworkResult.Success) { + val data = result.data.data sessionManager.saveSession( - accessToken = result.data.data.accessToken, - refreshToken = result.data.data.refreshToken, - userId = result.data.data.userId + accessToken = data.accessToken, + refreshToken = data.refreshToken, + userId = data.userId ) + sessionManager.updateAccountMeta(login = login, displayName = null) pushTokenManager.registerPushToken() } return result diff --git a/app/src/main/java/org/yobble/messenger/presentation/accounts/AccountSwitcherScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/accounts/AccountSwitcherScreen.kt new file mode 100644 index 0000000..a310647 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/accounts/AccountSwitcherScreen.kt @@ -0,0 +1,183 @@ +package org.yobble.messenger.presentation.accounts + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.yobble.messenger.data.local.AccountInfo +import org.yobble.messenger.presentation.common.InitialsAvatar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountSwitcherScreen( + onNavigateBack: () -> Unit, + onAccountSwitched: () -> Unit, + onAddAccount: () -> Unit, + viewModel: AccountSwitcherViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(uiState.switchedAccount) { + if (uiState.switchedAccount) { + onAccountSwitched() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Accounts", fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary + ) + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + items(uiState.accounts, key = { it.accountId }) { account -> + AccountItem( + account = account, + isActive = account.accountId == uiState.activeAccountId, + onClick = { viewModel.switchTo(account.accountId) }, + onRemove = { viewModel.removeAccount(account.accountId) } + ) + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = { + viewModel.prepareNewAccountLogin() + onAddAccount() + }) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Text( + "Add account", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } +} + +@Composable +private fun AccountItem( + account: AccountInfo, + isActive: Boolean, + onClick: () -> Unit, + onRemove: () -> Unit +) { + val displayName = account.displayName ?: account.login ?: "Account" + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .background( + if (isActive) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) + else MaterialTheme.colorScheme.surface + ) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + InitialsAvatar( + displayName = displayName, + size = 44.dp, + fontSize = 18.sp + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = displayName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (account.login != null) { + Text( + text = "@${account.login}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + } + + if (isActive) { + Icon( + Icons.Default.Check, + contentDescription = "Active", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } else { + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Close, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + } + } + + HorizontalDivider( + modifier = Modifier.padding(start = 72.dp), + color = MaterialTheme.colorScheme.outlineVariant, + thickness = 0.5.dp + ) +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/accounts/AccountSwitcherViewModel.kt b/app/src/main/java/org/yobble/messenger/presentation/accounts/AccountSwitcherViewModel.kt new file mode 100644 index 0000000..f7b17df --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/accounts/AccountSwitcherViewModel.kt @@ -0,0 +1,57 @@ +package org.yobble.messenger.presentation.accounts + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.yobble.messenger.data.local.AccountInfo +import org.yobble.messenger.data.local.SessionManager +import javax.inject.Inject + +data class AccountSwitcherUiState( + val accounts: List = emptyList(), + val activeAccountId: String? = null, + val switchedAccount: Boolean = false +) + +@HiltViewModel +class AccountSwitcherViewModel @Inject constructor( + private val sessionManager: SessionManager +) : ViewModel() { + + private val _uiState = MutableStateFlow(AccountSwitcherUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadAccounts() + } + + private fun loadAccounts() { + _uiState.update { + it.copy( + accounts = sessionManager.getAccounts(), + activeAccountId = sessionManager.activeAccountId.value + ) + } + } + + fun switchTo(accountId: String) { + if (accountId == _uiState.value.activeAccountId) return + sessionManager.switchAccount(accountId) + _uiState.update { it.copy(switchedAccount = true) } + } + + fun removeAccount(accountId: String) { + sessionManager.removeAccount(accountId) + loadAccounts() + if (sessionManager.activeAccountId.value == null) { + _uiState.update { it.copy(switchedAccount = true) } + } + } + + fun prepareNewAccountLogin() { + sessionManager.prepareNewAccountLogin() + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/main/HomeScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/main/HomeScreen.kt index 4c1f119..f8960a0 100644 --- a/app/src/main/java/org/yobble/messenger/presentation/main/HomeScreen.kt +++ b/app/src/main/java/org/yobble/messenger/presentation/main/HomeScreen.kt @@ -13,9 +13,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.filled.Contacts -import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Verified import androidx.compose.material3.* @@ -39,17 +37,15 @@ import kotlinx.coroutines.launch import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto import org.yobble.messenger.presentation.common.UserAvatar import org.yobble.messenger.presentation.contacts.ContactsScreen -import org.yobble.messenger.presentation.profile.ProfileScreen import org.yobble.messenger.presentation.settings.SettingsScreen import org.yobble.messenger.util.formatUtcToLocalTime private const val SWIPE_NAVIGATION_ENABLED = true -private enum class HomeTab(val label: String, val icon: ImageVector) { +private enum class HomeTab(val label: String, val icon: ImageVector?) { CHATS("Chats", Icons.AutoMirrored.Filled.Chat), CONTACTS("Contacts", Icons.Default.Contacts), - SETTINGS("Settings", Icons.Default.Settings), - PROFILE("Profile", Icons.Default.Person) + SETTINGS("Settings", null) } @OptIn(ExperimentalMaterial3Api::class) @@ -60,8 +56,10 @@ fun HomeScreen( onNavigateToSessions: () -> Unit, onNavigateToChangePassword: () -> Unit, onNavigateToBlacklist: () -> Unit, - onNavigateToUserProfile: (userId: String) -> Unit = {}, onNavigateToSearch: () -> Unit, + onNavigateToProfile: () -> Unit, + onAccountSwitched: () -> Unit, + onAddAccount: () -> Unit, onLogout: () -> Unit, viewModel: HomeViewModel = hiltViewModel() ) { @@ -76,6 +74,7 @@ fun HomeScreen( LifecycleResumeEffect(Unit) { viewModel.loadChats() + viewModel.refreshActiveAccount() onPauseOrDispose { } } @@ -136,7 +135,9 @@ fun HomeScreen( onNavigateToSessions = onNavigateToSessions, onNavigateToChangePassword = onNavigateToChangePassword, onNavigateToBlacklist = onNavigateToBlacklist, - onNavigateToUserProfile = onNavigateToUserProfile, + onNavigateToProfile = onNavigateToProfile, + onAccountSwitched = onAccountSwitched, + onAddAccount = onAddAccount, onLogout = { viewModel.logout() onLogout() @@ -154,7 +155,9 @@ fun HomeScreen( onNavigateToSessions = onNavigateToSessions, onNavigateToChangePassword = onNavigateToChangePassword, onNavigateToBlacklist = onNavigateToBlacklist, - onNavigateToUserProfile = onNavigateToUserProfile, + onNavigateToProfile = onNavigateToProfile, + onAccountSwitched = onAccountSwitched, + onAddAccount = onAddAccount, onLogout = { viewModel.logout() onLogout() @@ -177,6 +180,8 @@ fun HomeScreen( containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), tonalElevation = 8.dp ) { + val activeAccount = uiState.activeAccount + val indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f) tabs.forEachIndexed { index, tab -> NavigationBarItem( selected = selectedTab == index, @@ -189,10 +194,33 @@ fun HomeScreen( } } }, - icon = { Icon(tab.icon, contentDescription = null) }, + icon = { + Box( + modifier = Modifier + .size(36.dp) + .then( + if (selectedTab == index) + Modifier.clip(CircleShape).background(indicatorColor) + else Modifier + ), + contentAlignment = Alignment.Center + ) { + if (tab == HomeTab.SETTINGS && activeAccount != null) { + UserAvatar( + userId = activeAccount.userId, + fileId = activeAccount.avatarFileId, + displayName = activeAccount.displayName ?: activeAccount.login ?: "U", + size = 24.dp, + fontSize = 10.sp + ) + } else if (tab.icon != null) { + Icon(tab.icon, contentDescription = null) + } + } + }, label = { Text(tab.label, fontSize = 11.sp) }, colors = NavigationBarItemDefaults.colors( - indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f) + indicatorColor = Color.Transparent ) ) } @@ -211,7 +239,9 @@ private fun TabContent( onNavigateToSessions: () -> Unit, onNavigateToChangePassword: () -> Unit, onNavigateToBlacklist: () -> Unit, - onNavigateToUserProfile: (String) -> Unit, + onNavigateToProfile: () -> Unit, + onAccountSwitched: () -> Unit, + onAddAccount: () -> Unit, onLogout: () -> Unit, bottomPadding: androidx.compose.ui.unit.Dp ) { @@ -231,15 +261,12 @@ private fun TabContent( onNavigateToSessions = onNavigateToSessions, onNavigateToChangePassword = onNavigateToChangePassword, onNavigateToBlacklist = onNavigateToBlacklist, + onNavigateToProfile = onNavigateToProfile, + onAccountSwitched = onAccountSwitched, + onAddAccount = onAddAccount, onLogout = onLogout, bottomPadding = bottomPadding ) - HomeTab.PROFILE -> ProfileScreen( - onNavigateBack = { /* already on home, no-op */ }, - onNavigateToChat = onNavigateToChat, - showTopBar = false, - bottomPadding = bottomPadding - ) } } diff --git a/app/src/main/java/org/yobble/messenger/presentation/main/HomeViewModel.kt b/app/src/main/java/org/yobble/messenger/presentation/main/HomeViewModel.kt index e0b247d..957554c 100644 --- a/app/src/main/java/org/yobble/messenger/presentation/main/HomeViewModel.kt +++ b/app/src/main/java/org/yobble/messenger/presentation/main/HomeViewModel.kt @@ -13,6 +13,8 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.yobble.messenger.data.local.AccountInfo +import org.yobble.messenger.data.local.SessionManager import org.yobble.messenger.data.remote.NetworkResult import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto import org.yobble.messenger.data.remote.socket.SocketEvent @@ -24,7 +26,8 @@ import javax.inject.Inject data class HomeUiState( val chats: List = emptyList(), val isLoading: Boolean = false, - val hasMore: Boolean = false + val hasMore: Boolean = false, + val activeAccount: AccountInfo? = null ) sealed class HomeEvent { @@ -35,7 +38,8 @@ sealed class HomeEvent { class HomeViewModel @Inject constructor( private val chatRepository: ChatRepository, private val authRepository: AuthRepository, - private val socketManager: SocketManager + private val socketManager: SocketManager, + private val sessionManager: SessionManager ) : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState()) @@ -49,9 +53,18 @@ class HomeViewModel @Inject constructor( init { socketManager.connect() loadChats() + loadActiveAccount() observeSocket() } + fun refreshActiveAccount() { + val activeId = sessionManager.activeAccountId.value + val account = sessionManager.getAccounts().find { it.accountId == activeId } + _uiState.update { it.copy(activeAccount = account) } + } + + private fun loadActiveAccount() = refreshActiveAccount() + private fun observeSocket() { viewModelScope.launch { socketManager.events.collect { event -> diff --git a/app/src/main/java/org/yobble/messenger/presentation/navigation/AppNavGraph.kt b/app/src/main/java/org/yobble/messenger/presentation/navigation/AppNavGraph.kt index 667bbfa..7cd17b0 100644 --- a/app/src/main/java/org/yobble/messenger/presentation/navigation/AppNavGraph.kt +++ b/app/src/main/java/org/yobble/messenger/presentation/navigation/AppNavGraph.kt @@ -28,6 +28,7 @@ object Routes { const val CHANGE_PASSWORD = "settings/change_password" const val BLACKLIST = "settings/blacklist" const val SEARCH = "search" + const val MY_PROFILE = "my_profile" fun codeVerification(login: String) = "code_verification/$login" fun chat(chatId: String) = "chat/$chatId" @@ -53,7 +54,7 @@ fun AppNavGraph( }, onLoginSuccess = { navController.navigate(Routes.HOME) { - popUpTo(Routes.LOGIN) { inclusive = true } + popUpTo(0) { inclusive = true } } } ) @@ -77,7 +78,7 @@ fun AppNavGraph( }, onVerifySuccess = { navController.navigate(Routes.HOME) { - popUpTo(Routes.LOGIN) { inclusive = true } + popUpTo(0) { inclusive = true } } } ) @@ -99,15 +100,23 @@ fun AppNavGraph( onNavigateToBlacklist = { navController.navigate(Routes.BLACKLIST) }, - onNavigateToUserProfile = { userId -> - navController.navigate(Routes.userProfile(userId)) - }, onNavigateToSearch = { navController.navigate(Routes.SEARCH) }, + onNavigateToProfile = { + navController.navigate(Routes.MY_PROFILE) + }, + onAccountSwitched = { + navController.navigate(Routes.HOME) { + popUpTo(0) { inclusive = true } + } + }, + onAddAccount = { + navController.navigate(Routes.LOGIN) + }, onLogout = { navController.navigate(Routes.LOGIN) { - popUpTo(Routes.HOME) { inclusive = true } + popUpTo(0) { inclusive = true } } } ) @@ -170,5 +179,15 @@ fun AppNavGraph( } ) } + composable(Routes.MY_PROFILE) { + ProfileScreen( + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToChat = { chatId -> + navController.navigate(Routes.chat(chatId)) + } + ) + } } } diff --git a/app/src/main/java/org/yobble/messenger/presentation/profile/ProfileViewModel.kt b/app/src/main/java/org/yobble/messenger/presentation/profile/ProfileViewModel.kt index 0e49272..62b31cd 100644 --- a/app/src/main/java/org/yobble/messenger/presentation/profile/ProfileViewModel.kt +++ b/app/src/main/java/org/yobble/messenger/presentation/profile/ProfileViewModel.kt @@ -17,6 +17,7 @@ import org.yobble.messenger.data.remote.dto.ProfileUpdateRequestDto import org.yobble.messenger.data.remote.dto.RatingDataDto import org.yobble.messenger.data.remote.dto.RelationshipStatusDto import org.yobble.messenger.data.remote.dto.WalletBalanceDto +import org.yobble.messenger.data.local.SessionManager import org.yobble.messenger.domain.repository.ChatRepository import org.yobble.messenger.domain.repository.ProfileRepository import javax.inject.Inject @@ -58,7 +59,8 @@ sealed class ProfileEvent { class ProfileViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val profileRepository: ProfileRepository, - private val chatRepository: ChatRepository + private val chatRepository: ChatRepository, + private val sessionManager: SessionManager ) : ViewModel() { private val userId: String? = savedStateHandle.get("userId") @@ -108,6 +110,12 @@ class ProfileViewModel @Inject constructor( editBio = p.bio ?: "" ) } + sessionManager.updateAccountMeta( + login = p.login, + displayName = p.fullName ?: p.login, + avatarFileId = p.avatars?.current?.fileId, + bio = p.bio + ) } is NetworkResult.Error -> { _uiState.update { it.copy(isLoading = false) } diff --git a/app/src/main/java/org/yobble/messenger/presentation/settings/SettingsScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/settings/SettingsScreen.kt index 70d4af4..1193696 100644 --- a/app/src/main/java/org/yobble/messenger/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/org/yobble/messenger/presentation/settings/SettingsScreen.kt @@ -1,13 +1,17 @@ package org.yobble.messenger.presentation.settings +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.Shield @@ -15,10 +19,19 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.yobble.messenger.data.local.AccountInfo +import org.yobble.messenger.presentation.accounts.AccountSwitcherViewModel +import org.yobble.messenger.presentation.common.InitialsAvatar +import org.yobble.messenger.presentation.common.UserAvatar @Composable fun SettingsScreen( @@ -26,15 +39,96 @@ fun SettingsScreen( onNavigateToSessions: () -> Unit, onNavigateToChangePassword: () -> Unit, onNavigateToBlacklist: () -> Unit, + onNavigateToProfile: () -> Unit, + onAccountSwitched: () -> Unit, + onAddAccount: () -> Unit, onLogout: () -> Unit, - bottomPadding: Dp = 0.dp + bottomPadding: Dp = 0.dp, + accountsViewModel: AccountSwitcherViewModel = hiltViewModel() ) { + val accountsState by accountsViewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(accountsState.switchedAccount) { + if (accountsState.switchedAccount) { + onAccountSwitched() + } + } + + val activeAccount = accountsState.accounts.find { it.accountId == accountsState.activeAccountId } + val otherAccounts = accountsState.accounts.filter { it.accountId != accountsState.activeAccountId } + Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(top = 8.dp, bottom = bottomPadding + 8.dp) ) { + // Centered profile section + if (activeAccount != null) { + ProfileSection( + account = activeAccount, + onClick = onNavigateToProfile + ) + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant, + thickness = 0.5.dp + ) + + // Other accounts + if (otherAccounts.isNotEmpty()) { + otherAccounts.forEach { account -> + AccountItem( + account = account, + onClick = { accountsViewModel.switchTo(account.accountId) }, + onRemove = { accountsViewModel.removeAccount(account.accountId) } + ) + } + } + + // Add account + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + accountsViewModel.prepareNewAccountLogin() + onAddAccount() + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Text( + "Add account", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant, + thickness = 0.5.dp + ) + + // Settings items SettingsMenuItem( icon = Icons.Default.Shield, title = "Privacy", @@ -77,6 +171,114 @@ fun SettingsScreen( } } +@Composable +private fun ProfileSection( + account: AccountInfo, + onClick: () -> Unit +) { + val displayName = account.displayName ?: account.login ?: "Account" + + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + UserAvatar( + userId = account.userId, + fileId = account.avatarFileId, + displayName = displayName, + size = 80.dp, + fontSize = 32.sp + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = displayName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (account.login != null) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "@${account.login}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + + if (!account.bio.isNullOrBlank()) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = account.bio, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 32.dp) + ) + } + } +} + +@Composable +private fun AccountItem( + account: AccountInfo, + onClick: () -> Unit, + onRemove: () -> Unit +) { + val displayName = account.displayName ?: account.login ?: "Account" + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + InitialsAvatar( + displayName = displayName, + size = 40.dp, + fontSize = 16.sp + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = displayName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (account.login != null) { + Text( + text = "@${account.login}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + } + + IconButton(onClick = onRemove, modifier = Modifier.size(36.dp)) { + Icon( + Icons.Default.Close, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } +} + @Composable private fun SettingsMenuItem( icon: ImageVector,