- multisessions implemented
- update SessionManager
- add account switch logic
This commit is contained in:
YaAndreyIgorevich 2026-03-08 01:23:53 +07:00
parent 7e8fcfc24a
commit 61fd7b7f2e
9 changed files with 691 additions and 51 deletions

View File

@ -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<String?> = _activeAccountId.asStateFlow()
private val sessionCaches = mutableMapOf<String, SharedPreferences>()
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<AccountInfo> {
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"
}
}

View File

@ -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

View File

@ -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
)
}

View File

@ -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<AccountInfo> = 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<AccountSwitcherUiState> = _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()
}
}

View File

@ -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
)
}
}

View File

@ -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<PrivateChatListItemDto> = 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 ->

View File

@ -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))
}
)
}
}
}

View File

@ -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<String>("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) }

View File

@ -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,