Implement shopping lists screen (#129)

* Initialize shopping lists feature

* Start shopping lists screen with Compose

* Add icon to shopping list name

* Add shopping lists to menu

* Set max size for the list

* Replace compose-adapter with accompanist

* Remove unused fields from shopping lists response

* Show list of shopping lists from BE

* Hide shopping lists if Mealie is 0.5.6

* Add shopping list item click listener

* Create material app theme for Compose

* Use shorter names

* Load shopping lists by pages and save to db

* Make page handling logic match recipes

* Add swipe to refresh to shopping lists

* Extract SwipeToRefresh Composable

* Make LazyPagingColumn generic

* Show refresh only when mediator is refreshing

* Do not refresh automatically

* Allow controlling Activity state from modules

* Implement navigating to shopping list screen

* Move Compose libraries setup to a plugin

* Implement loading full shopping list info

* Move Storage classes to database module

* Save shopping list items to DB

* Use separate names for separate ids

* Do only one DB version update

* Use unique names for all columns

* Display shopping list items

* Move OperationUiState to ui module

* Subscribe to shopping lists updates

* Indicate progress with progress bar

* Use strings from resources

* Format shopping list item quantities

* Hide unit/food/note/quantity if they are not set

* Implement updating shopping list item checked state

* Remove unnecessary null checks

* Disable checkbox when it is being updated

* Split shopping list screen into composables

* Show items immediately if they are saved

* Fix showing "list is empty" before the items

* Show Snackbar when error happens

* Reduce shopping list items paddings

* Remove shopping lists when URL is changed

* Add error/empty state handling to shopping lists

* Fix empty error state

* Fix tests compilation

* Add margin between text and button

* Add divider between checked and unchecked items

* Move divider to the item

* Refresh the shopping lists on authentication

* Use retry when necessary

* Remove excessive logging

* Fix pages bounds check

* Move FlowExtensionsTest

* Update Compose version

* Fix showing loading indicator for shopping lists

* Add Russian translation

* Fix SDK version lint check

* Rename parameter to match interface

* Add DB migration TODO

* Get rid of DB migrations

* Do not use pagination with shopping lists

* Cleanup after the pagination removal

* Load shopping list items

* Remove shopping lists storage

* Rethrow CancellationException in LoadingHelper

* Add pull-to-refresh on shopping list screen

* Extract LazyColumnWithLoadingState

* Split refresh errors and loading state

* Reuse LazyColumnWithLoadingState for shopping list items

* Remove paging-compose dependency

* Refresh shopping list items on authentication

* Disable missing translation lint check

* Update Compose and Kotlin versions

* Fix order of checked items

* Hide useless information from a shopping list item
This commit is contained in:
Kirill Kamakin
2023-07-03 15:07:19 +02:00
committed by GitHub
parent a40f9a78ea
commit 1e5e727e92
157 changed files with 3360 additions and 3715 deletions

View File

@@ -0,0 +1,46 @@
package gq.kirmanak.mealient
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.google.android.material.color.DynamicColors
@Composable
fun AppTheme(
isDarkTheme: Boolean = isSystemInDarkTheme(),
isDynamicColor: Boolean = DynamicColors.isDynamicColorAvailable(),
content: @Composable () -> Unit
) {
val colorScheme = when {
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || !isDynamicColor -> {
if (isDarkTheme) darkColorScheme() else lightColorScheme()
}
isDarkTheme -> {
dynamicDarkColorScheme(LocalContext.current)
}
else -> {
dynamicLightColorScheme(LocalContext.current)
}
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
object Dimens {
val Small = 8.dp
val Medium = 16.dp
val Large = 24.dp
}

View File

@@ -0,0 +1,29 @@
package gq.kirmanak.mealient.shopping_lists
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.shopping_lists.network.ShoppingListsDataSource
import gq.kirmanak.mealient.shopping_lists.network.ShoppingListsDataSourceImpl
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepoImpl
import gq.kirmanak.mealient.shopping_lists.util.LoadingHelperFactory
import gq.kirmanak.mealient.shopping_lists.util.LoadingHelperFactoryImpl
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface ShoppingListsModule {
@Binds
@Singleton
fun bindShoppingListsDataSource(impl: ShoppingListsDataSourceImpl): ShoppingListsDataSource
@Binds
@Singleton
fun bindShoppingListsRepo(impl: ShoppingListsRepoImpl): ShoppingListsRepo
@Binds
fun bindLoadingHelperFactory(impl: LoadingHelperFactoryImpl): LoadingHelperFactory
}

View File

@@ -0,0 +1,13 @@
package gq.kirmanak.mealient.shopping_lists.network
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
interface ShoppingListsDataSource {
suspend fun getAllShoppingLists(): List<ShoppingListInfo>
suspend fun getShoppingList(id: String): FullShoppingListInfo
suspend fun updateIsShoppingListItemChecked(id: String, checked: Boolean)
}

View File

@@ -0,0 +1,30 @@
package gq.kirmanak.mealient.shopping_lists.network
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.model_mapper.ModelMapper
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ShoppingListsDataSourceImpl @Inject constructor(
private val v1Source: MealieDataSourceV1,
private val modelMapper: ModelMapper,
) : ShoppingListsDataSource {
override suspend fun getAllShoppingLists(): List<ShoppingListInfo> {
val response = v1Source.getShoppingLists(1, -1)
return response.items.map { modelMapper.toShoppingListInfo(it) }
}
override suspend fun getShoppingList(
id: String
): FullShoppingListInfo = modelMapper.toFullShoppingListInfo(v1Source.getShoppingList(id))
override suspend fun updateIsShoppingListItemChecked(
id: String,
checked: Boolean,
) = v1Source.updateIsShoppingListItemChecked(id, checked)
}

View File

@@ -0,0 +1,8 @@
package gq.kirmanak.mealient.shopping_lists.repo
import kotlinx.coroutines.flow.Flow
interface ShoppingListsAuthRepo {
val isAuthorizedFlow: Flow<Boolean>
}

View File

@@ -0,0 +1,13 @@
package gq.kirmanak.mealient.shopping_lists.repo
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
interface ShoppingListsRepo {
suspend fun updateIsShoppingListItemChecked(id: String, isChecked: Boolean)
suspend fun getShoppingLists(): List<ShoppingListInfo>
suspend fun getShoppingList(id: String): FullShoppingListInfo
}

View File

@@ -0,0 +1,30 @@
package gq.kirmanak.mealient.shopping_lists.repo
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.shopping_lists.network.ShoppingListsDataSource
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ShoppingListsRepoImpl @Inject constructor(
private val dataSource: ShoppingListsDataSource,
private val logger: Logger,
) : ShoppingListsRepo {
override suspend fun updateIsShoppingListItemChecked(id: String, isChecked: Boolean) {
logger.v { "updateIsShoppingListItemChecked() called with: id = $id, isChecked = $isChecked" }
dataSource.updateIsShoppingListItemChecked(id, isChecked)
}
override suspend fun getShoppingLists(): List<ShoppingListInfo> {
logger.v { "getShoppingLists() called" }
return dataSource.getAllShoppingLists()
}
override suspend fun getShoppingList(id: String): FullShoppingListInfo {
logger.v { "getShoppingListItems() called with: id = $id" }
return dataSource.getShoppingList(id)
}
}

View File

@@ -0,0 +1,18 @@
package gq.kirmanak.mealient.shopping_lists.ui
import androidx.compose.runtime.Composable
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.rememberNavHostEngine
@Composable
fun MealientApp() {
val engine = rememberNavHostEngine()
val controller = engine.rememberNavController()
DestinationsNavHost(
navGraph = NavGraphs.root,
engine = engine,
navController = controller,
startRoute = NavGraphs.root.startRoute,
)
}

View File

@@ -0,0 +1,223 @@
package gq.kirmanak.mealient.shopping_lists.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Divider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import com.ramcosta.composedestinations.annotation.Destination
import gq.kirmanak.mealient.AppTheme
import gq.kirmanak.mealient.Dimens
import gq.kirmanak.mealient.datasource.models.FullRecipeInfo
import gq.kirmanak.mealient.datasource.models.RecipeIngredientInfo
import gq.kirmanak.mealient.datasource.models.RecipeInstructionInfo
import gq.kirmanak.mealient.datasource.models.RecipeSettingsInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemRecipeReferenceInfo
import gq.kirmanak.mealient.shopping_list.R
import gq.kirmanak.mealient.shopping_lists.ui.composables.LazyColumnWithLoadingState
import gq.kirmanak.mealient.shopping_lists.util.data
import gq.kirmanak.mealient.shopping_lists.util.map
import java.text.DecimalFormat
data class ShoppingListNavArgs(
val shoppingListId: String,
)
@Destination(
navArgsDelegate = ShoppingListNavArgs::class,
)
@Composable
internal fun ShoppingListScreen(
shoppingListViewModel: ShoppingListViewModel = hiltViewModel(),
) {
val loadingState = shoppingListViewModel.loadingState.collectAsState().value
val defaultEmptyListError = stringResource(
R.string.shopping_list_screen_empty_list,
loadingState.data?.name.orEmpty()
)
LazyColumnWithLoadingState(
loadingState = loadingState.map { it.items },
defaultEmptyListError = defaultEmptyListError,
errorToShowInSnackbar = shoppingListViewModel.errorToShowInSnackbar,
onRefresh = shoppingListViewModel::refreshShoppingList,
onSnackbarShown = shoppingListViewModel::onSnackbarShown,
lazyColumnContent = { items ->
val firstCheckedItemIndex = items.indexOfFirst { it.item.checked }
itemsIndexed(items) { index, item ->
ShoppingListItem(
shoppingListItem = item.item,
isDisabled = item.isDisabled,
showDivider = index == firstCheckedItemIndex && index != 0,
) { isChecked ->
shoppingListViewModel.onItemCheckedChange(item.item, isChecked)
}
}
}
)
}
@Composable
fun ShoppingListItem(
shoppingListItem: ShoppingListItemInfo,
isDisabled: Boolean,
showDivider: Boolean,
modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit = {},
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(top = Dimens.Small, end = Dimens.Small, start = Dimens.Small),
) {
if (showDivider) {
Divider()
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
Checkbox(
checked = shoppingListItem.checked,
onCheckedChange = onCheckedChange,
enabled = !isDisabled,
)
val isFood = shoppingListItem.isFood
val quantity = shoppingListItem.quantity
.takeUnless { it == 0.0 }
.takeUnless { it == 1.0 && !isFood }
?.let { DecimalFormat.getInstance().format(it) }
val text = listOfNotNull(
quantity,
shoppingListItem.unit.takeIf { isFood },
shoppingListItem.food.takeIf { isFood },
shoppingListItem.note,
).filter { it.isNotBlank() }.joinToString(" ")
Text(text = text)
}
}
}
@Composable
@Preview
fun PreviewShoppingListItemChecked() {
AppTheme {
ShoppingListItem(shoppingListItem = PreviewData.milk, false, false)
}
}
@Composable
@Preview
fun PreviewShoppingListItemUnchecked() {
AppTheme {
ShoppingListItem(shoppingListItem = PreviewData.blackTeaBags, false, false)
}
}
@Composable
@Preview
fun PreviewShoppingListItemCheckedDisabled() {
AppTheme {
ShoppingListItem(shoppingListItem = PreviewData.milk, true, false)
}
}
@Composable
@Preview
fun PreviewShoppingListItemUncheckedDisabled() {
AppTheme {
ShoppingListItem(shoppingListItem = PreviewData.blackTeaBags, true, false)
}
}
private object PreviewData {
val teaWithMilkRecipe = FullRecipeInfo(
remoteId = "1",
name = "Tea with milk",
recipeYield = "1 serving",
recipeIngredients = listOf(
RecipeIngredientInfo(
note = "Tea bag",
food = "",
unit = "",
quantity = 1.0,
title = "",
),
RecipeIngredientInfo(
note = "",
food = "Milk",
unit = "ml",
quantity = 500.0,
title = "",
),
),
recipeInstructions = listOf(
RecipeInstructionInfo("Boil water"),
RecipeInstructionInfo("Put tea bag in a cup"),
RecipeInstructionInfo("Pour water into the cup"),
RecipeInstructionInfo("Wait for 5 minutes"),
RecipeInstructionInfo("Remove tea bag"),
RecipeInstructionInfo("Add milk"),
),
settings = RecipeSettingsInfo(
disableAmounts = false
),
)
val blackTeaBags = ShoppingListItemInfo(
id = "1",
shoppingListId = "1",
checked = false,
position = 0,
isFood = false,
note = "Black tea bags",
quantity = 30.0,
unit = "",
food = "",
recipeReferences = listOf(
ShoppingListItemRecipeReferenceInfo(
shoppingListId = "1",
id = "1",
recipeId = "1",
recipeQuantity = 1.0,
recipe = teaWithMilkRecipe,
),
),
)
val milk = ShoppingListItemInfo(
id = "2",
shoppingListId = "1",
checked = true,
position = 1,
isFood = true,
note = "Cold",
quantity = 500.0,
unit = "ml",
food = "Milk",
recipeReferences = listOf(
ShoppingListItemRecipeReferenceInfo(
shoppingListId = "1",
id = "2",
recipeId = "1",
recipeQuantity = 500.0,
recipe = teaWithMilkRecipe,
),
),
)
}

View File

@@ -0,0 +1,13 @@
package gq.kirmanak.mealient.shopping_lists.ui
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
internal data class ShoppingListScreenState(
val name: String,
val items: List<ShoppingListItemState>,
)
internal data class ShoppingListItemState(
val item: ShoppingListItemInfo,
val isDisabled: Boolean,
)

View File

@@ -0,0 +1,114 @@
package gq.kirmanak.mealient.shopping_lists.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.architecture.valueUpdatesOnly
import gq.kirmanak.mealient.datasource.models.FullShoppingListInfo
import gq.kirmanak.mealient.datasource.models.ShoppingListItemInfo
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo
import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination
import gq.kirmanak.mealient.shopping_lists.util.LoadingHelperFactory
import gq.kirmanak.mealient.shopping_lists.util.LoadingState
import gq.kirmanak.mealient.shopping_lists.util.LoadingStateNoData
import gq.kirmanak.mealient.shopping_lists.util.map
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
internal class ShoppingListViewModel @Inject constructor(
private val shoppingListsRepo: ShoppingListsRepo,
private val logger: Logger,
private val authRepo: ShoppingListsAuthRepo,
loadingHelperFactory: LoadingHelperFactory,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val args: ShoppingListNavArgs = ShoppingListScreenDestination.argsFrom(savedStateHandle)
private val _disabledItemIds: MutableStateFlow<Set<String>> = MutableStateFlow(mutableSetOf())
private val loadingHelper = loadingHelperFactory.create(viewModelScope) {
shoppingListsRepo.getShoppingList(args.shoppingListId)
}
val loadingState: StateFlow<LoadingState<ShoppingListScreenState>> = combine(
loadingHelper.loadingState,
_disabledItemIds,
::buildLoadingState,
).stateIn(viewModelScope, SharingStarted.Eagerly, LoadingStateNoData.InitialLoad)
private var _errorToShowInSnackbar: Throwable? by mutableStateOf(null)
val errorToShowInSnackbar: Throwable? get() = _errorToShowInSnackbar
init {
refreshShoppingList()
listenToAuthState()
}
private fun listenToAuthState() {
logger.v { "listenToAuthState() called" }
viewModelScope.launch {
authRepo.isAuthorizedFlow.valueUpdatesOnly().collect {
logger.d { "Authorization state changed to $it" }
if (it) refreshShoppingList()
}
}
}
fun refreshShoppingList() {
logger.v { "refreshShoppingList() called" }
viewModelScope.launch {
_errorToShowInSnackbar = loadingHelper.refresh().exceptionOrNull()
}
}
private fun buildLoadingState(
loadingState: LoadingState<FullShoppingListInfo>,
disabledItemIds: Set<String>,
): LoadingState<ShoppingListScreenState> {
logger.v { "buildLoadingState() called with: loadingState = $loadingState, disabledItems = $disabledItemIds" }
return loadingState.map { shoppingList ->
val items = shoppingList.items
.sortedBy { it.checked }
.map { ShoppingListItemState(item = it, isDisabled = it.id in disabledItemIds) }
ShoppingListScreenState(name = shoppingList.name, items = items)
}
}
fun onItemCheckedChange(item: ShoppingListItemInfo, isChecked: Boolean) {
logger.v { "onItemCheckedChange() called with: item = $item, isChecked = $isChecked" }
viewModelScope.launch {
_disabledItemIds.update { it + item.id }
val result = runCatchingExceptCancel {
shoppingListsRepo.updateIsShoppingListItemChecked(item.id, isChecked)
}.onFailure {
logger.e(it) { "Failed to update item's checked state" }
}
_disabledItemIds.update { it - item.id }
_errorToShowInSnackbar = result.exceptionOrNull()
if (result.isSuccess) {
logger.v { "Item's checked state updated" }
refreshShoppingList()
}
}
}
fun onSnackbarShown() {
logger.v { "onSnackbarShown() called" }
_errorToShowInSnackbar = null
}
}

View File

@@ -0,0 +1,47 @@
package gq.kirmanak.mealient.shopping_lists.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.AppTheme
import gq.kirmanak.mealient.ui.ActivityUiStateController
import gq.kirmanak.mealient.ui.CheckableMenuItem
import javax.inject.Inject
@AndroidEntryPoint
class ShoppingListsFragment : Fragment() {
@Inject
lateinit var activityUiStateController: ActivityUiStateController
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppTheme {
MealientApp()
}
}
}
}
override fun onResume() {
super.onResume()
activityUiStateController.updateUiState {
it.copy(
navigationVisible = true,
searchVisible = false,
checkedMenuItem = CheckableMenuItem.ShoppingLists,
)
}
}
}

