diff --git a/apps/mobile-app/build.gradle.kts b/apps/mobile-app/build.gradle.kts index 809dc9a..3c69b9a 100644 --- a/apps/mobile-app/build.gradle.kts +++ b/apps/mobile-app/build.gradle.kts @@ -52,7 +52,7 @@ android { } composeOptions { - kotlinCompilerExtensionVersion = libs.versions.kotlin.compose.compiler.get() + kotlinCompilerExtensionVersion = libs.versions.kotlinComposeCompiler.get() } packaging { resources { diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/di/data_module.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/di/data_module.kt index 5368b8b..003119f 100644 --- a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/di/data_module.kt +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/di/data_module.kt @@ -12,6 +12,7 @@ import dev.marlonlom.apps.cappajv.core.database.datasource.LocalDataSourceImpl import dev.marlonlom.apps.cappajv.core.preferences.UserPreferencesRepository import dev.marlonlom.apps.cappajv.dataStore import dev.marlonlom.apps.cappajv.features.catalog_list.CatalogListRepository +import dev.marlonlom.apps.cappajv.features.catalog_search.CatalogSearchRepository import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import java.util.Locale @@ -22,7 +23,8 @@ val dataModule = module { LocalDataSourceImpl( catalogItemsDao = db.catalogProductsDao(), catalogPunctuationsDao = db.catalogPunctuationsDao(), - catalogFavoriteItemsDao = db.catalogFavoriteItemsDao() + catalogFavoriteItemsDao = db.catalogFavoriteItemsDao(), + catalogSearchDao = db.catalogSearchDao(), ) } } @@ -35,6 +37,11 @@ val dataModule = module { catalogDataService = get(), ) } + single { + CatalogSearchRepository( + localDataSource = get(), + ) + } single { UserPreferencesRepository(androidContext().dataStore) } diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/di/viewmodels_module.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/di/viewmodels_module.kt index fc03fdf..6f13477 100644 --- a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/di/viewmodels_module.kt +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/di/viewmodels_module.kt @@ -7,6 +7,7 @@ package dev.marlonlom.apps.cappajv.di import dev.marlonlom.apps.cappajv.features.catalog_detail.CatalogDetailViewModel import dev.marlonlom.apps.cappajv.features.catalog_list.CatalogListViewModel +import dev.marlonlom.apps.cappajv.features.catalog_search.CatalogSearchViewModel import dev.marlonlom.apps.cappajv.features.settings.SettingsViewModel import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.dsl.module @@ -14,6 +15,7 @@ import org.koin.dsl.module val viewModelsModule = module { includes(dataModule) viewModelOf(::CatalogListViewModel) + viewModelOf(::CatalogSearchViewModel) viewModelOf(::CatalogDetailViewModel) viewModelOf(::SettingsViewModel) } diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_list/CatalogListContent.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_list/CatalogListContent.kt index f329bca..5824d3a 100644 --- a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_list/CatalogListContent.kt +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_list/CatalogListContent.kt @@ -43,6 +43,12 @@ fun CatalogListContent( onCatalogItemSelected: (Long) -> Unit, ) { when { + + (appState.devicePosture is DevicePosture.Separating.Book).and(appState.isCompactHeight.not()) + .and(appState.isLandscape.not()) -> { + + } + appState.isLandscape.and(appState.devicePosture == DevicePosture.Normal).and(appState.isCompactHeight) -> { LandscapeCompactCatalogListScreen( appState = appState, diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_list/CatalogListRepository.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_list/CatalogListRepository.kt index 34ec28d..98b0f1c 100644 --- a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_list/CatalogListRepository.kt +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_list/CatalogListRepository.kt @@ -12,7 +12,6 @@ import dev.marlonlom.apps.cappajv.core.database.datasource.LocalDataSource import dev.marlonlom.apps.cappajv.core.database.entities.CatalogItem import dev.marlonlom.apps.cappajv.core.database.entities.CatalogPunctuation import dev.marlonlom.apps.cappajv.ui.util.slug -import dev.marlonlom.apps.cappajv.ui.util.toSentenceCase import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -28,6 +27,7 @@ import dev.marlonlom.apps.cappajv.core.catalog_source.CatalogItem as RemoteCatal * * @property localDataSource Local data source. * @property catalogDataService Catalog data service. + * @property coroutineDispatcher Coroutine dispatcher for this repository. */ class CatalogListRepository( private val localDataSource: LocalDataSource, @@ -96,7 +96,7 @@ private val RemoteCatalogItem.toEntity: CatalogItem id = id, title = title, slug = title.slug, - titleNormalized = title.toSentenceCase, + titleNormalized = title.slug.replace("-", " "), picture = picture, category = category, detail = detail, diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_list/CatalogListViewModel.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_list/CatalogListViewModel.kt index 105bdd5..fd36f38 100644 --- a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_list/CatalogListViewModel.kt +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_list/CatalogListViewModel.kt @@ -36,14 +36,4 @@ class CatalogListViewModel( /** UI state object for view model */ val uiState = repository.allProducts.stateIn(viewModelScope, SharingStarted.Eagerly, Loading) - companion object { - fun factory( - repository: CatalogListRepository - ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return CatalogListViewModel(repository) as T - } - } - } } diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_list/slots/CatalogListBanner.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_list/slots/CatalogListBanner.kt index 0e8e7c4..c168639 100644 --- a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_list/slots/CatalogListBanner.kt +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_list/slots/CatalogListBanner.kt @@ -47,12 +47,14 @@ import kotlinx.coroutines.yield * * @author marlonlom * - * @param modifier + * @param appState Application ui state. + * @param modifier Modifier for this composable. */ @ExperimentalFoundationApi @Composable fun CatalogListBanner( - appState: CappajvAppState, modifier: Modifier = Modifier + appState: CappajvAppState, + modifier: Modifier = Modifier, ) { val bannerImagesList = listOf( painterResource(R.drawable.img_catalog_home_banner_01), @@ -82,7 +84,7 @@ fun CatalogListBanner( .fillMaxWidth() ) { page -> BannerCard( - page = page, + pageIndex = page, pagerState = pagerState, bannerImage = bannerImagesList[page], ) @@ -98,10 +100,19 @@ fun CatalogListBanner( } +/** + * Banner card item content composable ui. + * + * @author marlonlom + * + * @param pageIndex Page index for displayed banner. + * @param pagerState Pager ui state for displayed banner. + * @param bannerImage Banner picture painter resource. + */ @ExperimentalFoundationApi @Composable private fun BannerCard( - page: Int, + pageIndex: Int, pagerState: PagerState, bannerImage: Painter ) { @@ -109,7 +120,7 @@ private fun BannerCard( onClick = {}, shape = MaterialTheme.shapes.large, modifier = Modifier.graphicsLayer { - val pageOffset = pagerState.currentPage.minus(page).plus(pagerState.currentPageOffsetFraction) + val pageOffset = pagerState.currentPage.minus(pageIndex).plus(pagerState.currentPageOffsetFraction) lerp( start = 0.85f, @@ -136,6 +147,15 @@ private fun BannerCard( } } +/** + * Horizontal pager indicator composable ui. + * + * @author marlonlom + * + * @param activeColor Color that indicates the active displayed banner. + * @param pagerState Pager ui state for displayed banner. + * @param modifier Modifier for this composable. + */ @ExperimentalFoundationApi @Composable fun HorizontalPagerIndicator( diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchRepository.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchRepository.kt new file mode 100644 index 0000000..e080052 --- /dev/null +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchRepository.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.features.catalog_search + +import dev.marlonlom.apps.cappajv.core.database.datasource.LocalDataSource + +/** + * Catalog search repository. + * + * @author marlonlom + * + * @property localDataSource Local data source. + */ +class CatalogSearchRepository( + private val localDataSource: LocalDataSource, +) { + + /** + * Perform search using provided text. + * + * @param searchText Query text. + */ + fun performSearch(searchText: String) = localDataSource.searchProducts("%$searchText%") + +} diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchRoute.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchRoute.kt new file mode 100644 index 0000000..403003c --- /dev/null +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchRoute.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.features.catalog_search + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.marlonlom.apps.cappajv.features.catalog_search.screens.CatalogSearchRouteScreen +import dev.marlonlom.apps.cappajv.ui.main.CappajvAppState +import org.koin.androidx.compose.koinViewModel +import timber.log.Timber + +/** + * Catalog search route composable ui. + * + * @author marlonlom + * + * @param appState Application ui state. + * @param viewModel Catalog search viewmodel. + */ +@ExperimentalFoundationApi +@Composable +fun CatalogSearchRoute( + appState: CappajvAppState, + viewModel: CatalogSearchViewModel = koinViewModel(), +) { + val queryText = rememberSaveable { viewModel.queryText } + val showClearIcon = remember { + derivedStateOf { viewModel.queryText.value.isNotEmpty() } + } + val searchResultState by viewModel.searchResult.collectAsStateWithLifecycle() + CatalogSearchRouteScreen( + appState = appState, + queryText = queryText, + showClearIcon = showClearIcon, + onSearchReady = viewModel::onQueryTextChanged, + searchResultUiState = searchResultState, + onSearchedItemClicked = { + Timber.d("[CatalogSearchRoute] clicked item[$it] ") + }, + ) +} diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchUiState.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchUiState.kt new file mode 100644 index 0000000..e9937c4 --- /dev/null +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchUiState.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.features.catalog_search + +import dev.marlonlom.apps.cappajv.core.database.entities.CatalogItemTuple + +/** + * Catalog search ui state. + * + * @author marlonlom + */ +sealed class CatalogSearchUiState { + + /** + * Default catalog search ui state. + * + * @author marlonlom + * + */ + data object None : CatalogSearchUiState() + + /** + * Searching phase of catalog search ui state. + * + * @author marlonlom + * + */ + data object Searching : CatalogSearchUiState() + + /** + * Empty results phase of catalog search ui state. + * + * @author marlonlom + * + */ + data object Empty : CatalogSearchUiState() + + /** + * Success result phase of catalog search ui state. + * + * @author marlonlom + * + * @property results Catalog tuples result list. + */ + data class Success( + val results: List + ) : CatalogSearchUiState() +} diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchViewModel.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchViewModel.kt new file mode 100644 index 0000000..4ba6e88 --- /dev/null +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchViewModel.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.features.catalog_search + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.marlonlom.apps.cappajv.features.catalog_search.CatalogSearchUiState.Empty +import dev.marlonlom.apps.cappajv.features.catalog_search.CatalogSearchUiState.None +import dev.marlonlom.apps.cappajv.features.catalog_search.CatalogSearchUiState.Searching +import dev.marlonlom.apps.cappajv.features.catalog_search.CatalogSearchUiState.Success +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Catalog search viewmodel. + * + * @author marlonlom + * + * @property repository Catalog search repository. + */ +class CatalogSearchViewModel( + private val repository: CatalogSearchRepository +) : ViewModel() { + var queryText = mutableStateOf("") + private set + + private val _searchResult = MutableStateFlow(None) + + val searchResult = _searchResult.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = None + ) + + /** Handles query text value change. */ + fun onQueryTextChanged() { + _searchResult.value = if (queryText.value.isNotEmpty()) Searching else None + if (_searchResult.value is None) return + viewModelScope.launch { + _searchResult.update { + val searchResults = repository.performSearch(queryText.value).first() + if (searchResults.isEmpty()) Empty else Success(searchResults) + } + } + } + +} diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/SearchProductsRoute.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/SearchProductsRoute.kt deleted file mode 100644 index b1f3ca7..0000000 --- a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/SearchProductsRoute.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2024 Marlonlom - * SPDX-License-Identifier: Apache-2.0 - */ - -package dev.marlonlom.apps.cappajv.features.catalog_search - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.paddingFromBaseline -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import dev.marlonlom.apps.cappajv.ui.main.CappajvAppState - -@Composable -fun SearchProductsRoute( - appState: CappajvAppState, -) { - val contentHorizontalPadding = when { - appState.isLandscape.not().and(appState.isMediumWidth) -> 40.dp - appState.isLandscape.not().and(appState.isExpandedWidth) -> 80.dp - else -> 20.dp - } - Column( - modifier = Modifier - .fillMaxWidth() - .padding(contentHorizontalPadding) - ) { - Text( - modifier = Modifier - .fillMaxWidth() - .paddingFromBaseline(top = 40.dp, bottom = 20.dp), - text = "Favorite products", - style = MaterialTheme.typography.headlineLarge - ) - - } -} diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/parts/CatalogSearchHeadline.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/parts/CatalogSearchHeadline.kt new file mode 100644 index 0000000..842c218 --- /dev/null +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/parts/CatalogSearchHeadline.kt @@ -0,0 +1,66 @@ +package dev.marlonlom.apps.cappajv.features.catalog_search.parts + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.marlonlom.apps.cappajv.R +import dev.marlonlom.apps.cappajv.ui.layout.DevicePosture +import dev.marlonlom.apps.cappajv.ui.main.CappajvAppState + +/** + * Catalog search headline composable ui. + * + * @author marlonlom + * + * @param appState Application ui state. + * @param modifier Modifier for this composable. + */ +@Composable +fun CatalogSearchHeadline( + appState: CappajvAppState, + modifier: Modifier = Modifier, +) { + + val rowPaddingValues = when { + appState.isCompactHeight -> PaddingValues(vertical = 20.dp) + else -> PaddingValues(top = 40.dp, bottom = 20.dp) + } + val titleTextStyle = when { + appState.isCompactHeight -> MaterialTheme.typography.headlineSmall + else -> MaterialTheme.typography.headlineMedium + } + + val maxTitleLines = when { + appState.isLandscape.and(appState.devicePosture == DevicePosture.Normal) -> 2 + appState.isLandscape.and(appState.devicePosture is DevicePosture.Separating.Book) -> 2 + else -> 1 + } + + Row( + modifier = modifier + .background(MaterialTheme.colorScheme.surface) + .fillMaxWidth() + .padding(rowPaddingValues), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + modifier = Modifier.fillMaxWidth(0.75f), + text = stringResource(R.string.text_catalog_search_title), + style = titleTextStyle, + fontWeight = FontWeight.Bold, + maxLines = maxTitleLines + ) + } +} diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/parts/CatalogSearchedItemCard.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/parts/CatalogSearchedItemCard.kt new file mode 100644 index 0000000..2b572bd --- /dev/null +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/parts/CatalogSearchedItemCard.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.features.catalog_search.parts + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.layout.size +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import dev.marlonlom.apps.cappajv.core.database.entities.CatalogItemTuple + +/** + * Catalog searched item card composable ui. + * + * @author marlonlom + * + * @param row + * @param onSearchedItemClicked + * @param modifier Modifier for this composable. + * + */ +@Composable +fun CatalogSearchedItemCard( + row: CatalogItemTuple, + onSearchedItemClicked: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedCard( + onClick = { + onSearchedItemClicked(row.id) + }, + border = BorderStroke(0.dp, Color.Transparent), + modifier = modifier.fillMaxWidth(), + shape = CardDefaults.outlinedShape, + colors = CardDefaults.outlinedCardColors(), + ) { + Row( + modifier = modifier.padding( + horizontal = 10.dp, vertical = 4.dp + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + val imageRequest = ImageRequest.Builder(LocalContext.current) + .data(row.picture).crossfade(true).build() + + AsyncImage( + model = imageRequest, + contentDescription = row.title, + contentScale = ContentScale.Crop, + modifier = modifier + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.secondary, + shape = MaterialTheme.shapes.medium, + ) + .clip(MaterialTheme.shapes.medium) + .size(64.dp) + .background(Color.White), + ) + + Column { + Text( + text = row.title, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = row.category, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } +} diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/screens/CatalogSearchRouteScreen.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/screens/CatalogSearchRouteScreen.kt new file mode 100644 index 0000000..1205b8e --- /dev/null +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/screens/CatalogSearchRouteScreen.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.features.catalog_search.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import dev.marlonlom.apps.cappajv.features.catalog_search.CatalogSearchUiState +import dev.marlonlom.apps.cappajv.ui.layout.DevicePosture +import dev.marlonlom.apps.cappajv.ui.main.CappajvAppState +import dev.marlonlom.apps.cappajv.ui.navigation.NavigationType + +/** + * Catalog search route screen content composable ui. + * + * @author marlonlom + * + * @param appState Application ui state. + * @param queryText Query text for searching. + * @param showClearIcon True/False if query text should be cleared. + * @param onSearchReady Action for query text ready for search. + * @param searchResultUiState Catalog search results ui state. + * @param onSearchedItemClicked Action for searched item clicked. + */ +@ExperimentalFoundationApi +@Composable +fun CatalogSearchRouteScreen( + appState: CappajvAppState, + queryText: MutableState, + showClearIcon: State, + onSearchReady: () -> Unit, + searchResultUiState: CatalogSearchUiState, + onSearchedItemClicked: (Long) -> Unit, +) { + when { + appState.isLandscape + .and(appState.devicePosture is DevicePosture.Separating.Book) + .and(appState.navigationType == NavigationType.NAVIGATION_RAIL) -> { + LandscapeTwoPaneCatalogSearchScreen( + appState = appState, + queryText = queryText, + showClearIcon = showClearIcon, + onSearchReady = onSearchReady, + searchResultUiState = searchResultUiState, + onSearchedItemClicked = onSearchedItemClicked + ) + } + + appState.isLandscape + .and(appState.devicePosture == DevicePosture.Normal) + .and(appState.navigationType == NavigationType.NAVIGATION_RAIL) -> { + LandscapeTwoPaneCatalogSearchScreen( + appState = appState, + queryText = queryText, + showClearIcon = showClearIcon, + onSearchReady = onSearchReady, + searchResultUiState = searchResultUiState, + onSearchedItemClicked = onSearchedItemClicked + ) + } + + else -> { + DefaultPortraitCatalogSearchScreen( + appState = appState, + queryText = queryText, + showClearIcon = showClearIcon, + onSearchReady = onSearchReady, + searchResultUiState = searchResultUiState, + onSearchedItemClicked = onSearchedItemClicked + ) + } + } +} diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/screens/DefaultPortraitCatalogSearchScreen.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/screens/DefaultPortraitCatalogSearchScreen.kt new file mode 100644 index 0000000..4c88468 --- /dev/null +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/screens/DefaultPortraitCatalogSearchScreen.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.features.catalog_search.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.marlonlom.apps.cappajv.features.catalog_search.CatalogSearchUiState +import dev.marlonlom.apps.cappajv.features.catalog_search.parts.CatalogSearchHeadline +import dev.marlonlom.apps.cappajv.features.catalog_search.slots.CatalogSearchInputSlot +import dev.marlonlom.apps.cappajv.features.catalog_search.slots.CatalogSearchResultsSlot +import dev.marlonlom.apps.cappajv.ui.main.CappajvAppState + +/** + * Default portrait catalog search screen composable ui. + * + * @author marlonlom + * + * @param appState Application ui state. + * @param queryText Query text for searching. + * @param showClearIcon True/False if query text should be cleared. + * @param onSearchReady Action for query text ready for search. + * @param searchResultUiState Catalog search results ui state. + * @param onSearchedItemClicked Action for searched item clicked. + */ +@ExperimentalFoundationApi +@Composable +internal fun DefaultPortraitCatalogSearchScreen( + appState: CappajvAppState, + queryText: MutableState, + showClearIcon: State, + onSearchReady: () -> Unit, + searchResultUiState: CatalogSearchUiState, + onSearchedItemClicked: (Long) -> Unit +) { + val contentHorizontalPadding = when { + appState.isLandscape.not().and(appState.isMediumWidth) -> 40.dp + appState.isLandscape.not().and(appState.isExpandedWidth) -> 80.dp + else -> 20.dp + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(contentHorizontalPadding) + ) { + CatalogSearchHeadline(appState) + CatalogSearchInputSlot( + appState = appState, + queryText = queryText, + showClearIcon = showClearIcon, + onSearchReady = onSearchReady, + ) + CatalogSearchResultsSlot( + appState = appState, + searchResultUiState = searchResultUiState, + onSearchedItemClicked = onSearchedItemClicked, + ) + } +} diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/screens/LandscapeTwoPaneCatalogSearchScreen.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/screens/LandscapeTwoPaneCatalogSearchScreen.kt new file mode 100644 index 0000000..07e578a --- /dev/null +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/screens/LandscapeTwoPaneCatalogSearchScreen.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.features.catalog_search.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.marlonlom.apps.cappajv.features.catalog_search.CatalogSearchUiState +import dev.marlonlom.apps.cappajv.features.catalog_search.parts.CatalogSearchHeadline +import dev.marlonlom.apps.cappajv.features.catalog_search.slots.CatalogSearchInputSlot +import dev.marlonlom.apps.cappajv.features.catalog_search.slots.CatalogSearchResultsSlot +import dev.marlonlom.apps.cappajv.ui.main.CappajvAppState + +/** + * Landscape two-pane catalog search screen composable ui. + * + * @author marlonlom + * + * @param appState Application ui state. + * @param queryText Query text for searching. + * @param showClearIcon True/False if query text should be cleared. + * @param onSearchReady Action for query text ready for search. + * @param searchResultUiState Catalog search results ui state. + * @param onSearchedItemClicked Action for searched item clicked. + */ +@ExperimentalFoundationApi +@Composable +internal fun LandscapeTwoPaneCatalogSearchScreen( + appState: CappajvAppState, + queryText: MutableState, + showClearIcon: State, + onSearchReady: () -> Unit, + searchResultUiState: CatalogSearchUiState, + onSearchedItemClicked: (Long) -> Unit +) { + Row { + Column( + modifier = Modifier + .fillMaxWidth(0.45f) + .fillMaxHeight() + .padding(horizontal = 20.dp), + ) { + CatalogSearchHeadline(appState) + CatalogSearchInputSlot( + appState = appState, + queryText = queryText, + showClearIcon = showClearIcon, + onSearchReady = onSearchReady, + ) + CatalogSearchResultsSlot( + appState = appState, + searchResultUiState = searchResultUiState, + onSearchedItemClicked = onSearchedItemClicked, + ) + } + Column( + modifier = Modifier + .background(MaterialTheme.colorScheme.primaryContainer) + .fillMaxSize() + .safeContentPadding(), + ) { + Text(text = "Detail") + } + } +} + diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogEmptyResultsSlot.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogEmptyResultsSlot.kt new file mode 100644 index 0000000..031e2eb --- /dev/null +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogEmptyResultsSlot.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.features.catalog_search.slots + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.marlonlom.apps.cappajv.R +import dev.marlonlom.apps.cappajv.ui.main.CappajvAppState + +/** + * Catalog empty result slot composable ui. + * + * @author marlonlom + * + * @param modifier Modifier for this composable. + * + */ +@Composable +fun CatalogEmptyResultsSlot( + appState: CappajvAppState, + modifier: Modifier = Modifier, +) { + val imageSize = when { + appState.isCompactHeight -> 72.dp + else -> 120.dp + } + Column( + modifier = modifier + .fillMaxSize() + .padding(20.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.img_catalog_search_empty), + contentDescription = null, + modifier = modifier.size(imageSize), + contentScale = ContentScale.FillBounds + ) + Text( + modifier = modifier + .fillMaxWidth() + .padding(20.dp), + text = stringResource(R.string.text_catalog_search_empty_subtitle), + textAlign = TextAlign.Center, + ) + } +} diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogSearchInputSlot.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogSearchInputSlot.kt new file mode 100644 index 0000000..170eb15 --- /dev/null +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogSearchInputSlot.kt @@ -0,0 +1,127 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.features.catalog_search.slots + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.Clear +import androidx.compose.material.icons.twotone.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import dev.marlonlom.apps.cappajv.R +import dev.marlonlom.apps.cappajv.ui.main.CappajvAppState + +/** + * Catalog search input slot composable ui. + * + * @author marlonlom + * + * @param appState Application ui state. + * @param queryText Query text for searching. + * @param showClearIcon True/False if query text should be cleared. + * @param onSearchReady Action for query text ready for search. + * @param modifier Modifier for this composable. + */ +@Composable +fun CatalogSearchInputSlot( + appState: CappajvAppState, + queryText: MutableState, + showClearIcon: State, + onSearchReady: () -> Unit, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + val textFieldColors = getSearchSlotTextFieldColors() + val focusManager = LocalFocusManager.current + + OutlinedTextField( + value = queryText.value, + onValueChange = { queryText.value = it }, + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .heightIn(min = 56.dp), + singleLine = true, + shape = MaterialTheme.shapes.medium, + colors = textFieldColors, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Search + ), + keyboardActions = KeyboardActions( + onSearch = { + keyboardController?.hide() + focusManager.clearFocus() + onSearchReady() + }), + leadingIcon = { + Icon( + imageVector = Icons.TwoTone.Search, + contentDescription = "Search icon" + ) + }, + trailingIcon = { + if (showClearIcon.value) { + IconButton(onClick = { + queryText.value = "" + focusManager.clearFocus() + onSearchReady() + }) { + Icon( + imageVector = Icons.TwoTone.Clear, + contentDescription = "Clear icon" + ) + } + } + }, + placeholder = { + Text(stringResource(R.string.text_catalog_search_placeholder)) + }, + ) +} + + +@Composable +private fun getSearchSlotTextFieldColors() = OutlinedTextFieldDefaults.colors( + cursorColor = MaterialTheme.colorScheme.onSurface, + errorBorderColor = MaterialTheme.colorScheme.onSurface, + errorContainerColor = MaterialTheme.colorScheme.surface, + errorCursorColor = MaterialTheme.colorScheme.error, + errorLabelColor = MaterialTheme.colorScheme.onSurface, + errorLeadingIconColor = MaterialTheme.colorScheme.onSurface, + errorTextColor = MaterialTheme.colorScheme.onSurface, + errorTrailingIconColor = MaterialTheme.colorScheme.onSurface, + focusedBorderColor = MaterialTheme.colorScheme.primary, + focusedContainerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f), + focusedLabelColor = MaterialTheme.colorScheme.primary, + focusedLeadingIconColor = MaterialTheme.colorScheme.primary, + focusedTextColor = MaterialTheme.colorScheme.primary, + focusedTrailingIconColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.onSurface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedLabelColor = MaterialTheme.colorScheme.onSurface, + unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurface, +) diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogSearchResultsSlot.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogSearchResultsSlot.kt new file mode 100644 index 0000000..6dfcea8 --- /dev/null +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogSearchResultsSlot.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.features.catalog_search.slots + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.runtime.Composable +import dev.marlonlom.apps.cappajv.features.catalog_search.CatalogSearchUiState +import dev.marlonlom.apps.cappajv.ui.main.CappajvAppState + +/** + * Catalog search results slot composable ui. + * + * @author marlonlom + * + * @param appState Application ui state. + * @param searchResultUiState Catalog search result ui state. + */ +@ExperimentalFoundationApi +@Composable +fun CatalogSearchResultsSlot( + appState: CappajvAppState, + searchResultUiState: CatalogSearchUiState, + onSearchedItemClicked: (Long) -> Unit, +) { + when (searchResultUiState) { + CatalogSearchUiState.None -> CatalogSearchWelcomeSlot(appState) + + CatalogSearchUiState.Searching -> CatalogSearchingSlot() + + CatalogSearchUiState.Empty -> CatalogEmptyResultsSlot(appState=appState) + + is CatalogSearchUiState.Success -> CatalogSuccessResultsSlot( + searchResults = searchResultUiState.results, + onSearchedItemClicked = onSearchedItemClicked + ) + } +} diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogSearchWelcomeSlot.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogSearchWelcomeSlot.kt new file mode 100644 index 0000000..2192f10 --- /dev/null +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogSearchWelcomeSlot.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.features.catalog_search.slots + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import dev.marlonlom.apps.cappajv.R +import dev.marlonlom.apps.cappajv.ui.main.CappajvAppState + + +@Composable +fun CatalogSearchWelcomeSlot(appState: CappajvAppState) { + val imageSize = when { + appState.isCompactHeight -> 72.dp + else -> 120.dp + } + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.img_catalog_search_welcome), + contentDescription = null, + modifier = Modifier.size(imageSize), + contentScale = ContentScale.FillBounds + ) + + val annotatedString = buildAnnotatedString { + withStyle( + SpanStyle( + fontStyle = MaterialTheme.typography.titleLarge.fontStyle, + fontWeight = FontWeight.Bold, + ) + ) { + append(stringResource(R.string.text_catalog_search_default_subtitle)) + } + append("\n") + append(stringResource(R.string.text_catalog_search_default_detail)) + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + text = annotatedString, + textAlign = TextAlign.Center, + ) + } +} diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogSearchingSlot.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogSearchingSlot.kt new file mode 100644 index 0000000..0a29992 --- /dev/null +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogSearchingSlot.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.features.catalog_search.slots + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Catalog searching indicator slot. + * + * @author marlonlom + * + */ +@Composable +fun CatalogSearchingSlot() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + } +} diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogSuccessResultsSlot.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogSuccessResultsSlot.kt new file mode 100644 index 0000000..92b1637 --- /dev/null +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/slots/CatalogSuccessResultsSlot.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.features.catalog_search.slots + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.paddingFromBaseline +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.marlonlom.apps.cappajv.R +import dev.marlonlom.apps.cappajv.core.database.entities.CatalogItemTuple +import dev.marlonlom.apps.cappajv.features.catalog_search.parts.CatalogSearchedItemCard + +/** + * Catalog success results slot composable ui. + * + * @author marlonlom + * + * @param searchResults Catalog searched items list. + * @param onSearchedItemClicked Action for searched item clicked. + * @param modifier Modifier for this composable. + * + */ +@ExperimentalFoundationApi +@Composable +fun CatalogSuccessResultsSlot( + searchResults: List, + onSearchedItemClicked: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(top = 20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + stickyHeader { + Text( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .paddingFromBaseline(top = 20.dp, bottom = 20.dp), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + text = stringResource(R.string.text_catalog_search_success_subtitle, searchResults.size), + style = MaterialTheme.typography.titleMedium + ) + } + + items( + items = searchResults, + key = CatalogItemTuple::id, + ) { row -> + CatalogSearchedItemCard( + row = row, + onSearchedItemClicked = onSearchedItemClicked + ) + } + } +} diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/settings/SettingsViewModel.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/settings/SettingsViewModel.kt index 6cb4078..e4a390b 100644 --- a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/settings/SettingsViewModel.kt +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/features/settings/SettingsViewModel.kt @@ -6,7 +6,6 @@ package dev.marlonlom.apps.cappajv.features.settings import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import dev.marlonlom.apps.cappajv.core.preferences.UserPreferencesRepository import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly @@ -54,23 +53,6 @@ class SettingsViewModel( } } - companion object { - - /** - * Provides a factory for creating an instance for view model. - * - * @param repository Catalog settings repository dependency. - * @return - */ - fun factory( - repository: UserPreferencesRepository - ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return SettingsViewModel(repository) as T - } - } - } } /** diff --git a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/ui/navigation/MainNavHost.kt b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/ui/navigation/MainNavHost.kt index c7a6e73..3367f26 100644 --- a/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/ui/navigation/MainNavHost.kt +++ b/apps/mobile-app/src/main/kotlin/dev/marlonlom/apps/cappajv/ui/navigation/MainNavHost.kt @@ -14,7 +14,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import dev.marlonlom.apps.cappajv.features.catalog_favorites.FavoriteProductsRoute import dev.marlonlom.apps.cappajv.features.catalog_list.CatalogListRoute -import dev.marlonlom.apps.cappajv.features.catalog_search.SearchProductsRoute +import dev.marlonlom.apps.cappajv.features.catalog_search.CatalogSearchRoute import dev.marlonlom.apps.cappajv.ui.main.AppContentCallbacks import dev.marlonlom.apps.cappajv.ui.main.CappajvAppState @@ -84,10 +84,11 @@ internal fun NavGraphBuilder.catalogFavoritesDestination( * * @param appState Application ui state. */ +@ExperimentalFoundationApi internal fun NavGraphBuilder.catalogSearchDestination( appState: CappajvAppState, ) { composable(CatalogDestination.SearchProducts.route) { - SearchProductsRoute(appState) + CatalogSearchRoute(appState) } } diff --git a/apps/mobile-app/src/main/res/drawable/img_catalog_search_empty.webp b/apps/mobile-app/src/main/res/drawable/img_catalog_search_empty.webp new file mode 100644 index 0000000..1486189 Binary files /dev/null and b/apps/mobile-app/src/main/res/drawable/img_catalog_search_empty.webp differ diff --git a/apps/mobile-app/src/main/res/drawable/img_catalog_search_welcome.webp b/apps/mobile-app/src/main/res/drawable/img_catalog_search_welcome.webp new file mode 100644 index 0000000..e473b9f Binary files /dev/null and b/apps/mobile-app/src/main/res/drawable/img_catalog_search_welcome.webp differ diff --git a/apps/mobile-app/src/main/res/values-es/strings.xml b/apps/mobile-app/src/main/res/values-es/strings.xml index f10d828..5b93824 100644 --- a/apps/mobile-app/src/main/res/values-es/strings.xml +++ b/apps/mobile-app/src/main/res/values-es/strings.xml @@ -35,6 +35,13 @@ Bebidas calientes Pastelería + + Buscar en el catálogo + Buscar + Explora el catálogo + Busca artículos de catálogo para comprar en tiendas. + No se encontró ningún artículo del catálogo.\nInténtelo de nuevo. + Artículos encontrados: %1$s Tema oscuro Colores dinámicos diff --git a/apps/mobile-app/src/main/res/values/strings.xml b/apps/mobile-app/src/main/res/values/strings.xml index f160764..e4484dc 100644 --- a/apps/mobile-app/src/main/res/values/strings.xml +++ b/apps/mobile-app/src/main/res/values/strings.xml @@ -36,6 +36,12 @@ Hot drinks Pastry + + Search the catalog + Search + Explore the catalog + Look for catalog items to buy in stores. + No catalog item was found.\nTry again. Dark theme Dynamic colors @@ -47,4 +53,5 @@ https://juanvaldez.com/politica-de-tratamiento-de-datos-personales-promotora-de-cafe-colombia-s-a https://juanvaldez.com/politica-de-tratamiento-de-datos-personales-promotora-de-cafe-colombia-s-a https://juanvaldez.com/amigosjuanvaldez/terminos-y-condiciones + Found items: %1$s diff --git a/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/FakeLocalDataSource.kt b/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/FakeLocalDataSource.kt index abf8252..b774fa1 100644 --- a/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/FakeLocalDataSource.kt +++ b/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/FakeLocalDataSource.kt @@ -73,6 +73,41 @@ internal class FakeLocalDataSource( } override fun getFavorites(): Flow> = flowOf(localFavoriteItems) + override fun searchProducts(searchText: String): Flow> { + val listResponse = remoteDataService.fetchData() + .successOr(emptyList()) + .map { + CatalogItem( + id = it.id, + title = it.title, + slug = it.title.slug, + titleNormalized = it.title.slug.replace("-", " "), + picture = it.picture, + category = "Category one", + detail = "Lorem ipsum", + samplePunctuation = "", + punctuationsCount = it.punctuations.size - 1, + ) + } + + val itemTuples: List = listResponse.filter { + val queryingText = searchText.lowercase().replace("%", "").trim() + it.title.lowercase().contains(queryingText).or( + it.titleNormalized.lowercase().contains(queryingText) + ) + }.map { + CatalogItemTuple( + id = it.id, + title = it.title, + picture = it.picture, + category = "Category one", + samplePunctuation = "", + punctuationsCount = it.punctuationsCount, + ) + } + + return flowOf(itemTuples) + } override fun insertAllProducts(vararg products: CatalogItem) = Unit diff --git a/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchRepositoryTest.kt b/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchRepositoryTest.kt new file mode 100644 index 0000000..3a24228 --- /dev/null +++ b/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchRepositoryTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.features.catalog_search + +import dev.marlonlom.apps.cappajv.core.catalog_source.CatalogDataService +import dev.marlonlom.apps.cappajv.core.database.FakeLocalDataSource +import kotlinx.coroutines.runBlocking +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import java.util.Locale + +internal class CatalogSearchRepositoryTest { + + private lateinit var repository: CatalogSearchRepository + + @Before + fun setUp() { + repository = CatalogSearchRepository( + localDataSource = FakeLocalDataSource( + CatalogDataService(Locale.getDefault().language) + ), + ) + } + + @Test + fun `Should success after searching product by title`() = runBlocking { + val expectedTitle = "torta" + repository.performSearch(expectedTitle).collect { list -> + Assert.assertNotNull(list) + Assert.assertTrue(list.isNotEmpty()) + Assert.assertEquals(3, list.filter { it.title.lowercase().contains(expectedTitle) }.size) + } + } + + @Test + fun `Should fail after searching product by title`() = runBlocking { + val expectedTitle = "chamfle" + repository.performSearch(expectedTitle).collect { list -> + Assert.assertNotNull(list) + Assert.assertFalse(list.map { it.title.lowercase() }.contains(expectedTitle)) + Assert.assertTrue(list.isEmpty()) + } + } + +} diff --git a/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchViewModelTest.kt b/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchViewModelTest.kt new file mode 100644 index 0000000..07243de --- /dev/null +++ b/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/features/catalog_search/CatalogSearchViewModelTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.features.catalog_search + +import dev.marlonlom.apps.cappajv.core.catalog_source.CatalogDataService +import dev.marlonlom.apps.cappajv.core.database.FakeLocalDataSource +import dev.marlonlom.apps.cappajv.util.MainDispatcherRule +import dev.marlonlom.apps.cappajv.util.RethrowingExceptionHandler +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.MockitoAnnotations +import java.util.Locale + +@ExperimentalCoroutinesApi +internal class CatalogSearchViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val throwRule = RethrowingExceptionHandler() + + private val fakeLocalDataSource = FakeLocalDataSource( + CatalogDataService(Locale.getDefault().language) + ) + + private lateinit var viewModel: CatalogSearchViewModel + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + viewModel = CatalogSearchViewModel(CatalogSearchRepository(fakeLocalDataSource)) + } + + @Test + fun `Should return default search ui state`() = runTest { + viewModel.onQueryTextChanged() + val uiState = viewModel.searchResult.first() + assertTrue(viewModel.queryText.value.isEmpty()) + assertEquals(CatalogSearchUiState.None, uiState) + } + + @Test + fun `Should success after searching product by title`() = runTest { + val expectedTitle = "torta" + viewModel.queryText.value = expectedTitle + viewModel.onQueryTextChanged() + val uiState = viewModel.searchResult.first() + assertNotNull(uiState) + when (uiState) { + is CatalogSearchUiState.Success -> { + assertTrue(uiState.results.isNotEmpty()) + assertEquals( + 3, + uiState.results.filter { + it.title.lowercase().contains(expectedTitle) + }.size + ) + } + + else -> fail() + } + } + + @Test + fun `Should fail after searching product by title`() = runTest { + val expectedTitle = "chamfle" + viewModel.queryText.value = expectedTitle + viewModel.onQueryTextChanged() + viewModel.searchResult.collectLatest { uiState -> + assertNotNull(uiState) + assertTrue(uiState == CatalogSearchUiState.Empty) + } + } +} diff --git a/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/features/settings/SettingsViewModelTest.kt b/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/features/settings/SettingsViewModelTest.kt index 0ab618c..395b1fb 100644 --- a/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/features/settings/SettingsViewModelTest.kt +++ b/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/features/settings/SettingsViewModelTest.kt @@ -5,18 +5,25 @@ package dev.marlonlom.apps.cappajv.features.settings +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.mutablePreferencesOf import dev.marlonlom.apps.cappajv.core.preferences.UserPreferencesRepository -import dev.marlonlom.apps.cappajv.core.preferences.UserSettings import dev.marlonlom.apps.cappajv.util.MainDispatcherRule +import dev.marlonlom.apps.cappajv.util.RethrowingExceptionHandler import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest -import org.junit.Assert +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Rule import org.junit.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.`when` +import org.mockito.kotlin.mock @ExperimentalCoroutinesApi internal class SettingsViewModelTest { @@ -24,29 +31,56 @@ internal class SettingsViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() + @get:Rule + val throwRule = RethrowingExceptionHandler() + private lateinit var viewModel: SettingsViewModel @Test - fun `Should return valid settings from local storage`() = runTest { - val mockPreferencesRepository = mock(UserPreferencesRepository::class.java) - `when`(mockPreferencesRepository.userPreferencesFlow).thenReturn( - flowOf( - UserSettings( - useDarkTheme = false, - useDynamicColor = true, - isOnboarding = true + fun `Should return sampled settings from local storage`() = runTest { + val mockDataStore = mock> { + on(it.data).thenReturn( + flowOf( + mutablePreferencesOf( + booleanPreferencesKey("dark_theme") to true, + booleanPreferencesKey("dynamic_colors") to true, + booleanPreferencesKey("is_onboarding") to false + ) ) ) - ) + } + viewModel = SettingsViewModel(UserPreferencesRepository(mockDataStore)) + val uiState = viewModel.uiState.first() + assertNotNull(uiState) + when (uiState) { + is SettingsUiState.Success -> { + assertNotNull(uiState.settings) + assertTrue(uiState.settings.useDarkTheme) + assertTrue(uiState.settings.useDynamicColor) + assertFalse(uiState.settings.isOnboarding) + } + + else -> fail() + } + } - viewModel = SettingsViewModel(mockPreferencesRepository) + @Test + fun `Should return default settings from local storage`() = runTest { + val mockDataStore = mock> { + on(it.data).thenReturn(flowOf(emptyPreferences())) + } + viewModel = SettingsViewModel(UserPreferencesRepository(mockDataStore)) + val uiState = viewModel.uiState.first() + assertNotNull(uiState) + when (uiState) { + is SettingsUiState.Success -> { + assertNotNull(uiState.settings) + assertFalse(uiState.settings.useDarkTheme) + assertTrue(uiState.settings.useDynamicColor) + assertTrue(uiState.settings.isOnboarding) + } - try { - val state = viewModel.uiState.first() - Assert.assertNotNull(state) - } catch (e: Exception) { - println(e.message) - Assert.fail() + else -> fail() } } } diff --git a/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/util/RethrowingExceptionHandler.kt b/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/util/RethrowingExceptionHandler.kt new file mode 100644 index 0000000..3f5907e --- /dev/null +++ b/apps/mobile-app/src/test/kotlin/dev/marlonlom/apps/cappajv/util/RethrowingExceptionHandler.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.util + +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class RethrowingExceptionHandler : TestRule, Thread.UncaughtExceptionHandler { + override fun uncaughtException( + thread: Thread, + throwable: Throwable + ): Nothing = throw UncaughtException(throwable) + + override fun apply(base: Statement, description: Description): Statement { + return object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + } + } + } +} + +internal class UncaughtException(cause: Throwable) : Exception(cause) diff --git a/features/core/catalog-source/src/main/resources/en/catalog.json b/features/core/catalog-source/src/main/resources/en/catalog.json index e7abc52..c1a7c3d 100644 --- a/features/core/catalog-source/src/main/resources/en/catalog.json +++ b/features/core/catalog-source/src/main/resources/en/catalog.json @@ -7,7 +7,7 @@ "picture": "https://juanvaldez.com/wp-content/uploads/2022/10/Afogatto-Juan-Valdez.jpg", "punctuations": [ { - "label": "Affogato", + "label": "Unit", "pointsQty": 1750 } ] @@ -360,7 +360,7 @@ }, { "id": "12971", - "title": "Nevado cafe", + "title": "Nevado café", "category": "Cold drinks", "detail": "It is a creamy cold coffee-based drink decorated with Chantilly.", "picture": "https://juanvaldez.com/wp-content/uploads/2023/02/nevado_de_cafe___300ml_700x700px.jpg", diff --git a/features/core/catalog-source/src/main/resources/es/catalog.json b/features/core/catalog-source/src/main/resources/es/catalog.json index 727726b..5150892 100644 --- a/features/core/catalog-source/src/main/resources/es/catalog.json +++ b/features/core/catalog-source/src/main/resources/es/catalog.json @@ -7,7 +7,7 @@ "picture": "https://juanvaldez.com/wp-content/uploads/2022/10/Afogatto-Juan-Valdez.jpg", "punctuations": [ { - "label": "Affogato", + "label": "Unidad", "pointsQty": 1750 } ] @@ -360,7 +360,7 @@ }, { "id": "12971", - "title": "Nevado cafe", + "title": "Nevado café", "category": "Bebidas frías", "detail": "Es una cremosa bebida fría a base de café decorada con Chantilly.", "picture": "https://juanvaldez.com/wp-content/uploads/2023/02/nevado_de_cafe___300ml_700x700px.jpg", diff --git a/features/core/database/src/androidTest/kotlin/dev/marlonlom/apps/cappajv/core/database/CatalogSearchDaoTest.kt b/features/core/database/src/androidTest/kotlin/dev/marlonlom/apps/cappajv/core/database/CatalogSearchDaoTest.kt new file mode 100644 index 0000000..4d1127c --- /dev/null +++ b/features/core/database/src/androidTest/kotlin/dev/marlonlom/apps/cappajv/core/database/CatalogSearchDaoTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.core.database + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dev.marlonlom.apps.cappajv.core.database.dao.CatalogItemsDao +import dev.marlonlom.apps.cappajv.core.database.dao.CatalogSearchDao +import dev.marlonlom.apps.cappajv.core.database.entities.CatalogItem +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class CatalogSearchDaoTest { + + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + private lateinit var database: CappaDatabase + private lateinit var dao: CatalogSearchDao + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, CappaDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = database.catalogSearchDao() + fillSampleCatalogItems(database.catalogProductsDao()) + } + + @After + fun teardown() { + clearSampleCatalogItems(database.catalogProductsDao()) + database.close() + } + + @Test + fun shouldSuccessAfterSearchProductByTitle() = runBlocking { + val expectedTitle = "torta" + val searchedProducts = dao.searchProducts("%$expectedTitle%").first() + assertThat(searchedProducts).isNotEmpty() + assertThat(searchedProducts).hasSize(2) + assertThat(searchedProducts.filter { it.title.lowercase().contains(expectedTitle) }).hasSize(2) + } + + @Test + fun shouldFailAfterSearchProductByTitle() = runBlocking { + val expectedTitle = "chanfle" + val searchedProducts = dao.searchProducts("%$expectedTitle%").first() + assertThat(searchedProducts).isEmpty() + assertThat(searchedProducts.map { it.title.lowercase() }).doesNotContain(expectedTitle) + } + + private fun clearSampleCatalogItems(catalogProductsDao: CatalogItemsDao) { + catalogProductsDao.deleteAll() + } + + private fun fillSampleCatalogItems( + catalogProductsDao: CatalogItemsDao + ) { + listOf( + "affogato", "almojabana", "cappuccino", + "chai", "chocolate", "granizado", + "pandebono", "torta de banano", "torta de zanahoria" + ).mapIndexed { index, title -> + CatalogItem( + id = index.toLong() + 1, + title = title, + slug = title, + titleNormalized = title, + picture = "https://noimage.no.com/$title.png", + category = "CategoryOne", + detail = title, + samplePunctuation = "", + punctuationsCount = 0 + ) + }.also { items -> + catalogProductsDao.insertAll( + *items.toTypedArray() + ) + } + } +} diff --git a/features/core/database/src/main/kotlin/dev/marlonlom/apps/cappajv/core/database/CappaDatabase.kt b/features/core/database/src/main/kotlin/dev/marlonlom/apps/cappajv/core/database/CappaDatabase.kt index ecc9357..c08e4b8 100644 --- a/features/core/database/src/main/kotlin/dev/marlonlom/apps/cappajv/core/database/CappaDatabase.kt +++ b/features/core/database/src/main/kotlin/dev/marlonlom/apps/cappajv/core/database/CappaDatabase.kt @@ -12,6 +12,7 @@ import androidx.room.RoomDatabase import dev.marlonlom.apps.cappajv.core.database.dao.CatalogFavoriteItemsDao import dev.marlonlom.apps.cappajv.core.database.dao.CatalogItemsDao import dev.marlonlom.apps.cappajv.core.database.dao.CatalogPunctuationsDao +import dev.marlonlom.apps.cappajv.core.database.dao.CatalogSearchDao import dev.marlonlom.apps.cappajv.core.database.entities.CatalogFavoriteItem import dev.marlonlom.apps.cappajv.core.database.entities.CatalogItem import dev.marlonlom.apps.cappajv.core.database.entities.CatalogPunctuation @@ -27,7 +28,7 @@ import dev.marlonlom.apps.cappajv.core.database.entities.CatalogPunctuation CatalogFavoriteItem::class, CatalogPunctuation::class ], - version = 5, + version = 6, exportSchema = false ) abstract class CappaDatabase : RoomDatabase() { @@ -53,6 +54,13 @@ abstract class CappaDatabase : RoomDatabase() { */ abstract fun catalogPunctuationsDao(): CatalogPunctuationsDao + /** + * Catalog search dao instance. + * + * @return Catalog dao + */ + abstract fun catalogSearchDao(): CatalogSearchDao + companion object { @Volatile diff --git a/features/core/database/src/main/kotlin/dev/marlonlom/apps/cappajv/core/database/dao/CatalogSearchDao.kt b/features/core/database/src/main/kotlin/dev/marlonlom/apps/cappajv/core/database/dao/CatalogSearchDao.kt new file mode 100644 index 0000000..35235e3 --- /dev/null +++ b/features/core/database/src/main/kotlin/dev/marlonlom/apps/cappajv/core/database/dao/CatalogSearchDao.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import dev.marlonlom.apps.cappajv.core.database.entities.CatalogItemTuple +import kotlinx.coroutines.flow.Flow + +/** + * Catalog product search data access object interface definition. + * + * @author marlonlom + * + */ +@Dao +interface CatalogSearchDao { + + /** + * Query for retrieving product items list by provided text. + * + * @return Product items list, or empty list, as Flow. + */ + @Query( + "SELECT c.id, c.title, c.category, c.picture, c.samplePunctuation, c.punctuationsCount FROM catalog_item c WHERE LOWER(c.titleNormalized) LIKE :searchText OR LOWER(c.title) LIKE :searchText " + ) + fun searchProducts(searchText: String): Flow> + +} diff --git a/features/core/database/src/main/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/LocalDataSource.kt b/features/core/database/src/main/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/LocalDataSource.kt index 8da61a1..32d5f37 100644 --- a/features/core/database/src/main/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/LocalDataSource.kt +++ b/features/core/database/src/main/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/LocalDataSource.kt @@ -48,6 +48,13 @@ interface LocalDataSource { */ fun getFavorites(): Flow> + /** + * Return Catalog searched items by provided query text. + * + * @param searchText Query text. + */ + fun searchProducts(searchText: String): Flow> + /** * Insert all product items. * diff --git a/features/core/database/src/main/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/LocalDataSourceImpl.kt b/features/core/database/src/main/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/LocalDataSourceImpl.kt index a313edb..b26db56 100644 --- a/features/core/database/src/main/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/LocalDataSourceImpl.kt +++ b/features/core/database/src/main/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/LocalDataSourceImpl.kt @@ -8,8 +8,10 @@ package dev.marlonlom.apps.cappajv.core.database.datasource import dev.marlonlom.apps.cappajv.core.database.dao.CatalogFavoriteItemsDao import dev.marlonlom.apps.cappajv.core.database.dao.CatalogItemsDao import dev.marlonlom.apps.cappajv.core.database.dao.CatalogPunctuationsDao +import dev.marlonlom.apps.cappajv.core.database.dao.CatalogSearchDao import dev.marlonlom.apps.cappajv.core.database.entities.CatalogFavoriteItem import dev.marlonlom.apps.cappajv.core.database.entities.CatalogItem +import dev.marlonlom.apps.cappajv.core.database.entities.CatalogItemTuple import dev.marlonlom.apps.cappajv.core.database.entities.CatalogPunctuation import kotlinx.coroutines.flow.Flow @@ -21,11 +23,13 @@ import kotlinx.coroutines.flow.Flow * @property catalogItemsDao Catalog products dao. * @property catalogPunctuationsDao Catalog punctuations dao. * @property catalogFavoriteItemsDao Catalog favorite items dao. + * @property catalogSearchDao Catalog search dao. */ class LocalDataSourceImpl( - private val catalogItemsDao: CatalogItemsDao, - private val catalogPunctuationsDao: CatalogPunctuationsDao, - private val catalogFavoriteItemsDao: CatalogFavoriteItemsDao, + private val catalogItemsDao: CatalogItemsDao, + private val catalogPunctuationsDao: CatalogPunctuationsDao, + private val catalogFavoriteItemsDao: CatalogFavoriteItemsDao, + private val catalogSearchDao: CatalogSearchDao, ) : LocalDataSource { override fun getAllProducts() = catalogItemsDao.getProducts() @@ -39,6 +43,9 @@ class LocalDataSourceImpl( override fun getFavorites(): Flow> = catalogFavoriteItemsDao.getFavoriteItems() + override fun searchProducts(searchText: String): Flow> = + catalogSearchDao.searchProducts(searchText) + override fun insertAllProducts(vararg products: CatalogItem) = catalogItemsDao.insertAll(*products) diff --git a/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/dao/FakeCatalogSearchDao.kt b/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/dao/FakeCatalogSearchDao.kt new file mode 100644 index 0000000..7cd2b37 --- /dev/null +++ b/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/dao/FakeCatalogSearchDao.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.core.database.dao + +import dev.marlonlom.apps.cappajv.core.database.entities.CatalogItem +import dev.marlonlom.apps.cappajv.core.database.entities.CatalogItemTuple +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import java.util.Locale + +/** + * Fake catalog products search dao implementation class. + * + * @author marlonlom + * + * @property list Mutable punctuations list. + */ +internal class FakeCatalogSearchDao( + private val list: MutableList = mutableListOf() +) : CatalogSearchDao { + + override fun searchProducts( + searchText: String + ): Flow> = flowOf( + list + .filter { + val searchingText = searchText.lowercase(Locale.getDefault()) + it.titleNormalized.lowercase().contains(searchingText).or(it.title.lowercase().contains(searchingText)) + } + .map { + CatalogItemTuple( + it.id, + it.title, + it.picture, + it.category, + it.samplePunctuation, + it.punctuationsCount + ) + } + ) + + internal fun insertAll(vararg products: CatalogItem) { + list.addAll(products) + } + + internal fun deleteAll() { + list.clear() + } +} diff --git a/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/CatalogFavoritesLocalDataSourceTest.kt b/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/CatalogFavoritesLocalDataSourceTest.kt index dd927ed..385d397 100644 --- a/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/CatalogFavoritesLocalDataSourceTest.kt +++ b/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/CatalogFavoritesLocalDataSourceTest.kt @@ -8,6 +8,7 @@ package dev.marlonlom.apps.cappajv.core.database.datasource import dev.marlonlom.apps.cappajv.core.database.dao.FakeCatalogFavoriteItemsDao import dev.marlonlom.apps.cappajv.core.database.dao.FakeCatalogItemsDao import dev.marlonlom.apps.cappajv.core.database.dao.FakeCatalogPunctuationsDao +import dev.marlonlom.apps.cappajv.core.database.dao.FakeCatalogSearchDao import dev.marlonlom.apps.cappajv.core.database.entities.CatalogFavoriteItem import kotlinx.coroutines.flow.filter import kotlinx.coroutines.runBlocking @@ -26,7 +27,8 @@ internal class CatalogFavoritesLocalDataSourceTest { dataSource = LocalDataSourceImpl( catalogItemsDao = FakeCatalogItemsDao(), catalogPunctuationsDao = FakeCatalogPunctuationsDao(), - catalogFavoriteItemsDao = FakeCatalogFavoriteItemsDao() + catalogFavoriteItemsDao = FakeCatalogFavoriteItemsDao(), + catalogSearchDao = FakeCatalogSearchDao() ) } diff --git a/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/CatalogItemsLocalDataSourceTest.kt b/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/CatalogItemsLocalDataSourceTest.kt index 5b5f145..e110dc5 100644 --- a/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/CatalogItemsLocalDataSourceTest.kt +++ b/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/CatalogItemsLocalDataSourceTest.kt @@ -8,6 +8,7 @@ package dev.marlonlom.apps.cappajv.core.database.datasource import dev.marlonlom.apps.cappajv.core.database.dao.FakeCatalogFavoriteItemsDao import dev.marlonlom.apps.cappajv.core.database.dao.FakeCatalogItemsDao import dev.marlonlom.apps.cappajv.core.database.dao.FakeCatalogPunctuationsDao +import dev.marlonlom.apps.cappajv.core.database.dao.FakeCatalogSearchDao import dev.marlonlom.apps.cappajv.core.database.entities.CatalogItem import kotlinx.coroutines.flow.filter import kotlinx.coroutines.runBlocking @@ -28,7 +29,8 @@ internal class CatalogItemsLocalDataSourceTest { dataSource = LocalDataSourceImpl( catalogItemsDao = FakeCatalogItemsDao(), catalogPunctuationsDao = FakeCatalogPunctuationsDao(), - catalogFavoriteItemsDao = FakeCatalogFavoriteItemsDao() + catalogFavoriteItemsDao = FakeCatalogFavoriteItemsDao(), + catalogSearchDao = FakeCatalogSearchDao() ) } diff --git a/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/CatalogPunctuationsLocalDataSourceTest.kt b/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/CatalogPunctuationsLocalDataSourceTest.kt index a5f73fd..3a239d0 100644 --- a/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/CatalogPunctuationsLocalDataSourceTest.kt +++ b/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/CatalogPunctuationsLocalDataSourceTest.kt @@ -5,13 +5,16 @@ package dev.marlonlom.apps.cappajv.core.database.datasource +import dev.marlonlom.apps.cappajv.core.database.dao.CatalogItemsDao import dev.marlonlom.apps.cappajv.core.database.dao.FakeCatalogFavoriteItemsDao import dev.marlonlom.apps.cappajv.core.database.dao.FakeCatalogItemsDao import dev.marlonlom.apps.cappajv.core.database.dao.FakeCatalogPunctuationsDao +import dev.marlonlom.apps.cappajv.core.database.dao.FakeCatalogSearchDao import dev.marlonlom.apps.cappajv.core.database.entities.CatalogItem import dev.marlonlom.apps.cappajv.core.database.entities.CatalogPunctuation import kotlinx.coroutines.flow.combine import kotlinx.coroutines.runBlocking +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue @@ -27,7 +30,8 @@ internal class CatalogPunctuationsLocalDataSourceTest { dataSource = LocalDataSourceImpl( catalogItemsDao = FakeCatalogItemsDao(), catalogPunctuationsDao = FakeCatalogPunctuationsDao(), - catalogFavoriteItemsDao = FakeCatalogFavoriteItemsDao() + catalogFavoriteItemsDao = FakeCatalogFavoriteItemsDao(), + catalogSearchDao = FakeCatalogSearchDao() ) } diff --git a/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/CatalogSearchLocalDataSourceTest.kt b/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/CatalogSearchLocalDataSourceTest.kt new file mode 100644 index 0000000..038cc77 --- /dev/null +++ b/features/core/database/src/test/kotlin/dev/marlonlom/apps/cappajv/core/database/datasource/CatalogSearchLocalDataSourceTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2024 Marlonlom + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.marlonlom.apps.cappajv.core.database.datasource + +import dev.marlonlom.apps.cappajv.core.database.dao.FakeCatalogFavoriteItemsDao +import dev.marlonlom.apps.cappajv.core.database.dao.FakeCatalogItemsDao +import dev.marlonlom.apps.cappajv.core.database.dao.FakeCatalogPunctuationsDao +import dev.marlonlom.apps.cappajv.core.database.dao.FakeCatalogSearchDao +import dev.marlonlom.apps.cappajv.core.database.entities.CatalogItem +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +internal class CatalogSearchLocalDataSourceTest { + + private lateinit var dataSource: LocalDataSource + private val fakeCatalogSearchDao = FakeCatalogSearchDao() + + @Before + fun setup() { + fillSampleCatalogItems() + dataSource = LocalDataSourceImpl( + catalogItemsDao = FakeCatalogItemsDao(), + catalogPunctuationsDao = FakeCatalogPunctuationsDao(), + catalogFavoriteItemsDao = FakeCatalogFavoriteItemsDao(), + catalogSearchDao = fakeCatalogSearchDao + ) + } + + @After + fun teardown() { + clearSampleCatalogItems() + } + + @Test + fun `Should success after searching product by title`() = runBlocking { + val expectedTitle = "torta" + dataSource.searchProducts(expectedTitle).collect { list -> + assertNotNull(list) + assertTrue(list.isNotEmpty()) + assertEquals(2, list.filter { it.title.lowercase().contains(expectedTitle) }.size) + } + } + + @Test + fun `Should fail after searching product by title`() = runBlocking { + val expectedTitle = "chamfle" + dataSource.searchProducts(expectedTitle).collect { list -> + assertNotNull(list) + assertFalse(list.map { it.title.lowercase() }.contains(expectedTitle)) + assertTrue(list.isEmpty()) + } + } + + private fun clearSampleCatalogItems() { + fakeCatalogSearchDao.deleteAll() + } + + private fun fillSampleCatalogItems() { + listOf( + "affogato", "almojabana", "cappuccino", + "chai", "chocolate", "granizado", + "pandebono", "torta de banano", "torta de zanahoria" + ).mapIndexed { index, title -> + CatalogItem( + id = index.toLong() + 1, + title = title, + slug = title, + titleNormalized = title, + picture = "https://noimage.no.com/$title.png", + category = "CategoryOne", + detail = title, + samplePunctuation = "", + punctuationsCount = 0 + ) + }.also { items -> + fakeCatalogSearchDao.insertAll( + *items.toTypedArray() + ) + } + } + +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2a6dc2d..0131047 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] agp = "8.3.0" kotlin = "1.9.23" -kotlin-compose-compiler = "1.5.11" +kotlinComposeCompiler = "1.5.11" ksp = "1.9.22-1.0.18" [libraries] @@ -20,7 +20,7 @@ androidx-room-ktx = "androidx.room:room-ktx:2.6.1" androidx-room-runtime = "androidx.room:room-runtime:2.6.1" androidx-window = "androidx.window:window:1.2.0" coil-compose = "io.coil-kt:coil-compose:2.6.0" -google-guava = "com.google.guava:guava:33.0.0-android" +google-guava = "com.google.guava:guava:33.1.0-android" google-oss-licenses = "com.google.android.gms:play-services-oss-licenses:17.0.1" google-oss-licenses-plugin = "com.google.android.gms:oss-licenses-plugin:0.10.6" kotlin-gradle-plugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.23"