Use Compose to draw the list of recipes (#187)

* Add paging-compose dependency

* Move progress indicator to separate module

* Introduce color scheme preview

* Move loading helper to UI module

* Move helper composables to UI module

* Rearrange shopping lists module

* Add LazyPagingColumnPullRefresh Composable

* Add BaseComposeFragment

* Add pagingDataRecipeState

* Add showFavoriteIcon to recipe state

* Disable unused placeholders

* Make "Try again" button optional

* Fix example email

* Wrap recipe info into a Scaffold

* Add dialog to confirm deletion

* Add RecipeItem Composable

* Add RecipeListError Composable

* Add RecipeList Composable

* Replace recipes list Views with Compose

* Update UI test

* Remove application from ViewModel
This commit is contained in:
Kirill Kamakin
2023-11-23 07:23:30 +01:00
committed by GitHub
parent 4301c623c9
commit f6f44c7592
72 changed files with 935 additions and 1131 deletions

View File

@@ -8,7 +8,7 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ActivityUiStateControllerImpl @Inject constructor() : ActivityUiStateController {
internal class ActivityUiStateControllerImpl @Inject constructor() : ActivityUiStateController {
private val uiState = MutableStateFlow(ActivityUiState())
override fun updateUiState(update: (ActivityUiState) -> ActivityUiState) {

View File

@@ -0,0 +1,37 @@
package gq.kirmanak.mealient.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
@AndroidEntryPoint
abstract class BaseComposeFragment : Fragment() {
@Inject
lateinit var logger: Logger
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
logger.v { "onCreateView() called" }
return ComposeView(requireContext()).apply {
setContent {
AppTheme {
Screen()
}
}
}
}
@Composable
abstract fun Screen()
}

View File

@@ -4,11 +4,16 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.ui.util.LoadingHelperFactory
import gq.kirmanak.mealient.ui.util.LoadingHelperFactoryImpl
@Module
@InstallIn(SingletonComponent::class)
interface UiModule {
internal interface UiModule {
@Binds
fun bindActivityUiStateController(impl: ActivityUiStateControllerImpl): ActivityUiStateController
@Binds
fun bindLoadingHelperFactory(impl: LoadingHelperFactoryImpl): LoadingHelperFactory
}

View File

@@ -0,0 +1,30 @@
package gq.kirmanak.mealient.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
@Composable
fun CenteredProgressIndicator(
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
@ColorSchemePreview
@Composable
fun PreviewCenteredProgressIndicator() {
AppTheme {
CenteredProgressIndicator()
}
}

View File

@@ -0,0 +1,59 @@
package gq.kirmanak.mealient.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
@Composable
fun EmptyListError(
text: String,
modifier: Modifier = Modifier,
onRetry: () -> Unit = {},
retryButtonText: String? = null,
) {
Box(
modifier = modifier,
) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
modifier = Modifier
.padding(top = Dimens.Medium)
.semantics { testTag = "empty-list-error-text" },
text = text,
)
if (!retryButtonText.isNullOrBlank()) {
Button(
modifier = Modifier.padding(top = Dimens.Medium),
onClick = onRetry,
) {
Text(text = retryButtonText)
}
}
}
}
}
@Composable
@ColorSchemePreview
fun PreviewEmptyListError() {
AppTheme {
EmptyListError(
text = "No items in the list",
retryButtonText = "Try again",
onRetry = {}
)
}
}

View File

@@ -0,0 +1,28 @@
package gq.kirmanak.mealient.ui.components
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
@Composable
fun ErrorSnackbar(
text: String?,
snackbarHostState: SnackbarHostState,
onSnackbarShown: () -> Unit,
) {
if (text.isNullOrBlank()) {
snackbarHostState.currentSnackbarData?.dismiss()
return
}
val scope = rememberCoroutineScope()
LaunchedEffect(snackbarHostState) {
scope.launch {
snackbarHostState.showSnackbar(message = text)
onSnackbarShown()
}
}
}

View File

@@ -0,0 +1,41 @@
package gq.kirmanak.mealient.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.PullRefreshState
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
@OptIn(ExperimentalMaterialApi::class)
fun LazyColumnPullRefresh(
refreshState: PullRefreshState,
isRefreshing: Boolean,
contentPadding: PaddingValues,
verticalArrangement: Arrangement.Vertical,
lazyColumnContent: LazyListScope.() -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.pullRefresh(refreshState),
) {
LazyColumn(
contentPadding = contentPadding,
verticalArrangement = verticalArrangement,
content = lazyColumnContent
)
PullRefreshIndicator(
modifier = Modifier.align(Alignment.TopCenter),
refreshing = isRefreshing,
state = refreshState
)
}
}

View File

@@ -0,0 +1,91 @@
package gq.kirmanak.mealient.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import gq.kirmanak.mealient.ui.util.LoadingState
import gq.kirmanak.mealient.ui.util.LoadingStateNoData
import gq.kirmanak.mealient.ui.util.data
import gq.kirmanak.mealient.ui.util.isLoading
import gq.kirmanak.mealient.ui.util.isRefreshing
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun <T> LazyColumnWithLoadingState(
loadingState: LoadingState<List<T>>,
emptyListError: String,
retryButtonText: String,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
snackbarText: String?,
onSnackbarShown: () -> Unit = {},
onRefresh: () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
floatingActionButtonPosition: FabPosition = FabPosition.End,
lazyColumnContent: LazyListScope.(List<T>) -> Unit = {},
) {
val refreshState = rememberPullRefreshState(
refreshing = loadingState.isRefreshing,
onRefresh = onRefresh,
)
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
modifier = modifier,
floatingActionButton = floatingActionButton,
floatingActionButtonPosition = floatingActionButtonPosition,
snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues ->
val innerModifier = Modifier
.padding(paddingValues)
.fillMaxSize()
val list = loadingState.data ?: emptyList()
when {
loadingState is LoadingStateNoData.InitialLoad -> {
CenteredProgressIndicator(modifier = innerModifier)
}
!loadingState.isLoading && list.isEmpty() -> {
EmptyListError(
text = emptyListError,
retryButtonText = retryButtonText,
onRetry = onRefresh,
modifier = innerModifier,
)
}
else -> {
LazyColumnPullRefresh(
refreshState = refreshState,
isRefreshing = loadingState.isRefreshing,
contentPadding = contentPadding,
verticalArrangement = verticalArrangement,
lazyColumnContent = { lazyColumnContent(list) },
modifier = innerModifier,
)
ErrorSnackbar(
text = snackbarText,
snackbarHostState = snackbarHostState,
onSnackbarShown = onSnackbarShown,
)
}
}
}
}