View File

@@ -0,0 +1,96 @@
package gq.kirmanak.mealient.shopping_lists.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import gq.kirmanak.mealient.AppTheme
import gq.kirmanak.mealient.Dimens
import gq.kirmanak.mealient.datasource.models.ShoppingListInfo
import gq.kirmanak.mealient.shopping_list.R
import gq.kirmanak.mealient.shopping_lists.ui.composables.LazyColumnWithLoadingState
import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination
@RootNavGraph(start = true)
@Destination(start = true)
@Composable
fun ShoppingListsScreen(
navigator: DestinationsNavigator,
shoppingListsViewModel: ShoppingListsViewModel = hiltViewModel(),
) {
val loadingState = shoppingListsViewModel.loadingState.collectAsState()
val errorToShowInSnackbar = shoppingListsViewModel.errorToShowInSnackBar
LazyColumnWithLoadingState(
loadingState = loadingState.value,
errorToShowInSnackbar = errorToShowInSnackbar,
onSnackbarShown = shoppingListsViewModel::onSnackbarShown,
onRefresh = shoppingListsViewModel::refresh,
defaultEmptyListError = stringResource(R.string.shopping_lists_screen_empty),
lazyColumnContent = { items ->
items(items) { shoppingList ->
ShoppingListCard(
shoppingListEntity = shoppingList,
onItemClick = { clickedEntity ->
val shoppingListId = clickedEntity.id
navigator.navigate(ShoppingListScreenDestination(shoppingListId))
}
)
}
}
)
}
@Composable
@Preview
private fun PreviewShoppingListCard() {
AppTheme {
ShoppingListCard(shoppingListEntity = ShoppingListInfo("1", "Weekend shopping"))
}
}
@Composable
private fun ShoppingListCard(
shoppingListEntity: ShoppingListInfo?,
modifier: Modifier = Modifier,
onItemClick: (ShoppingListInfo) -> Unit = {},
) {
Card(
modifier = modifier
.padding(horizontal = Dimens.Medium, vertical = Dimens.Small)
.fillMaxWidth()
.clickable { shoppingListEntity?.let { onItemClick(it) } },
) {
Row(
modifier = Modifier.padding(Dimens.Medium),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(id = R.drawable.ic_shopping_cart),
contentDescription = stringResource(id = R.string.shopping_lists_screen_cart_icon),
modifier = Modifier.height(Dimens.Large),
)
Text(
text = shoppingListEntity?.name.orEmpty(),
modifier = Modifier.padding(start = Dimens.Medium),
)
}
}
}

