Merge pull request #92 from kirmanak/recipe-search

Implement searching recipe by name
This commit is contained in:
Kirill Kamakin
2022-11-13 13:43:23 +01:00
committed by GitHub
32 changed files with 343 additions and 85 deletions

View File

@@ -5,6 +5,7 @@ import gq.kirmanak.mealient.database.recipe.entity.FullRecipeEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
interface RecipeRepo { interface RecipeRepo {
fun createPager(): Pager<Int, RecipeSummaryEntity> fun createPager(): Pager<Int, RecipeSummaryEntity>
suspend fun clearLocalData() suspend fun clearLocalData()
@@ -12,4 +13,6 @@ interface RecipeRepo {
suspend fun refreshRecipeInfo(recipeSlug: String): Result<Unit> suspend fun refreshRecipeInfo(recipeSlug: String): Result<Unit>
suspend fun loadRecipeInfo(recipeId: String): FullRecipeEntity? suspend fun loadRecipeInfo(recipeId: String): FullRecipeEntity?
fun updateNameQuery(name: String?)
} }

View File

@@ -9,7 +9,7 @@ import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
interface RecipeStorage { interface RecipeStorage {
suspend fun saveRecipes(recipes: List<RecipeSummaryInfo>) suspend fun saveRecipes(recipes: List<RecipeSummaryInfo>)
fun queryRecipes(): PagingSource<Int, RecipeSummaryEntity> fun queryRecipes(query: String?): PagingSource<Int, RecipeSummaryEntity>
suspend fun refreshAll(recipes: List<RecipeSummaryInfo>) suspend fun refreshAll(recipes: List<RecipeSummaryInfo>)

View File

@@ -23,21 +23,17 @@ class RecipeStorageImpl @Inject constructor(
) : RecipeStorage { ) : RecipeStorage {
private val recipeDao: RecipeDao by lazy { db.recipeDao() } private val recipeDao: RecipeDao by lazy { db.recipeDao() }
override suspend fun saveRecipes( override suspend fun saveRecipes(recipes: List<RecipeSummaryInfo>) {
recipes: List<RecipeSummaryInfo>
) = db.withTransaction {
logger.v { "saveRecipes() called with $recipes" } logger.v { "saveRecipes() called with $recipes" }
val entities = recipes.map { it.toRecipeSummaryEntity() }
for (recipe in recipes) { logger.v { "saveRecipes: entities = $entities" }
val recipeSummaryEntity = recipe.toRecipeSummaryEntity() db.withTransaction { recipeDao.insertRecipes(entities) }
recipeDao.insertRecipe(recipeSummaryEntity)
}
} }
override fun queryRecipes(query: String?): PagingSource<Int, RecipeSummaryEntity> {
override fun queryRecipes(): PagingSource<Int, RecipeSummaryEntity> { logger.v { "queryRecipes() called with: query = $query" }
logger.v { "queryRecipes() called" } return if (query == null) recipeDao.queryRecipesByPages()
return recipeDao.queryRecipesByPages() else recipeDao.queryRecipesByPages(query)
} }
override suspend fun refreshAll(recipes: List<RecipeSummaryInfo>) { override suspend fun refreshAll(recipes: List<RecipeSummaryInfo>) {

View File

@@ -0,0 +1,9 @@
package gq.kirmanak.mealient.data.recipes.impl
import androidx.paging.PagingSource
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
interface RecipePagingSourceFactory : () -> PagingSource<Int, RecipeSummaryEntity> {
fun setQuery(newQuery: String?)
fun invalidate()
}

View File

@@ -0,0 +1,33 @@
package gq.kirmanak.mealient.data.recipes.impl
import androidx.paging.InvalidatingPagingSourceFactory
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.logging.Logger
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecipePagingSourceFactoryImpl @Inject constructor(
private val recipeStorage: RecipeStorage,
private val logger: Logger,
) : RecipePagingSourceFactory {
private val query = AtomicReference<String>(null)
private val delegate = InvalidatingPagingSourceFactory {
val currentQuery = query.get()
logger.d { "Creating paging source, query is $currentQuery" }
recipeStorage.queryRecipes(currentQuery)
}
override fun invoke() = delegate.invoke()
override fun setQuery(newQuery: String?) {
logger.v { "setQuery() called with: newQuery = $newQuery" }
query.set(newQuery)
invalidate()
}
override fun invalidate() = delegate.invalidate()
}

View File

@@ -1,7 +1,6 @@
package gq.kirmanak.mealient.data.recipes.impl package gq.kirmanak.mealient.data.recipes.impl
import androidx.paging.ExperimentalPagingApi import androidx.paging.ExperimentalPagingApi
import androidx.paging.InvalidatingPagingSourceFactory
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo
@@ -19,17 +18,18 @@ import javax.inject.Singleton
class RecipeRepoImpl @Inject constructor( class RecipeRepoImpl @Inject constructor(
private val mediator: RecipesRemoteMediator, private val mediator: RecipesRemoteMediator,
private val storage: RecipeStorage, private val storage: RecipeStorage,
private val pagingSourceFactory: InvalidatingPagingSourceFactory<Int, RecipeSummaryEntity>, private val pagingSourceFactory: RecipePagingSourceFactory,
private val dataSource: RecipeDataSource, private val dataSource: RecipeDataSource,
private val logger: Logger, private val logger: Logger,
) : RecipeRepo { ) : RecipeRepo {
override fun createPager(): Pager<Int, RecipeSummaryEntity> { override fun createPager(): Pager<Int, RecipeSummaryEntity> {
logger.v { "createPager() called" } logger.v { "createPager() called" }
val pagingConfig = PagingConfig(pageSize = 5, enablePlaceholders = true) val pagingConfig = PagingConfig(pageSize = 5, enablePlaceholders = true)
return Pager( return Pager(
config = pagingConfig, config = pagingConfig,
remoteMediator = mediator, remoteMediator = mediator,
pagingSourceFactory = pagingSourceFactory pagingSourceFactory = pagingSourceFactory,
) )
} }
@@ -53,4 +53,9 @@ class RecipeRepoImpl @Inject constructor(
logger.v { "loadRecipeInfo() returned: $recipeInfo" } logger.v { "loadRecipeInfo() returned: $recipeInfo" }
return recipeInfo return recipeInfo
} }
override fun updateNameQuery(name: String?) {
logger.v { "updateNameQuery() called with: name = $name" }
pagingSourceFactory.setQuery(name)
}
} }

View File

@@ -17,7 +17,7 @@ import javax.inject.Singleton
class RecipesRemoteMediator @Inject constructor( class RecipesRemoteMediator @Inject constructor(
private val storage: RecipeStorage, private val storage: RecipeStorage,
private val network: RecipeDataSource, private val network: RecipeDataSource,
private val pagingSourceFactory: InvalidatingPagingSourceFactory<Int, RecipeSummaryEntity>, private val pagingSourceFactory: RecipePagingSourceFactory,
private val logger: Logger, private val logger: Logger,
) : RemoteMediator<Int, RecipeSummaryEntity>() { ) : RemoteMediator<Int, RecipeSummaryEntity>() {

View File

@@ -1,6 +1,5 @@
package gq.kirmanak.mealient.di package gq.kirmanak.mealient.di
import androidx.paging.InvalidatingPagingSourceFactory
import com.bumptech.glide.load.model.ModelLoaderFactory import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import dagger.Binds import dagger.Binds
@@ -13,9 +12,7 @@ import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper
import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.data.recipes.db.RecipeStorageImpl import gq.kirmanak.mealient.data.recipes.db.RecipeStorageImpl
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider import gq.kirmanak.mealient.data.recipes.impl.*
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProviderImpl
import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.ui.recipes.images.RecipeModelLoaderFactory import gq.kirmanak.mealient.ui.recipes.images.RecipeModelLoaderFactory
@@ -46,13 +43,11 @@ interface RecipeModule {
@Singleton @Singleton
fun bindModelLoaderFactory(recipeModelLoaderFactory: RecipeModelLoaderFactory): ModelLoaderFactory<RecipeSummaryEntity, InputStream> fun bindModelLoaderFactory(recipeModelLoaderFactory: RecipeModelLoaderFactory): ModelLoaderFactory<RecipeSummaryEntity, InputStream>
companion object { @Binds
@Singleton
fun bindRecipePagingSourceFactory(recipePagingSourceFactoryImpl: RecipePagingSourceFactoryImpl): RecipePagingSourceFactory
@Provides companion object {
@Singleton
fun provideRecipePagingSourceFactory(
recipeStorage: RecipeStorage
) = InvalidatingPagingSourceFactory { recipeStorage.queryRecipes() }
@Provides @Provides
@Singleton @Singleton

View File

@@ -1,6 +1,5 @@
package gq.kirmanak.mealient.extensions package gq.kirmanak.mealient.extensions
import android.widget.Toast
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -18,10 +17,7 @@ fun <T> Fragment.collectWhenViewResumed(flow: Flow<T>, collector: FlowCollector<
} }
} }
fun Fragment.showLongToast(@StringRes text: Int) = showLongToast(getString(text)) fun Fragment.showLongToast(@StringRes text: Int) = context?.showLongToast(text) != null
fun Fragment.showLongToast(text: String) = showToast(text, Toast.LENGTH_LONG) fun Fragment.showLongToast(text: String) = context?.showLongToast(text) != null
private fun Fragment.showToast(text: String, length: Int): Boolean {
return context?.let { Toast.makeText(it, text, length).show() } != null
}

View File

@@ -1,8 +1,10 @@
package gq.kirmanak.mealient.extensions package gq.kirmanak.mealient.extensions
import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.widget.EditText import android.widget.EditText
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
@@ -95,4 +97,13 @@ fun <T> LiveData<T>.observeOnce(lifecycleOwner: LifecycleOwner, observer: Observ
observer.onChanged(value) observer.onChanged(value)
} }
}) })
} }
fun Context.showLongToast(text: String) = showToast(text, Toast.LENGTH_LONG)
fun Context.showLongToast(@StringRes text: Int) = showLongToast(getString(text))
private fun Context.showToast(text: String, length: Int) {
Toast.makeText(this, text, length).show()
}

