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:
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user