View File

@@ -0,0 +1,59 @@
package gq.kirmanak.mealient.shopping_lists.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.architecture.valueUpdatesOnly
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo
import gq.kirmanak.mealient.shopping_lists.util.LoadingHelperFactory
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ShoppingListsViewModel @Inject constructor(
private val logger: Logger,
private val shoppingListsRepo: ShoppingListsRepo,
private val authRepo: ShoppingListsAuthRepo,
loadingHelperFactory: LoadingHelperFactory,
) : ViewModel() {
private val loadingHelper = loadingHelperFactory.create(viewModelScope) {
shoppingListsRepo.getShoppingLists()
}
val loadingState = loadingHelper.loadingState
private var _errorToShowInSnackbar by mutableStateOf<Throwable?>(null)
val errorToShowInSnackBar: Throwable? get() = _errorToShowInSnackbar
init {
refresh()
listenToAuthState()
}
private fun listenToAuthState() {
logger.v { "listenToAuthState() called" }
viewModelScope.launch {
authRepo.isAuthorizedFlow.valueUpdatesOnly().collect {
logger.d { "Authorization state changed to $it" }
if (it) refresh()
}
}
}
fun refresh() {
logger.v { "refresh() called" }
viewModelScope.launch {
_errorToShowInSnackbar = loadingHelper.refresh().exceptionOrNull()
}
}
fun onSnackbarShown() {
logger.v { "onSnackbarShown() called" }
_errorToShowInSnackbar = null
}
}

