diff --git a/app/build.gradle b/app/build.gradle index a903a0e..b0d8eb2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -51,4 +51,5 @@ dependencies { testImplementation testLibs.values() testImplementation androidxTestLibs.values() + androidTestImplementation androidTestLibs.values() } diff --git a/app/src/androidTest/assets/product_detail.json b/app/src/androidTest/assets/product_detail.json new file mode 100644 index 0000000..b9722cf --- /dev/null +++ b/app/src/androidTest/assets/product_detail.json @@ -0,0 +1,20 @@ +{ + "status_verbose": "product found", + "product": { + "product_name": "Coca Cola", + "image_small_url": "https://static.openfoodfacts.org/images/products/544/900/000/0996/front_es.446.200.jpg", + "stores": "Tesco,Auchan,Carrefour", + "quantity": "330 ml", + "categories": "Beverages, Carbonated drinks, Sodas, Non-Alcoholic beverages, Colas, Sweetened beverages, pl:zawiera-kofeinę", + "image_front_url": "https://static.openfoodfacts.org/images/products/544/900/000/0996/front_es.446.400.jpg", + "brands": "Coca-Cola", + "image_nutrition_url": "https://static.openfoodfacts.org/images/products/544/900/000/0996/nutrition_es.218.400.jpg", + "image_ingredients_url": "https://static.openfoodfacts.org/images/products/544/900/000/0996/ingredients_es.215.400.jpg", + "ingredients_text": "water, sugar, carbon dioxide, dye e150d, acidifier e338, natural flavors including caffeine,", + "countries": "Algérie,Autriche,Belgique,Brésil,Cameroun,Canada,France,Géorgie,Allemagne,Inde,Italie,Luxembourg,Mali,Martinique,Mexique,Maroc,Pays-Bas,Nouvelle-Calédonie,Pologne,Portugal,La Réunion,Arabie saoudite,Sénégal,Espagne,Suisse,Tunisie,Royaume-Uni,États-Unis, en:romania, en:ireland, en:andorra", + "code": "5449000000996", + "generic_name": "Sparkling Soft Drink with Vegetable Extracts" + }, + "code": "5449000000996", + "status": 1 +} \ No newline at end of file diff --git a/app/src/androidTest/assets/search_products.json b/app/src/androidTest/assets/search_products.json new file mode 100644 index 0000000..8a186f9 --- /dev/null +++ b/app/src/androidTest/assets/search_products.json @@ -0,0 +1,128 @@ +{ + "page_size": "20", + "count": 149845, + "products": [ + { + "quantity": "300 g ℮, (15 biscuits de 20 g)", + "code": "7622210449283", + "product_name": "Prince: Goût Chocolat au Blé Complet", + "image_small_url": "https://static.openfoodfacts.org/images/products/762/221/044/9283/front_fr.286.200.jpg" + }, + { + "code": "5449000000996", + "quantity": "330 ml", + "product_name": "Coca Cola", + "image_small_url": "https://static.openfoodfacts.org/images/products/544/900/000/0996/front_es.446.200.jpg" + }, + { + "quantity": "400 g", + "code": "3017620422003", + "product_name": "Nutella", + "image_small_url": "https://static.openfoodfacts.org/images/products/301/762/042/2003/front_fr.168.200.jpg" + }, + { + "product_name": "Coca Cola Zero", + "quantity": "330 ml", + "code": "5449000131805", + "image_small_url": "https://static.openfoodfacts.org/images/products/544/900/013/1805/front_bg.275.200.jpg" + }, + { + "image_small_url": "https://static.openfoodfacts.org/images/products/800/050/031/0427/front_fr.29.200.jpg", + "quantity": "304g", + "code": "8000500310427", + "product_name": "Nutella biscuits" + }, + { + "product_name": "Biscuité vanille", + "quantity": "350 g", + "code": "16130357", + "image_small_url": "https://static.openfoodfacts.org/images/products/16130357/front_fr.239.200.jpg" + }, + { + "image_small_url": "https://static.openfoodfacts.org/images/products/541/018/803/1072/front_fr.30.200.jpg", + "quantity": "1 L", + "code": "5410188031072", + "product_name": "Gazpacho" + }, + { + "code": "3033710065967", + "quantity": "1 kg", + "product_name": "Nesquik", + "image_small_url": "https://static.openfoodfacts.org/images/products/303/371/006/5967/front_fr.80.200.jpg" + }, + { + "product_name": "Nutella", + "code": "3017620421006", + "quantity": "750 g", + "image_small_url": "https://static.openfoodfacts.org/images/products/301/762/042/1006/front_fr.158.200.jpg" + }, + { + "image_small_url": "https://static.openfoodfacts.org/images/products/322/982/012/9488/front_fr.94.200.jpg", + "product_name": "Muesli sans sucre ajouté* Bio", + "code": "3229820129488", + "quantity": "375 g ℮" + }, + { + "product_name": "Nesquik", + "code": "3033710065066", + "quantity": "250 g", + "image_small_url": "https://static.openfoodfacts.org/images/products/303/371/006/5066/front_es.130.200.jpg" + }, + { + "quantity": "100 g ℮", + "code": "3046920022606", + "product_name": "Chocolat Noir 85% Cacao", + "image_small_url": "https://static.openfoodfacts.org/images/products/304/692/002/2606/front_es.85.200.jpg" + }, + { + "product_name": "Coca-Cola", + "code": "5449000267412", + "quantity": "1,25 l", + "image_small_url": "https://static.openfoodfacts.org/images/products/544/900/026/7412/front_fr.36.200.jpg" + }, + { + "image_small_url": "https://static.openfoodfacts.org/images/products/315/947/000/0120/front_fr.83.200.jpg", + "product_name": "Corn Flakes", + "quantity": "500 g", + "code": "3159470000120" + }, + { + "image_small_url": "https://static.openfoodfacts.org/images/products/301/762/042/9484/front_es.228.200.jpg", + "quantity": "825 g", + "code": "3017620429484", + "product_name": "Nutella" + }, + { + "code": "7613034626844", + "quantity": "430 g", + "product_name": "Chocapic", + "image_small_url": "https://static.openfoodfacts.org/images/products/761/303/462/6844/front_es.102.200.jpg" + }, + { + "image_small_url": "https://static.openfoodfacts.org/images/products/541/004/100/1204/front_es.178.200.jpg", + "code": "5410041001204", + "quantity": "100 g", + "product_name": "TUC original" + }, + { + "quantity": "100 g", + "code": "3045140105502", + "product_name": "Alpenmilch", + "image_small_url": "https://static.openfoodfacts.org/images/products/304/514/010/5502/front_es.185.200.jpg" + }, + { + "image_small_url": "https://static.openfoodfacts.org/images/products/316/893/001/0265/front_fr.94.200.jpg", + "product_name": "Cruesli", + "quantity": "450 g ℮", + "code": "3168930010265" + }, + { + "quantity": "500 ml", + "code": "8715700407760", + "product_name": "Tomato Ketchup", + "image_small_url": "https://static.openfoodfacts.org/images/products/871/570/040/7760/front_de.75.200.jpg" + } + ], + "page": "1", + "skip": 0 +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/pabji/myfridge/ui/UITest.kt b/app/src/androidTest/java/com/pabji/myfridge/ui/UITest.kt new file mode 100644 index 0000000..e63ab5e --- /dev/null +++ b/app/src/androidTest/java/com/pabji/myfridge/ui/UITest.kt @@ -0,0 +1,95 @@ +package com.pabji.myfridge.ui + +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.pressBack +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.rule.ActivityTestRule +import androidx.test.rule.GrantPermissionRule +import com.jakewharton.espresso.OkHttp3IdlingResource +import com.pabji.myfridge.R +import com.pabji.myfridge.model.network.api.RetrofitApiClient +import com.pabji.myfridge.ui.main.MainActivity +import com.pabji.myfridge.ui.rules.MockWebServerRule +import com.pabji.myfridge.utils.fromJson +import okhttp3.mockwebserver.MockResponse +import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.CoreMatchers.not +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.koin.test.KoinTest +import org.koin.test.get + +class UITest : KoinTest { + + @get:Rule + val mockWebServerRule = MockWebServerRule() + + @get:Rule + val activityTestRule = ActivityTestRule(MainActivity::class.java, false, false) + + @get:Rule + val grantPermissionRule: GrantPermissionRule = + GrantPermissionRule.grant( + "android.permission.CAMERA" + ) + + @Before + fun setUp() { + mockWebServerRule.server.enqueue(MockResponse().fromJson("search_products.json")) + mockWebServerRule.server.enqueue(MockResponse().fromJson("product_detail.json")) + mockWebServerRule.server.enqueue(MockResponse().fromJson("product_detail.json")) + val resource = OkHttp3IdlingResource.create("OkHttp", get().okHttpClient) + IdlingRegistry.getInstance().register(resource) + } + + @Test + fun onSearchProducts() { + activityTestRule.launchActivity(null) + + onView( + allOf( + withId(R.id.action_search), + isDescendantOfA(withId(R.id.bottom_navigation)) + ) + ).perform(click()) + + onView(withId(R.id.fab)).check(matches(not(isDisplayed()))) + + onView(withId(R.id.rv_product_list)).perform( + RecyclerViewActions.actionOnItemAtPosition( + 1, + click() + ) + ) + + onView(withId(R.id.btn_add)).perform(click()) + + onView(isRoot()).perform(pressBack()) + + onView( + allOf( + withId(R.id.action_fridge), + isDescendantOfA(withId(R.id.bottom_navigation)) + ) + ).perform(click()) + + onView(withId(R.id.fab)).check(matches(isDisplayed())) + + onView(withId(R.id.rv_product_list)).perform( + RecyclerViewActions.actionOnItemAtPosition( + 0, + click() + ) + ) + + onView(isRoot()).perform(pressBack()) + + onView(withId(R.id.fab)).perform(click()) + } +} diff --git a/app/src/androidTest/java/com/pabji/myfridge/ui/rules/MockWebServerRule.kt b/app/src/androidTest/java/com/pabji/myfridge/ui/rules/MockWebServerRule.kt new file mode 100644 index 0000000..a53c545 --- /dev/null +++ b/app/src/androidTest/java/com/pabji/myfridge/ui/rules/MockWebServerRule.kt @@ -0,0 +1,45 @@ +package com.pabji.myfridge.ui.rules + +import okhttp3.mockwebserver.MockWebServer +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement +import org.koin.core.context.loadKoinModules +import org.koin.core.qualifier.named +import org.koin.dsl.module +import kotlin.concurrent.thread + +class MockWebServerRule : TestRule { + + val server = MockWebServer() + + override fun apply(base: Statement?, description: Description?) = object : Statement() { + override fun evaluate() { + server.start() + replaceBaseUrl() + base?.evaluate() + server.shutdown() + } + } + + private fun replaceBaseUrl() { + val testModule = module { + single(named("baseUrl"), override = true) { askMockServerUrlOnAnotherThread() } + } + loadKoinModules(testModule) + } + + private fun askMockServerUrlOnAnotherThread(): String { + /* + This needs to be done immediately, but the App will crash with + "NetworkOnMainThreadException" if this is not extracted from the main thread. So this is + a "hack" to prevent it. We don't care about blocking in a test, and it's fast. + */ + var url = "" + val t = thread { + url = server.url("/").toString() + } + t.join() + return url + } +} diff --git a/app/src/androidTest/java/com/pabji/myfridge/utils/MockWebServerUtils.kt b/app/src/androidTest/java/com/pabji/myfridge/utils/MockWebServerUtils.kt new file mode 100644 index 0000000..52b70fe --- /dev/null +++ b/app/src/androidTest/java/com/pabji/myfridge/utils/MockWebServerUtils.kt @@ -0,0 +1,37 @@ +package com.pabji.myfridge.utils + +import androidx.test.platform.app.InstrumentationRegistry +import okhttp3.mockwebserver.MockResponse +import java.io.BufferedReader +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets.UTF_8 + +fun MockResponse.fromJson(jsonFile: String): MockResponse = + setBody(readJsonFile(jsonFile)) + +private fun readJsonFile(jsonFilePath: String): String { + val context = InstrumentationRegistry.getInstrumentation().context + + var br: BufferedReader? = null + + try { + br = BufferedReader( + InputStreamReader( + context.assets.open( + jsonFilePath + ), UTF_8 + ) + ) + var line: String? + val text = StringBuilder() + + do { + line = br.readLine() + line?.let { text.append(line) } + } while (line != null) + br.close() + return text.toString() + } finally { + br?.close() + } +} diff --git a/app/src/main/java/com/pabji/myfridge/di/Modules.kt b/app/src/main/java/com/pabji/myfridge/di/Modules.kt index 6469943..a9cb871 100644 --- a/app/src/main/java/com/pabji/myfridge/di/Modules.kt +++ b/app/src/main/java/com/pabji/myfridge/di/Modules.kt @@ -37,15 +37,18 @@ fun Application.initDI() { } } +val BASE_URL = named("baseUrl") + val appModule = module { single { - Room.databaseBuilder(get(), RoomDatabase::class.java, "products.db") + Room.databaseBuilder(get(), RoomDatabase::class.java, "products1.db") .fallbackToDestructiveMigration().build() } - single { RetrofitApiClient.createService() } factory { RoomDataSource(get()) } factory { RetrofitDataSource(get()) } single { Dispatchers.Main } + single(BASE_URL) { "https://es.openfoodfacts.org" } + single { RetrofitApiClient(get(BASE_URL)) } } val dataModule = module { diff --git a/app/src/main/java/com/pabji/myfridge/model/network/RetrofitDataSource.kt b/app/src/main/java/com/pabji/myfridge/model/network/RetrofitDataSource.kt index be3c90f..c160fe5 100644 --- a/app/src/main/java/com/pabji/myfridge/model/network/RetrofitDataSource.kt +++ b/app/src/main/java/com/pabji/myfridge/model/network/RetrofitDataSource.kt @@ -5,15 +5,15 @@ import com.pabji.domain.DetailError import com.pabji.domain.Either import com.pabji.domain.SearchError import com.pabji.myfridge.model.network.api.DETAIL_FIELDS -import com.pabji.myfridge.model.network.api.RetrofitApiService +import com.pabji.myfridge.model.network.api.RetrofitApiClient import com.pabji.myfridge.model.network.api.SIMPLE_FIELDS import com.pabji.myfridge.model.network.responses.toProduct -class RetrofitDataSource(private val apiService: RetrofitApiService) : RemoteDatasource { +class RetrofitDataSource(private val apiClient: RetrofitApiClient) : RemoteDatasource { override suspend fun searchProducts(searchTerm: String?) = with( - apiService.searchProductsByName( + apiClient.service.searchProductsByName( searchTerm, fields = SIMPLE_FIELDS.joinToString(",") ) @@ -26,7 +26,7 @@ class RetrofitDataSource(private val apiService: RetrofitApiService) : RemoteDat } override suspend fun getProductByBarcode(barcode: String) = - with(apiService.getProductDetailById(barcode, DETAIL_FIELDS.joinToString(","))) { + with(apiClient.service.getProductDetailById(barcode, DETAIL_FIELDS.joinToString(","))) { if (isSuccessful) { body()?.product?.run { Either.Right(toProduct()) diff --git a/app/src/main/java/com/pabji/myfridge/model/network/api/RetrofitApiClient.kt b/app/src/main/java/com/pabji/myfridge/model/network/api/RetrofitApiClient.kt index 586abe1..abbdf04 100644 --- a/app/src/main/java/com/pabji/myfridge/model/network/api/RetrofitApiClient.kt +++ b/app/src/main/java/com/pabji/myfridge/model/network/api/RetrofitApiClient.kt @@ -6,20 +6,15 @@ import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory -object RetrofitApiClient { +class RetrofitApiClient(baseUrl: String) { - const val BASE_URL = "https://es.openfoodfacts.org" + val okHttpClient = OkHttpClient.Builder().addInterceptor(HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + }).build() - fun createService(): RetrofitApiService { - - val interceptor = HttpLoggingInterceptor() - interceptor.level = HttpLoggingInterceptor.Level.BODY - val client = OkHttpClient.Builder().addInterceptor(interceptor).build() - - return Retrofit.Builder() - .baseUrl(BASE_URL) - .client(client) - .addConverterFactory(GsonConverterFactory.create(GsonBuilder().create())) - .build().create(RetrofitApiService::class.java) - } + val service = Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(GsonBuilder().create())) + .build().create(RetrofitApiService::class.java) } diff --git a/app/src/main/java/com/pabji/myfridge/ui/main/MainActivity.kt b/app/src/main/java/com/pabji/myfridge/ui/main/MainActivity.kt index ac3f90a..757d4ac 100644 --- a/app/src/main/java/com/pabji/myfridge/ui/main/MainActivity.kt +++ b/app/src/main/java/com/pabji/myfridge/ui/main/MainActivity.kt @@ -5,6 +5,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.viewpager2.widget.ViewPager2 import com.pabji.myfridge.R import com.pabji.myfridge.ui.barcode.BarcodeReaderActivity +import com.pabji.myfridge.ui.common.extensions.gone import com.pabji.myfridge.ui.common.extensions.startActivity import com.pabji.myfridge.ui.productList.ProductListFragment import com.pabji.myfridge.ui.searchProducts.SearchProductsFragment @@ -25,7 +26,6 @@ class MainActivity : AppCompatActivity() { private fun setViewPager() { vp_container.run { orientation = ViewPager2.ORIENTATION_HORIZONTAL - offscreenPageLimit = 2 adapter = MainViewPagerAdapter( supportFragmentManager, lifecycle @@ -56,6 +56,10 @@ class MainActivity : AppCompatActivity() { } } + private fun hideBottomNavigation() { + bottom_navigation.gone() + } + private fun setFab() { fab.setOnClickListener { startActivity {} } } diff --git a/app/src/main/java/com/pabji/myfridge/ui/main/MainActivityViewModel.kt b/app/src/main/java/com/pabji/myfridge/ui/main/MainActivityViewModel.kt new file mode 100644 index 0000000..dda27c8 --- /dev/null +++ b/app/src/main/java/com/pabji/myfridge/ui/main/MainActivityViewModel.kt @@ -0,0 +1,19 @@ +package com.pabji.myfridge.ui.main + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.pabji.myfridge.ui.common.BaseViewModel +import kotlinx.coroutines.CoroutineDispatcher + +class MainActivityViewModel( + uiDispatcher: CoroutineDispatcher +) : BaseViewModel(uiDispatcher) { + + private val _model = MutableLiveData() + val model: LiveData = _model + + sealed class UiModel { + object ShowSearch : UiModel() + object ShowFridge : UiModel() + } +} diff --git a/app/src/main/java/com/pabji/myfridge/ui/searchProducts/SearchProductsFragment.kt b/app/src/main/java/com/pabji/myfridge/ui/searchProducts/SearchProductsFragment.kt index 7faed57..5d09d21 100644 --- a/app/src/main/java/com/pabji/myfridge/ui/searchProducts/SearchProductsFragment.kt +++ b/app/src/main/java/com/pabji/myfridge/ui/searchProducts/SearchProductsFragment.kt @@ -64,7 +64,7 @@ class SearchProductsFragment : BaseFragment() { inflater.inflate(R.menu.main_toolbar_menu, menu) val searchManager = activity?.getSystemService(Context.SEARCH_SERVICE) as? SearchManager - val searchMenuItem = menu.findItem(R.id.action_search) as SupportMenuItem + val searchMenuItem = menu.findItem(R.id.action_toolbar_search) as SupportMenuItem searchView = (searchMenuItem.actionView as SearchView).apply { setSearchableInfo(searchManager?.getSearchableInfo(activity?.componentName)) setOnQueryTextFocusChangeListener { _, isVisible -> diff --git a/app/src/main/res/menu/main_toolbar_menu.xml b/app/src/main/res/menu/main_toolbar_menu.xml index 4689a71..103c6c7 100644 --- a/app/src/main/res/menu/main_toolbar_menu.xml +++ b/app/src/main/res/menu/main_toolbar_menu.xml @@ -2,7 +2,7 @@