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

Merge pull request #8128 from wmontwe/add-drawer-folder-list-part1

Add drawer folder list - Part 1
This commit is contained in:
Wolf-Martell Montwé 2024-09-10 13:37:26 +02:00 committed by GitHub
commit 3cd270818a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 600 additions and 54 deletions

View File

@ -0,0 +1,6 @@
package app.k9mail.ui.catalog.ui
interface CatalogPage {
val displayName: String
val isFullScreen: Boolean
}

View File

@ -29,15 +29,17 @@ fun CatalogAtomContent(
pages = pages,
initialPage = initialPage,
modifier = modifier,
) {
when (it) {
TYPOGRAPHY -> typographyItems()
COLOR -> colorItems()
BUTTON -> buttonItems()
SELECTION_CONTROL -> selectionControlItems()
TEXT_FIELD -> textFieldItems()
ICON -> iconItems()
IMAGE -> imageItems()
}
}
onRenderPage = {
when (it) {
TYPOGRAPHY -> typographyItems()
COLOR -> colorItems()
BUTTON -> buttonItems()
SELECTION_CONTROL -> selectionControlItems()
TEXT_FIELD -> textFieldItems()
ICON -> iconItems()
IMAGE -> imageItems()
}
},
onRenderFullScreenPage = {},
)
}

View File

@ -1,10 +1,12 @@
package app.k9mail.ui.catalog.ui.atom
import app.k9mail.ui.catalog.ui.CatalogPage
import kotlinx.collections.immutable.toImmutableList
enum class CatalogAtomPage(
private val displayName: String,
) {
override val displayName: String,
override val isFullScreen: Boolean = false,
) : CatalogPage {
TYPOGRAPHY("Typography"),
COLOR("Colors"),
BUTTON("Buttons"),

View File

@ -1,6 +1,5 @@
package app.k9mail.ui.catalog.ui.common
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@ -20,16 +19,17 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import app.k9mail.core.ui.compose.designsystem.template.ResponsiveContentWithSurface
import app.k9mail.core.ui.compose.theme2.MainTheme
import app.k9mail.ui.catalog.ui.CatalogPage
import app.k9mail.ui.catalog.ui.common.list.fullSpanItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T> PagedContent(
fun <T : CatalogPage> PagedContent(
pages: ImmutableList<T>,
initialPage: T,
modifier: Modifier = Modifier,
onRenderFullScreenPage: @Composable (T) -> Unit = {},
onRenderPage: LazyGridScope.(T) -> Unit,
) {
val pagerState = rememberPagerState(
@ -63,17 +63,21 @@ fun <T> PagedContent(
state = pagerState,
modifier = Modifier
.fillMaxSize(),
) { page ->
LazyVerticalGrid(
columns = GridCells.Adaptive(MainTheme.sizes.larger),
modifier = Modifier
.fillMaxSize()
.imePadding(),
horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.double),
verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double),
) {
onRenderPage(pages[page])
fullSpanItem { Spacer(modifier = Modifier.height(MainTheme.sizes.smaller)) }
) { pageIndex ->
if (pages[pageIndex].isFullScreen) {
onRenderFullScreenPage(pages[pageIndex])
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(MainTheme.sizes.larger),
modifier = Modifier
.fillMaxSize()
.imePadding(),
horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.double),
verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double),
) {
onRenderPage(pages[pageIndex])
fullSpanItem { Spacer(modifier = Modifier.height(MainTheme.sizes.smaller)) }
}
}
}
}

View File

@ -4,7 +4,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import app.k9mail.ui.catalog.ui.common.PagedContent
import app.k9mail.ui.catalog.ui.molecule.CatalogMoleculePage.INPUT
import app.k9mail.ui.catalog.ui.molecule.CatalogMoleculePage.PULL_TO_REFRESH
import app.k9mail.ui.catalog.ui.molecule.CatalogMoleculePage.STATE
import app.k9mail.ui.catalog.ui.molecule.items.PullToRefresh
import app.k9mail.ui.catalog.ui.molecule.items.inputItems
import app.k9mail.ui.catalog.ui.molecule.items.stateItems
import kotlinx.collections.immutable.ImmutableList
@ -19,10 +21,18 @@ fun CatalogMoleculeContent(
pages = pages,
initialPage = initialPage,
modifier = modifier,
) {
when (it) {
INPUT -> inputItems()
STATE -> stateItems()
}
}
onRenderPage = {
when (it) {
INPUT -> inputItems()
STATE -> stateItems()
else -> throw IllegalArgumentException("Unknown page: $it")
}
},
onRenderFullScreenPage = { page ->
when (page) {
PULL_TO_REFRESH -> PullToRefresh()
else -> throw IllegalArgumentException("Unknown page: $page")
}
},
)
}