View File

@@ -0,0 +1,30 @@
package gq.kirmanak.mealient.shopping_lists.ui.composables
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 androidx.compose.ui.tooling.preview.Preview
import gq.kirmanak.mealient.AppTheme
@Composable
fun CenteredProgressIndicator(
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
@Preview
@Composable
fun PreviewCenteredProgressIndicator() {
AppTheme {
CenteredProgressIndicator()
}
}

View File

@@ -0,0 +1,31 @@
package gq.kirmanak.mealient.shopping_lists.ui.composables
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import gq.kirmanak.mealient.AppTheme
@Composable
fun CenteredText(
text: String,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(text = text)
}
}
@Preview
@Composable
fun PreviewCenteredText() {
AppTheme {
CenteredText(text = "Hello World")
}
}

View File

@@ -0,0 +1,56 @@
package gq.kirmanak.mealient.shopping_lists.ui.composables
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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import gq.kirmanak.mealient.AppTheme
import gq.kirmanak.mealient.Dimens
import gq.kirmanak.mealient.shopping_list.R
@Composable
fun EmptyListError(
loadError: Throwable?,
onRetry: () -> Unit,
defaultError: String,
modifier: Modifier = Modifier,
) {
val text = loadError?.let { getErrorMessage(it) } ?: defaultError
Box(
modifier = modifier,
) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
modifier = Modifier.padding(top = Dimens.Medium),
text = text,
)
Button(
modifier = Modifier.padding(top = Dimens.Medium),
onClick = onRetry,
) {
Text(text = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh))
}
}
}
}
@Composable
@Preview
fun PreviewEmptyListError() {
AppTheme {
EmptyListError(
loadError = null,
onRetry = {},
defaultError = "No items in the list"
)
}
}

