diff --git a/app/build.gradle b/app/build.gradle index 7581c69..2a62af1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.application' id 'kotlin-android' id 'com.google.gms.google-services' + id 'kotlin-kapt' } android { @@ -87,4 +88,6 @@ dependencies { implementation "io.ktor:ktor-client-cio:$ktor_version" implementation "io.ktor:ktor-client-content-negotiation:$ktor_version" implementation "io.ktor:ktor-serialization-gson:$ktor_version" + implementation "androidx.room:room-runtime:$room_version" + kapt("androidx.room:room-compiler:$room_version") } \ No newline at end of file diff --git a/app/src/main/java/com/cookit/AppModule.kt b/app/src/main/java/com/cookit/AppModule.kt index 4d410dd..121c5fb 100644 --- a/app/src/main/java/com/cookit/AppModule.kt +++ b/app/src/main/java/com/cookit/AppModule.kt @@ -2,10 +2,11 @@ package com.cookit import com.cookit.service.IRecipeService import com.cookit.service.KtorRecipeService +import org.koin.android.ext.koin.androidApplication import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module val appModule = module { viewModel { MainViewModel(get()) } - single { KtorRecipeService() } + single { KtorRecipeService(androidApplication()) } } \ No newline at end of file diff --git a/app/src/main/java/com/cookit/MainViewModel.kt b/app/src/main/java/com/cookit/MainViewModel.kt index e497f4b..5a19bbc 100644 --- a/app/src/main/java/com/cookit/MainViewModel.kt +++ b/app/src/main/java/com/cookit/MainViewModel.kt @@ -13,7 +13,6 @@ import com.cookit.dto.Photo import com.cookit.dto.Recipe import com.cookit.dto.User import com.cookit.service.IRecipeService -import com.cookit.service.KtorRecipeService import com.cookit.service.TextFieldIngredientMapService import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestoreSettings @@ -24,12 +23,12 @@ import kotlinx.coroutines.launch * Class for the primary view model * Used to supply [MutableLiveData] to views */ -class MainViewModel(var recipeService: IRecipeService = KtorRecipeService()) : ViewModel() { +class MainViewModel(var recipeService: IRecipeService) : ViewModel() { val photos: ArrayList = ArrayList() var user: User? = null val NEW_RECIPE = "New Recipe" - val recipes: MutableLiveData> = MutableLiveData>() + val recipes = recipeService.getRecipeDAO().getAllRecipes() val userRecipes: MutableLiveData> = MutableLiveData>() var selectedRecipe by mutableStateOf(Recipe()) val ingredientMapper = TextFieldIngredientMapService() @@ -70,9 +69,7 @@ class MainViewModel(var recipeService: IRecipeService = KtorRecipeService()) : V internal fun fetchRecipes() { viewModelScope.launch { - val innerRecipeList = recipeService.fetchRecipes() - val innerRecipes: ArrayList = innerRecipeList?.recipes ?: ArrayList() - recipes.postValue(innerRecipes) + recipeService.fetchRecipes() } } @@ -176,7 +173,6 @@ class MainViewModel(var recipeService: IRecipeService = KtorRecipeService()) : V } else -> return listOf() } - } - return listOf() + } ?: return listOf() } } diff --git a/app/src/main/java/com/cookit/RetrofitClientInstance.kt b/app/src/main/java/com/cookit/RetrofitClientInstance.kt deleted file mode 100644 index 32cd836..0000000 --- a/app/src/main/java/com/cookit/RetrofitClientInstance.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.cookit - -import com.cookit.dto.Recipe -import com.cookit.service.RecipeSerializationService -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory - -object RetrofitClientInstance { - - private var retrofit: Retrofit? = null - private const val BASE_URL = "https://www.themealdb.com/api/json/v2/" - private val recipeDeserializer = RecipeSerializationService() - private val customGSON: Gson = GsonBuilder() - .registerTypeAdapter(Recipe::class.java, recipeDeserializer) - .create() - - val retrofitInstance : Retrofit? - get() { - if (retrofit == null) { - retrofit = Retrofit.Builder() - .baseUrl(BASE_URL) - .addConverterFactory(GsonConverterFactory.create(customGSON)) - .build() - } - return retrofit - } -} \ No newline at end of file diff --git a/app/src/main/java/com/cookit/dao/IRecipeDAO.kt b/app/src/main/java/com/cookit/dao/IRecipeDAO.kt new file mode 100644 index 0000000..0b5f51c --- /dev/null +++ b/app/src/main/java/com/cookit/dao/IRecipeDAO.kt @@ -0,0 +1,17 @@ +package com.cookit.dao + +import androidx.lifecycle.LiveData +import androidx.room.* +import com.cookit.dto.Recipe + +@Dao +interface IRecipeDAO { + @Query("SELECT * FROM recipe") + fun getAllRecipes(): LiveData> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(recipes: ArrayList) + + @Delete + fun delete(recipe: Recipe) +} \ No newline at end of file diff --git a/app/src/main/java/com/cookit/dao/RecipeDatabase.kt b/app/src/main/java/com/cookit/dao/RecipeDatabase.kt new file mode 100644 index 0000000..98c830d --- /dev/null +++ b/app/src/main/java/com/cookit/dao/RecipeDatabase.kt @@ -0,0 +1,13 @@ +package com.cookit.dao + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.cookit.dto.Recipe +import com.cookit.service.StringMapConverter + +@Database(entities = [Recipe::class], version = 1) +@TypeConverters(StringMapConverter::class) +abstract class RecipeDatabase : RoomDatabase() { + abstract fun recipeDao(): IRecipeDAO +} \ No newline at end of file diff --git a/app/src/main/java/com/cookit/dto/Recipe.kt b/app/src/main/java/com/cookit/dto/Recipe.kt index 75f83fc..04e24a1 100644 --- a/app/src/main/java/com/cookit/dto/Recipe.kt +++ b/app/src/main/java/com/cookit/dto/Recipe.kt @@ -1,19 +1,24 @@ package com.cookit.dto +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey import com.google.gson.annotations.Expose import com.google.gson.annotations.SerializedName +@Entity(tableName = "recipe") data class Recipe( - @SerializedName("idMeal")var recipeID : String = "", - @SerializedName("strMeal")var name : String = "", - @SerializedName("strCategory")var category : String = "", - @SerializedName("strArea")var cuisine : String = "", - @SerializedName("strInstructions")var instructions : String = "", - @SerializedName("strMealThumb")var imageURL : String = "", - @SerializedName("strYoutube")var youtubeURL : String = "", - @Expose var ingredients : MutableMap = LinkedHashMap(), - @Transient var fireStoreID: String = "", - ) { + @PrimaryKey @ColumnInfo(name = "id") @SerializedName("idMeal") var recipeID: String = "", + @SerializedName("strMeal") var name: String = "", + @SerializedName("strCategory") var category: String = "", + @SerializedName("strArea") var cuisine: String = "", + @SerializedName("strInstructions") var instructions: String = "", + @ColumnInfo(name = "image_url") @SerializedName("strMealThumb") var imageURL: String = "", + @ColumnInfo(name = "youtube_url") @SerializedName("strYoutube") var youtubeURL: String = "", + @Expose var ingredients: MutableMap = LinkedHashMap(), + @Ignore @Transient var fireStoreID: String = "", +) { override fun toString(): String { return name } diff --git a/app/src/main/java/com/cookit/service/KtorRecipeService.kt b/app/src/main/java/com/cookit/service/KtorRecipeService.kt index ba9019b..e8579de 100644 --- a/app/src/main/java/com/cookit/service/KtorRecipeService.kt +++ b/app/src/main/java/com/cookit/service/KtorRecipeService.kt @@ -1,6 +1,13 @@ package com.cookit.service +import android.app.Application +import android.content.ContentValues.TAG +import android.util.Log +import androidx.room.Room import com.cookit.KtorClientInstance +import com.cookit.dao.IRecipeDAO +import com.cookit.dao.RecipeDatabase +import com.cookit.dto.Recipe import com.cookit.dto.RecipeList import io.ktor.client.call.* import io.ktor.client.request.* @@ -12,18 +19,44 @@ private val CALLS = mapOf("allRecipes" to "9973533/search.php?s=") interface IRecipeService { suspend fun fetchRecipes(call: String = "allRecipes"): RecipeList? + fun getRecipeDAO(): IRecipeDAO } -class KtorRecipeService : IRecipeService { +class KtorRecipeService(val application: Application) : IRecipeService { + lateinit var db: RecipeDatabase + override suspend fun fetchRecipes(call: String): RecipeList? { return withContext(Dispatchers.IO) { val response = KtorClientInstance.ktorInstance?.get(BASE_URL + CALLS[call]) ?: throw Exception("Error!, ktorClient was null for some reason!") if (response.status.value in 200..299) { + updateRecipes(response.body()) return@withContext response.body() } else { throw Exception("Failed to get recipes. Server Response: ${response.status.value}") } } } + + private fun updateRecipes(recipeList: RecipeList?) { + try { + val recipes = recipeList?.recipes ?: arrayListOf() + recipes.let { + val recipeDao = getRecipeDAO() + recipeDao.insertAll(recipes) + } + } catch (e: Exception) { + Log.e(TAG, "Error saving recipes") + } + } + + override fun getRecipeDAO(): IRecipeDAO { + if (!this::db.isInitialized) { + db = Room.databaseBuilder( + application, + RecipeDatabase::class.java, "recipe-db" + ).build() + } + return db.recipeDao() + } } \ No newline at end of file diff --git a/app/src/main/java/com/cookit/service/StringMapConverter.kt b/app/src/main/java/com/cookit/service/StringMapConverter.kt new file mode 100644 index 0000000..6e7d2b7 --- /dev/null +++ b/app/src/main/java/com/cookit/service/StringMapConverter.kt @@ -0,0 +1,27 @@ +package com.cookit.service + +import androidx.room.TypeConverter +import java.util.* + +class StringMapConverter { + @TypeConverter + fun fromStringMap(value: Map): String { + val sortedMap = TreeMap(value) + return sortedMap.keys.joinToString(separator = ",").plus("") + .plus(sortedMap.values.joinToString(separator = ",")) + } + + @TypeConverter + fun toStringMap(value: String): Map { + return value.split("").run { + val keys = getOrNull(0)?.split(",")?.map { it } + val values = getOrNull(1)?.split(",")?.map { it } + + val res = hashMapOf() + keys?.forEachIndexed { index, s -> + res[s] = values?.getOrNull(index) ?: "" + } + res + } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/cookit/RecipeIntegrationTest.kt b/app/src/test/java/com/cookit/RecipeIntegrationTest.kt index 13800c2..65420f0 100644 --- a/app/src/test/java/com/cookit/RecipeIntegrationTest.kt +++ b/app/src/test/java/com/cookit/RecipeIntegrationTest.kt @@ -1,17 +1,30 @@ package com.cookit +import android.app.Application import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.cookit.dto.RecipeList import com.cookit.service.KtorRecipeService import junit.framework.Assert.assertNotNull import junit.framework.Assert.assertTrue import kotlinx.coroutines.test.runTest +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.mockito.Mock +import org.mockito.MockitoAnnotations + class RecipeIntegrationTest { + @Mock + private lateinit var application: Application + + @Before + fun init() { + MockitoAnnotations.initMocks(this) + } + @get:Rule var rule: TestRule = InstantTaskExecutorRule() @@ -35,7 +48,7 @@ class RecipeIntegrationTest { private fun givenRecipeServiceIsInitialized() { - recipeService = KtorRecipeService() + recipeService = KtorRecipeService(application) } private suspend fun whenRecipeDataIsParsed() { diff --git a/build.gradle b/build.gradle index 371863f..38559a1 100644 --- a/build.gradle +++ b/build.gradle @@ -4,6 +4,7 @@ buildscript { compose_version = '1.0.1' koin_version = "3.1.5" ktor_version = "2.0.1" + room_version = "2.4.2" } repositories { google()