- 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.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
import androidx.core.content.edit 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 @Singleton
class SessionManager @Inject constructor( class SessionManager @Inject constructor(
@ApplicationContext context: Context @ApplicationContext private val context: Context
) { ) {
private val masterKey = MasterKey.Builder(context) private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
private val prefs: SharedPreferences = EncryptedSharedPreferences.create( private val accountsPrefs: SharedPreferences = context.getSharedPreferences(
context, "yobble_accounts", Context.MODE_PRIVATE
"yobble_session_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
) )
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( private val localPrefs: SharedPreferences = context.getSharedPreferences(
"yobble_local_prefs", Context.MODE_PRIVATE "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? var accessToken: String?
get() = prefs.getString(KEY_ACCESS_TOKEN, null) get() = activePrefs?.getString(KEY_ACCESS_TOKEN, null)
set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) } set(value) = activePrefs?.edit { putString(KEY_ACCESS_TOKEN, value) } ?: Unit
var refreshToken: String? var refreshToken: String?
get() = prefs.getString(KEY_REFRESH_TOKEN, null) get() = activePrefs?.getString(KEY_REFRESH_TOKEN, null)
set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) } set(value) = activePrefs?.edit { putString(KEY_REFRESH_TOKEN, value) } ?: Unit
var userId: String? var userId: String?
get() = prefs.getString(KEY_USER_ID, null) get() = activePrefs?.getString(KEY_USER_ID, null)
set(value) = prefs.edit { putString(KEY_USER_ID, value) } set(value) = activePrefs?.edit { putString(KEY_USER_ID, value) } ?: Unit
val isLoggedIn: Boolean val isLoggedIn: Boolean
get() = accessToken != null get() = accessToken != null
fun saveSession(accessToken: String, refreshToken: String, userId: String) { 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 { prefs.edit {
putString(KEY_ACCESS_TOKEN, accessToken) putString(KEY_ACCESS_TOKEN, accessToken)
.putString(KEY_REFRESH_TOKEN, refreshToken) putString(KEY_REFRESH_TOKEN, refreshToken)
.putString(KEY_USER_ID, userId) putString(KEY_USER_ID, userId)
} }
addAccountToList(accountId, userId)
} }
fun clearSession() { fun clearSession() {
prefs.edit { clear() } val accountId = _activeAccountId.value ?: return
localPrefs.edit { clear() } 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) { 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_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token" private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_USER_ID = "user_id" 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)) authApi.login(LoginRequestDto(login, password))
} }
if (result is NetworkResult.Success) { if (result is NetworkResult.Success) {
val data = result.data.data
sessionManager.saveSession( sessionManager.saveSession(
accessToken = result.data.data.accessToken, accessToken = data.accessToken,
refreshToken = result.data.data.refreshToken, refreshToken = data.refreshToken,
userId = result.data.data.userId userId = data.userId
) )
sessionManager.updateAccountMeta(login = login, displayName = null)
pushTokenManager.registerPushToken() pushTokenManager.registerPushToken()
} }
return result return result
@ -43,11 +45,13 @@ class AuthRepositoryImpl @Inject constructor(
authApi.verifyCode(VerifyCodeRequestDto(login, otp)) authApi.verifyCode(VerifyCodeRequestDto(login, otp))
} }
if (result is NetworkResult.Success) { if (result is NetworkResult.Success) {
val data = result.data.data
sessionManager.saveSession( sessionManager.saveSession(
accessToken = result.data.data.accessToken, accessToken = data.accessToken,
refreshToken = result.data.data.refreshToken, refreshToken = data.refreshToken,
userId = result.data.data.userId userId = data.userId
) )
sessionManager.updateAccountMeta(login = login, displayName = null)
pushTokenManager.registerPushToken() pushTokenManager.registerPushToken()
} }
return result 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.Icons
import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Contacts 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.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Verified import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.* import androidx.compose.material3.*
@ -39,17 +37,15 @@ import kotlinx.coroutines.launch
import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto
import org.yobble.messenger.presentation.common.UserAvatar import org.yobble.messenger.presentation.common.UserAvatar
import org.yobble.messenger.presentation.contacts.ContactsScreen 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.presentation.settings.SettingsScreen
import org.yobble.messenger.util.formatUtcToLocalTime import org.yobble.messenger.util.formatUtcToLocalTime
private const val SWIPE_NAVIGATION_ENABLED = true 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), CHATS("Chats", Icons.AutoMirrored.Filled.Chat),
CONTACTS("Contacts", Icons.Default.Contacts), CONTACTS("Contacts", Icons.Default.Contacts),
SETTINGS("Settings", Icons.Default.Settings), SETTINGS("Settings", null)
PROFILE("Profile", Icons.Default.Person)
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -60,8 +56,10 @@ fun HomeScreen(
onNavigateToSessions: () -> Unit, onNavigateToSessions: () -> Unit,
onNavigateToChangePassword: () -> Unit, onNavigateToChangePassword: () -> Unit,
onNavigateToBlacklist: () -> Unit, onNavigateToBlacklist: () -> Unit,
onNavigateToUserProfile: (userId: String) -> Unit = {},
onNavigateToSearch: () -> Unit, onNavigateToSearch: () -> Unit,
onNavigateToProfile: () -> Unit,
onAccountSwitched: () -> Unit,
onAddAccount: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
viewModel: HomeViewModel = hiltViewModel() viewModel: HomeViewModel = hiltViewModel()
) { ) {
@ -76,6 +74,7 @@ fun HomeScreen(
LifecycleResumeEffect(Unit) { LifecycleResumeEffect(Unit) {
viewModel.loadChats() viewModel.loadChats()
viewModel.refreshActiveAccount()
onPauseOrDispose { } onPauseOrDispose { }
} }
@ -136,7 +135,9 @@ fun HomeScreen(
onNavigateToSessions = onNavigateToSessions, onNavigateToSessions = onNavigateToSessions,
onNavigateToChangePassword = onNavigateToChangePassword, onNavigateToChangePassword = onNavigateToChangePassword,
onNavigateToBlacklist = onNavigateToBlacklist, onNavigateToBlacklist = onNavigateToBlacklist,
onNavigateToUserProfile = onNavigateToUserProfile, onNavigateToProfile = onNavigateToProfile,
onAccountSwitched = onAccountSwitched,
onAddAccount = onAddAccount,
onLogout = { onLogout = {
viewModel.logout() viewModel.logout()
onLogout() onLogout()
@ -154,7 +155,9 @@ fun HomeScreen(
onNavigateToSessions = onNavigateToSessions, onNavigateToSessions = onNavigateToSessions,
onNavigateToChangePassword = onNavigateToChangePassword, onNavigateToChangePassword = onNavigateToChangePassword,
onNavigateToBlacklist = onNavigateToBlacklist, onNavigateToBlacklist = onNavigateToBlacklist,
onNavigateToUserProfile = onNavigateToUserProfile, onNavigateToProfile = onNavigateToProfile,
onAccountSwitched = onAccountSwitched,
onAddAccount = onAddAccount,
onLogout = { onLogout = {
viewModel.logout() viewModel.logout()
onLogout() onLogout()
@ -177,6 +180,8 @@ fun HomeScreen(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
tonalElevation = 8.dp tonalElevation = 8.dp
) { ) {
val activeAccount = uiState.activeAccount
val indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f)
tabs.forEachIndexed { index, tab -> tabs.forEachIndexed { index, tab ->
NavigationBarItem( NavigationBarItem(
selected = selectedTab == index, 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) }, label = { Text(tab.label, fontSize = 11.sp) },
colors = NavigationBarItemDefaults.colors( colors = NavigationBarItemDefaults.colors(
indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f) indicatorColor = Color.Transparent
) )
) )
} }
@ -211,7 +239,9 @@ private fun TabContent(
onNavigateToSessions: () -> Unit, onNavigateToSessions: () -> Unit,
onNavigateToChangePassword: () -> Unit, onNavigateToChangePassword: () -> Unit,
onNavigateToBlacklist: () -> Unit, onNavigateToBlacklist: () -> Unit,
onNavigateToUserProfile: (String) -> Unit, onNavigateToProfile: () -> Unit,
onAccountSwitched: () -> Unit,
onAddAccount: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
bottomPadding: androidx.compose.ui.unit.Dp bottomPadding: androidx.compose.ui.unit.Dp
) { ) {
@ -231,15 +261,12 @@ private fun TabContent(
onNavigateToSessions = onNavigateToSessions, onNavigateToSessions = onNavigateToSessions,
onNavigateToChangePassword = onNavigateToChangePassword, onNavigateToChangePassword = onNavigateToChangePassword,
onNavigateToBlacklist = onNavigateToBlacklist, onNavigateToBlacklist = onNavigateToBlacklist,
onNavigateToProfile = onNavigateToProfile,
onAccountSwitched = onAccountSwitched,
onAddAccount = onAddAccount,
onLogout = onLogout, onLogout = onLogout,
bottomPadding = bottomPadding 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.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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.NetworkResult
import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto
import org.yobble.messenger.data.remote.socket.SocketEvent import org.yobble.messenger.data.remote.socket.SocketEvent
@ -24,7 +26,8 @@ import javax.inject.Inject
data class HomeUiState( data class HomeUiState(
val chats: List<PrivateChatListItemDto> = emptyList(), val chats: List<PrivateChatListItemDto> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val hasMore: Boolean = false val hasMore: Boolean = false,
val activeAccount: AccountInfo? = null
) )
sealed class HomeEvent { sealed class HomeEvent {
@ -35,7 +38,8 @@ sealed class HomeEvent {
class HomeViewModel @Inject constructor( class HomeViewModel @Inject constructor(
private val chatRepository: ChatRepository, private val chatRepository: ChatRepository,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val socketManager: SocketManager private val socketManager: SocketManager,
private val sessionManager: SessionManager
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState()) private val _uiState = MutableStateFlow(HomeUiState())
@ -49,9 +53,18 @@ class HomeViewModel @Inject constructor(
init { init {
socketManager.connect() socketManager.connect()
loadChats() loadChats()
loadActiveAccount()
observeSocket() 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() { private fun observeSocket() {
viewModelScope.launch { viewModelScope.launch {
socketManager.events.collect { event -> socketManager.events.collect { event ->

View File

@ -28,6 +28,7 @@ object Routes {
const val CHANGE_PASSWORD = "settings/change_password" const val CHANGE_PASSWORD = "settings/change_password"
const val BLACKLIST = "settings/blacklist" const val BLACKLIST = "settings/blacklist"
const val SEARCH = "search" const val SEARCH = "search"
const val MY_PROFILE = "my_profile"
fun codeVerification(login: String) = "code_verification/$login" fun codeVerification(login: String) = "code_verification/$login"
fun chat(chatId: String) = "chat/$chatId" fun chat(chatId: String) = "chat/$chatId"
@ -53,7 +54,7 @@ fun AppNavGraph(
}, },
onLoginSuccess = { onLoginSuccess = {
navController.navigate(Routes.HOME) { navController.navigate(Routes.HOME) {
popUpTo(Routes.LOGIN) { inclusive = true } popUpTo(0) { inclusive = true }
} }
} }
) )
@ -77,7 +78,7 @@ fun AppNavGraph(
}, },
onVerifySuccess = { onVerifySuccess = {
navController.navigate(Routes.HOME) { navController.navigate(Routes.HOME) {
popUpTo(Routes.LOGIN) { inclusive = true } popUpTo(0) { inclusive = true }
} }
} }
) )
@ -99,15 +100,23 @@ fun AppNavGraph(
onNavigateToBlacklist = { onNavigateToBlacklist = {
navController.navigate(Routes.BLACKLIST) navController.navigate(Routes.BLACKLIST)
}, },
onNavigateToUserProfile = { userId ->
navController.navigate(Routes.userProfile(userId))
},
onNavigateToSearch = { onNavigateToSearch = {
navController.navigate(Routes.SEARCH) 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 = { onLogout = {
navController.navigate(Routes.LOGIN) { 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.RatingDataDto
import org.yobble.messenger.data.remote.dto.RelationshipStatusDto import org.yobble.messenger.data.remote.dto.RelationshipStatusDto
import org.yobble.messenger.data.remote.dto.WalletBalanceDto 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.ChatRepository
import org.yobble.messenger.domain.repository.ProfileRepository import org.yobble.messenger.domain.repository.ProfileRepository
import javax.inject.Inject import javax.inject.Inject
@ -58,7 +59,8 @@ sealed class ProfileEvent {
class ProfileViewModel @Inject constructor( class ProfileViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val profileRepository: ProfileRepository, private val profileRepository: ProfileRepository,
private val chatRepository: ChatRepository private val chatRepository: ChatRepository,
private val sessionManager: SessionManager
) : ViewModel() { ) : ViewModel() {
private val userId: String? = savedStateHandle.get<String>("userId") private val userId: String? = savedStateHandle.get<String>("userId")
@ -108,6 +110,12 @@ class ProfileViewModel @Inject constructor(
editBio = p.bio ?: "" editBio = p.bio ?: ""
) )
} }
sessionManager.updateAccountMeta(
login = p.login,
displayName = p.fullName ?: p.login,
avatarFileId = p.avatars?.current?.fileId,
bio = p.bio
)
} }
is NetworkResult.Error -> { is NetworkResult.Error -> {
_uiState.update { it.copy(isLoading = false) } _uiState.update { it.copy(isLoading = false) }

View File

@ -1,13 +1,17 @@
package org.yobble.messenger.presentation.settings package org.yobble.messenger.presentation.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 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.Block
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.Shield
@ -15,10 +19,19 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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 @Composable
fun SettingsScreen( fun SettingsScreen(
@ -26,15 +39,96 @@ fun SettingsScreen(
onNavigateToSessions: () -> Unit, onNavigateToSessions: () -> Unit,
onNavigateToChangePassword: () -> Unit, onNavigateToChangePassword: () -> Unit,
onNavigateToBlacklist: () -> Unit, onNavigateToBlacklist: () -> Unit,
onNavigateToProfile: () -> Unit,
onAccountSwitched: () -> Unit,
onAddAccount: () -> Unit,
onLogout: () -> 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(top = 8.dp, bottom = bottomPadding + 8.dp) .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( SettingsMenuItem(
icon = Icons.Default.Shield, icon = Icons.Default.Shield,
title = "Privacy", 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 @Composable
private fun SettingsMenuItem( private fun SettingsMenuItem(
icon: ImageVector, icon: ImageVector,