View File

@@ -0,0 +1,29 @@
package gq.kirmanak.mealient.shopping_lists.ui.composables
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(
error: Throwable?,
snackbarHostState: SnackbarHostState,
onSnackbarShown: () -> Unit,
) {
if (error == null) {
snackbarHostState.currentSnackbarData?.dismiss()
return
}
val text = getErrorMessage(error = error)
val scope = rememberCoroutineScope()
LaunchedEffect(snackbarHostState) {
scope.launch {
snackbarHostState.showSnackbar(message = text)
onSnackbarShown()
}
}
}

View File

@@ -0,0 +1,13 @@
package gq.kirmanak.mealient.shopping_lists.ui.composables
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.shopping_list.R
@Composable
fun getErrorMessage(error: Throwable): String = when (error) {
is NetworkError.Unauthorized -> stringResource(R.string.shopping_lists_screen_unauthorized_error)
is NetworkError.NoServerConnection -> stringResource(R.string.shopping_lists_screen_no_connection)
else -> error.message ?: stringResource(R.string.shopping_lists_screen_unknown_error)
}

View File

@@ -0,0 +1,33 @@
package gq.kirmanak.mealient.shopping_lists.ui.composables
import androidx.compose.foundation.layout.Box
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(
modifier: Modifier = Modifier,
refreshState: PullRefreshState,
isRefreshing: Boolean,
lazyColumnContent: LazyListScope.() -> Unit
) {
Box(
modifier = modifier.pullRefresh(refreshState),
) {
LazyColumn(modifier = modifier, content = lazyColumnContent)
PullRefreshIndicator(
modifier = Modifier.align(Alignment.TopCenter),
refreshing = isRefreshing,
state = refreshState
)
}
}

