[feat]
- multisessions implemented - update SessionManager - add account switch logic
This commit is contained in:
parent
7e8fcfc24a
commit
61fd7b7f2e
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 ->
|
||||
|
||||
@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user