View File

@@ -0,0 +1,38 @@
package gq.kirmanak.mealient.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun <T : Any> LazyPagingColumnPullRefresh(
lazyPagingItems: LazyPagingItems<T>,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
lazyColumnContent: LazyListScope.() -> Unit,
) {
val isRefreshing = lazyPagingItems.loadState.refresh is LoadState.Loading
val refreshState = rememberPullRefreshState(
refreshing = isRefreshing,
onRefresh = lazyPagingItems::refresh,
)
LazyColumnPullRefresh(
modifier = modifier,
refreshState = refreshState,
isRefreshing = isRefreshing,
contentPadding = contentPadding,
verticalArrangement = verticalArrangement,
lazyColumnContent = lazyColumnContent,
)
}

View File

@@ -0,0 +1,45 @@
package gq.kirmanak.mealient.ui.preview
import android.content.res.Configuration.UI_MODE_NIGHT_MASK
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Wallpapers
@Preview(
name = "Blue",
group = "Day",
showBackground = true,
wallpaper = Wallpapers.BLUE_DOMINATED_EXAMPLE,
)
@Preview(
name = "Red",
group = "Day",
showBackground = true,
wallpaper = Wallpapers.RED_DOMINATED_EXAMPLE,
)
@Preview(
name = "None",
group = "Day",
showBackground = true,
)
@Preview(
name = "Blue",
group = "Night",
showBackground = true,
wallpaper = Wallpapers.BLUE_DOMINATED_EXAMPLE,
uiMode = UI_MODE_NIGHT_MASK and UI_MODE_NIGHT_YES,
)
@Preview(
name = "Red",
group = "Night",
showBackground = true,
wallpaper = Wallpapers.RED_DOMINATED_EXAMPLE,
uiMode = UI_MODE_NIGHT_MASK and UI_MODE_NIGHT_YES,
)
@Preview(
name = "None",
group = "Night",
showBackground = true,
uiMode = UI_MODE_NIGHT_MASK and UI_MODE_NIGHT_YES,
)
annotation class ColorSchemePreview