View File

@@ -0,0 +1,79 @@
package gq.kirmanak.mealient.shopping_lists.ui.composables
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.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 gq.kirmanak.mealient.shopping_lists.util.LoadingState
import gq.kirmanak.mealient.shopping_lists.util.LoadingStateNoData
import gq.kirmanak.mealient.shopping_lists.util.data
import gq.kirmanak.mealient.shopping_lists.util.error
import gq.kirmanak.mealient.shopping_lists.util.isLoading
import gq.kirmanak.mealient.shopping_lists.util.isRefreshing
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun <T> LazyColumnWithLoadingState(
loadingState: LoadingState<List<T>>,
defaultEmptyListError: String,
modifier: Modifier = Modifier,
errorToShowInSnackbar: Throwable? = null,
onSnackbarShown: () -> Unit = {},
onRefresh: () -> Unit = {},
lazyColumnContent: LazyListScope.(List<T>) -> Unit = {},
) {
val refreshState = rememberPullRefreshState(
refreshing = loadingState.isRefreshing,
onRefresh = onRefresh,
)
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
modifier = modifier,
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(
loadError = loadingState.error,
onRetry = onRefresh,
defaultError = defaultEmptyListError,
modifier = innerModifier,
)
}
else -> {
LazyColumnPullRefresh(
modifier = innerModifier,
refreshState = refreshState,
isRefreshing = loadingState.isRefreshing,
lazyColumnContent = { lazyColumnContent(list) },
)
ErrorSnackbar(
error = errorToShowInSnackbar,
snackbarHostState = snackbarHostState,
onSnackbarShown = onSnackbarShown,
)
}
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
package gq.kirmanak.mealient.shopping_lists.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
class LoadingHelperFactoryImpl @Inject constructor(
private val logger: Logger
) : LoadingHelperFactory {
override fun <T> create(
coroutineScope: CoroutineScope,
fetch: suspend () -> T
): LoadingHelper<T> = LoadingHelperImpl(logger, fetch)
}