View File

@@ -5,6 +5,8 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.SearchView.OnQueryTextListener
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.navigation.NavController import androidx.navigation.NavController
@@ -47,6 +49,7 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
} }
private fun configureNavGraph() { private fun configureNavGraph() {
logger.v { "configureNavGraph() called" }
viewModel.startDestination.observeOnce(this) { viewModel.startDestination.observeOnce(this) {
logger.d { "configureNavGraph: received destination" } logger.d { "configureNavGraph: received destination" }
val graph = navController.navInflater.inflate(R.navigation.nav_graph) val graph = navController.navInflater.inflate(R.navigation.nav_graph)
@@ -104,9 +107,37 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
menuInflater.inflate(R.menu.main_toolbar, menu) menuInflater.inflate(R.menu.main_toolbar, menu)
menu.findItem(R.id.logout).isVisible = uiState.canShowLogout menu.findItem(R.id.logout).isVisible = uiState.canShowLogout
menu.findItem(R.id.login).isVisible = uiState.canShowLogin menu.findItem(R.id.login).isVisible = uiState.canShowLogin
val searchItem = menu.findItem(R.id.search_recipe_action)
searchItem.isVisible = uiState.searchVisible
setupSearchItem(searchItem)
return true return true
} }
private fun setupSearchItem(searchItem: MenuItem) {
logger.v { "setupSearchItem() called with: searchItem = $searchItem" }
val searchView = searchItem.actionView as? SearchView
if (searchView == null) {
logger.e { "setupSearchItem: search item's actionView is null or not SearchView" }
return
}
searchView.queryHint = getString(R.string.search_recipes_hint)
searchView.setOnCloseListener {
logger.v { "onClose() called" }
viewModel.onSearchQuery(null)
false
}
searchView.setOnQueryTextListener(object : OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean = true
override fun onQueryTextChange(newText: String?): Boolean {
logger.v { "onQueryTextChange() called with: newText = $newText" }
viewModel.onSearchQuery(newText?.trim()?.takeUnless { it.isEmpty() })
return true
}
})
}
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
logger.v { "onOptionsItemSelected() called with: item = $item" } logger.v { "onOptionsItemSelected() called with: item = $item" }
val result = when (item.itemId) { val result = when (item.itemId) {

View File

@@ -5,6 +5,7 @@ data class MainActivityUiState(
val titleVisible: Boolean = true, val titleVisible: Boolean = true,
val isAuthorized: Boolean = false, val isAuthorized: Boolean = false,
val navigationVisible: Boolean = false, val navigationVisible: Boolean = false,
val searchVisible: Boolean = false,
) { ) {
val canShowLogin: Boolean val canShowLogin: Boolean
get() = !isAuthorized && loginButtonVisible get() = !isAuthorized && loginButtonVisible

View File

@@ -6,6 +6,7 @@ import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@@ -18,6 +19,7 @@ class MainActivityViewModel @Inject constructor(
private val logger: Logger, private val logger: Logger,
private val disclaimerStorage: DisclaimerStorage, private val disclaimerStorage: DisclaimerStorage,
private val serverInfoRepo: ServerInfoRepo, private val serverInfoRepo: ServerInfoRepo,
private val recipeRepo: RecipeRepo,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableLiveData(MainActivityUiState()) private val _uiState = MutableLiveData(MainActivityUiState())
@@ -52,4 +54,9 @@ class MainActivityViewModel @Inject constructor(
logger.v { "logout() called" } logger.v { "logout() called" }
viewModelScope.launch { authRepo.logout() } viewModelScope.launch { authRepo.logout() }
} }
fun onSearchQuery(query: String?) {
logger.v { "onSearchQuery() called with: query = $query" }
recipeRepo.updateNameQuery(query)
}
} }

View File

@@ -38,7 +38,12 @@ class AddRecipeFragment : Fragment(R.layout.fragment_add_recipe) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" } logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" }
activityViewModel.updateUiState { activityViewModel.updateUiState {
it.copy(loginButtonVisible = true, titleVisible = false, navigationVisible = true) it.copy(
loginButtonVisible = true,
titleVisible = false,
navigationVisible = true,
searchVisible = false,
)
} }
viewModel.loadPreservedRequest() viewModel.loadPreservedRequest()
setupViews() setupViews()

View File

@@ -32,7 +32,12 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" } logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" }
binding.button.setOnClickListener { onLoginClicked() } binding.button.setOnClickListener { onLoginClicked() }
activityViewModel.updateUiState { activityViewModel.updateUiState {
it.copy(loginButtonVisible = false, titleVisible = true, navigationVisible = false) it.copy(
loginButtonVisible = false,
titleVisible = true,
navigationVisible = false,
searchVisible = false
)
} }
viewModel.uiState.observe(viewLifecycleOwner, ::onUiStateChange) viewModel.uiState.observe(viewLifecycleOwner, ::onUiStateChange)
} }