View File

@ -1,12 +1,15 @@
package app.k9mail.ui.catalog.ui.molecule
import app.k9mail.ui.catalog.ui.CatalogPage
import kotlinx.collections.immutable.toImmutableList
enum class CatalogMoleculePage(
private val displayName: String,
) {
override val displayName: String,
override val isFullScreen: Boolean = false,
) : CatalogPage {
INPUT("Inputs"),
STATE("States"),
PULL_TO_REFRESH("Pull to refresh", isFullScreen = true),
;
override fun toString(): String {

View File

@ -0,0 +1,47 @@
package app.k9mail.ui.catalog.ui.molecule.items
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium
import app.k9mail.core.ui.compose.designsystem.molecule.PullToRefreshBox
import app.k9mail.core.ui.compose.theme2.MainTheme
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Suppress("MagicNumber")
@Composable
fun PullToRefresh(
modifier: Modifier = Modifier,
) {
val isRefreshing = remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
PullToRefreshBox(
isRefreshing = isRefreshing.value,
onRefresh = {
isRefreshing.value = true
coroutineScope.launch {
delay(2000)
isRefreshing.value = false
}
},
contentAlignment = Alignment.Center,
modifier = modifier.fillMaxWidth(),
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double),
) {
items(10) {
TextTitleMedium(text = "Item $it")
}
}
}
}

View File

@ -17,9 +17,11 @@ fun CatalogOrganismContent(
pages = pages,
initialPage = initialPage,
modifier = modifier,
) {
when (it) {
APP_BAR -> appBarItems()
}
}
onRenderPage = {
when (it) {
APP_BAR -> appBarItems()
}
},
onRenderFullScreenPage = {},
)
}

View File

@ -1,10 +1,12 @@
package app.k9mail.ui.catalog.ui.organism
import app.k9mail.ui.catalog.ui.CatalogPage
import kotlinx.collections.immutable.toImmutableList
enum class CatalogOrganismPage(
private val displayName: String,
) {
override val displayName: String,
override val isFullScreen: Boolean = false,
) : CatalogPage {
APP_BAR("App Bars"),
;

View File

@ -0,0 +1,45 @@
package app.k9mail.core.ui.compose.designsystem.molecule
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes
import app.k9mail.core.ui.compose.designsystem.atom.Surface
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge
import app.k9mail.core.ui.compose.theme2.MainTheme
@Composable
@Preview(showBackground = true)
internal fun PullToRefreshBoxPreview() {
PreviewWithThemes {
PullToRefreshBox(
isRefreshing = false,
onRefresh = {},
modifier = Modifier.fillMaxWidth()
.height(MainTheme.sizes.medium),
) {
Surface {
TextBodyLarge("Pull to refresh")
}
}
}
}
@Composable
@Preview(showBackground = true)
internal fun PullToRefreshBoxRefreshingPreview() {
PreviewWithThemes {
PullToRefreshBox(
isRefreshing = true,
onRefresh = {},
modifier = Modifier.fillMaxWidth()
.height(MainTheme.sizes.medium),
) {
Surface {
TextBodyLarge("Refreshing ...")
}
}
}
}

View File

@ -0,0 +1,15 @@
package app.k9mail.core.ui.compose.designsystem.organism.drawer
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes
@Composable
@Preview(showBackground = true)
internal fun NavigationDrawerItemBadgePreview() {
PreviewWithThemes {
NavigationDrawerItemBadge(
label = "100+",
)
}
}

View File

@ -0,0 +1,67 @@
package app.k9mail.core.ui.compose.designsystem.organism.drawer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccountBox
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes
import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon
import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelLarge
@Composable
@Preview(showBackground = true)
internal fun NavigationDrawerItemSelectedPreview() {
PreviewWithThemes {
NavigationDrawerItem(
label = "DrawerItem",
selected = true,
onClick = {},
)
}
}
@Composable
@Preview(showBackground = true)
internal fun NavigationDrawerItemUnselectedPreview() {
PreviewWithThemes {
NavigationDrawerItem(
label = "DrawerItem",
selected = false,
onClick = {},
)
}
}
@Composable
@Preview(showBackground = true)
internal fun NavigationDrawerItemWithIconPreview() {
PreviewWithThemes {
NavigationDrawerItem(
label = "DrawerItem",
selected = false,
onClick = {},
icon = {
Icon(
imageVector = Icons.Outlined.AccountBox,
)
},
)
}
}
@Composable
@Preview(showBackground = true)
internal fun NavigationDrawerItemWithLabelBadgePreview() {
PreviewWithThemes {
NavigationDrawerItem(
label = "DrawerItem",
selected = false,
onClick = {},
badge = {
TextLabelLarge(
text = "100+",
)
},
)
}
}

View File

@ -0,0 +1,41 @@
package app.k9mail.core.ui.compose.designsystem.molecule
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.material3.pulltorefresh.PullToRefreshBox as Material3PullToRefreshBox
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PullToRefreshBox(
isRefreshing: Boolean,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
content: @Composable BoxScope.() -> Unit,
) {
val state = rememberPullToRefreshState()
Material3PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = onRefresh,
modifier = modifier
.testTag("PullToRefreshBox"),
state = state,
contentAlignment = contentAlignment,
indicator = {
Indicator(
modifier = Modifier.align(Alignment.TopCenter)
.testTag("PullToRefreshIndicator"),
isRefreshing = isRefreshing,
state = state,
)
},
content = content,
)
}