View File

@@ -0,0 +1,42 @@
package gq.kirmanak.mealient.shopping_lists.util
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
class LoadingHelperImpl<T>(
private val logger: Logger,
private val fetch: suspend () -> 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 = runCatchingExceptCancel { 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.shopping_lists.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)
}

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:tint="?attr/colorPrimary"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M14.35,43.95Q12.85,43.95 11.8,42.9Q10.75,41.85 10.75,40.35Q10.75,38.85 11.8,37.8Q12.85,36.75 14.35,36.75Q15.85,36.75 16.9,37.8Q17.95,38.85 17.95,40.35Q17.95,41.85 16.9,42.9Q15.85,43.95 14.35,43.95ZM34.35,43.95Q32.85,43.95 31.8,42.9Q30.75,41.85 30.75,40.35Q30.75,38.85 31.8,37.8Q32.85,36.75 34.35,36.75Q35.85,36.75 36.9,37.8Q37.95,38.85 37.95,40.35Q37.95,41.85 36.9,42.9Q35.85,43.95 34.35,43.95ZM11.75,10.95 L17.25,22.35H31.65Q31.65,22.35 31.65,22.35Q31.65,22.35 31.65,22.35L37.9,10.95Q37.9,10.95 37.9,10.95Q37.9,10.95 37.9,10.95ZM10.25,7.95H39.7Q40.85,7.95 41.45,9Q42.05,10.05 41.45,11.1L34.7,23.25Q34.15,24.2 33.275,24.775Q32.4,25.35 31.35,25.35H16.2L13.4,30.55Q13.4,30.55 13.4,30.55Q13.4,30.55 13.4,30.55H37.95V33.55H13.85Q11.75,33.55 10.825,32.15Q9.9,30.75 10.85,29L14.05,23.1L6.45,7H2.55V4H8.4ZM17.25,22.35H31.65Q31.65,22.35 31.65,22.35Q31.65,22.35 31.65,22.35Z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="shopping_lists_screen_cart_icon">Корзина для покупок</string>
<string name="shopping_list_screen_unknown_error">Неизвестная ошибка</string>
<string name="shopping_list_screen_empty_list">%1$s пуст</string>
<string name="shopping_lists_screen_empty">Списки покупок не найдены</string>
<string name="shopping_lists_screen_unauthorized_error">Требуется авторизация</string>
<string name="shopping_lists_screen_no_connection">Нет соединения с сервером</string>
<string name="shopping_lists_screen_unknown_error">Неизвестная ошибка</string>
<string name="shopping_lists_screen_empty_button_refresh">Попробовать снова</string>
</resources>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="shopping_lists_screen_cart_icon">Shopping cart</string>
<string name="shopping_list_screen_unknown_error">Unknown error</string>
<string name="shopping_list_screen_empty_list">%1$s is empty</string>
<string name="shopping_lists_screen_empty">No shopping lists found</string>
<string name="shopping_lists_screen_unauthorized_error">Authentication is required</string>
<string name="shopping_lists_screen_no_connection">No server connection</string>
<string name="shopping_lists_screen_unknown_error">Unknown error</string>
<string name="shopping_lists_screen_empty_button_refresh">Try again</string>
</resources>