View File

@@ -33,7 +33,12 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
binding.button.setOnClickListener(::onProceedClick) binding.button.setOnClickListener(::onProceedClick)
viewModel.uiState.observe(viewLifecycleOwner, ::onUiStateChange) viewModel.uiState.observe(viewLifecycleOwner, ::onUiStateChange)
activityViewModel.updateUiState { activityViewModel.updateUiState {
it.copy(loginButtonVisible = false, titleVisible = true, navigationVisible = false) it.copy(
loginButtonVisible = false,
titleVisible = true,
navigationVisible = false,
searchVisible = false
)
} }
} }

View File

@@ -57,7 +57,12 @@ class DisclaimerFragment : Fragment(R.layout.fragment_disclaimer) {
} }
viewModel.startCountDown() viewModel.startCountDown()
activityViewModel.updateUiState { activityViewModel.updateUiState {
it.copy(loginButtonVisible = false, titleVisible = true, navigationVisible = false) it.copy(
loginButtonVisible = false,
titleVisible = true,
navigationVisible = false,
searchVisible = false
)
} }
} }
} }

View File

@@ -50,7 +50,12 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" } logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" }
activityViewModel.updateUiState { activityViewModel.updateUiState {
it.copy(loginButtonVisible = true, titleVisible = false, navigationVisible = true) it.copy(
loginButtonVisible = true,
titleVisible = false,
navigationVisible = true,
searchVisible = true,
)
} }
setupRecipeAdapter() setupRecipeAdapter()
} }

