[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.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 ->
|
||||||
|
|||||||
@ -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))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) }
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user