View File

@@ -0,0 +1,10 @@
package gq.kirmanak.mealient.ui.util
import kotlinx.coroutines.flow.StateFlow
interface LoadingHelper<T> {
val loadingState: StateFlow<LoadingState<T>>
suspend fun refresh(): Result<T>
}

View File

@@ -0,0 +1,11 @@
package gq.kirmanak.mealient.ui.util
import kotlinx.coroutines.CoroutineScope
interface LoadingHelperFactory {
fun <T> create(
coroutineScope: CoroutineScope,
fetch: suspend () -> Result<T>,
): LoadingHelper<T>
}

View File

@@ -0,0 +1,17 @@
package gq.kirmanak.mealient.ui.util
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.CoroutineScope
import javax.inject.Inject
// @AssistedFactory does not currently support type parameters in the creator method.
// See https://github.com/google/dagger/issues/2279
internal class LoadingHelperFactoryImpl @Inject constructor(
private val logger: Logger
) : LoadingHelperFactory {
override fun <T> create(
coroutineScope: CoroutineScope,
fetch: suspend () -> Result<T>,
): LoadingHelper<T> = LoadingHelperImpl(logger, fetch)
}

View File

@@ -0,0 +1,41 @@
package gq.kirmanak.mealient.ui.util
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
internal class LoadingHelperImpl<T>(
private val logger: Logger,
private val fetch: suspend () -> Result<T>,
) : LoadingHelper<T> {
private val _loadingState = MutableStateFlow<LoadingState<T>>(LoadingStateNoData.InitialLoad)
override val loadingState: StateFlow<LoadingState<T>> = _loadingState
override suspend fun refresh(): Result<T> {
logger.v { "refresh() called" }
_loadingState.update { currentState ->
when (currentState) {
is LoadingStateWithData -> LoadingStateWithData.Refreshing(currentState.data)
is LoadingStateNoData -> LoadingStateNoData.InitialLoad
}
}
val result = fetch()
_loadingState.update { currentState ->
result.fold(
onSuccess = { data ->
LoadingStateWithData.Success(data)
},
onFailure = { error ->
when (currentState) {
is LoadingStateWithData -> LoadingStateWithData.Success(currentState.data)
is LoadingStateNoData -> LoadingStateNoData.LoadError(error)
}
},
)
}
return result
}
}

View File

@@ -0,0 +1,58 @@
package gq.kirmanak.mealient.ui.util
sealed class LoadingState<out T>
sealed class LoadingStateWithData<out T> : LoadingState<T>() {
abstract val data: T
data class Refreshing<T>(override val data: T) : LoadingStateWithData<T>()
data class Success<T>(override val data: T) : LoadingStateWithData<T>()
}
sealed class LoadingStateNoData<out T> : LoadingState<T>() {
object InitialLoad : LoadingStateNoData<Nothing>()
data class LoadError<T>(val error: Throwable) : LoadingStateNoData<T>()
}
val <T> LoadingState<T>.isLoading: Boolean
get() = when (this) {
is LoadingStateNoData.LoadError,
is LoadingStateWithData.Success -> false
is LoadingStateNoData.InitialLoad,
is LoadingStateWithData.Refreshing -> true
}
val <T> LoadingState<T>.error: Throwable?
get() = when (this) {
is LoadingStateNoData.LoadError -> error
is LoadingStateNoData.InitialLoad,
is LoadingStateWithData.Refreshing,
is LoadingStateWithData.Success -> null
}
val <T> LoadingState<T>.data: T?
get() = when (this) {
is LoadingStateWithData -> data
is LoadingStateNoData -> null
}
val <T> LoadingState<T>.isRefreshing: Boolean
get() = when (this) {
is LoadingStateWithData.Refreshing -> true
is LoadingStateWithData.Success,
is LoadingStateNoData.InitialLoad,
is LoadingStateNoData.LoadError -> false
}
inline fun <T, E> LoadingState<T>.map(block: (T) -> E) = when (this) {
is LoadingStateWithData.Success -> LoadingStateWithData.Success(block(data))
is LoadingStateWithData.Refreshing -> LoadingStateWithData.Refreshing(block(data))
is LoadingStateNoData.InitialLoad -> LoadingStateNoData.InitialLoad
is LoadingStateNoData.LoadError -> LoadingStateNoData.LoadError(error)
}