View File

@@ -22,6 +22,7 @@
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"
style="@style/Widget.MaterialComponents.Toolbar.Primary" style="@style/Widget.MaterialComponents.Toolbar.Primary"
android:theme="@style/ThemeOverlay.Toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?actionBarSize" android:layout_height="?actionBarSize"
app:layout_scrollFlags="scroll|snap|enterAlways" /> app:layout_scrollFlags="scroll|snap|enterAlways" />

View File

@@ -1,17 +1,24 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android" <menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<item <item
android:id="@+id/login" android:id="@+id/login"
android:contentDescription="@string/menu_main_toolbar_content_description_login" android:contentDescription="@string/menu_main_toolbar_content_description_login"
android:title="@string/menu_main_toolbar_login" android:title="@string/menu_main_toolbar_login"
app:showAsAction="ifRoom" /> app:showAsAction="ifRoom" />
<item <item
android:id="@+id/logout" android:id="@+id/logout"
android:contentDescription="@string/menu_main_toolbar_content_description_logout" android:contentDescription="@string/menu_main_toolbar_content_description_logout"
android:title="@string/menu_main_toolbar_logout" android:title="@string/menu_main_toolbar_logout"
app:showAsAction="never" /> app:showAsAction="never" />
<item
android:id="@+id/search_recipe_action"
android:icon="@android:drawable/ic_menu_search"
android:title="@string/search_recipes_hint"
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="ifRoom" />
</menu> </menu>