View File

@ -0,0 +1,16 @@
package app.k9mail.core.ui.compose.designsystem.organism.drawer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelLarge
@Composable
fun NavigationDrawerItemBadge(
label: String,
modifier: Modifier = Modifier,
) {
TextLabelLarge(
text = label,
modifier = modifier,
)
}

View File

@ -0,0 +1,36 @@
package app.k9mail.core.ui.compose.testing
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
/**
* Base class for providing fake MVI ViewModels for testing.
*
* This class provides a way to capture events and emit effects on a fake ViewModel.
* The state can be set directly using [applyState].
*
* Example usage:
*
* ```
* class FakeViewModel(
* initialState: State = State(),
* ) : BaseFakeViewModel<State, Event, Effect>(initialState), ViewModel
* ```
*/
abstract class BaseFakeViewModel<STATE, EVENT, EFFECT>(
initialState: STATE,
) : BaseViewModel<STATE, EVENT, EFFECT>(initialState = initialState) {
val events = mutableListOf<EVENT>()
override fun event(event: EVENT) {
events.add(event)
}
fun effect(effect: EFFECT) {
emitEffect(effect)
}
fun applyState(state: STATE) {
updateState { state }
}
}

View File

@ -0,0 +1,13 @@
package app.k9mail.feature.navigation.drawer.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme
@Composable
@Preview(showBackground = true)
internal fun DrawerContentPreview() {
PreviewWithTheme {
DrawerContent()
}
}

View File

@ -1,17 +1,13 @@
package app.k9mail.feature.navigation.drawer
import android.view.Surface
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import app.k9mail.core.ui.compose.designsystem.atom.Surface
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import app.k9mail.core.ui.theme.api.FeatureThemeProvider
import app.k9mail.feature.navigation.drawer.ui.DrawerView
import app.k9mail.legacy.account.Account
import com.mikepenz.materialdrawer.widget.MaterialDrawerSliderView
import org.koin.core.component.KoinComponent
@ -26,19 +22,16 @@ class FolderDrawer(
private val drawer: DrawerLayout = parent.findViewById(R.id.navigation_drawer_layout)
private val drawerView: ComposeView = parent.findViewById(R.id.material_drawer_compose_view)
private val sliderView: MaterialDrawerSliderView = parent.findViewById(R.id.material_drawer_slider)
private val swipeRefreshLayout: SwipeRefreshLayout = parent.findViewById(R.id.material_drawer_swipe_refresh)
init {
sliderView.visibility = View.GONE
drawerView.visibility = View.VISIBLE
swipeRefreshLayout.isEnabled = false
drawerView.setContent {
themeProvider.WithTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = Color.Red,
) {
TextBodyLarge("Folder Drawer")
}
DrawerView()
}
}
}

View File

@ -0,0 +1,11 @@
package app.k9mail.feature.navigation.drawer
import app.k9mail.feature.navigation.drawer.ui.DrawerViewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.module.Module
import org.koin.dsl.module
val navigationDrawerModule: Module = module {
viewModel { DrawerViewModel() }
}

View File

@ -0,0 +1,52 @@
package app.k9mail.feature.navigation.drawer.ui
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import app.k9mail.core.ui.compose.designsystem.atom.Surface
import app.k9mail.core.ui.compose.designsystem.organism.drawer.NavigationDrawerItem
import app.k9mail.core.ui.compose.theme2.MainTheme
@Composable
fun DrawerContent(
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier
.fillMaxSize()
.testTag("DrawerContent"),
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(
vertical = MainTheme.spacings.oneHalf,
),
) {
item {
NavigationDrawerItem(
label = "Folder1",
selected = true,
onClick = {},
)
}
item {
NavigationDrawerItem(
label = "Folder2",
selected = false,
onClick = {},
)
}
item {
NavigationDrawerItem(
label = "Folder3",
selected = false,
onClick = {},
)
}
}
}
}

View File

