Improve AccountTab UI and add account change

This commit is contained in:
Shinokuni 2024-07-09 15:44:53 +02:00
parent 016d309d05
commit 45c2de4459
10 changed files with 159 additions and 74 deletions

View File

@ -15,15 +15,18 @@ import com.readrops.db.entities.Folder
import com.readrops.db.entities.account.Account import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType import com.readrops.db.entities.account.AccountType
import com.readrops.db.filters.MainFilter import com.readrops.db.filters.MainFilter
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class AccountScreenModel( class AccountScreenModel(
private val database: Database private val database: Database,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : TabScreenModel(database) { ) : TabScreenModel(database) {
private val _closeHome = MutableStateFlow(false) private val _closeHome = MutableStateFlow(false)
@ -33,7 +36,7 @@ class AccountScreenModel(
val accountState = _accountState.asStateFlow() val accountState = _accountState.asStateFlow()
init { init {
screenModelScope.launch(Dispatchers.IO) { screenModelScope.launch(dispatcher) {
accountEvent.collect { account -> accountEvent.collect { account ->
_accountState.update { _accountState.update {
it.copy( it.copy(
@ -42,6 +45,14 @@ class AccountScreenModel(
} }
} }
} }
screenModelScope.launch(dispatcher) {
database.accountDao().selectAllAccounts()
.map { it.filter { account -> !account.isCurrentAccount } }
.collect { accounts ->
_accountState.update { it.copy(accounts = accounts) }
}
}
} }
fun openDialog(dialog: DialogState) = _accountState.update { it.copy(dialog = dialog) } fun openDialog(dialog: DialogState) = _accountState.update { it.copy(dialog = dialog) }
@ -57,13 +68,17 @@ class AccountScreenModel(
} }
fun deleteAccount() { fun deleteAccount() {
screenModelScope.launch(Dispatchers.IO) { screenModelScope.launch(dispatcher) {
database.accountDao() database.accountDao()
.delete(currentAccount!!) .delete(currentAccount!!)
if (_accountState.value.accounts.isNotEmpty()) {
database.accountDao().updateCurrentAccount(_accountState.value.accounts.first().id)
} else {
_closeHome.update { true } _closeHome.update { true }
} }
} }
}
fun exportOPMLFile(uri: Uri, context: Context) { fun exportOPMLFile(uri: Uri, context: Context) {
screenModelScope.launch { screenModelScope.launch {
@ -92,7 +107,7 @@ class AccountScreenModel(
} }
fun parseOPMLFile(uri: Uri, context: Context) { fun parseOPMLFile(uri: Uri, context: Context) {
screenModelScope.launch(Dispatchers.IO) { screenModelScope.launch(dispatcher) {
val foldersAndFeeds: Map<Folder?, List<Feed>> val foldersAndFeeds: Map<Folder?, List<Feed>>
try { try {
@ -144,6 +159,12 @@ class AccountScreenModel(
_accountState.update { it.copy(opmlExportUri = null, opmlExportSuccess = false) } _accountState.update { it.copy(opmlExportUri = null, opmlExportSuccess = false) }
fun resetCloseHome() = _closeHome.update { false } fun resetCloseHome() = _closeHome.update { false }
fun updateCurrentAccount(account: Account) {
screenModelScope.launch(dispatcher) {
database.accountDao().updateCurrentAccount(account.id)
}
}
} }
@Stable @Stable
@ -154,6 +175,7 @@ data class AccountState(
val error: Exception? = null, val error: Exception? = null,
val opmlExportSuccess: Boolean = false, val opmlExportSuccess: Boolean = false,
val opmlExportUri: Uri? = null, val opmlExportUri: Uri? = null,
val accounts: List<Account> = emptyList()
) )
sealed interface DialogState { sealed interface DialogState {

View File

@ -4,7 +4,6 @@ import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -13,11 +12,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
@ -36,6 +34,7 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.koin.getScreenModel import cafe.adriel.voyager.koin.getScreenModel
@ -54,9 +53,11 @@ import com.readrops.app.notifications.NotificationsScreen
import com.readrops.app.timelime.ErrorListDialog import com.readrops.app.timelime.ErrorListDialog
import com.readrops.app.util.components.ErrorDialog import com.readrops.app.util.components.ErrorDialog
import com.readrops.app.util.components.SelectableIconText import com.readrops.app.util.components.SelectableIconText
import com.readrops.app.util.components.SelectableImageText
import com.readrops.app.util.components.TwoChoicesDialog import com.readrops.app.util.components.TwoChoicesDialog
import com.readrops.app.util.theme.LargeSpacer import com.readrops.app.util.theme.LargeSpacer
import com.readrops.app.util.theme.MediumSpacer import com.readrops.app.util.theme.MediumSpacer
import com.readrops.app.util.theme.VeryShortSpacer
import com.readrops.app.util.theme.spacing import com.readrops.app.util.theme.spacing
import com.readrops.db.entities.account.Account import com.readrops.db.entities.account.Account
@ -231,17 +232,7 @@ object AccountTab : Tab {
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(text = stringResource(R.string.account)) }, title = { Text(text = stringResource(R.string.account)) }
actions = {
IconButton(
onClick = { }
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = null
)
}
}
) )
}, },
floatingActionButton = { floatingActionButton = {
@ -262,31 +253,47 @@ object AccountTab : Tab {
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, modifier = Modifier
modifier = Modifier.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = MaterialTheme.spacing.mediumSpacing)
) { ) {
Image( Image(
painter = adaptiveIconPainterResource(id = R.drawable.ic_freshrss), painter = adaptiveIconPainterResource(id = state.account.accountType!!.iconRes),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(48.dp) modifier = Modifier.size(48.dp)
) )
MediumSpacer() MediumSpacer()
Column {
Text( Text(
text = state.account.accountName!!, text = state.account.accountName!!,
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge
) )
if (state.account.displayedName != null) {
VeryShortSpacer()
Text(
text = state.account.displayedName!!,
style = MaterialTheme.typography.bodyMedium
)
}
}
} }
LargeSpacer() LargeSpacer()
if (!state.account.isLocal) {
SelectableIconText( SelectableIconText(
icon = painterResource(id = R.drawable.ic_add_account), icon = painterResource(id = R.drawable.ic_person),
text = stringResource(R.string.credentials), text = stringResource(R.string.credentials),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal),
spacing = MaterialTheme.spacing.mediumSpacing, spacing = MaterialTheme.spacing.largeSpacing,
padding = MaterialTheme.spacing.mediumSpacing, padding = MaterialTheme.spacing.mediumSpacing,
tint = MaterialTheme.colorScheme.primary,
iconSize = 24.dp,
onClick = { onClick = {
navigator.push( navigator.push(
AccountCredentialsScreen( AccountCredentialsScreen(
@ -296,35 +303,69 @@ object AccountTab : Tab {
) )
} }
) )
}
SelectableIconText( SelectableIconText(
icon = painterResource(id = R.drawable.ic_notifications), icon = painterResource(id = R.drawable.ic_notifications),
text = stringResource(R.string.notifications), text = stringResource(R.string.notifications),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal),
spacing = MaterialTheme.spacing.mediumSpacing, spacing = MaterialTheme.spacing.largeSpacing,
padding = MaterialTheme.spacing.mediumSpacing, padding = MaterialTheme.spacing.mediumSpacing,
tint = MaterialTheme.colorScheme.primary,
iconSize = 24.dp,
onClick = { navigator.push(NotificationsScreen(state.account)) } onClick = { navigator.push(NotificationsScreen(state.account)) }
) )
if (state.account.isLocal) {
SelectableIconText( SelectableIconText(
icon = painterResource(id = R.drawable.ic_import_export), icon = painterResource(id = R.drawable.ic_import_export),
text = stringResource(R.string.opml_import_export), text = stringResource(R.string.opml_import_export),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal),
spacing = MaterialTheme.spacing.mediumSpacing, spacing = MaterialTheme.spacing.largeSpacing,
padding = MaterialTheme.spacing.mediumSpacing, padding = MaterialTheme.spacing.mediumSpacing,
tint = MaterialTheme.colorScheme.primary,
iconSize = 24.dp,
onClick = { screenModel.openDialog(DialogState.OPMLChoice) } onClick = { screenModel.openDialog(DialogState.OPMLChoice) }
) )
}
SelectableIconText( SelectableIconText(
icon = rememberVectorPainter(image = Icons.Default.AccountCircle), icon = rememberVectorPainter(image = Icons.Default.AccountCircle),
text = stringResource(R.string.delete_account), text = stringResource(R.string.delete_account),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal),
spacing = MaterialTheme.spacing.mediumSpacing, spacing = MaterialTheme.spacing.largeSpacing,
padding = MaterialTheme.spacing.mediumSpacing, padding = MaterialTheme.spacing.mediumSpacing,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
iconSize = 24.dp,
onClick = { screenModel.openDialog(DialogState.DeleteAccount) } onClick = { screenModel.openDialog(DialogState.DeleteAccount) }
) )
if (state.accounts.isNotEmpty()) {
HorizontalDivider(
modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing)
)
Text(
text = stringResource(id = R.string.other_accounts),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = MaterialTheme.spacing.mediumSpacing)
)
VeryShortSpacer()
for (account in state.accounts) {
SelectableImageText(
image = adaptiveIconPainterResource(id = account.accountType!!.iconRes),
text = account.accountName!!,
style = MaterialTheme.typography.titleMedium,
padding = MaterialTheme.spacing.mediumSpacing,
spacing = MaterialTheme.spacing.mediumSpacing,
imageSize = 24.dp,
onClick = { screenModel.updateCurrentAccount(account) }
)
}
}
} }
} }
} }

View File

@ -83,6 +83,7 @@ class AccountCredentialsScreenModel(
if (mode == AccountCredentialsScreenMode.NEW_CREDENTIALS) { if (mode == AccountCredentialsScreenMode.NEW_CREDENTIALS) {
newAccount.id = database.accountDao().insert(newAccount).toInt() newAccount.id = database.accountDao().insert(newAccount).toInt()
database.accountDao().updateCurrentAccount(newAccount.id)
get<SharedPreferences>().edit() get<SharedPreferences>().edit()
.putString(newAccount.loginKey, newAccount.login) .putString(newAccount.loginKey, newAccount.login)

View File

@ -8,8 +8,12 @@ import com.readrops.api.utils.AuthInterceptor
import com.readrops.app.repositories.BaseRepository import com.readrops.app.repositories.BaseRepository
import com.readrops.db.Database import com.readrops.db.Database
import com.readrops.db.entities.account.Account import com.readrops.db.entities.account.Account
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
@ -20,6 +24,7 @@ import org.koin.core.parameter.parametersOf
*/ */
abstract class TabScreenModel( abstract class TabScreenModel(
private val database: Database, private val database: Database,
dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ScreenModel, KoinComponent { ) : ScreenModel, KoinComponent {
/** /**
@ -29,31 +34,38 @@ abstract class TabScreenModel(
protected var currentAccount: Account? = null protected var currentAccount: Account? = null
protected val accountEvent = MutableSharedFlow<Account>() private val _accountEvent = MutableSharedFlow<Account>()
protected val accountEvent =
_accountEvent.shareIn(scope = screenModelScope, started = SharingStarted.Eagerly)
init { init {
screenModelScope.launch { screenModelScope.launch(dispatcher) {
database.accountDao() database.accountDao()
.selectCurrentAccount() .selectCurrentAccount()
.distinctUntilChanged() .distinctUntilChanged()
.collect { account -> .collect { account ->
if (account != null) { if (account != null) {
if (!account.isLocal) {
if (account.login == null || account.password == null) { if (account.login == null || account.password == null) {
val encryptedPreferences = get<SharedPreferences>() val encryptedPreferences = get<SharedPreferences>()
account.login = encryptedPreferences.getString(account.loginKey, null) account.login =
account.password = encryptedPreferences.getString(account.passwordKey, null) encryptedPreferences.getString(account.loginKey, null)
account.password =
encryptedPreferences.getString(account.passwordKey, null)
}
// very important to avoid credentials conflicts between accounts
get<AuthInterceptor>().credentials = Credentials.toCredentials(account)
} }
currentAccount = account currentAccount = account
repository = get(parameters = { parametersOf(account) }) repository = get(parameters = { parametersOf(account) })
// very important to avoid credentials conflicts between accounts
get<AuthInterceptor>().credentials = Credentials.toCredentials(account)
accountEvent.emit(account)
}
}
}
}
_accountEvent.emit(account)
}
}
}
}
} }

View File

@ -19,7 +19,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -49,7 +49,7 @@ class FeedScreenModel(
init { init {
screenModelScope.launch(context = Dispatchers.IO) { screenModelScope.launch(context = Dispatchers.IO) {
accountEvent accountEvent
.flatMapConcat { account -> .flatMapLatest { account ->
_feedState.update { it.copy(displayFolderCreationButton = account.config.canCreateFolder) } _feedState.update { it.copy(displayFolderCreationButton = account.config.canCreateFolder) }
_updateFeedDialogState.update { _updateFeedDialogState.update {
it.copy( it.copy(
@ -69,12 +69,14 @@ class FeedScreenModel(
it.copy(foldersAndFeeds = FolderAndFeedsState.LoadedState(foldersAndFeeds)) it.copy(foldersAndFeeds = FolderAndFeedsState.LoadedState(foldersAndFeeds))
} }
} }
} }
screenModelScope.launch(context = Dispatchers.IO) { screenModelScope.launch(context = Dispatchers.IO) {
database.accountDao() database.accountDao()
.selectAllAccounts() .selectAllAccounts()
.collect { accounts -> .collect { accounts ->
if (accounts.isNotEmpty()) {
_addFeedDialogState.update { dialogState -> _addFeedDialogState.update { dialogState ->
dialogState.copy( dialogState.copy(
accounts = accounts, accounts = accounts,
@ -83,10 +85,10 @@ class FeedScreenModel(
} }
} }
} }
}
screenModelScope.launch(context = Dispatchers.IO) { screenModelScope.launch(context = Dispatchers.IO) {
accountEvent accountEvent.flatMapLatest { account ->
.flatMapConcat { account ->
_updateFeedDialogState.update { _updateFeedDialogState.update {
it.copy( it.copy(
isFeedUrlReadOnly = account.config.isFeedUrlReadOnly, isFeedUrlReadOnly = account.config.isFeedUrlReadOnly,

View File

@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -97,7 +97,7 @@ class TimelineScreenModel(
} }
screenModelScope.launch(dispatcher) { screenModelScope.launch(dispatcher) {
accountEvent.flatMapConcat { accountEvent.flatMapLatest {
getFoldersWithFeeds.getNewItemsUnreadCount(it.id, it.config.useSeparateState) getFoldersWithFeeds.getNewItemsUnreadCount(it.id, it.config.useSeparateState)
}.collectLatest { count -> }.collectLatest { count ->
_timelineState.update { _timelineState.update {

View File

@ -115,6 +115,7 @@ fun SelectableIconText(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
color: Color = LocalContentColor.current, color: Color = LocalContentColor.current,
tint: Color = LocalContentColor.current, tint: Color = LocalContentColor.current,
iconSize: Dp = style.toDp(),
spacing: Dp = MaterialTheme.spacing.veryShortSpacing, spacing: Dp = MaterialTheme.spacing.veryShortSpacing,
padding: Dp = MaterialTheme.spacing.shortSpacing padding: Dp = MaterialTheme.spacing.shortSpacing
) { ) {
@ -134,7 +135,7 @@ fun SelectableIconText(
painter = icon, painter = icon,
tint = tint, tint = tint,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(style.toDp()), modifier = Modifier.size(iconSize),
) )
} }
} }

View File

@ -170,4 +170,5 @@
<string name="disable_all">Tout désactiver</string> <string name="disable_all">Tout désactiver</string>
<string name="open_source_libraries">Bibliothèques Open source</string> <string name="open_source_libraries">Bibliothèques Open source</string>
<string name="make_donation">Faire une donation</string> <string name="make_donation">Faire une donation</string>
<string name="other_accounts">Autres comptes</string>
</resources> </resources>

View File

@ -179,4 +179,5 @@
<string name="make_donation">Make a donation</string> <string name="make_donation">Make a donation</string>
<string name="app_changelog_url" translatable="false">https://github.com/readrops/Readrops/blob/develop/CHANGELOG.md</string> <string name="app_changelog_url" translatable="false">https://github.com/readrops/Readrops/blob/develop/CHANGELOG.md</string>
<string name="app_issues_url" translatable="false">https://github.com/readrops/Readrops/issues</string> <string name="app_issues_url" translatable="false">https://github.com/readrops/Readrops/issues</string>
<string name="other_accounts">Other accounts</string>
</resources> </resources>

View File

@ -28,4 +28,8 @@ interface AccountDao : BaseDao<Account> {
@Query("Select notifications_enabled From Account Where id = :accountId") @Query("Select notifications_enabled From Account Where id = :accountId")
fun selectAccountNotificationsState(accountId: Int): Flow<Boolean> fun selectAccountNotificationsState(accountId: Int): Flow<Boolean>
@Query("""Update Account set current_account = Case When id = :accountId Then 1
When id Is Not :accountId Then 0 End""")
suspend fun updateCurrentAccount(accountId: Int)
} }