View File

@@ -1,31 +1,32 @@
<resources> <resources>
<style name="AppTheme" parent="Theme.Material3.Dark.NoActionBar"> <style name="AppTheme" parent="Theme.Material3.Dark.NoActionBar">
<item name="colorPrimary">@color/md_theme_dark_primary</item> <item name="colorPrimary">@color/md_theme_dark_primary</item>
<item name="colorOnPrimary">@color/md_theme_dark_onPrimary</item> <item name="colorOnPrimary">@color/md_theme_dark_onPrimary</item>
<item name="colorPrimaryContainer">@color/md_theme_dark_primaryContainer</item> <item name="colorPrimaryContainer">@color/md_theme_dark_primaryContainer</item>
<item name="colorOnPrimaryContainer">@color/md_theme_dark_onPrimaryContainer</item> <item name="colorOnPrimaryContainer">@color/md_theme_dark_onPrimaryContainer</item>
<item name="colorSecondary">@color/md_theme_dark_secondary</item> <item name="colorSecondary">@color/md_theme_dark_secondary</item>
<item name="colorOnSecondary">@color/md_theme_dark_onSecondary</item> <item name="colorOnSecondary">@color/md_theme_dark_onSecondary</item>
<item name="colorSecondaryContainer">@color/md_theme_dark_secondaryContainer</item> <item name="colorSecondaryContainer">@color/md_theme_dark_secondaryContainer</item>
<item name="colorOnSecondaryContainer">@color/md_theme_dark_onSecondaryContainer</item> <item name="colorOnSecondaryContainer">@color/md_theme_dark_onSecondaryContainer</item>
<item name="colorTertiary">@color/md_theme_dark_tertiary</item> <item name="colorTertiary">@color/md_theme_dark_tertiary</item>
<item name="colorOnTertiary">@color/md_theme_dark_onTertiary</item> <item name="colorOnTertiary">@color/md_theme_dark_onTertiary</item>
<item name="colorTertiaryContainer">@color/md_theme_dark_tertiaryContainer</item> <item name="colorTertiaryContainer">@color/md_theme_dark_tertiaryContainer</item>
<item name="colorOnTertiaryContainer">@color/md_theme_dark_onTertiaryContainer</item> <item name="colorOnTertiaryContainer">@color/md_theme_dark_onTertiaryContainer</item>
<item name="colorError">@color/md_theme_dark_error</item> <item name="colorError">@color/md_theme_dark_error</item>
<item name="colorErrorContainer">@color/md_theme_dark_errorContainer</item> <item name="colorErrorContainer">@color/md_theme_dark_errorContainer</item>
<item name="colorOnError">@color/md_theme_dark_onError</item> <item name="colorOnError">@color/md_theme_dark_onError</item>
<item name="colorOnErrorContainer">@color/md_theme_dark_onErrorContainer</item> <item name="colorOnErrorContainer">@color/md_theme_dark_onErrorContainer</item>
<item name="android:colorBackground">@color/md_theme_dark_background</item> <item name="android:colorBackground">@color/md_theme_dark_background</item>
<item name="colorOnBackground">@color/md_theme_dark_onBackground</item> <item name="colorOnBackground">@color/md_theme_dark_onBackground</item>
<item name="colorSurface">@color/md_theme_dark_surface</item> <item name="colorSurface">@color/md_theme_dark_surface</item>
<item name="colorOnSurface">@color/md_theme_dark_onSurface</item> <item name="colorOnSurface">@color/md_theme_dark_onSurface</item>
<item name="colorSurfaceVariant">@color/md_theme_dark_surfaceVariant</item> <item name="colorSurfaceVariant">@color/md_theme_dark_surfaceVariant</item>
<item name="colorOnSurfaceVariant">@color/md_theme_dark_onSurfaceVariant</item> <item name="colorOnSurfaceVariant">@color/md_theme_dark_onSurfaceVariant</item>
<item name="colorOutline">@color/md_theme_dark_outline</item> <item name="colorOutline">@color/md_theme_dark_outline</item>
<item name="colorOnSurfaceInverse">@color/md_theme_dark_inverseOnSurface</item> <item name="colorOnSurfaceInverse">@color/md_theme_dark_inverseOnSurface</item>
<item name="colorSurfaceInverse">@color/md_theme_dark_inverseSurface</item> <item name="colorSurfaceInverse">@color/md_theme_dark_inverseSurface</item>
<item name="colorPrimaryInverse">@color/md_theme_dark_primaryInverse</item> <item name="colorPrimaryInverse">@color/md_theme_dark_primaryInverse</item>
</style> <item name="android:overScrollMode">never</item>
</style>
</resources> </resources>

