Complete migration to Compose (#194)

* Migrate disclaimer screen to Compose

* Migrate base URL screen to Compose

* Migrate base URL screen to Compose

* Migrate authentication screen to Compose

* Initialize add recipe screen

* Remove unused resources

* Display add recipe operation result

* Add delete icon to ingredients and instructions

* Allow navigating between fields on add recipe

* Allow navigating between fields on authentication screen

* Allow to proceed from keyboard on base url screen

* Use material icons for recipe item

* Expose base URL as flow

* Initialize Compose navigation

* Allow sending logs again

* Allow to override navigation and top bar per screen

* Add additional logs

* Migrate share recipe screen to Compose

* Fix unit tests

* Restore recipe list tests

* Ensure authentication is shown after URL input

* Add autofill to authentication

* Complete first set up test

* Use image vector from Icons instead of drawable

* Add transition animations

* Fix logging host in Host header

* Do not fail test if login token is used
This commit is contained in:
Kirill Kamakin
2024-01-13 11:28:10 +01:00
committed by GitHub
parent 94f12820bc
commit de4df95a0e
107 changed files with 3294 additions and 2321 deletions

View File

@@ -12,6 +12,10 @@ android {
namespace = "gq.kirmanak.mealient.shopping_list"
}
ksp {
arg("compose-destinations.generateNavGraphs", "false")
}
dependencies {
implementation(project(":architecture"))
implementation(project(":logging"))

View File

@@ -59,8 +59,10 @@ import gq.kirmanak.mealient.shopping_list.R
import gq.kirmanak.mealient.shopping_lists.ui.composables.getErrorMessage
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.components.BaseScreen
import gq.kirmanak.mealient.ui.components.LazyColumnWithLoadingState
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
import gq.kirmanak.mealient.ui.util.LoadingState
import gq.kirmanak.mealient.ui.util.data
import gq.kirmanak.mealient.ui.util.error
import gq.kirmanak.mealient.ui.util.map
@@ -71,7 +73,6 @@ data class ShoppingListNavArgs(
val shoppingListId: String,
)
@OptIn(ExperimentalMaterial3Api::class)
@Destination(
navArgsDelegate = ShoppingListNavArgs::class,
)
@@ -80,12 +81,50 @@ internal fun ShoppingListScreen(
shoppingListViewModel: ShoppingListViewModel = hiltViewModel(),
) {
val loadingState by shoppingListViewModel.loadingState.collectAsState()
BaseScreen { modifier ->
ShoppingListScreen(
modifier = modifier,
loadingState = loadingState,
errorToShowInSnackbar = shoppingListViewModel.errorToShowInSnackbar,
onSnackbarShown = shoppingListViewModel::onSnackbarShown,
onRefreshRequest = shoppingListViewModel::refreshShoppingList,
onAddItemClicked = shoppingListViewModel::onAddItemClicked,
onEditCancel = shoppingListViewModel::onEditCancel,
onEditConfirm = shoppingListViewModel::onEditConfirm,
onItemCheckedChange = shoppingListViewModel::onItemCheckedChange,
onDeleteItem = shoppingListViewModel::deleteShoppingListItem,
onEditStart = shoppingListViewModel::onEditStart,
onAddCancel = shoppingListViewModel::onAddCancel,
onAddConfirm = shoppingListViewModel::onAddConfirm,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ShoppingListScreen(
loadingState: LoadingState<ShoppingListScreenState>,
errorToShowInSnackbar: Throwable?,
onSnackbarShown: () -> Unit,
onRefreshRequest: () -> Unit,
onAddItemClicked: () -> Unit,
onEditCancel: (ShoppingListItemState.ExistingItem) -> Unit,
onEditConfirm: (ShoppingListItemState.ExistingItem, ShoppingListItemEditorState) -> Unit,
onItemCheckedChange: (ShoppingListItemState.ExistingItem, Boolean) -> Unit,
onDeleteItem: (ShoppingListItemState.ExistingItem) -> Unit,
onEditStart: (ShoppingListItemState.ExistingItem) -> Unit,
onAddCancel: (ShoppingListItemState.NewItem) -> Unit,
onAddConfirm: (ShoppingListItemState.NewItem) -> Unit,
modifier: Modifier = Modifier,
) {
val defaultEmptyListError = stringResource(
R.string.shopping_list_screen_empty_list,
loadingState.data?.name.orEmpty()
)
LazyColumnWithLoadingState(
modifier = modifier,
loadingState = loadingState.map { it.items },
emptyListError = loadingState.error?.let { getErrorMessage(it) } ?: defaultEmptyListError,
retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh),
@@ -96,11 +135,11 @@ internal fun ShoppingListScreen(
bottom = Dimens.Large * 4,
),
verticalArrangement = Arrangement.spacedBy(Dimens.Medium),
snackbarText = shoppingListViewModel.errorToShowInSnackbar?.let { getErrorMessage(error = it) },
onSnackbarShown = shoppingListViewModel::onSnackbarShown,
onRefresh = shoppingListViewModel::refreshShoppingList,
snackbarText = errorToShowInSnackbar?.let { getErrorMessage(error = it) },
onSnackbarShown = onSnackbarShown,
onRefresh = onRefreshRequest,
floatingActionButton = {
FloatingActionButton(onClick = shoppingListViewModel::onAddItemClicked) {
FloatingActionButton(onClick = onAddItemClicked) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(id = R.string.shopping_list_screen_add_icon_content_description),
@@ -122,31 +161,24 @@ internal fun ShoppingListScreen(
}
ShoppingListItemEditor(
state = state,
onEditCancelled = { shoppingListViewModel.onEditCancel(itemState) },
onEditConfirmed = {
shoppingListViewModel.onEditConfirm(
itemState,
state
)
}
onEditCancelled = { onEditCancel(itemState) },
onEditConfirmed = { onEditConfirm(itemState, state) },
)
} else {
ShoppingListItem(
itemState = itemState,
showDivider = index == firstCheckedItemIndex && index != 0,
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
onCheckedChange = {
shoppingListViewModel.onItemCheckedChange(itemState, it)
},
onDismissed = { shoppingListViewModel.deleteShoppingListItem(itemState) },
onEditStart = { shoppingListViewModel.onEditStart(itemState) },
onCheckedChange = { onItemCheckedChange(itemState, it) },
onDismissed = { onDeleteItem(itemState) },
onEditStart = { onEditStart(itemState) },
)
}
} else if (itemState is ShoppingListItemState.NewItem) {
ShoppingListItemEditor(
state = itemState.item,
onEditCancelled = { shoppingListViewModel.onAddCancel(itemState) },
onEditConfirmed = { shoppingListViewModel.onAddConfirm(itemState) }
onEditCancelled = { onAddCancel(itemState) },
onEditConfirmed = { onAddConfirm(itemState) }
)
}
}
@@ -493,7 +525,7 @@ fun ShoppingListItem(
}
true
}
)
),
) {
val shoppingListItem = itemState.item
SwipeToDismiss(

View File

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

View File

@@ -1,47 +0,0 @@
package gq.kirmanak.mealient.shopping_lists.ui.list
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.ui.ActivityUiStateController
import gq.kirmanak.mealient.ui.AppTheme
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

@@ -6,6 +6,8 @@ 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.material.icons.Icons
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
@@ -14,49 +16,55 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.navigate
import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse
import gq.kirmanak.mealient.shopping_list.R
import gq.kirmanak.mealient.shopping_lists.ui.composables.getErrorMessage
import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.components.BaseScreenState
import gq.kirmanak.mealient.ui.components.BaseScreenWithNavigation
import gq.kirmanak.mealient.ui.components.LazyColumnWithLoadingState
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
import gq.kirmanak.mealient.ui.util.error
@RootNavGraph(start = true)
@Destination(start = true)
@Destination
@Composable
fun ShoppingListsScreen(
navigator: DestinationsNavigator,
navController: NavController,
baseScreenState: BaseScreenState,
shoppingListsViewModel: ShoppingListsViewModel = hiltViewModel(),
) {
val loadingState by shoppingListsViewModel.loadingState.collectAsState()
val errorToShowInSnackbar = shoppingListsViewModel.errorToShowInSnackBar
LazyColumnWithLoadingState(
loadingState = loadingState,
emptyListError = loadingState.error?.let { getErrorMessage(it) }
?: stringResource(R.string.shopping_lists_screen_empty),
retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh),
snackbarText = errorToShowInSnackbar?.let { getErrorMessage(error = it) },
onSnackbarShown = shoppingListsViewModel::onSnackbarShown,
onRefresh = shoppingListsViewModel::refresh
) { items ->
items(items) { shoppingList ->
ShoppingListCard(
shoppingList = shoppingList,
onItemClick = { clickedEntity ->
val shoppingListId = clickedEntity.id
navigator.navigate(ShoppingListScreenDestination(shoppingListId))
}
)
BaseScreenWithNavigation(
baseScreenState = baseScreenState,
) { modifier ->
LazyColumnWithLoadingState(
modifier = modifier,
loadingState = loadingState,
emptyListError = loadingState.error?.let { getErrorMessage(it) }
?: stringResource(R.string.shopping_lists_screen_empty),
retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh),
snackbarText = errorToShowInSnackbar?.let { getErrorMessage(error = it) },
onSnackbarShown = shoppingListsViewModel::onSnackbarShown,
onRefresh = shoppingListsViewModel::refresh
) { items ->
items(items) { shoppingList ->
ShoppingListCard(
shoppingList = shoppingList,
onItemClick = { clickedEntity ->
val shoppingListId = clickedEntity.id
navController.navigate(ShoppingListScreenDestination(shoppingListId))
}
)
}
}
}
}
@@ -88,7 +96,7 @@ private fun ShoppingListCard(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(id = R.drawable.ic_shopping_cart),
imageVector = Icons.Default.ShoppingCart,
contentDescription = stringResource(id = R.string.shopping_lists_screen_cart_icon),
modifier = Modifier.height(Dimens.Large),
)

View File

@@ -1,10 +0,0 @@
<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>