@ -0,0 +1,18 @@
package app.k9mail.feature.navigation.drawer.ui
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
interface DrawerContract {
interface ViewModel : UnidirectionalViewModel<State, Event, Effect>
data class State(
val isLoading: Boolean = false,
)
sealed interface Event {
data object OnRefresh : Event
}
sealed interface Effect
}

View File

@ -0,0 +1,20 @@
package app.k9mail.feature.navigation.drawer.ui
import androidx.compose.runtime.Composable
import app.k9mail.core.ui.compose.common.mvi.observe
import app.k9mail.core.ui.compose.designsystem.molecule.PullToRefreshBox
import org.koin.androidx.compose.koinViewModel
@Composable
fun DrawerView(
viewModel: DrawerContract.ViewModel = koinViewModel<DrawerViewModel>(),
) {
val (state, dispatch) = viewModel.observe { }
PullToRefreshBox(
isRefreshing = state.value.isLoading,
onRefresh = { dispatch(DrawerContract.Event.OnRefresh) },
) {
DrawerContent()
}
}

View File

@ -0,0 +1,43 @@
package app.k9mail.feature.navigation.drawer.ui
import androidx.lifecycle.viewModelScope
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
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.State
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.ViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Suppress("MagicNumber")
class DrawerViewModel(
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(
initialState = initialState,
),
ViewModel {
override fun event(event: Event) {
when (event) {
Event.OnRefresh -> refresh()
}
}
private fun refresh() {
if (state.value.isLoading) {
return
}
viewModelScope.launch {
updateState {
it.copy(isLoading = true)
}
// TODO: replace with actual data loading
delay(500)
updateState {
it.copy(isLoading = false)
}
}
}
}

View File

@ -0,0 +1,47 @@
package app.k9mail.feature.navigation.drawer.ui
import androidx.compose.ui.test.onChildAt
import androidx.compose.ui.test.printToString
import app.k9mail.core.ui.compose.testing.ComposeTest
import app.k9mail.core.ui.compose.testing.onNodeWithTag
import app.k9mail.core.ui.compose.testing.setContentWithTheme
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.State
import kotlin.test.Test
import kotlinx.coroutines.test.runTest
class DrawerViewKtTest : ComposeTest() {
@Test
fun `pull refresh should listen to view model state`() = runTest {
val initialState = State(
isLoading = false,
)
val viewModel = FakeDrawerViewModel(initialState)
setContentWithTheme {
DrawerView(
viewModel = viewModel,
)
}
onNodeWithTag("PullToRefreshBox").assertExists()
onNodeWithTag("PullToRefreshIndicator").assertExists()
.onChildAt(0).assertExists()
.printToString()
.contains("ProgressBarRangeInfo(current=0.0, range=0.0..1.0, steps=0)")
viewModel.applyState(initialState.copy(isLoading = true))
onNodeWithTag("PullToRefreshIndicator").assertExists()
.onChildAt(0).assertExists()
.printToString()
.contains("ProgressBarRangeInfo(current=0.0, range=0.0..0.0, steps=0)")
viewModel.applyState(initialState.copy(isLoading = false))
onNodeWithTag("PullToRefreshIndicator").assertExists()
.onChildAt(0).assertExists()
.printToString()
.contains("ProgressBarRangeInfo(current=0.0, range=0.0..1.0, steps=0)")
}
}

View File

@ -0,0 +1,37 @@
package app.k9mail.feature.navigation.drawer.ui
import app.k9mail.core.ui.compose.testing.MainDispatcherRule
import app.k9mail.core.ui.compose.testing.mvi.eventStateTest
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Event
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.State
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlin.test.Test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@OptIn(ExperimentalCoroutinesApi::class)
class DrawerViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val testSubject = DrawerViewModel()
@Test
fun `should change loading state when OnRefresh event is received`() = runTest {
eventStateTest(
viewModel = testSubject,
initialState = State(isLoading = false),
event = Event.OnRefresh,
expectedState = State(isLoading = true),
coroutineScope = backgroundScope,
)
advanceUntilIdle()
assertThat(testSubject.state.value.isLoading).isEqualTo(false)
}
}

View File

@ -0,0 +1,11 @@
package app.k9mail.feature.navigation.drawer.ui
import app.k9mail.core.ui.compose.testing.BaseFakeViewModel
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.State
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.ViewModel
class FakeDrawerViewModel(
initialState: State = State(),
) : BaseFakeViewModel<State, Event, Effect>(initialState), ViewModel

View File

@ -1,9 +1,12 @@
package com.fsck.k9.ui.messagelist
import app.k9mail.feature.navigation.drawer.navigationDrawerModule
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val messageListUiModule = module {
includes(navigationDrawerModule)
viewModel { MessageListViewModel(get()) }
factory { DefaultFolderProvider() }
factory {