View File

@@ -48,4 +48,5 @@
<string name="fragment_recipes_load_failure_toast_no_connection">нет соединения</string> <string name="fragment_recipes_load_failure_toast_no_connection">нет соединения</string>
<string name="fragment_recipes_load_failure_toast_no_reason">Ошибка загрузки.</string> <string name="fragment_recipes_load_failure_toast_no_reason">Ошибка загрузки.</string>
<string name="menu_bottom_navigation_change_url">Сменить URL</string> <string name="menu_bottom_navigation_change_url">Сменить URL</string>
<string name="search_recipes_hint">Найти рецепты</string>
</resources> </resources>

View File

@@ -52,4 +52,5 @@
<string name="fragment_recipes_load_failure_toast_unexpected_response">unexpected response</string> <string name="fragment_recipes_load_failure_toast_unexpected_response">unexpected response</string>
<string name="fragment_recipes_load_failure_toast_no_connection">no connection</string> <string name="fragment_recipes_load_failure_toast_no_connection">no connection</string>
<string name="menu_bottom_navigation_change_url">Change URL</string> <string name="menu_bottom_navigation_change_url">Change URL</string>
<string name="search_recipes_hint">Search recipes</string>
</resources> </resources>

View File

@@ -35,4 +35,9 @@
<item name="colorPrimaryInverse">@color/md_theme_light_primaryInverse</item> <item name="colorPrimaryInverse">@color/md_theme_light_primaryInverse</item>
<item name="android:overScrollMode">never</item> <item name="android:overScrollMode">never</item>
</style> </style>
<style name="ThemeOverlay.Toolbar" parent="ThemeOverlay.MaterialComponents.Toolbar.Primary">
<item name="android:editTextColor">?colorOnPrimary</item>
<item name="android:textColorHint">?colorOnPrimary</item>
</style>
</resources> </resources>

