0
0
mirror of https://github.com/thunderbird/thunderbird-android.git synced 2024-09-19 19:52:14 +02:00

Merge pull request #8143 from wmontwe/add-drawer-folder-list-part4

Add drawer folder list - part 4
This commit is contained in:
Wolf-Martell Montwé 2024-09-17 13:09:32 +00:00 committed by GitHub
commit 41c06cebbb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 523 additions and 66 deletions

View File

@ -13,7 +13,7 @@ internal fun DrawerContentPreview() {
DrawerContent( DrawerContent(
state = DrawerContract.State( state = DrawerContract.State(
accounts = persistentListOf(), accounts = persistentListOf(),
currentAccount = null, selectedAccount = null,
folders = persistentListOf(), folders = persistentListOf(),
), ),
onEvent = {}, onEvent = {},
@ -28,7 +28,8 @@ fun DrawerContentWithAccountPreview() {
DrawerContent( DrawerContent(
state = DrawerContract.State( state = DrawerContract.State(
accounts = persistentListOf(DISPLAY_ACCOUNT), accounts = persistentListOf(DISPLAY_ACCOUNT),
currentAccount = DISPLAY_ACCOUNT, selectedAccount = DISPLAY_ACCOUNT,
folders = persistentListOf(),
), ),
onEvent = {}, onEvent = {},
) )

View File

@ -3,9 +3,9 @@ package app.k9mail.feature.navigation.drawer.ui
import app.k9mail.core.mail.folder.api.Folder import app.k9mail.core.mail.folder.api.Folder
import app.k9mail.core.mail.folder.api.FolderType import app.k9mail.core.mail.folder.api.FolderType
import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccount import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccount
import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccountFolder
import app.k9mail.legacy.account.Account import app.k9mail.legacy.account.Account
import app.k9mail.legacy.account.Identity import app.k9mail.legacy.account.Identity
import app.k9mail.legacy.ui.folder.DisplayFolder
internal object FakeData { internal object FakeData {
@ -45,7 +45,8 @@ internal object FakeData {
isLocalOnly = false, isLocalOnly = false,
) )
val DISPLAY_FOLDER = DisplayFolder( val DISPLAY_FOLDER = DisplayAccountFolder(
accountUuid = ACCOUNT_UUID,
folder = FOLDER, folder = FOLDER,
isInTopGroup = false, isInTopGroup = false,
unreadMessageCount = 14, unreadMessageCount = 14,

View File

@ -15,6 +15,9 @@ import org.koin.core.component.inject
class FolderDrawer( class FolderDrawer(
override val parent: AppCompatActivity, override val parent: AppCompatActivity,
private val openAccount: (account: Account) -> Unit,
private val openFolder: (folderId: Long) -> Unit,
createDrawerListener: () -> DrawerLayout.DrawerListener,
) : NavigationDrawer, KoinComponent { ) : NavigationDrawer, KoinComponent {
private val themeProvider: FeatureThemeProvider by inject() private val themeProvider: FeatureThemeProvider by inject()
@ -28,10 +31,15 @@ class FolderDrawer(
sliderView.visibility = View.GONE sliderView.visibility = View.GONE
drawerView.visibility = View.VISIBLE drawerView.visibility = View.VISIBLE
swipeRefreshLayout.isEnabled = false swipeRefreshLayout.isEnabled = false
drawer.addDrawerListener(createDrawerListener())
drawerView.setContent { drawerView.setContent {
themeProvider.WithTheme { themeProvider.WithTheme {
DrawerView() DrawerView(
openAccount = openAccount,
openFolder = openFolder,
closeDrawer = { close() },
)
} }
} }
} }

View File

@ -0,0 +1,10 @@
package app.k9mail.feature.navigation.drawer
import app.k9mail.feature.navigation.drawer.domain.entity.DrawerConfig
interface NavigationDrawerExternalContract {
fun interface DrawerConfigLoader {
fun loadDrawerConfig(): DrawerConfig
}
}

View File

@ -3,6 +3,8 @@ package app.k9mail.feature.navigation.drawer
import app.k9mail.feature.navigation.drawer.domain.DomainContract.UseCase import app.k9mail.feature.navigation.drawer.domain.DomainContract.UseCase
import app.k9mail.feature.navigation.drawer.domain.usecase.GetDisplayAccounts import app.k9mail.feature.navigation.drawer.domain.usecase.GetDisplayAccounts
import app.k9mail.feature.navigation.drawer.domain.usecase.GetDisplayFoldersForAccount import app.k9mail.feature.navigation.drawer.domain.usecase.GetDisplayFoldersForAccount
import app.k9mail.feature.navigation.drawer.domain.usecase.GetDrawerConfig
import app.k9mail.feature.navigation.drawer.domain.usecase.SyncMail
import app.k9mail.feature.navigation.drawer.legacy.AccountsViewModel import app.k9mail.feature.navigation.drawer.legacy.AccountsViewModel
import app.k9mail.feature.navigation.drawer.legacy.FoldersViewModel import app.k9mail.feature.navigation.drawer.legacy.FoldersViewModel
import app.k9mail.feature.navigation.drawer.ui.DrawerViewModel import app.k9mail.feature.navigation.drawer.ui.DrawerViewModel
@ -14,6 +16,12 @@ import org.koin.dsl.module
val navigationDrawerModule: Module = module { val navigationDrawerModule: Module = module {
single<UseCase.GetDrawerConfig> {
GetDrawerConfig(
configProver = get(),
)
}
single<UseCase.GetDisplayAccounts> { single<UseCase.GetDisplayAccounts> {
GetDisplayAccounts( GetDisplayAccounts(
accountManager = get(), accountManager = get(),
@ -28,6 +36,12 @@ val navigationDrawerModule: Module = module {
) )
} }
single<UseCase.SyncMail> {
SyncMail(
messagingController = get(),
)
}
viewModel { viewModel {
AccountsViewModel( AccountsViewModel(
getDisplayAccounts = get(), getDisplayAccounts = get(),
@ -48,8 +62,10 @@ val navigationDrawerModule: Module = module {
viewModel { viewModel {
DrawerViewModel( DrawerViewModel(
getDrawerConfig = get(),
getDisplayAccounts = get(), getDisplayAccounts = get(),
getDisplayFoldersForAccount = get(), getDisplayFoldersForAccount = get(),
syncMail = get(),
) )
} }
} }

View File

@ -1,18 +1,33 @@
package app.k9mail.feature.navigation.drawer.domain package app.k9mail.feature.navigation.drawer.domain
import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccount import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccount
import app.k9mail.legacy.ui.folder.DisplayFolder import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccountFolder
import app.k9mail.feature.navigation.drawer.domain.entity.DrawerConfig
import app.k9mail.legacy.account.Account
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface DomainContract { interface DomainContract {
interface UseCase { interface UseCase {
fun interface GetDrawerConfig {
operator fun invoke(): Flow<DrawerConfig>
}
fun interface GetDisplayAccounts { fun interface GetDisplayAccounts {
operator fun invoke(): Flow<List<DisplayAccount>> operator fun invoke(): Flow<List<DisplayAccount>>
} }
fun interface GetDisplayFoldersForAccount { fun interface GetDisplayFoldersForAccount {
operator fun invoke(accountUuid: String): Flow<List<DisplayFolder>> operator fun invoke(accountUuid: String): Flow<List<DisplayAccountFolder>>
}
/**
* Synchronize mail for the given account.
*
* Account can be null to synchronize unified inbox or account list.
*/
fun interface SyncMail {
operator fun invoke(account: Account?): Flow<Result<Unit>>
} }
} }
} }

View File

@ -0,0 +1,11 @@
package app.k9mail.feature.navigation.drawer.domain.entity
import app.k9mail.core.mail.folder.api.Folder
data class DisplayAccountFolder(
val accountUuid: String,
val folder: Folder,
val isInTopGroup: Boolean,
val unreadMessageCount: Int,
val starredMessageCount: Int,
)

View File

@ -0,0 +1,6 @@
package app.k9mail.feature.navigation.drawer.domain.entity
data class DrawerConfig(
val showUnifiedInbox: Boolean,
val showStarredCount: Boolean,
)

View File

@ -1,14 +1,25 @@
package app.k9mail.feature.navigation.drawer.domain.usecase package app.k9mail.feature.navigation.drawer.domain.usecase
import app.k9mail.feature.navigation.drawer.domain.DomainContract.UseCase import app.k9mail.feature.navigation.drawer.domain.DomainContract.UseCase
import app.k9mail.legacy.ui.folder.DisplayFolder import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccountFolder
import app.k9mail.legacy.ui.folder.DisplayFolderRepository import app.k9mail.legacy.ui.folder.DisplayFolderRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetDisplayFoldersForAccount( class GetDisplayFoldersForAccount(
private val repository: DisplayFolderRepository, private val repository: DisplayFolderRepository,
) : UseCase.GetDisplayFoldersForAccount { ) : UseCase.GetDisplayFoldersForAccount {
override fun invoke(accountUuid: String): Flow<List<DisplayFolder>> { override fun invoke(accountUuid: String): Flow<List<DisplayAccountFolder>> {
return repository.getDisplayFoldersFlow(accountUuid) return repository.getDisplayFoldersFlow(accountUuid).map { displayFolders ->
displayFolders.map { displayFolder ->
DisplayAccountFolder(
accountUuid = accountUuid,
folder = displayFolder.folder,
isInTopGroup = displayFolder.isInTopGroup,
unreadMessageCount = displayFolder.unreadMessageCount,
starredMessageCount = displayFolder.starredMessageCount,
)
}
}
} }
} }

View File

@ -0,0 +1,18 @@
package app.k9mail.feature.navigation.drawer.domain.usecase
import app.k9mail.feature.navigation.drawer.NavigationDrawerExternalContract.DrawerConfigLoader
import app.k9mail.feature.navigation.drawer.domain.DomainContract.UseCase
import app.k9mail.feature.navigation.drawer.domain.entity.DrawerConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class GetDrawerConfig(
private val configProver: DrawerConfigLoader,
) : UseCase.GetDrawerConfig {
override operator fun invoke(): Flow<DrawerConfig> {
// TODO This needs to be updated when the config changes
return flow {
emit(configProver.loadDrawerConfig())
}
}
}

View File

@ -0,0 +1,37 @@
package app.k9mail.feature.navigation.drawer.domain.usecase
import android.content.Context
import app.k9mail.feature.navigation.drawer.domain.DomainContract.UseCase
import app.k9mail.legacy.account.Account
import app.k9mail.legacy.message.controller.MessagingControllerMailChecker
import app.k9mail.legacy.message.controller.SimpleMessagingListener
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
class SyncMail(
private val messagingController: MessagingControllerMailChecker,
private val coroutineContext: CoroutineContext = Dispatchers.IO,
) : UseCase.SyncMail {
override fun invoke(account: Account?): Flow<Result<Unit>> = callbackFlow {
val listener = object : SimpleMessagingListener() {
override fun checkMailFinished(context: Context?, account: Account?) {
trySend(Result.success(Unit))
close()
}
}
messagingController.checkMail(
account = account,
ignoreLastCheckedTime = true,
useManualWakeLock = true,
notify = true,
listener = listener,
)
awaitClose()
}.flowOn(coroutineContext)
}

View File

@ -34,7 +34,7 @@ fun DrawerContent(
), ),
verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default),
) { ) {
state.currentAccount?.let { state.selectedAccount?.let {
AccountView( AccountView(
displayName = it.account.displayName, displayName = it.account.displayName,
emailAddress = it.account.email, emailAddress = it.account.email,
@ -46,9 +46,11 @@ fun DrawerContent(
} }
FolderList( FolderList(
folders = state.folders, folders = state.folders,
selectedFolder = state.folders.firstOrNull(), // TODO Use selected folder from state selectedFolder = state.selectedFolder,
onFolderClick = { }, onFolderClick = { folder ->
showStarredCount = state.showStarredCount, onEvent(Event.OnFolderClick(folder))
},
showStarredCount = state.config.showStarredCount,
) )
} }
} }

View File

@ -3,7 +3,9 @@ package app.k9mail.feature.navigation.drawer.ui
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccount import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccount
import app.k9mail.legacy.ui.folder.DisplayFolder import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccountFolder
import app.k9mail.feature.navigation.drawer.domain.entity.DrawerConfig
import app.k9mail.legacy.account.Account
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -13,18 +15,27 @@ interface DrawerContract {
@Stable @Stable
data class State( data class State(
val currentAccount: DisplayAccount? = null, val config: DrawerConfig = DrawerConfig(
showUnifiedInbox = false,
showStarredCount = false,
),
val accounts: ImmutableList<DisplayAccount> = persistentListOf(), val accounts: ImmutableList<DisplayAccount> = persistentListOf(),
val folders: ImmutableList<DisplayFolder> = persistentListOf(), val selectedAccount: DisplayAccount? = null,
val showStarredCount: Boolean = false, val folders: ImmutableList<DisplayAccountFolder> = persistentListOf(),
val selectedFolder: DisplayAccountFolder? = null,
val isLoading: Boolean = false, val isLoading: Boolean = false,
) )
sealed interface Event { sealed interface Event {
data object OnRefresh : Event
data class OnAccountClick(val account: DisplayAccount) : Event data class OnAccountClick(val account: DisplayAccount) : Event
data class OnAccountViewClick(val account: DisplayAccount) : Event data class OnAccountViewClick(val account: DisplayAccount) : Event
data class OnFolderClick(val folder: DisplayAccountFolder) : Event
data object OnRefresh : Event
} }
sealed interface Effect sealed interface Effect {
data class OpenAccount(val account: Account) : Effect
data class OpenFolder(val folderId: Long) : Effect
data object CloseDrawer : Effect
}
} }

View File

@ -3,15 +3,26 @@ package app.k9mail.feature.navigation.drawer.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import app.k9mail.core.ui.compose.common.mvi.observe import app.k9mail.core.ui.compose.common.mvi.observe
import app.k9mail.core.ui.compose.designsystem.molecule.PullToRefreshBox import app.k9mail.core.ui.compose.designsystem.molecule.PullToRefreshBox
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Effect
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Event import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Event
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.ViewModel import app.k9mail.feature.navigation.drawer.ui.DrawerContract.ViewModel
import app.k9mail.legacy.account.Account
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@Composable @Composable
fun DrawerView( fun DrawerView(
openAccount: (account: Account) -> Unit,
openFolder: (folderId: Long) -> Unit,
closeDrawer: () -> Unit,
viewModel: ViewModel = koinViewModel<DrawerViewModel>(), viewModel: ViewModel = koinViewModel<DrawerViewModel>(),
) { ) {
val (state, dispatch) = viewModel.observe { } val (state, dispatch) = viewModel.observe { effect ->
when (effect) {
is Effect.OpenAccount -> openAccount(effect.account)
is Effect.OpenFolder -> openFolder(effect.folderId)
Effect.CloseDrawer -> closeDrawer()
}
}
PullToRefreshBox( PullToRefreshBox(
isRefreshing = state.value.isLoading, isRefreshing = state.value.isLoading,

View File

@ -4,6 +4,7 @@ import androidx.lifecycle.viewModelScope
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.navigation.drawer.domain.DomainContract.UseCase import app.k9mail.feature.navigation.drawer.domain.DomainContract.UseCase
import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccount import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccount
import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccountFolder
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Effect import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Effect
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Event import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Event
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.State import app.k9mail.feature.navigation.drawer.ui.DrawerContract.State
@ -12,6 +13,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@ -20,8 +22,10 @@ import kotlinx.coroutines.launch
@Suppress("MagicNumber") @Suppress("MagicNumber")
class DrawerViewModel( class DrawerViewModel(
private val getDrawerConfig: UseCase.GetDrawerConfig,
private val getDisplayAccounts: UseCase.GetDisplayAccounts, private val getDisplayAccounts: UseCase.GetDisplayAccounts,
private val getDisplayFoldersForAccount: UseCase.GetDisplayFoldersForAccount, private val getDisplayFoldersForAccount: UseCase.GetDisplayFoldersForAccount,
private val syncMail: UseCase.SyncMail,
initialState: State = State(), initialState: State = State(),
) : BaseViewModel<State, Event, Effect>( ) : BaseViewModel<State, Event, Effect>(
initialState = initialState, initialState = initialState,
@ -29,6 +33,14 @@ class DrawerViewModel(
ViewModel { ViewModel {
init { init {
viewModelScope.launch {
getDrawerConfig().collectLatest { config ->
updateState {
it.copy(config = config)
}
}
}
viewModelScope.launch { viewModelScope.launch {
loadAccounts() loadAccounts()
} }
@ -45,38 +57,45 @@ class DrawerViewModel(
} }
private fun updateAccounts(accounts: List<DisplayAccount>) { private fun updateAccounts(accounts: List<DisplayAccount>) {
val currentAccountUuid = state.value.currentAccount?.account?.uuid val selectedAccount = accounts.find { it.account.uuid == state.value.selectedAccount?.account?.uuid }
val isCurrentAccountAvailable = accounts.any { currentAccountUuid == it.account.uuid } ?: accounts.firstOrNull()
updateState { updateState {
if (isCurrentAccountAvailable) { it.copy(
it.copy(accounts = accounts.toImmutableList()) accounts = accounts.toImmutableList(),
} else { selectedAccount = selectedAccount,
it.copy( )
accounts = accounts.toImmutableList(),
currentAccount = accounts.firstOrNull(),
)
}
} }
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private suspend fun loadFolders() { private suspend fun loadFolders() {
state.mapNotNull { it.currentAccount?.account?.uuid } state.mapNotNull { it.selectedAccount?.account?.uuid }
.distinctUntilChanged() .distinctUntilChanged()
.flatMapLatest { accountUuid -> .flatMapLatest { accountUuid ->
getDisplayFoldersForAccount(accountUuid) getDisplayFoldersForAccount(accountUuid)
}.collectLatest { folders -> }.collectLatest { folders ->
updateState { updateFolders(folders)
it.copy(folders = folders.toImmutableList())
}
} }
} }
private fun updateFolders(displayFolders: List<DisplayAccountFolder>) {
val selectedFolder = displayFolders.find { it == state.value.selectedFolder }
?: displayFolders.firstOrNull()
updateState {
it.copy(
folders = displayFolders.toImmutableList(),
selectedFolder = selectedFolder,
)
}
}
override fun event(event: Event) { override fun event(event: Event) {
when (event) { when (event) {
Event.OnRefresh -> refresh() Event.OnRefresh -> refresh()
is Event.OnAccountClick -> selectAccount(event.account) is Event.OnAccountClick -> selectAccount(event.account)
is Event.OnFolderClick -> selectFolder(event.folder)
is Event.OnAccountViewClick -> { is Event.OnAccountViewClick -> {
selectAccount( selectAccount(
state.value.accounts.nextOrFirst(event.account)!!, state.value.accounts.nextOrFirst(event.account)!!,
@ -89,10 +108,12 @@ class DrawerViewModel(
viewModelScope.launch { viewModelScope.launch {
updateState { updateState {
it.copy( it.copy(
currentAccount = account, selectedAccount = account,
) )
} }
} }
emitEffect(Effect.OpenAccount(account.account))
} }
private fun ImmutableList<DisplayAccount>.nextOrFirst(account: DisplayAccount): DisplayAccount? { private fun ImmutableList<DisplayAccount>.nextOrFirst(account: DisplayAccount): DisplayAccount? {
@ -106,17 +127,25 @@ class DrawerViewModel(
} }
} }
private fun refresh() { private fun selectFolder(folder: DisplayAccountFolder) {
if (state.value.isLoading) { updateState {
return it.copy(selectedFolder = folder)
} }
emitEffect(Effect.OpenFolder(folder.folder.id))
viewModelScope.launch {
delay(DRAWER_CLOSE_DELAY)
emitEffect(Effect.CloseDrawer)
}
}
private fun refresh() {
viewModelScope.launch { viewModelScope.launch {
updateState { updateState {
it.copy(isLoading = true) it.copy(isLoading = true)
} }
// TODO: replace with actual data loading syncMail(state.value.selectedAccount?.account).collect()
delay(500)
updateState { updateState {
it.copy(isLoading = false) it.copy(isLoading = false)
@ -124,3 +153,9 @@ class DrawerViewModel(
} }
} }
} }
/**
* Delay before closing the drawer to avoid the drawer being closed immediately and give time
* for the ripple effect to finish.
*/
private const val DRAWER_CLOSE_DELAY = 250L

View File

@ -5,14 +5,14 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import app.k9mail.legacy.ui.folder.DisplayFolder import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccountFolder
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
fun FolderList( fun FolderList(
folders: ImmutableList<DisplayFolder>, folders: ImmutableList<DisplayAccountFolder>,
selectedFolder: DisplayFolder?, selectedFolder: DisplayAccountFolder?,
onFolderClick: (DisplayFolder) -> Unit, onFolderClick: (DisplayAccountFolder) -> Unit,
showStarredCount: Boolean, showStarredCount: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {

View File

@ -7,14 +7,14 @@ import app.k9mail.core.mail.folder.api.FolderType
import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon
import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons
import app.k9mail.core.ui.compose.designsystem.organism.drawer.NavigationDrawerItem import app.k9mail.core.ui.compose.designsystem.organism.drawer.NavigationDrawerItem
import app.k9mail.legacy.ui.folder.DisplayFolder import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccountFolder
@Composable @Composable
fun FolderListItem( fun FolderListItem(
displayFolder: DisplayFolder, displayFolder: DisplayAccountFolder,
selected: Boolean, selected: Boolean,
showStarredCount: Boolean, showStarredCount: Boolean,
onClick: (DisplayFolder) -> Unit, onClick: (DisplayAccountFolder) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
NavigationDrawerItem( NavigationDrawerItem(

View File

@ -0,0 +1,32 @@
package app.k9mail.feature.navigation.drawer.domain.usecase
import app.k9mail.feature.navigation.drawer.domain.entity.DrawerConfig
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlin.test.Test
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
class GetDrawerConfigTest {
@Test
fun `should get drawer config`() = runTest {
val drawerConfig = DrawerConfig(
showUnifiedInbox = true,
showStarredCount = true,
)
val testSubject = GetDrawerConfig(
configProver = { drawerConfig },
)
val result = testSubject().first()
assertThat(result).isEqualTo(
DrawerConfig(
showUnifiedInbox = true,
showStarredCount = true,
),
)
}
}

View File

@ -0,0 +1,43 @@
package app.k9mail.feature.navigation.drawer.domain.usecase
import app.k9mail.legacy.account.Account
import app.k9mail.legacy.message.controller.MessagingControllerMailChecker
import app.k9mail.legacy.message.controller.MessagingListener
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SyncMailTest {
@Test
fun `should sync mail`() = runTest {
val listenerExecutor: (MessagingListener?) -> Unit = { listener ->
listener?.checkMailFinished(null, null)
}
val testSubject = SyncMail(
messagingController = FakeMessagingControllerMailChecker(
listenerExecutor = listenerExecutor,
),
)
val result = testSubject(null).first()
assertThat(result.isSuccess).isEqualTo(true)
}
private class FakeMessagingControllerMailChecker(
private val listenerExecutor: (MessagingListener?) -> Unit = {},
) : MessagingControllerMailChecker {
override fun checkMail(
account: Account?,
ignoreLastCheckedTime: Boolean,
useManualWakeLock: Boolean,
notify: Boolean,
listener: MessagingListener?,
) {
listenerExecutor(listener)
}
}
}

View File

@ -0,0 +1,30 @@
package app.k9mail.feature.navigation.drawer.ui
import app.k9mail.feature.navigation.drawer.domain.entity.DrawerConfig
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.State
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
class DrawerStateTest {
@Test
fun `should set default values`() {
val state = State()
assertThat(state).isEqualTo(
State(
config = DrawerConfig(
showUnifiedInbox = false,
showStarredCount = false,
),
accounts = persistentListOf(),
selectedAccount = null,
folders = persistentListOf(),
selectedFolder = null,
isLoading = false,
),
)
}
}

View File

@ -5,12 +5,53 @@ import androidx.compose.ui.test.printToString
import app.k9mail.core.ui.compose.testing.ComposeTest import app.k9mail.core.ui.compose.testing.ComposeTest
import app.k9mail.core.ui.compose.testing.onNodeWithTag import app.k9mail.core.ui.compose.testing.onNodeWithTag
import app.k9mail.core.ui.compose.testing.setContentWithTheme import app.k9mail.core.ui.compose.testing.setContentWithTheme
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Effect
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.State import app.k9mail.feature.navigation.drawer.ui.DrawerContract.State
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlin.test.Test import kotlin.test.Test
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
class DrawerViewKtTest : ComposeTest() { class DrawerViewKtTest : ComposeTest() {
@Test
fun `should delegate effects`() = runTest {
val initialState = State()
val viewModel = FakeDrawerViewModel(initialState)
var openAccountCounter = 0
var openFolderCounter = 0
var closeDrawerCounter = 0
setContentWithTheme {
DrawerView(
openAccount = { openAccountCounter++ },
openFolder = { openFolderCounter++ },
closeDrawer = { closeDrawerCounter++ },
viewModel = viewModel,
)
}
assertThat(openAccountCounter).isEqualTo(0)
assertThat(openFolderCounter).isEqualTo(0)
assertThat(closeDrawerCounter).isEqualTo(0)
viewModel.effect(Effect.OpenAccount(FakeData.ACCOUNT))
assertThat(openAccountCounter).isEqualTo(1)
viewModel.effect(Effect.OpenFolder(1))
assertThat(openAccountCounter).isEqualTo(1)
assertThat(openFolderCounter).isEqualTo(1)
assertThat(closeDrawerCounter).isEqualTo(0)
viewModel.effect(Effect.CloseDrawer)
assertThat(openAccountCounter).isEqualTo(1)
assertThat(openFolderCounter).isEqualTo(1)
assertThat(closeDrawerCounter).isEqualTo(1)
}
@Test @Test
fun `pull refresh should listen to view model state`() = runTest { fun `pull refresh should listen to view model state`() = runTest {
val initialState = State( val initialState = State(
@ -20,6 +61,9 @@ class DrawerViewKtTest : ComposeTest() {
setContentWithTheme { setContentWithTheme {
DrawerView( DrawerView(
openAccount = {},
openFolder = {},
closeDrawer = {},
viewModel = viewModel, viewModel = viewModel,
) )
} }

View File

@ -3,17 +3,23 @@ package app.k9mail.feature.navigation.drawer.ui
import app.k9mail.core.mail.folder.api.Folder import app.k9mail.core.mail.folder.api.Folder
import app.k9mail.core.mail.folder.api.FolderType import app.k9mail.core.mail.folder.api.FolderType
import app.k9mail.core.ui.compose.testing.MainDispatcherRule import app.k9mail.core.ui.compose.testing.MainDispatcherRule
import app.k9mail.core.ui.compose.testing.mvi.assertThatAndEffectTurbineConsumed
import app.k9mail.core.ui.compose.testing.mvi.eventStateTest import app.k9mail.core.ui.compose.testing.mvi.eventStateTest
import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck
import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccount import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccount
import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccountFolder
import app.k9mail.feature.navigation.drawer.domain.entity.DrawerConfig
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Effect
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Event import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Event
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.State import app.k9mail.feature.navigation.drawer.ui.DrawerContract.State
import app.k9mail.legacy.account.Account import app.k9mail.legacy.account.Account
import app.k9mail.legacy.account.Identity import app.k9mail.legacy.account.Identity
import app.k9mail.legacy.ui.folder.DisplayFolder
import assertk.assertThat import assertk.assertThat
import assertk.assertions.isEqualTo import assertk.assertions.isEqualTo
import kotlin.test.Test import kotlin.test.Test
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@ -27,9 +33,35 @@ class DrawerViewModelTest {
@get:Rule @get:Rule
val mainDispatcherRule = MainDispatcherRule() val mainDispatcherRule = MainDispatcherRule()
@Test
fun `should collect drawer config`() = runTest {
val drawerConfig = createDrawerConfig()
val getDrawerConfigFlow = MutableStateFlow(drawerConfig)
val testSubject = createTestSubject(
drawerConfigFlow = getDrawerConfigFlow,
)
advanceUntilIdle()
assertThat(testSubject.state.value.config).isEqualTo(drawerConfig)
val newDrawerConfig = createDrawerConfig(showUnifiedInbox = true)
getDrawerConfigFlow.emit(newDrawerConfig)
advanceUntilIdle()
assertThat(testSubject.state.value.config).isEqualTo(newDrawerConfig)
}
@Test @Test
fun `should change loading state when OnRefresh event is received`() = runTest { fun `should change loading state when OnRefresh event is received`() = runTest {
val testSubject = createTestSubject() val testSubject = createTestSubject(
syncMailFlow = flow {
delay(25)
emit(Result.success(Unit))
},
)
eventStateTest( eventStateTest(
viewModel = testSubject, viewModel = testSubject,
@ -45,8 +77,8 @@ class DrawerViewModelTest {
} }
@Test @Test
fun `should collect display accounts when created and select first as current`() = runTest { fun `should collect display accounts when created and select first as selected`() = runTest {
val displayAccounts = createDisplayAccountList(3) val displayAccounts = createDisplayAccountList(2)
val getDisplayAccountsFlow = MutableStateFlow(displayAccounts) val getDisplayAccountsFlow = MutableStateFlow(displayAccounts)
val testSubject = createTestSubject( val testSubject = createTestSubject(
displayAccountsFlow = getDisplayAccountsFlow, displayAccountsFlow = getDisplayAccountsFlow,
@ -56,11 +88,11 @@ class DrawerViewModelTest {
assertThat(testSubject.state.value.accounts.size).isEqualTo(displayAccounts.size) assertThat(testSubject.state.value.accounts.size).isEqualTo(displayAccounts.size)
assertThat(testSubject.state.value.accounts).isEqualTo(displayAccounts) assertThat(testSubject.state.value.accounts).isEqualTo(displayAccounts)
assertThat(testSubject.state.value.currentAccount).isEqualTo(displayAccounts.first()) assertThat(testSubject.state.value.selectedAccount).isEqualTo(displayAccounts.first())
} }
@Test @Test
fun `should reselect current account when old not present anymore`() = runTest { fun `should reselect selected account when old not present anymore`() = runTest {
val displayAccounts = createDisplayAccountList(3) val displayAccounts = createDisplayAccountList(3)
val getDisplayAccountsFlow = MutableStateFlow(displayAccounts) val getDisplayAccountsFlow = MutableStateFlow(displayAccounts)
val testSubject = createTestSubject( val testSubject = createTestSubject(
@ -76,11 +108,11 @@ class DrawerViewModelTest {
assertThat(testSubject.state.value.accounts.size).isEqualTo(newDisplayAccounts.size) assertThat(testSubject.state.value.accounts.size).isEqualTo(newDisplayAccounts.size)
assertThat(testSubject.state.value.accounts).isEqualTo(newDisplayAccounts) assertThat(testSubject.state.value.accounts).isEqualTo(newDisplayAccounts)
assertThat(testSubject.state.value.currentAccount).isEqualTo(newDisplayAccounts.first()) assertThat(testSubject.state.value.selectedAccount).isEqualTo(newDisplayAccounts.first())
} }
@Test @Test
fun `should set current account to null when no accounts are present`() = runTest { fun `should set selected account to null when no accounts are present`() = runTest {
val getDisplayAccountsFlow = MutableStateFlow(emptyList<DisplayAccount>()) val getDisplayAccountsFlow = MutableStateFlow(emptyList<DisplayAccount>())
val testSubject = createTestSubject( val testSubject = createTestSubject(
displayAccountsFlow = getDisplayAccountsFlow, displayAccountsFlow = getDisplayAccountsFlow,
@ -89,16 +121,23 @@ class DrawerViewModelTest {
advanceUntilIdle() advanceUntilIdle()
assertThat(testSubject.state.value.accounts.size).isEqualTo(0) assertThat(testSubject.state.value.accounts.size).isEqualTo(0)
assertThat(testSubject.state.value.currentAccount).isEqualTo(null) assertThat(testSubject.state.value.selectedAccount).isEqualTo(null)
} }
@Test @Test
fun `should set current account when OnAccountClick event is received`() = runTest { fun `should set selected account when OnAccountClick event is received`() = runTest {
val displayAccounts = createDisplayAccountList(3) val displayAccounts = createDisplayAccountList(3)
val getDisplayAccountsFlow = MutableStateFlow(displayAccounts) val getDisplayAccountsFlow = MutableStateFlow(displayAccounts)
val testSubject = createTestSubject( val testSubject = createTestSubject(
displayAccountsFlow = getDisplayAccountsFlow, displayAccountsFlow = getDisplayAccountsFlow,
) )
val turbines = turbinesWithInitialStateCheck(
testSubject,
State(
accounts = displayAccounts.toImmutableList(),
selectedAccount = displayAccounts.first(),
),
)
advanceUntilIdle() advanceUntilIdle()
@ -106,11 +145,15 @@ class DrawerViewModelTest {
advanceUntilIdle() advanceUntilIdle()
assertThat(testSubject.state.value.currentAccount).isEqualTo(displayAccounts[1]) assertThat(turbines.awaitStateItem().selectedAccount).isEqualTo(displayAccounts[1])
turbines.assertThatAndEffectTurbineConsumed {
isEqualTo(Effect.OpenAccount(displayAccounts[1].account))
}
} }
@Test @Test
fun `should collect display folders for current account`() = runTest { fun `should collect display folders for selected account`() = runTest {
val displayAccounts = createDisplayAccountList(3) val displayAccounts = createDisplayAccountList(3)
val getDisplayAccountsFlow = MutableStateFlow(displayAccounts) val getDisplayAccountsFlow = MutableStateFlow(displayAccounts)
val displayFoldersMap = mapOf( val displayFoldersMap = mapOf(
@ -129,7 +172,7 @@ class DrawerViewModelTest {
} }
@Test @Test
fun `should collect display folders when current account is changed`() = runTest { fun `should collect display folders when selected account is changed`() = runTest {
val displayAccounts = createDisplayAccountList(3) val displayAccounts = createDisplayAccountList(3)
val getDisplayAccountsFlow = MutableStateFlow(displayAccounts) val getDisplayAccountsFlow = MutableStateFlow(displayAccounts)
val displayFoldersMap = mapOf( val displayFoldersMap = mapOf(
@ -153,15 +196,62 @@ class DrawerViewModelTest {
assertThat(testSubject.state.value.folders).isEqualTo(displayFolders) assertThat(testSubject.state.value.folders).isEqualTo(displayFolders)
} }
@Test
fun `should set selected folder when OnFolderClick event is received`() = runTest {
val displayAccounts = createDisplayAccountList(3)
val getDisplayAccountsFlow = MutableStateFlow(displayAccounts)
val displayFoldersMap = mapOf(
displayAccounts[0].account.uuid to createDisplayFolderList(3),
)
val initialState = State(
accounts = displayAccounts.toImmutableList(),
selectedAccount = displayAccounts[0],
folders = displayFoldersMap[displayAccounts[0].account.uuid]!!.toImmutableList(),
selectedFolder = displayFoldersMap[displayAccounts[0].account.uuid]!![0],
)
val testSubject = createTestSubject(
displayAccountsFlow = getDisplayAccountsFlow,
displayFoldersMap = displayFoldersMap,
)
val turbines = turbinesWithInitialStateCheck(testSubject, initialState)
advanceUntilIdle()
val displayFolders = displayFoldersMap[displayAccounts[0].account.uuid] ?: emptyList()
testSubject.event(Event.OnFolderClick(displayFolders[1]))
assertThat(turbines.awaitStateItem().selectedFolder).isEqualTo(displayFolders[1])
assertThat(turbines.awaitEffectItem()).isEqualTo(Effect.OpenFolder(displayFolders[1].folder.id))
turbines.assertThatAndEffectTurbineConsumed {
isEqualTo(Effect.CloseDrawer)
}
}
private fun createTestSubject( private fun createTestSubject(
drawerConfigFlow: Flow<DrawerConfig> = flow { emit(createDrawerConfig()) },
displayAccountsFlow: Flow<List<DisplayAccount>> = flow { emit(emptyList()) }, displayAccountsFlow: Flow<List<DisplayAccount>> = flow { emit(emptyList()) },
displayFoldersMap: Map<String, List<DisplayFolder>> = emptyMap(), displayFoldersMap: Map<String, List<DisplayAccountFolder>> = emptyMap(),
syncMailFlow: Flow<Result<Unit>> = flow { emit(Result.success(Unit)) },
): DrawerViewModel { ): DrawerViewModel {
return DrawerViewModel( return DrawerViewModel(
getDrawerConfig = { drawerConfigFlow },
getDisplayAccounts = { displayAccountsFlow }, getDisplayAccounts = { displayAccountsFlow },
getDisplayFoldersForAccount = { accountUuid -> getDisplayFoldersForAccount = { accountUuid ->
flow { emit(displayFoldersMap[accountUuid] ?: emptyList()) } flow { emit(displayFoldersMap[accountUuid] ?: emptyList()) }
}, },
syncMail = { syncMailFlow },
)
}
private fun createDrawerConfig(
showUnifiedInbox: Boolean = false,
showStarredCount: Boolean = false,
): DrawerConfig {
return DrawerConfig(
showUnifiedInbox = showUnifiedInbox,
showStarredCount = showStarredCount,
) )
} }
@ -204,12 +294,13 @@ class DrawerViewModelTest {
} }
private fun createDisplayFolder( private fun createDisplayFolder(
accountUuid: String = "uuid",
id: Long = 1234, id: Long = 1234,
name: String = "name", name: String = "name",
type: FolderType = FolderType.REGULAR, type: FolderType = FolderType.REGULAR,
unreadCount: Int = 0, unreadCount: Int = 0,
starredCount: Int = 0, starredCount: Int = 0,
): DisplayFolder { ): DisplayAccountFolder {
val folder = Folder( val folder = Folder(
id = id, id = id,
name = name, name = name,
@ -217,7 +308,8 @@ class DrawerViewModelTest {
isLocalOnly = false, isLocalOnly = false,
) )
return DisplayFolder( return DisplayAccountFolder(
accountUuid = accountUuid,
folder = folder, folder = folder,
isInTopGroup = false, isInTopGroup = false,
unreadMessageCount = unreadCount, unreadMessageCount = unreadCount,
@ -225,7 +317,7 @@ class DrawerViewModelTest {
) )
} }
private fun createDisplayFolderList(count: Int): List<DisplayFolder> { private fun createDisplayFolderList(count: Int): List<DisplayAccountFolder> {
return List(count) { index -> return List(count) { index ->
createDisplayFolder( createDisplayFolder(
id = index.toLong() + 100, id = index.toLong() + 100,

View File

@ -15,6 +15,7 @@ dependencies {
implementation(projects.feature.account.setup) implementation(projects.feature.account.setup)
implementation(projects.feature.account.edit) implementation(projects.feature.account.edit)
implementation(projects.feature.navigation.drawer)
implementation(projects.feature.settings.import) implementation(projects.feature.settings.import)
implementation(projects.feature.widget.unread) implementation(projects.feature.widget.unread)

View File

@ -1,6 +1,7 @@
package com.fsck.k9.feature package com.fsck.k9.feature
import app.k9mail.feature.launcher.FeatureLauncherExternalContract import app.k9mail.feature.launcher.FeatureLauncherExternalContract
import app.k9mail.feature.navigation.drawer.NavigationDrawerExternalContract
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
@ -10,4 +11,8 @@ val featureModule = module {
context = androidContext(), context = androidContext(),
) )
} }
single<NavigationDrawerExternalContract.DrawerConfigLoader> {
NavigationDrawerConfigLoader()
}
} }

View File

@ -0,0 +1,14 @@
package com.fsck.k9.feature
import app.k9mail.feature.navigation.drawer.NavigationDrawerExternalContract.DrawerConfigLoader
import app.k9mail.feature.navigation.drawer.domain.entity.DrawerConfig
import com.fsck.k9.K9
class NavigationDrawerConfigLoader : DrawerConfigLoader {
override fun loadDrawerConfig(): DrawerConfig {
return DrawerConfig(
showUnifiedInbox = K9.isShowUnifiedInbox,
showStarredCount = K9.isShowStarredCount,
)
}
}

View File

@ -608,6 +608,9 @@ open class MessageList :
private fun initializeFolderDrawer() { private fun initializeFolderDrawer() {
navigationDrawer = FolderDrawer( navigationDrawer = FolderDrawer(
parent = this, parent = this,
openAccount = { account -> openRealAccount(account) },
openFolder = { folderId -> openFolder(folderId) },
createDrawerListener = { createDrawerListener() },
) )
} }
@ -630,7 +633,7 @@ open class MessageList :
} }
} }
fun openFolder(folderId: Long) { private fun openFolder(folderId: Long) {
if (displayMode == DisplayMode.SPLIT_VIEW) { if (displayMode == DisplayMode.SPLIT_VIEW) {
removeMessageViewContainerFragment() removeMessageViewContainerFragment()
showMessageViewPlaceHolder() showMessageViewPlaceHolder()