View File

@@ -0,0 +1,63 @@
package gq.kirmanak.mealient.data.recipes.impl
import androidx.paging.PagingSource
import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.test.HiltRobolectricTest
import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_RECIPE_SUMMARY_ENTITY
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_SUMMARY_ENTITY
import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARIES
import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARY_ENTITIES
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
@OptIn(ExperimentalCoroutinesApi::class)
class RecipePagingSourceFactoryImplTest : HiltRobolectricTest() {
@Inject
lateinit var subject: RecipePagingSourceFactory
@Inject
lateinit var storage: RecipeStorage
@Test
fun `when query is ca expect cake only is returned`() = runTest {
storage.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.setQuery("ca")
assertThat(queryRecipes()).isEqualTo(listOf(CAKE_RECIPE_SUMMARY_ENTITY))
}
@Test
fun `when query is po expect porridge only is returned`() = runTest {
storage.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.setQuery("po")
assertThat(queryRecipes()).isEqualTo(listOf(PORRIDGE_RECIPE_SUMMARY_ENTITY))
}
@Test
fun `when query is e expect cake and porridge are returned`() = runTest {
storage.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.setQuery("e")
assertThat(queryRecipes()).isEqualTo(TEST_RECIPE_SUMMARY_ENTITIES)
}
@Test
fun `when query is null expect cake and porridge are returned`() = runTest {
storage.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.setQuery(null)
assertThat(queryRecipes()).isEqualTo(TEST_RECIPE_SUMMARY_ENTITIES)
}
private suspend fun queryRecipes(): List<RecipeSummaryEntity> {
val loadParam = PagingSource.LoadParams.Refresh<Int>(null, Int.MAX_VALUE, false)
val loadResult = subject.invoke().load(loadParam)
return (loadResult as PagingSource.LoadResult.Page).data
}
}

View File

@@ -1,17 +1,16 @@
package gq.kirmanak.mealient.data.recipes.impl package gq.kirmanak.mealient.data.recipes.impl
import androidx.paging.InvalidatingPagingSourceFactory
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.BaseUnitTest
import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_FULL_RECIPE_INFO import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_FULL_RECIPE_INFO
import gq.kirmanak.mealient.test.RecipeImplTestData.FULL_CAKE_INFO_ENTITY import gq.kirmanak.mealient.test.RecipeImplTestData.FULL_CAKE_INFO_ENTITY
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
@@ -29,8 +28,8 @@ class RecipeRepoTest : BaseUnitTest() {
@MockK @MockK
lateinit var remoteMediator: RecipesRemoteMediator lateinit var remoteMediator: RecipesRemoteMediator
@MockK @MockK(relaxUnitFun = true)
lateinit var pagingSourceFactory: InvalidatingPagingSourceFactory<Int, RecipeSummaryEntity> lateinit var pagingSourceFactory: RecipePagingSourceFactory
lateinit var subject: RecipeRepo lateinit var subject: RecipeRepo
@@ -59,4 +58,10 @@ class RecipeRepoTest : BaseUnitTest() {
subject.clearLocalData() subject.clearLocalData()
coVerify { storage.clearAllLocalData() } coVerify { storage.clearAllLocalData() }
} }
@Test
fun `when updateNameQuery expect sets query in paging source factory`() {
subject.updateNameQuery("query")
verify { pagingSourceFactory.setQuery("query") }
}
} }

View File

@@ -37,7 +37,7 @@ class RecipesRemoteMediatorTest : BaseUnitTest() {
lateinit var dataSource: RecipeDataSource lateinit var dataSource: RecipeDataSource
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)
lateinit var pagingSourceFactory: InvalidatingPagingSourceFactory<Int, RecipeSummaryEntity> lateinit var pagingSourceFactory: RecipePagingSourceFactory
@Before @Before
override fun setUp() { override fun setUp() {

View File

@@ -69,6 +69,9 @@ object RecipeImplTestData {
imageId = "porridge", imageId = "porridge",
) )
val TEST_RECIPE_SUMMARY_ENTITIES =
listOf(CAKE_RECIPE_SUMMARY_ENTITY, PORRIDGE_RECIPE_SUMMARY_ENTITY)
val SUGAR_INGREDIENT = RecipeIngredientInfo( val SUGAR_INGREDIENT = RecipeIngredientInfo(
note = "2 oz of white sugar", note = "2 oz of white sugar",
) )

View File

@@ -0,0 +1,55 @@
package gq.kirmanak.mealient.ui.activity
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.verify
import kotlinx.coroutines.flow.emptyFlow
import org.junit.Before
import org.junit.Test
class MainActivityViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
lateinit var authRepo: AuthRepo
@MockK(relaxUnitFun = true)
lateinit var disclaimerStorage: DisclaimerStorage
@MockK(relaxUnitFun = true)
lateinit var serverInfoRepo: ServerInfoRepo
@MockK(relaxUnitFun = true)
lateinit var recipeRepo: RecipeRepo
private lateinit var subject: MainActivityViewModel
@Before
override fun setUp() {
super.setUp()
every { authRepo.isAuthorizedFlow } returns emptyFlow()
subject = MainActivityViewModel(
authRepo = authRepo,
logger = logger,
disclaimerStorage = disclaimerStorage,
serverInfoRepo = serverInfoRepo,
recipeRepo = recipeRepo,
)
}
@Test
fun `when onSearchQuery with query expect call to recipe repo`() {
subject.onSearchQuery("query")
verify { recipeRepo.updateNameQuery("query") }
}
@Test
fun `when onSearchQuery with null expect call to recipe repo`() {
subject.onSearchQuery(null)
verify { recipeRepo.updateNameQuery(null) }
}
}

View File

@@ -33,4 +33,5 @@ sonarqube {
rootCoverage { rootCoverage {
generateXml = true generateXml = true
includeNoLocationClasses = true
} }

View File

@@ -11,7 +11,7 @@ import gq.kirmanak.mealient.database.recipe.entity.*
RecipeSummaryEntity::class, RecipeSummaryEntity::class,
RecipeEntity::class, RecipeEntity::class,
RecipeIngredientEntity::class, RecipeIngredientEntity::class,
RecipeInstructionEntity::class RecipeInstructionEntity::class,
], ],
exportSchema = true, exportSchema = true,
autoMigrations = [ autoMigrations = [

View File

@@ -9,8 +9,11 @@ interface RecipeDao {
@Query("SELECT * FROM recipe_summaries ORDER BY date_added DESC") @Query("SELECT * FROM recipe_summaries ORDER BY date_added DESC")
fun queryRecipesByPages(): PagingSource<Int, RecipeSummaryEntity> fun queryRecipesByPages(): PagingSource<Int, RecipeSummaryEntity>
@Query("SELECT * FROM recipe_summaries WHERE recipe_summaries.name LIKE '%' || :query || '%' ORDER BY date_added DESC")
fun queryRecipesByPages(query: String): PagingSource<Int, RecipeSummaryEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipe(recipeSummaryEntity: RecipeSummaryEntity) suspend fun insertRecipes(recipeSummaryEntity: Iterable<RecipeSummaryEntity>)
@Query("DELETE FROM recipe_summaries") @Query("DELETE FROM recipe_summaries")
suspend fun removeAllRecipes() suspend fun removeAllRecipes()