├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── values
│ │ │ │ ├── strings.xml
│ │ │ │ ├── colors.xml
│ │ │ │ ├── dimen.xml
│ │ │ │ └── styles.xml
│ │ │ ├── drawable
│ │ │ │ ├── beef.jpg
│ │ │ │ ├── brunch.jpg
│ │ │ │ ├── dinner.jpg
│ │ │ │ ├── wine.jpg
│ │ │ │ ├── barbeque.jpg
│ │ │ │ ├── chicken.jpg
│ │ │ │ ├── italian.jpg
│ │ │ │ ├── breakfast.jpg
│ │ │ │ ├── white_background.png
│ │ │ │ ├── ic_error_outline_black_24dp.xml
│ │ │ │ ├── ic_mood_bad_black_24dp.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── menu
│ │ │ │ └── recipe_search_menu.xml
│ │ │ ├── xml
│ │ │ │ └── network_security_config.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── layout
│ │ │ │ ├── layout_loading_list_item.xml
│ │ │ │ ├── layout_search_exhausted.xml
│ │ │ │ ├── activity_base.xml
│ │ │ │ ├── layout_category_list_item.xml
│ │ │ │ ├── activity_recipe_list.xml
│ │ │ │ ├── activity_recipe.xml
│ │ │ │ └── layout_recipe_list_item.xml
│ │ │ └── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── codingwithmitch
│ │ │ │ └── foodrecipes
│ │ │ │ ├── adapters
│ │ │ │ ├── OnRecipeListener.java
│ │ │ │ ├── LoadingViewHolder.java
│ │ │ │ ├── SearchExhaustedViewHolder.java
│ │ │ │ ├── CategoryViewHolder.java
│ │ │ │ ├── RecipeViewHolder.java
│ │ │ │ └── RecipeRecyclerAdapter.java
│ │ │ │ ├── util
│ │ │ │ ├── Testing.java
│ │ │ │ ├── VerticalSpacingItemDecorator.java
│ │ │ │ ├── Resource.java
│ │ │ │ ├── Constants.java
│ │ │ │ ├── LiveDataCallAdapter.java
│ │ │ │ ├── LiveDataCallAdapterFactory.java
│ │ │ │ ├── HorizontalDottedProgress.java
│ │ │ │ └── NetworkBoundResource.java
│ │ │ │ ├── requests
│ │ │ │ ├── responses
│ │ │ │ │ ├── CheckRecipeApiKey.java
│ │ │ │ │ ├── RecipeResponse.java
│ │ │ │ │ ├── RecipeSearchResponse.java
│ │ │ │ │ └── ApiResponse.java
│ │ │ │ ├── RecipeApi.java
│ │ │ │ └── ServiceGenerator.java
│ │ │ │ ├── persistence
│ │ │ │ ├── Converters.java
│ │ │ │ ├── RecipeDatabase.java
│ │ │ │ └── RecipeDao.java
│ │ │ │ ├── viewmodels
│ │ │ │ ├── RecipeViewModel.java
│ │ │ │ └── RecipeListViewModel.java
│ │ │ │ ├── BaseActivity.java
│ │ │ │ ├── AppExecutors.java
│ │ │ │ ├── models
│ │ │ │ └── Recipe.java
│ │ │ │ ├── RecipeActivity.java
│ │ │ │ ├── repositories
│ │ │ │ └── RecipeRepository.java
│ │ │ │ └── RecipeListActivity.java
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── com
│ │ │ └── codingwithmitch
│ │ │ └── foodrecipes
│ │ │ └── ExampleUnitTest.java
│ └── androidTest
│ │ └── java
│ │ └── com
│ │ └── codingwithmitch
│ │ └── foodrecipes
│ │ └── ExampleInstrumentedTest.java
├── proguard-rules.pro
└── build.gradle
├── settings.gradle
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .idea
├── vcs.xml
├── misc.xml
├── runConfigurations.xml
├── gradle.xml
└── codeStyles
│ └── Project.xml
├── .gitignore
├── gradle.properties
├── README.md
├── gradlew.bat
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':app'
2 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | FoodRecipes
3 |
4 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/drawable/beef.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/drawable/beef.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable/brunch.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/drawable/brunch.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable/dinner.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/drawable/dinner.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable/wine.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/drawable/wine.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable/barbeque.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/drawable/barbeque.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable/chicken.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/drawable/chicken.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable/italian.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/drawable/italian.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable/breakfast.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/drawable/breakfast.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable/white_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/drawable/white_background.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appearance/Local-db-Cache-Retrofit-REST-API-MVVM/master/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/menu/recipe_search_menu.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | /build
12 | /captures
13 | .externalNativeBuild
14 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/adapters/OnRecipeListener.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.adapters;
2 |
3 | public interface OnRecipeListener {
4 |
5 | void onRecipeClick(int position);
6 |
7 | void onCategoryClick(String category);
8 | }
9 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Mon Nov 18 08:25:42 PST 2019
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip
7 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/network_security_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | static.food2fork.com
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #fff
4 | #b3b3b3
5 | #FF4081
6 |
7 | #e22b2b
8 |
9 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/values/dimen.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 250dp
4 | 20sp
5 | 16sp
6 | 80dp
7 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/adapters/LoadingViewHolder.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.adapters;
2 |
3 | import android.support.annotation.NonNull;
4 | import android.support.v7.widget.RecyclerView;
5 | import android.view.View;
6 |
7 | public class LoadingViewHolder extends RecyclerView.ViewHolder {
8 |
9 | public LoadingViewHolder(@NonNull View itemView) {
10 | super(itemView);
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/adapters/SearchExhaustedViewHolder.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.adapters;
2 |
3 | import android.support.annotation.NonNull;
4 | import android.support.v7.widget.RecyclerView;
5 | import android.view.View;
6 |
7 | public class SearchExhaustedViewHolder extends RecyclerView.ViewHolder {
8 |
9 | public SearchExhaustedViewHolder(@NonNull View itemView) {
10 | super(itemView);
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/util/Testing.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.util;
2 |
3 | import android.util.Log;
4 |
5 | import com.codingwithmitch.foodrecipes.models.Recipe;
6 |
7 | import java.util.List;
8 |
9 | public class Testing {
10 |
11 | public static void printRecipes(Listlist, String tag){
12 | for(Recipe recipe: list){
13 | Log.d(tag, "onChanged: " + recipe.getTitle());
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/requests/responses/CheckRecipeApiKey.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.requests.responses;
2 |
3 | public class CheckRecipeApiKey {
4 |
5 | protected static boolean isRecipeApiKeyValid(RecipeSearchResponse response){
6 | return response.getError() == null;
7 | }
8 |
9 | protected static boolean isRecipeApiKeyValid(RecipeResponse response){
10 | return response.getError() == null;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/test/java/com/codingwithmitch/foodrecipes/ExampleUnitTest.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes;
2 |
3 | import org.junit.Test;
4 |
5 | import static org.junit.Assert.*;
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * @see Testing documentation
11 | */
12 | public class ExampleUnitTest {
13 | @Test
14 | public void addition_isCorrect() {
15 | assertEquals(4, 2 + 2);
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/layout_loading_list_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_error_outline_black_24dp.xml:
--------------------------------------------------------------------------------
1 |
7 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.idea/runConfigurations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/layout_search_exhausted.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
15 |
16 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_mood_bad_black_24dp.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx1536m
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 |
15 |
16 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/util/VerticalSpacingItemDecorator.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.util;
2 |
3 | import android.graphics.Rect;
4 | import android.support.annotation.NonNull;
5 | import android.support.v7.widget.RecyclerView;
6 | import android.view.View;
7 |
8 | public class VerticalSpacingItemDecorator extends RecyclerView.ItemDecoration {
9 |
10 | private final int verticalSpaceHeight;
11 |
12 | public VerticalSpacingItemDecorator(int verticalSpaceHeight) {
13 | this.verticalSpaceHeight = verticalSpaceHeight;
14 | }
15 |
16 | @Override
17 | public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
18 |
19 | outRect.top = verticalSpaceHeight;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/persistence/Converters.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.persistence;
2 |
3 | import android.arch.persistence.room.TypeConverter;
4 |
5 | import com.google.gson.Gson;
6 | import com.google.gson.reflect.TypeToken;
7 |
8 | import java.lang.reflect.Type;
9 |
10 | public class Converters {
11 |
12 | @TypeConverter
13 | public static String[] fromString(String value){
14 | Type listType = new TypeToken(){}.getType();
15 | return new Gson().fromJson(value, listType);
16 | }
17 |
18 | @TypeConverter
19 | public static String fromArrayList(String[] list){
20 | Gson gson = new Gson();
21 | String json = gson.toJson(list);
22 | return json;
23 | }
24 | }
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/codingwithmitch/foodrecipes/ExampleInstrumentedTest.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes;
2 |
3 | import android.content.Context;
4 | import android.support.test.InstrumentationRegistry;
5 | import android.support.test.runner.AndroidJUnit4;
6 |
7 | import org.junit.Test;
8 | import org.junit.runner.RunWith;
9 |
10 | import static org.junit.Assert.*;
11 |
12 | /**
13 | * Instrumented test, which will execute on an Android device.
14 | *
15 | * @see Testing documentation
16 | */
17 | @RunWith(AndroidJUnit4.class)
18 | public class ExampleInstrumentedTest {
19 | @Test
20 | public void useAppContext() {
21 | // Context of the app under test.
22 | Context appContext = InstrumentationRegistry.getTargetContext();
23 |
24 | assertEquals("com.codingwithmitch.foodrecipes", appContext.getPackageName());
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/requests/RecipeApi.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.requests;
2 |
3 | import android.arch.lifecycle.LiveData;
4 |
5 | import com.codingwithmitch.foodrecipes.requests.responses.ApiResponse;
6 | import com.codingwithmitch.foodrecipes.requests.responses.RecipeResponse;
7 | import com.codingwithmitch.foodrecipes.requests.responses.RecipeSearchResponse;
8 |
9 | import retrofit2.Call;
10 | import retrofit2.http.GET;
11 | import retrofit2.http.Query;
12 |
13 | public interface RecipeApi {
14 |
15 | // SEARCH
16 | @GET("api/search")
17 | LiveData> searchRecipe(
18 | @Query("key") String key,
19 | @Query("q") String query,
20 | @Query("page") String page
21 | );
22 |
23 | // GET RECIPE REQUEST
24 | @GET("api/get")
25 | LiveData> getRecipe(
26 | @Query("key") String key,
27 | @Query("rId") String recipe_id
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/requests/responses/RecipeResponse.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.requests.responses;
2 |
3 | import android.support.annotation.NonNull;
4 | import android.support.annotation.Nullable;
5 |
6 | import com.codingwithmitch.foodrecipes.models.Recipe;
7 | import com.google.gson.annotations.Expose;
8 | import com.google.gson.annotations.SerializedName;
9 |
10 | public class RecipeResponse {
11 |
12 | @SerializedName("recipe")
13 | @Expose()
14 | private Recipe recipe;
15 |
16 | @SerializedName("error")
17 | @Expose()
18 | private String error;
19 |
20 | public String getError() {
21 | return error;
22 | }
23 |
24 | @Nullable
25 | public Recipe getRecipe(){
26 | return recipe;
27 | }
28 |
29 | @Override
30 | public String toString() {
31 | return "RecipeResponse{" +
32 | "recipe=" + recipe +
33 | ", error='" + error + '\'' +
34 | '}';
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_base.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
12 |
13 |
14 |
15 |
25 |
26 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/viewmodels/RecipeViewModel.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.viewmodels;
2 |
3 |
4 | import android.app.Application;
5 | import android.arch.lifecycle.AndroidViewModel;
6 | import android.arch.lifecycle.LiveData;
7 | import android.support.annotation.NonNull;
8 |
9 | import com.codingwithmitch.foodrecipes.models.Recipe;
10 | import com.codingwithmitch.foodrecipes.repositories.RecipeRepository;
11 | import com.codingwithmitch.foodrecipes.util.Resource;
12 |
13 |
14 | public class RecipeViewModel extends AndroidViewModel {
15 |
16 | private RecipeRepository recipeRepository;
17 |
18 | public RecipeViewModel(@NonNull Application application) {
19 | super(application);
20 | recipeRepository = RecipeRepository.getInstance(application);
21 | }
22 |
23 | public LiveData> searchRecipeApi(String recipeId){
24 | return recipeRepository.searchRecipesApi(recipeId);
25 | }
26 | }
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/BaseActivity.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes;
2 |
3 | import android.support.constraint.ConstraintLayout;
4 | import android.support.v7.app.AppCompatActivity;
5 | import android.view.View;
6 | import android.widget.FrameLayout;
7 | import android.widget.ProgressBar;
8 |
9 | public abstract class BaseActivity extends AppCompatActivity {
10 |
11 | public ProgressBar mProgressBar;
12 |
13 | @Override
14 | public void setContentView(int layoutResID) {
15 |
16 | ConstraintLayout constraintLayout = (ConstraintLayout) getLayoutInflater().inflate(R.layout.activity_base, null);
17 | FrameLayout frameLayout = constraintLayout.findViewById(R.id.activity_content);
18 | mProgressBar = constraintLayout.findViewById(R.id.progress_bar);
19 |
20 | getLayoutInflater().inflate(layoutResID, frameLayout, true);
21 | super.setContentView(constraintLayout);
22 | }
23 |
24 | public void showProgressBar(boolean visibility){
25 | mProgressBar.setVisibility(visibility ? View.VISIBLE : View.INVISIBLE);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/util/Resource.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.util;
2 |
3 | import android.support.annotation.NonNull;
4 | import android.support.annotation.Nullable;
5 |
6 | public class Resource {
7 |
8 | @NonNull
9 | public final Status status;
10 |
11 | @Nullable
12 | public final T data;
13 |
14 | @Nullable
15 | public final String message;
16 |
17 | public Resource(@NonNull Status status, @Nullable T data, @Nullable String message) {
18 | this.status = status;
19 | this.data = data;
20 | this.message = message;
21 | }
22 |
23 | public static Resource success(@NonNull T data) {
24 | return new Resource<>(Status.SUCCESS, data, null);
25 | }
26 |
27 | public static Resource error(@NonNull String msg, @Nullable T data) {
28 | return new Resource<>(Status.ERROR, data, msg);
29 | }
30 |
31 | public static Resource loading(@Nullable T data) {
32 | return new Resource<>(Status.LOADING, data, null);
33 | }
34 |
35 | public enum Status { SUCCESS, ERROR, LOADING}
36 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/persistence/RecipeDatabase.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.persistence;
2 |
3 | import android.arch.persistence.room.Database;
4 | import android.arch.persistence.room.Room;
5 | import android.arch.persistence.room.RoomDatabase;
6 | import android.arch.persistence.room.TypeConverters;
7 | import android.content.Context;
8 |
9 | import com.codingwithmitch.foodrecipes.models.Recipe;
10 |
11 | @Database(entities = {Recipe.class}, version = 1)
12 | @TypeConverters({Converters.class})
13 | public abstract class RecipeDatabase extends RoomDatabase {
14 |
15 | public static final String DATABASE_NAME = "recipes_db";
16 |
17 | private static RecipeDatabase instance;
18 |
19 | public static RecipeDatabase getInstance(final Context context){
20 | if(instance == null){
21 | instance = Room.databaseBuilder(
22 | context.getApplicationContext(),
23 | RecipeDatabase.class,
24 | DATABASE_NAME
25 | ).build();
26 | }
27 | return instance;
28 | }
29 |
30 | public abstract RecipeDao getRecipeDao();
31 |
32 | }
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/requests/responses/RecipeSearchResponse.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.requests.responses;
2 |
3 | import android.support.annotation.NonNull;
4 | import android.support.annotation.Nullable;
5 |
6 | import com.codingwithmitch.foodrecipes.models.Recipe;
7 | import com.google.gson.annotations.Expose;
8 | import com.google.gson.annotations.SerializedName;
9 |
10 | import java.util.List;
11 |
12 | public class RecipeSearchResponse {
13 |
14 | @SerializedName("count")
15 | @Expose()
16 | private int count;
17 |
18 | @SerializedName("recipes")
19 | @Expose()
20 | private List recipes;
21 |
22 | @SerializedName("error")
23 | @Expose()
24 | private String error;
25 |
26 | public String getError() {
27 | return error;
28 | }
29 |
30 | public int getCount() {
31 | return count;
32 | }
33 |
34 | @Nullable
35 | public List getRecipes() {
36 | return recipes;
37 | }
38 |
39 | @Override
40 | public String toString() {
41 | return "RecipeSearchResponse{" +
42 | "count=" + count +
43 | ", recipes=" + recipes +
44 | ", error='" + error + '\'' +
45 | '}';
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/AppExecutors.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes;
2 |
3 |
4 | import android.os.Handler;
5 | import android.os.Looper;
6 | import android.support.annotation.NonNull;
7 |
8 | import java.util.concurrent.Executor;
9 | import java.util.concurrent.Executors;
10 | import java.util.concurrent.ScheduledExecutorService;
11 |
12 | public class AppExecutors {
13 |
14 | private static AppExecutors instance;
15 |
16 | public static AppExecutors getInstance(){
17 | if(instance == null){
18 | instance = new AppExecutors();
19 | }
20 | return instance;
21 | }
22 |
23 | private final Executor mDiskIO = Executors.newSingleThreadExecutor();
24 |
25 | private final Executor mMainThreadExecutor = new MainThreadExecutor();
26 |
27 |
28 | public Executor diskIO(){
29 | return mDiskIO;
30 | }
31 |
32 | public Executor mainThread(){
33 | return mMainThreadExecutor;
34 | }
35 |
36 | private static class MainThreadExecutor implements Executor{
37 |
38 | private Handler mainThreadHandler = new Handler(Looper.getMainLooper());
39 |
40 | @Override
41 | public void execute(@NonNull Runnable command) {
42 | mainThreadHandler.post(command);
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/layout_category_list_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
15 |
16 |
21 |
22 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/util/Constants.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.util;
2 |
3 | public class Constants {
4 |
5 | // public static final String BASE_URL = "https://www.food2fork.com";
6 | public static final String BASE_URL = "https://recipesapi.herokuapp.com";
7 |
8 | // API_KEY is no longer necessary since food2fork has shutdown. This can be empty it doesn't matter.
9 | // public static final String API_KEY = "453556cb475252e7e42d65aa11912447";
10 | public static final String API_KEY = "";
11 |
12 | public static final int CONNECTION_TIMEOUT = 10; // 10 seconds
13 | public static final int READ_TIMEOUT = 2; // 2 seconds
14 | public static final int WRITE_TIMEOUT = 2; // 2 seconds
15 |
16 | public static final int RECIPE_REFRESH_TIME = 60 * 60 * 24 * 30; // 30 days (in seconds)
17 |
18 |
19 | public static final String[] DEFAULT_SEARCH_CATEGORIES =
20 | {"Barbeque", "Breakfast", "Chicken", "Beef", "Brunch", "Dinner", "Wine", "Italian"};
21 |
22 | public static final String[] DEFAULT_SEARCH_CATEGORY_IMAGES =
23 | {
24 | "barbeque",
25 | "breakfast",
26 | "chicken",
27 | "beef",
28 | "brunch",
29 | "dinner",
30 | "wine",
31 | "italian"
32 | };
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/persistence/RecipeDao.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.persistence;
2 |
3 | import android.arch.lifecycle.LiveData;
4 | import android.arch.persistence.room.Dao;
5 | import android.arch.persistence.room.Ignore;
6 | import android.arch.persistence.room.Insert;
7 | import android.arch.persistence.room.Query;
8 |
9 | import com.codingwithmitch.foodrecipes.models.Recipe;
10 |
11 | import java.util.List;
12 |
13 | import static android.arch.persistence.room.OnConflictStrategy.IGNORE;
14 | import static android.arch.persistence.room.OnConflictStrategy.REPLACE;
15 |
16 | @Dao
17 | public interface RecipeDao {
18 |
19 | @Insert(onConflict = IGNORE)
20 | long[] insertRecipes(Recipe... recipe);
21 |
22 | @Insert(onConflict = REPLACE)
23 | void insertRecipe(Recipe recipe);
24 |
25 | @Query("UPDATE recipes SET title = :title, publisher = :publisher, image_url = :image_url, social_rank = :social_rank " +
26 | "WHERE recipe_id = :recipe_id")
27 | void updateRecipe(String recipe_id, String title, String publisher, String image_url, float social_rank);
28 |
29 | @Query("SELECT * FROM recipes WHERE title LIKE '%' || :query || '%' OR ingredients LIKE '%' || :query || '%' " +
30 | "ORDER BY social_rank DESC LIMIT (:pageNumber * 30)")
31 | LiveData> searchRecipes(String query, int pageNumber);
32 |
33 | @Query("SELECT * FROM recipes WHERE recipe_id = :recipe_id")
34 | LiveData getRecipe(String recipe_id);
35 |
36 | }
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_recipe_list.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
13 |
14 |
19 |
20 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/util/LiveDataCallAdapter.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.util;
2 |
3 | import android.arch.lifecycle.LiveData;
4 |
5 | import com.codingwithmitch.foodrecipes.requests.responses.ApiResponse;
6 |
7 | import java.io.IOException;
8 | import java.lang.reflect.Type;
9 |
10 | import retrofit2.Call;
11 | import retrofit2.CallAdapter;
12 | import retrofit2.Callback;
13 | import retrofit2.Response;
14 |
15 | public class LiveDataCallAdapter implements CallAdapter>> {
16 |
17 | private Type responseType;
18 |
19 | public LiveDataCallAdapter(Type responseType) {
20 | this.responseType = responseType;
21 | }
22 |
23 | @Override
24 | public Type responseType() {
25 | return responseType;
26 | }
27 |
28 | @Override
29 | public LiveData> adapt(final Call call) {
30 | return new LiveData>(){
31 | @Override
32 | protected void onActive() {
33 | super.onActive();
34 | final ApiResponse apiResponse = new ApiResponse();
35 | if(!call.isExecuted()){
36 | call.enqueue(new Callback() {
37 | @Override
38 | public void onResponse(Call call, Response response) {
39 | postValue(apiResponse.create(response));
40 | }
41 |
42 | @Override
43 | public void onFailure(Call call, Throwable t) {
44 | postValue(apiResponse.create(t));
45 | }
46 | });
47 | }
48 |
49 | }
50 | };
51 | }
52 |
53 | }
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/adapters/CategoryViewHolder.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.adapters;
2 |
3 | import android.net.Uri;
4 | import android.support.annotation.NonNull;
5 | import android.support.v7.widget.RecyclerView;
6 | import android.view.View;
7 | import android.widget.TextView;
8 |
9 | import com.bumptech.glide.Glide;
10 | import com.bumptech.glide.RequestManager;
11 | import com.bumptech.glide.request.RequestOptions;
12 | import com.codingwithmitch.foodrecipes.R;
13 | import com.codingwithmitch.foodrecipes.models.Recipe;
14 |
15 | import de.hdodenhof.circleimageview.CircleImageView;
16 |
17 | public class CategoryViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
18 |
19 | CircleImageView categoryImage;
20 | TextView categoryTitle;
21 | OnRecipeListener listener;
22 | RequestManager requestManager;
23 |
24 | public CategoryViewHolder(@NonNull View itemView,
25 | OnRecipeListener listener,
26 | RequestManager requestManager) {
27 | super(itemView);
28 |
29 | this.requestManager = requestManager;
30 | this.listener = listener;
31 | categoryImage = itemView.findViewById(R.id.category_image);
32 | categoryTitle = itemView.findViewById(R.id.category_title);
33 |
34 | itemView.setOnClickListener(this);
35 | }
36 |
37 | public void onBind(Recipe recipe){
38 |
39 | Uri path = Uri.parse("android.resource://com.codingwithmitch.foodrecipes/drawable/" + recipe.getImage_url());
40 | requestManager
41 | .load(path)
42 | .into(categoryImage);
43 |
44 | categoryTitle.setText(recipe.getTitle());
45 | }
46 |
47 | @Override
48 | public void onClick(View v) {
49 | listener.onCategoryClick(categoryTitle.getText().toString());
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/requests/ServiceGenerator.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.requests;
2 |
3 | import com.codingwithmitch.foodrecipes.util.Constants;
4 | import com.codingwithmitch.foodrecipes.util.LiveDataCallAdapter;
5 | import com.codingwithmitch.foodrecipes.util.LiveDataCallAdapterFactory;
6 |
7 | import java.util.concurrent.TimeUnit;
8 |
9 | import okhttp3.OkHttpClient;
10 | import retrofit2.Retrofit;
11 | import retrofit2.converter.gson.GsonConverterFactory;
12 |
13 | import static com.codingwithmitch.foodrecipes.util.Constants.CONNECTION_TIMEOUT;
14 | import static com.codingwithmitch.foodrecipes.util.Constants.READ_TIMEOUT;
15 | import static com.codingwithmitch.foodrecipes.util.Constants.WRITE_TIMEOUT;
16 |
17 | public class ServiceGenerator {
18 |
19 | private static OkHttpClient client = new OkHttpClient.Builder()
20 |
21 | // establish connection to server
22 | .connectTimeout(CONNECTION_TIMEOUT, TimeUnit.SECONDS)
23 |
24 | // time between each byte read from the server
25 | .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
26 |
27 | // time between each byte sent to server
28 | .writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS)
29 |
30 | .retryOnConnectionFailure(false)
31 |
32 | .build();
33 |
34 |
35 | private static Retrofit.Builder retrofitBuilder =
36 | new Retrofit.Builder()
37 | .baseUrl(Constants.BASE_URL)
38 | .client(client)
39 | .addCallAdapterFactory(new LiveDataCallAdapterFactory())
40 | .addConverterFactory(GsonConverterFactory.create());
41 |
42 | private static Retrofit retrofit = retrofitBuilder.build();
43 |
44 | private static RecipeApi recipeApi = retrofit.create(RecipeApi.class);
45 |
46 | public static RecipeApi getRecipeApi(){
47 | return recipeApi;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
12 |
13 |
19 |
22 |
25 |
26 |
27 |
28 |
34 |
35 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | In this course you'll learn how to build a local database cache with SQLite and Room . The cache retrieves data from a REST API using Retrofit2. Architecture is MVVM.
7 |
8 | Here's the specifics of what you will see in the course:
9 |
10 |
11 | Caching data for when the network goes offline
12 | Reading cached data when the network is down
13 | Custom SQLite queries using Room
14 | Customizing the cache (how long data will live in the cache)
15 | How Retrofit caching works
16 | Why SQLite and Room is better for caching than Retrofit
17 | How to design a database cache
18 |
19 | There is no "one size fits all"
20 | Retrofit is better for some things but room is better for others
21 |
22 |
23 | How Glide caching works
24 | Glide RecyclerView Preloader (Customizing how many list items get cached)
25 | Dealing with Network Errors and slow network speeds
26 | OkHttp Network Timeouts
27 | Converting Retrofit Calls to LiveData (Call Adapters)
28 | And much more...
29 |
30 |
31 |
32 | Architecture Diagram
33 |
34 |
35 |
36 |
37 |
38 |
39 | Contributors
40 |
41 |
42 | gmiklay
43 |
44 | Converted to AndroidX: branch: pr/1
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/adapters/RecipeViewHolder.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.adapters;
2 |
3 | import android.support.annotation.NonNull;
4 | import android.support.v7.widget.AppCompatImageView;
5 | import android.support.v7.widget.RecyclerView;
6 | import android.view.View;
7 | import android.widget.TextView;
8 |
9 | import com.bumptech.glide.Glide;
10 | import com.bumptech.glide.ListPreloader;
11 | import com.bumptech.glide.RequestManager;
12 | import com.bumptech.glide.request.RequestOptions;
13 | import com.bumptech.glide.util.ViewPreloadSizeProvider;
14 | import com.codingwithmitch.foodrecipes.R;
15 | import com.codingwithmitch.foodrecipes.models.Recipe;
16 |
17 | public class RecipeViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
18 |
19 | TextView title, publisher, socialScore;
20 | AppCompatImageView image;
21 | OnRecipeListener onRecipeListener;
22 | RequestManager requestManager;
23 | ViewPreloadSizeProvider viewPreloadSizeProvider;
24 |
25 | public RecipeViewHolder(@NonNull View itemView,
26 | OnRecipeListener onRecipeListener,
27 | RequestManager requestManager,
28 | ViewPreloadSizeProvider preloadSizeProvider) {
29 | super(itemView);
30 |
31 | this.onRecipeListener = onRecipeListener;
32 | this.requestManager = requestManager;
33 | this.viewPreloadSizeProvider = preloadSizeProvider;
34 |
35 | title = itemView.findViewById(R.id.recipe_title);
36 | publisher = itemView.findViewById(R.id.recipe_publisher);
37 | socialScore = itemView.findViewById(R.id.recipe_social_score);
38 | image = itemView.findViewById(R.id.recipe_image);
39 |
40 | itemView.setOnClickListener(this);
41 | }
42 |
43 | public void onBind(Recipe recipe){
44 |
45 | requestManager
46 | .load(recipe.getImage_url())
47 | .into(image);
48 |
49 | title.setText(recipe.getTitle());
50 | publisher.setText(recipe.getPublisher());
51 | socialScore.setText(String.valueOf(Math.round(recipe.getSocial_rank())));
52 |
53 | viewPreloadSizeProvider.setView(image);
54 | }
55 |
56 | @Override
57 | public void onClick(View v) {
58 | onRecipeListener.onRecipeClick(getAdapterPosition());
59 | }
60 | }
61 |
62 |
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/util/LiveDataCallAdapterFactory.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.util;
2 |
3 |
4 | import android.arch.lifecycle.LiveData;
5 |
6 | import com.codingwithmitch.foodrecipes.requests.responses.ApiResponse;
7 |
8 | import java.lang.annotation.Annotation;
9 | import java.lang.reflect.ParameterizedType;
10 | import java.lang.reflect.Type;
11 |
12 | import retrofit2.CallAdapter;
13 | import retrofit2.Retrofit;
14 |
15 | public class LiveDataCallAdapterFactory extends CallAdapter.Factory {
16 |
17 |
18 | /**
19 | * This method performs a number of checks and then returns the Response type for the Retrofit requests.
20 | * (@bodyType is the ResponseType. It can be RecipeResponse or RecipeSearchResponse)
21 | *
22 | * CHECK #1) returnType returns LiveData
23 | * CHECK #2) Type LiveData is of ApiResponse.class
24 | * CHECK #3) Make sure ApiResponse is parameterized. AKA: ApiResponse exists.
25 | *
26 | *
27 | */
28 | @Override
29 | public CallAdapter, ?> get(Type returnType, Annotation[] annotations, Retrofit retrofit) {
30 |
31 | // Check #1
32 | // Make sure the CallAdapter is returning a type of LiveData
33 | if(CallAdapter.Factory.getRawType(returnType) != LiveData.class){
34 | return null;
35 | }
36 |
37 | // Check #2
38 | // Type that LiveData is wrapping
39 | Type observableType = CallAdapter.Factory.getParameterUpperBound(0, (ParameterizedType) returnType);
40 |
41 | // Check if it's of Type ApiResponse
42 | Type rawObservableType = CallAdapter.Factory.getRawType(observableType);
43 | if(rawObservableType != ApiResponse.class){
44 | throw new IllegalArgumentException("Type must be a defined resource");
45 | }
46 |
47 | // Check #3
48 | // Check if ApiResponse is parameterized. AKA: Does ApiResponse exists? (must wrap around T)
49 | // FYI: T is either RecipeResponse or T will be a RecipeSearchResponse
50 | if(!(observableType instanceof ParameterizedType)){
51 | throw new IllegalArgumentException("resource must be parameterized");
52 | }
53 |
54 | Type bodyType = CallAdapter.Factory.getParameterUpperBound(0, (ParameterizedType) observableType);
55 | return new LiveDataCallAdapter(bodyType);
56 | }
57 | }
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.application'
2 |
3 | android {
4 | compileSdkVersion 28
5 | defaultConfig {
6 | applicationId "com.codingwithmitch.foodrecipes"
7 | minSdkVersion 21
8 | targetSdkVersion 28
9 | versionCode 1
10 | versionName "1.0"
11 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
12 |
13 | javaCompileOptions {
14 | annotationProcessorOptions {
15 | arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
16 | }
17 | }
18 | }
19 | buildTypes {
20 | release {
21 | minifyEnabled false
22 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
23 | }
24 | }
25 | }
26 |
27 | dependencies {
28 | def retrofitVersion = "2.5.0"
29 | def lifecycle_version = "1.1.1"
30 | def supportVersion = "28.0.0"
31 | def glideVersion = "4.8.0"
32 |
33 | implementation fileTree(dir: 'libs', include: ['*.jar'])
34 | implementation "com.android.support:appcompat-v7:$supportVersion"
35 | implementation 'com.android.support.constraint:constraint-layout:1.1.3'
36 | testImplementation 'junit:junit:4.12'
37 | androidTestImplementation 'com.android.support.test:runner:1.0.2'
38 | androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
39 |
40 | implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
41 |
42 | implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
43 |
44 | // ViewModel and LiveData
45 | implementation "android.arch.lifecycle:extensions:$lifecycle_version"
46 |
47 | // Cardview
48 | implementation "com.android.support:cardview-v7:$supportVersion"
49 |
50 | // Recyclerview
51 | implementation "com.android.support:recyclerview-v7:$supportVersion"
52 |
53 | // Design support
54 | implementation "com.android.support:design:$supportVersion"
55 |
56 | // Glide
57 | implementation "com.github.bumptech.glide:glide:$glideVersion"
58 | annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion"
59 |
60 | // Glide RecyclerView Preloader
61 | implementation ("com.github.bumptech.glide:recyclerview-integration:$glideVersion") {
62 | // Excludes the support library because it's already included by Glide.
63 | transitive = false
64 | }
65 |
66 | implementation 'de.hdodenhof:circleimageview:3.0.0'
67 |
68 | // Room
69 | implementation "android.arch.persistence.room:runtime:$lifecycle_version"
70 | annotationProcessor "android.arch.persistence.room:compiler:$lifecycle_version"
71 | }
72 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_recipe.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
13 |
14 |
15 |
20 |
21 |
30 |
31 |
32 |
40 |
41 |
50 |
51 |
60 |
61 |
62 |
63 |
64 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/layout_recipe_list_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
13 |
14 |
18 |
19 |
25 |
26 |
27 |
32 |
33 |
39 |
40 |
45 |
46 |
53 |
54 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/requests/responses/ApiResponse.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.requests.responses;
2 |
3 |
4 | import java.io.IOException;
5 |
6 | import retrofit2.Response;
7 |
8 | import static com.codingwithmitch.foodrecipes.viewmodels.RecipeListViewModel.QUERY_EXHAUSTED;
9 |
10 |
11 | /**
12 | * Generic class for handling responses from Retrofit
13 | * @param
14 | */
15 | public class ApiResponse {
16 |
17 | public ApiResponse create(Throwable error){
18 | return new ApiErrorResponse<>(error.getMessage().equals("") ? error.getMessage() : "Unknown error\nCheck network connection");
19 | }
20 |
21 | public ApiResponse create(Response response){
22 |
23 | if(response.isSuccessful()){
24 | T body = response.body();
25 |
26 | if(body instanceof RecipeSearchResponse){
27 | if(!CheckRecipeApiKey.isRecipeApiKeyValid((RecipeSearchResponse)body)){
28 | String errorMsg = "Api key is invalid or expired.";
29 | return new ApiErrorResponse<>(errorMsg);
30 | }
31 | if(((RecipeSearchResponse) body).getCount() == 0){
32 | // query is exhausted
33 | return new ApiErrorResponse<>(QUERY_EXHAUSTED);
34 | }
35 | }
36 |
37 | if(body instanceof RecipeResponse){
38 | if(!CheckRecipeApiKey.isRecipeApiKeyValid((RecipeResponse)body)){
39 | String errorMsg = "Api key is invalid or expired.";
40 | return new ApiErrorResponse<>(errorMsg);
41 | }
42 | }
43 |
44 | if(body == null || response.code() == 204){ // 204 is empty response
45 | return new ApiEmptyResponse<>();
46 | }
47 | else{
48 | return new ApiSuccessResponse<>(body);
49 | }
50 | }
51 | else{
52 | String errorMsg = "";
53 | try {
54 | errorMsg = response.errorBody().string();
55 | } catch (IOException e) {
56 | e.printStackTrace();
57 | errorMsg = response.message();
58 | }
59 | return new ApiErrorResponse<>(errorMsg);
60 | }
61 | }
62 |
63 | /**
64 | * Generic success response from api
65 | * @param
66 | */
67 | public class ApiSuccessResponse extends ApiResponse {
68 |
69 | private T body;
70 |
71 | ApiSuccessResponse(T body) {
72 | this.body = body;
73 | }
74 |
75 | public T getBody() {
76 | return body;
77 | }
78 |
79 | }
80 |
81 | /**
82 | * Generic Error response from API
83 | * @param
84 | */
85 | public class ApiErrorResponse extends ApiResponse {
86 |
87 | private String errorMessage;
88 |
89 | ApiErrorResponse(String errorMessage) {
90 | this.errorMessage = errorMessage;
91 | }
92 |
93 | public String getErrorMessage() {
94 | return errorMessage;
95 | }
96 |
97 | }
98 |
99 |
100 | /**
101 | * separate class for HTTP 204 resposes so that we can make ApiSuccessResponse's body non-null.
102 | */
103 | public class ApiEmptyResponse extends ApiResponse { }
104 |
105 | }
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | xmlns:android
11 |
12 | ^$
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | xmlns:.*
22 |
23 | ^$
24 |
25 |
26 | BY_NAME
27 |
28 |
29 |
30 |
31 |
32 |
33 | .*:id
34 |
35 | http://schemas.android.com/apk/res/android
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | .*:name
45 |
46 | http://schemas.android.com/apk/res/android
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | name
56 |
57 | ^$
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | style
67 |
68 | ^$
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | .*
78 |
79 | ^$
80 |
81 |
82 | BY_NAME
83 |
84 |
85 |
86 |
87 |
88 |
89 | .*
90 |
91 | http://schemas.android.com/apk/res/android
92 |
93 |
94 | ANDROID_ATTRIBUTE_ORDER
95 |
96 |
97 |
98 |
99 |
100 |
101 | .*
102 |
103 | .*
104 |
105 |
106 | BY_NAME
107 |
108 |
109 |
110 |
111 |
112 |
113 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/util/HorizontalDottedProgress.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.util;
2 |
3 | import android.content.Context;
4 | import android.graphics.Canvas;
5 | import android.graphics.Color;
6 | import android.graphics.Paint;
7 | import android.util.AttributeSet;
8 | import android.util.Log;
9 | import android.view.View;
10 | import android.view.animation.Animation;
11 | import android.view.animation.LinearInterpolator;
12 | import android.view.animation.Transformation;
13 |
14 | public class HorizontalDottedProgress extends View {
15 |
16 | //actual dot radius
17 | private int mDotRadius = 5;
18 |
19 | //Bounced Dot Radius
20 | private int mBounceDotRadius = 8;
21 |
22 | //to get identified in which position dot has to bounce
23 | private int mDotPosition;
24 |
25 | //specify how many dots you need in a progressbar
26 | private int mDotAmount = 10;
27 |
28 | public HorizontalDottedProgress(Context context) {
29 | super(context);
30 | }
31 |
32 | public HorizontalDottedProgress(Context context, AttributeSet attrs) {
33 | super(context, attrs);
34 | }
35 |
36 | public HorizontalDottedProgress(Context context, AttributeSet attrs, int defStyleAttr) {
37 | super(context, attrs, defStyleAttr);
38 | }
39 |
40 | //Method to draw your customized dot on the canvas
41 | @Override
42 | protected void onDraw(Canvas canvas) {
43 | super.onDraw(canvas);
44 |
45 | Paint paint = new Paint();
46 |
47 | //set the color for the dot that you want to draw
48 | paint.setColor(Color.parseColor("#fd583f"));
49 |
50 | //function to create dot
51 | createDot(canvas,paint);
52 | }
53 |
54 | @Override
55 | protected void onAttachedToWindow() {
56 | super.onAttachedToWindow();
57 | //Animation called when attaching to the window, i.e to your screen
58 | startAnimation();
59 | }
60 |
61 | private void createDot(Canvas canvas, Paint paint) {
62 |
63 | //here i have setted progress bar with 10 dots , so repeat and wnen i = mDotPosition then increase the radius of dot i.e mBounceDotRadius
64 | for(int i = 0; i < mDotAmount; i++ ){
65 | if(i == mDotPosition){
66 | canvas.drawCircle(10+(i*20), mBounceDotRadius, mBounceDotRadius, paint);
67 | }else {
68 | canvas.drawCircle(10+(i*20), mBounceDotRadius, mDotRadius, paint);
69 | }
70 | }
71 |
72 |
73 | }
74 |
75 | @Override
76 | protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
77 | super.onMeasure(widthMeasureSpec, heightMeasureSpec);
78 | int width;
79 | int height;
80 |
81 | //calculate the view width
82 | int calculatedWidth = (20*9);
83 |
84 | width = calculatedWidth;
85 | height = (mBounceDotRadius*2);
86 |
87 |
88 |
89 | //MUST CALL THIS
90 | setMeasuredDimension(width, height);
91 | }
92 |
93 | private void startAnimation() {
94 | BounceAnimation bounceAnimation = new BounceAnimation();
95 | bounceAnimation.setDuration(100);
96 | bounceAnimation.setRepeatCount(Animation.INFINITE);
97 | bounceAnimation.setInterpolator(new LinearInterpolator());
98 | bounceAnimation.setAnimationListener(new Animation.AnimationListener() {
99 | @Override
100 | public void onAnimationStart(Animation animation) {
101 |
102 | }
103 |
104 | @Override
105 | public void onAnimationEnd(Animation animation) {
106 |
107 | }
108 |
109 | @Override
110 | public void onAnimationRepeat(Animation animation) {
111 | mDotPosition++;
112 | //when mDotPosition == mDotAmount , then start again applying animation from 0th positon , i.e mDotPosition = 0;
113 | if (mDotPosition == mDotAmount) {
114 | mDotPosition = 0;
115 | }
116 | Log.d("INFOMETHOD","----On Animation Repeat----");
117 |
118 | }
119 | });
120 | startAnimation(bounceAnimation);
121 | }
122 |
123 |
124 | private class BounceAnimation extends Animation {
125 | @Override
126 | protected void applyTransformation(float interpolatedTime, Transformation t) {
127 | super.applyTransformation(interpolatedTime, t);
128 | //call invalidate to redraw your view againg.
129 | invalidate();
130 | }
131 | }
132 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/models/Recipe.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.models;
2 |
3 |
4 | import android.arch.persistence.room.ColumnInfo;
5 | import android.arch.persistence.room.Entity;
6 | import android.arch.persistence.room.PrimaryKey;
7 | import android.os.Parcel;
8 | import android.os.Parcelable;
9 | import android.support.annotation.NonNull;
10 |
11 | import java.util.Arrays;
12 |
13 | @Entity(tableName = "recipes")
14 | public class Recipe implements Parcelable{
15 |
16 | @PrimaryKey
17 | @NonNull
18 | private String recipe_id;
19 |
20 | @ColumnInfo(name = "title")
21 | private String title;
22 |
23 | @ColumnInfo(name = "publisher")
24 | private String publisher;
25 |
26 | @ColumnInfo(name = "image_url")
27 | private String image_url;
28 |
29 | @ColumnInfo(name = "social_rank")
30 | private float social_rank;
31 |
32 | @ColumnInfo(name = "ingredients")
33 | private String[] ingredients;
34 |
35 | @ColumnInfo(name = "timestamp")
36 | private int timestamp;
37 |
38 |
39 | public Recipe(@NonNull String recipe_id, String title, String publisher, String image_url,
40 | float social_rank, String[] ingredients, int timestamp) {
41 | this.recipe_id = recipe_id;
42 | this.title = title;
43 | this.publisher = publisher;
44 | this.image_url = image_url;
45 | this.social_rank = social_rank;
46 | this.ingredients = ingredients;
47 | this.timestamp = timestamp;
48 | }
49 |
50 | public Recipe() {
51 | }
52 |
53 | protected Recipe(Parcel in) {
54 | recipe_id = in.readString();
55 | title = in.readString();
56 | publisher = in.readString();
57 | image_url = in.readString();
58 | social_rank = in.readFloat();
59 | ingredients = in.createStringArray();
60 | timestamp = in.readInt();
61 | }
62 |
63 | @Override
64 | public void writeToParcel(Parcel dest, int flags) {
65 | dest.writeString(recipe_id);
66 | dest.writeString(title);
67 | dest.writeString(publisher);
68 | dest.writeString(image_url);
69 | dest.writeFloat(social_rank);
70 | dest.writeStringArray(ingredients);
71 | dest.writeInt(timestamp);
72 | }
73 |
74 | @Override
75 | public int describeContents() {
76 | return 0;
77 | }
78 |
79 | public static final Creator CREATOR = new Creator() {
80 | @Override
81 | public Recipe createFromParcel(Parcel in) {
82 | return new Recipe(in);
83 | }
84 |
85 | @Override
86 | public Recipe[] newArray(int size) {
87 | return new Recipe[size];
88 | }
89 | };
90 |
91 | public int getTimestamp() {
92 | return timestamp;
93 | }
94 |
95 | public void setTimestamp(int timestamp) {
96 | this.timestamp = timestamp;
97 | }
98 |
99 | public String getTitle() {
100 | return title;
101 | }
102 |
103 | public void setTitle(String title) {
104 | this.title = title;
105 | }
106 |
107 | public String getPublisher() {
108 | return publisher;
109 | }
110 |
111 | public void setPublisher(String publisher) {
112 | this.publisher = publisher;
113 | }
114 |
115 | public String[] getIngredients() {
116 | return ingredients;
117 | }
118 |
119 | public void setIngredients(String[] ingredients) {
120 | this.ingredients = ingredients;
121 | }
122 |
123 | public String getImage_url() {
124 | return image_url;
125 | }
126 |
127 | public void setImage_url(String image_url) {
128 | this.image_url = image_url;
129 | }
130 |
131 | public float getSocial_rank() {
132 | return social_rank;
133 | }
134 |
135 | public void setSocial_rank(float social_rank) {
136 | this.social_rank = social_rank;
137 | }
138 |
139 | public String getRecipe_id() {
140 | return recipe_id;
141 | }
142 |
143 | public void setRecipe_id(String recipe_id) {
144 | this.recipe_id = recipe_id;
145 | }
146 |
147 | @Override
148 | public String toString() {
149 | return "Recipe{" +
150 | "recipe_id='" + recipe_id + '\'' +
151 | ", title='" + title + '\'' +
152 | ", publisher='" + publisher + '\'' +
153 | ", image_url='" + image_url + '\'' +
154 | ", social_rank=" + social_rank +
155 | ", ingredients=" + Arrays.toString(ingredients) +
156 | ", timestamp=" + timestamp +
157 | '}';
158 | }
159 | }
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Attempt to set APP_HOME
10 | # Resolve links: $0 may be a link
11 | PRG="$0"
12 | # Need this for relative symlinks.
13 | while [ -h "$PRG" ] ; do
14 | ls=`ls -ld "$PRG"`
15 | link=`expr "$ls" : '.*-> \(.*\)$'`
16 | if expr "$link" : '/.*' > /dev/null; then
17 | PRG="$link"
18 | else
19 | PRG=`dirname "$PRG"`"/$link"
20 | fi
21 | done
22 | SAVED="`pwd`"
23 | cd "`dirname \"$PRG\"`/" >/dev/null
24 | APP_HOME="`pwd -P`"
25 | cd "$SAVED" >/dev/null
26 |
27 | APP_NAME="Gradle"
28 | APP_BASE_NAME=`basename "$0"`
29 |
30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31 | DEFAULT_JVM_OPTS=""
32 |
33 | # Use the maximum available, or set MAX_FD != -1 to use that value.
34 | MAX_FD="maximum"
35 |
36 | warn () {
37 | echo "$*"
38 | }
39 |
40 | die () {
41 | echo
42 | echo "$*"
43 | echo
44 | exit 1
45 | }
46 |
47 | # OS specific support (must be 'true' or 'false').
48 | cygwin=false
49 | msys=false
50 | darwin=false
51 | nonstop=false
52 | case "`uname`" in
53 | CYGWIN* )
54 | cygwin=true
55 | ;;
56 | Darwin* )
57 | darwin=true
58 | ;;
59 | MINGW* )
60 | msys=true
61 | ;;
62 | NONSTOP* )
63 | nonstop=true
64 | ;;
65 | esac
66 |
67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
68 |
69 | # Determine the Java command to use to start the JVM.
70 | if [ -n "$JAVA_HOME" ] ; then
71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72 | # IBM's JDK on AIX uses strange locations for the executables
73 | JAVACMD="$JAVA_HOME/jre/sh/java"
74 | else
75 | JAVACMD="$JAVA_HOME/bin/java"
76 | fi
77 | if [ ! -x "$JAVACMD" ] ; then
78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79 |
80 | Please set the JAVA_HOME variable in your environment to match the
81 | location of your Java installation."
82 | fi
83 | else
84 | JAVACMD="java"
85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86 |
87 | Please set the JAVA_HOME variable in your environment to match the
88 | location of your Java installation."
89 | fi
90 |
91 | # Increase the maximum file descriptors if we can.
92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93 | MAX_FD_LIMIT=`ulimit -H -n`
94 | if [ $? -eq 0 ] ; then
95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96 | MAX_FD="$MAX_FD_LIMIT"
97 | fi
98 | ulimit -n $MAX_FD
99 | if [ $? -ne 0 ] ; then
100 | warn "Could not set maximum file descriptor limit: $MAX_FD"
101 | fi
102 | else
103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104 | fi
105 | fi
106 |
107 | # For Darwin, add options to specify how the application appears in the dock
108 | if $darwin; then
109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110 | fi
111 |
112 | # For Cygwin, switch paths to Windows format before running java
113 | if $cygwin ; then
114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116 | JAVACMD=`cygpath --unix "$JAVACMD"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in $ROOTDIRSRAW ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args$i`="\"$arg\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case $i in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Escape application args
158 | save () {
159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160 | echo " "
161 | }
162 | APP_ARGS=$(save "$@")
163 |
164 | # Collect all arguments for the java command, following the shell quoting and substitution rules
165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
166 |
167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169 | cd "$(dirname "$0")"
170 | fi
171 |
172 | exec "$JAVACMD" "$@"
173 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/RecipeActivity.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes;
2 |
3 | import android.arch.lifecycle.Observer;
4 | import android.arch.lifecycle.ViewModelProviders;
5 | import android.os.Bundle;
6 | import android.support.annotation.Nullable;
7 | import android.support.v7.widget.AppCompatImageView;
8 | import android.util.Log;
9 | import android.view.View;
10 | import android.view.ViewGroup;
11 | import android.widget.LinearLayout;
12 | import android.widget.ScrollView;
13 | import android.widget.TextView;
14 |
15 |
16 | import com.bumptech.glide.Glide;
17 | import com.bumptech.glide.request.RequestOptions;
18 | import com.codingwithmitch.foodrecipes.models.Recipe;
19 | import com.codingwithmitch.foodrecipes.util.Resource;
20 | import com.codingwithmitch.foodrecipes.viewmodels.RecipeViewModel;
21 |
22 | public class RecipeActivity extends BaseActivity {
23 |
24 | private static final String TAG = "RecipeActivity";
25 |
26 | // UI components
27 | private AppCompatImageView mRecipeImage;
28 | private TextView mRecipeTitle, mRecipeRank;
29 | private LinearLayout mRecipeIngredientsContainer;
30 | private ScrollView mScrollView;
31 |
32 | private RecipeViewModel mRecipeViewModel;
33 |
34 |
35 | @Override
36 | protected void onCreate(@Nullable Bundle savedInstanceState) {
37 | super.onCreate(savedInstanceState);
38 | setContentView(R.layout.activity_recipe);
39 | mRecipeImage = findViewById(R.id.recipe_image);
40 | mRecipeTitle = findViewById(R.id.recipe_title);
41 | mRecipeRank = findViewById(R.id.recipe_social_score);
42 | mRecipeIngredientsContainer = findViewById(R.id.ingredients_container);
43 | mScrollView = findViewById(R.id.parent);
44 |
45 | mRecipeViewModel = ViewModelProviders.of(this).get(RecipeViewModel.class);
46 |
47 | getIncomingIntent();
48 | }
49 |
50 | private void getIncomingIntent(){
51 | if(getIntent().hasExtra("recipe")){
52 | Recipe recipe = getIntent().getParcelableExtra("recipe");
53 | Log.d(TAG, "getIncomingIntent: " + recipe.getTitle());
54 | subscribeObservers(recipe.getRecipe_id());
55 | }
56 | }
57 |
58 | private void subscribeObservers(final String recipeId){
59 | mRecipeViewModel.searchRecipeApi(recipeId).observe(this, new Observer>() {
60 | @Override
61 | public void onChanged(@Nullable Resource recipeResource) {
62 | if(recipeResource != null){
63 | if(recipeResource.data != null){
64 | switch (recipeResource.status){
65 |
66 | case LOADING:{
67 | showProgressBar(true);
68 | break;
69 | }
70 |
71 | case ERROR:{
72 | Log.e(TAG, "onChanged: status: ERROR, Recipe: " + recipeResource.data.getTitle() );
73 | Log.e(TAG, "onChanged: ERROR message: " + recipeResource.message );
74 | showParent();showProgressBar(false);
75 | setRecipeProperties(recipeResource.data);
76 | break;
77 | }
78 |
79 | case SUCCESS:{
80 | Log.d(TAG, "onChanged: cache has been refreshed.");
81 | Log.d(TAG, "onChanged: status: SUCCESS, Recipe: " + recipeResource.data.getTitle());
82 | showParent();
83 | showProgressBar(false);
84 | setRecipeProperties(recipeResource.data);
85 | break;
86 | }
87 | }
88 | }
89 | }
90 | }
91 | });
92 | }
93 |
94 | private void setRecipeProperties(Recipe recipe){
95 | if(recipe != null){
96 | RequestOptions options = new RequestOptions()
97 | .placeholder(R.drawable.white_background)
98 | .error(R.drawable.white_background);
99 |
100 | Glide.with(this)
101 | .setDefaultRequestOptions(options)
102 | .load(recipe.getImage_url())
103 | .into(mRecipeImage);
104 |
105 | mRecipeTitle.setText(recipe.getTitle());
106 | mRecipeRank.setText(String.valueOf(Math.round(recipe.getSocial_rank())));
107 |
108 | setIngredients(recipe);
109 | }
110 | }
111 |
112 | private void setIngredients(Recipe recipe){
113 | mRecipeIngredientsContainer.removeAllViews();
114 |
115 | if(recipe.getIngredients() != null){
116 | for(String ingredient: recipe.getIngredients()){
117 | TextView textView = new TextView(this);
118 | textView.setText(ingredient);
119 | textView.setTextSize(15);
120 | textView.setLayoutParams(
121 | new LinearLayout.LayoutParams(
122 | ViewGroup.LayoutParams.WRAP_CONTENT,
123 | ViewGroup.LayoutParams.WRAP_CONTENT));
124 | mRecipeIngredientsContainer.addView(textView);
125 | }
126 | }
127 | else{
128 | TextView textView = new TextView(this);
129 | textView.setText("Error retrieving ingredients.\nCheck network connection.");
130 | textView.setTextSize(15);
131 | textView.setLayoutParams(
132 | new LinearLayout.LayoutParams(
133 | ViewGroup.LayoutParams.WRAP_CONTENT,
134 | ViewGroup.LayoutParams.WRAP_CONTENT));
135 | mRecipeIngredientsContainer.addView(textView);
136 | }
137 | }
138 |
139 |
140 | private void showParent(){
141 | mScrollView.setVisibility(View.VISIBLE);
142 | }
143 | }
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/viewmodels/RecipeListViewModel.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.viewmodels;
2 |
3 |
4 | import android.app.Application;
5 | import android.arch.lifecycle.AndroidViewModel;
6 | import android.arch.lifecycle.LiveData;
7 | import android.arch.lifecycle.MediatorLiveData;
8 | import android.arch.lifecycle.MutableLiveData;
9 | import android.arch.lifecycle.Observer;
10 | import android.support.annotation.NonNull;
11 | import android.support.annotation.Nullable;
12 | import android.util.Log;
13 |
14 |
15 | import com.codingwithmitch.foodrecipes.models.Recipe;
16 | import com.codingwithmitch.foodrecipes.repositories.RecipeRepository;
17 | import com.codingwithmitch.foodrecipes.requests.responses.RecipeSearchResponse;
18 | import com.codingwithmitch.foodrecipes.util.NetworkBoundResource;
19 | import com.codingwithmitch.foodrecipes.util.Resource;
20 |
21 | import java.util.List;
22 |
23 | public class RecipeListViewModel extends AndroidViewModel {
24 |
25 | private static final String TAG = "RecipeListViewModel";
26 |
27 | public static final String QUERY_EXHAUSTED = "No more results.";
28 | public enum ViewState {CATEGORIES, RECIPES}
29 |
30 | private MutableLiveData viewState;
31 | private MediatorLiveData>> recipes = new MediatorLiveData<>();
32 | private RecipeRepository recipeRepository;
33 |
34 | // query extras
35 | private boolean isQueryExhausted;
36 | private boolean isPerformingQuery;
37 | private int pageNumber;
38 | private String query;
39 | private boolean cancelRequest;
40 | private long requestStartTime;
41 |
42 | public RecipeListViewModel(@NonNull Application application) {
43 | super(application);
44 | recipeRepository = RecipeRepository.getInstance(application);
45 | init();
46 |
47 | }
48 |
49 | private void init(){
50 | if(viewState == null){
51 | viewState = new MutableLiveData<>();
52 | viewState.setValue(ViewState.CATEGORIES);
53 | }
54 | }
55 | public LiveData getViewstate(){
56 | return viewState;
57 | }
58 |
59 | public LiveData>> getRecipes(){
60 | return recipes;
61 | }
62 |
63 | public int getPageNumber(){
64 | return pageNumber;
65 | }
66 |
67 | public void setViewCategories(){
68 | viewState.setValue(ViewState.CATEGORIES);
69 | }
70 |
71 | public void searchRecipesApi(String query, int pageNumber){
72 | if(!isPerformingQuery){
73 | if(pageNumber == 0){
74 | pageNumber = 1;
75 | }
76 | this.pageNumber = pageNumber;
77 | this.query = query;
78 | isQueryExhausted = false;
79 | executeSearch();
80 | }
81 | }
82 |
83 | public void searchNextPage(){
84 | if(!isQueryExhausted && !isPerformingQuery){
85 | pageNumber++;
86 | executeSearch();
87 | }
88 | }
89 |
90 | private void executeSearch(){
91 | requestStartTime = System.currentTimeMillis();
92 | cancelRequest = false;
93 | isPerformingQuery = true;
94 | viewState.setValue(ViewState.RECIPES);
95 | final LiveData>> repositorySource = recipeRepository.searchRecipesApi(query, pageNumber);
96 | recipes.addSource(repositorySource, new Observer>>() {
97 | @Override
98 | public void onChanged(@Nullable Resource> listResource) {
99 | if(!cancelRequest){
100 | if(listResource != null){
101 | if(listResource.status == Resource.Status.SUCCESS){
102 | Log.d(TAG, "onChanged: REQUEST TIME: " + (System.currentTimeMillis() - requestStartTime) / 1000 + " seconds.");
103 | Log.d(TAG, "onChanged: page number: " + pageNumber);
104 | Log.d(TAG, "onChanged: " + listResource.data);
105 |
106 | isPerformingQuery = false;
107 | if(listResource.data != null){
108 | if(listResource.data.size() == 0 ){
109 | Log.d(TAG, "onChanged: query is exhausted...");
110 | recipes.setValue(
111 | new Resource>(
112 | Resource.Status.ERROR,
113 | listResource.data,
114 | QUERY_EXHAUSTED
115 | )
116 | );
117 | isQueryExhausted = true;
118 | }
119 | }
120 | recipes.removeSource(repositorySource);
121 | }
122 | else if(listResource.status == Resource.Status.ERROR){
123 | Log.d(TAG, "onChanged: REQUEST TIME: " + (System.currentTimeMillis() - requestStartTime) / 1000 + " seconds.");
124 | isPerformingQuery = false;
125 | if(listResource.message.equals(QUERY_EXHAUSTED)){
126 | isQueryExhausted = true;
127 | }
128 | recipes.removeSource(repositorySource);
129 | }
130 | recipes.setValue(listResource);
131 | }
132 | else{
133 | recipes.removeSource(repositorySource);
134 | }
135 | }
136 | else{
137 | recipes.removeSource(repositorySource);
138 | }
139 | }
140 | });
141 | }
142 |
143 | public void cancelSearchRequest(){
144 | if(isPerformingQuery){
145 | Log.d(TAG, "cancelSearchRequest: canceling the search request.");
146 | cancelRequest = true;
147 | isPerformingQuery = false;
148 | pageNumber = 1;
149 | }
150 | }
151 | }
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/repositories/RecipeRepository.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.repositories;
2 |
3 | import android.arch.lifecycle.LiveData;
4 | import android.content.Context;
5 | import android.support.annotation.NonNull;
6 | import android.support.annotation.Nullable;
7 | import android.util.Log;
8 |
9 | import com.codingwithmitch.foodrecipes.AppExecutors;
10 | import com.codingwithmitch.foodrecipes.models.Recipe;
11 | import com.codingwithmitch.foodrecipes.persistence.RecipeDao;
12 | import com.codingwithmitch.foodrecipes.persistence.RecipeDatabase;
13 | import com.codingwithmitch.foodrecipes.requests.ServiceGenerator;
14 | import com.codingwithmitch.foodrecipes.requests.responses.ApiResponse;
15 | import com.codingwithmitch.foodrecipes.requests.responses.RecipeResponse;
16 | import com.codingwithmitch.foodrecipes.requests.responses.RecipeSearchResponse;
17 | import com.codingwithmitch.foodrecipes.util.Constants;
18 | import com.codingwithmitch.foodrecipes.util.NetworkBoundResource;
19 | import com.codingwithmitch.foodrecipes.util.Resource;
20 |
21 | import java.util.List;
22 |
23 | public class RecipeRepository {
24 |
25 | private static final String TAG = "RecipeRepository";
26 |
27 | private static RecipeRepository instance;
28 | private RecipeDao recipeDao;
29 |
30 | public static RecipeRepository getInstance(Context context){
31 | if(instance == null){
32 | instance = new RecipeRepository(context);
33 | }
34 | return instance;
35 | }
36 |
37 |
38 | private RecipeRepository(Context context) {
39 | recipeDao = RecipeDatabase.getInstance(context).getRecipeDao();
40 | }
41 |
42 |
43 | public LiveData>> searchRecipesApi(final String query, final int pageNumber){
44 | return new NetworkBoundResource, RecipeSearchResponse>(AppExecutors.getInstance()){
45 |
46 | @Override
47 | protected void saveCallResult(@NonNull RecipeSearchResponse item) {
48 |
49 | if(item.getRecipes() != null){ // recipe list will be null if the api key is expired
50 | // Log.d(TAG, "saveCallResult: recipe response: " + item.toString());
51 |
52 | Recipe[] recipes = new Recipe[item.getRecipes().size()];
53 |
54 | int index = 0;
55 | for(long rowid: recipeDao.insertRecipes((Recipe[]) (item.getRecipes().toArray(recipes)))){
56 | if(rowid == -1){
57 | Log.d(TAG, "saveCallResult: CONFLICT... This recipe is already in the cache");
58 | // if the recipe already exists... I don't want to set the ingredients or timestamp b/c
59 | // they will be erased
60 | recipeDao.updateRecipe(
61 | recipes[index].getRecipe_id(),
62 | recipes[index].getTitle(),
63 | recipes[index].getPublisher(),
64 | recipes[index].getImage_url(),
65 | recipes[index].getSocial_rank()
66 | );
67 | }
68 | index++;
69 | }
70 | }
71 | }
72 |
73 | @Override
74 | protected boolean shouldFetch(@Nullable List data) {
75 | return true;
76 | }
77 |
78 | @NonNull
79 | @Override
80 | protected LiveData> loadFromDb() {
81 | return recipeDao.searchRecipes(query, pageNumber);
82 | }
83 |
84 | @NonNull
85 | @Override
86 | protected LiveData> createCall() {
87 | return ServiceGenerator.getRecipeApi()
88 | .searchRecipe(
89 | Constants.API_KEY,
90 | query,
91 | String.valueOf(pageNumber)
92 | );
93 | }
94 | }.getAsLiveData();
95 | }
96 |
97 | public LiveData> searchRecipesApi(final String recipeId){
98 | return new NetworkBoundResource(AppExecutors.getInstance()){
99 | @Override
100 | protected void saveCallResult(@NonNull RecipeResponse item) {
101 |
102 | // will be null if API key is expired
103 | if(item.getRecipe() != null){
104 | item.getRecipe().setTimestamp((int)(System.currentTimeMillis() / 1000));
105 | recipeDao.insertRecipe(item.getRecipe());
106 | }
107 | }
108 |
109 | @Override
110 | protected boolean shouldFetch(@Nullable Recipe data) {
111 | Log.d(TAG, "shouldFetch: recipe: " + data.toString());
112 | int currentTime = (int)(System.currentTimeMillis() / 1000);
113 | Log.d(TAG, "shouldFetch: current time: " + currentTime);
114 | int lastRefresh = data.getTimestamp();
115 | Log.d(TAG, "shouldFetch: last refresh: " + lastRefresh);
116 | Log.d(TAG, "shouldFetch: it's been " + ((currentTime - lastRefresh) / 60 / 60 / 24) +
117 | " days since this recipe was refreshed. 30 days must elapse before refreshing. ");
118 | if((currentTime - data.getTimestamp()) >= Constants.RECIPE_REFRESH_TIME){
119 | Log.d(TAG, "shouldFetch: SHOULD REFRESH RECIPE?! " + true);
120 | return true;
121 | }
122 | Log.d(TAG, "shouldFetch: SHOULD REFRESH RECIPE?! " + false);
123 | return false;
124 | }
125 |
126 | @NonNull
127 | @Override
128 | protected LiveData loadFromDb() {
129 | return recipeDao.getRecipe(recipeId);
130 | }
131 |
132 | @NonNull
133 | @Override
134 | protected LiveData> createCall() {
135 | return ServiceGenerator.getRecipeApi().getRecipe(
136 | Constants.API_KEY,
137 | recipeId
138 | );
139 | }
140 | }.getAsLiveData();
141 | }
142 | }
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/util/NetworkBoundResource.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.util;
2 |
3 | import android.arch.lifecycle.LiveData;
4 | import android.arch.lifecycle.MediatorLiveData;
5 | import android.arch.lifecycle.MutableLiveData;
6 | import android.arch.lifecycle.Observer;
7 | import android.support.annotation.MainThread;
8 | import android.support.annotation.NonNull;
9 | import android.support.annotation.Nullable;
10 | import android.support.annotation.WorkerThread;
11 | import android.util.Log;
12 |
13 | import com.codingwithmitch.foodrecipes.AppExecutors;
14 | import com.codingwithmitch.foodrecipes.requests.responses.ApiResponse;
15 |
16 | // CacheObject: Type for the Resource data. (database cache)
17 | // RequestObject: Type for the API response. (network request)
18 | public abstract class NetworkBoundResource {
19 |
20 | private static final String TAG = "NetworkBoundResource";
21 |
22 | private AppExecutors appExecutors;
23 | private MediatorLiveData> results = new MediatorLiveData<>();
24 |
25 | public NetworkBoundResource(AppExecutors appExecutors) {
26 | this.appExecutors = appExecutors;
27 | init();
28 | }
29 |
30 | private void init(){
31 |
32 | // update LiveData for loading status
33 | results.setValue((Resource) Resource.loading(null));
34 |
35 | // observe LiveData source from local db
36 | final LiveData dbSource = loadFromDb();
37 |
38 | results.addSource(dbSource, new Observer() {
39 | @Override
40 | public void onChanged(@Nullable CacheObject cacheObject) {
41 |
42 | results.removeSource(dbSource);
43 |
44 | if(shouldFetch(cacheObject)){
45 | // get data from the network
46 | fetchFromNetwork(dbSource);
47 | }
48 | else{
49 | results.addSource(dbSource, new Observer() {
50 | @Override
51 | public void onChanged(@Nullable CacheObject cacheObject) {
52 | setValue(Resource.success(cacheObject));
53 | }
54 | });
55 | }
56 | }
57 | });
58 | }
59 |
60 | /**
61 | * 1) observe local db
62 | * 2) if query the network
63 | * 3) stop observing the local db
64 | * 4) insert new data into local db
65 | * 5) begin observing local db again to see the refreshed data from network
66 | * @param dbSource
67 | */
68 | private void fetchFromNetwork(final LiveData dbSource){
69 |
70 | Log.d(TAG, "fetchFromNetwork: called.");
71 |
72 | // update LiveData for loading status
73 | results.addSource(dbSource, new Observer() {
74 | @Override
75 | public void onChanged(@Nullable CacheObject cacheObject) {
76 | setValue(Resource.loading(cacheObject));
77 | }
78 | });
79 |
80 | final LiveData> apiResponse = createCall();
81 |
82 | results.addSource(apiResponse, new Observer>() {
83 | @Override
84 | public void onChanged(@Nullable final ApiResponse requestObjectApiResponse) {
85 | results.removeSource(dbSource);
86 | results.removeSource(apiResponse);
87 |
88 | /*
89 | 3 cases:
90 | 1) ApiSuccessResponse
91 | 2) ApiErrorResponse
92 | 3) ApiEmptyResponse
93 | */
94 |
95 | if(requestObjectApiResponse instanceof ApiResponse.ApiSuccessResponse){
96 | Log.d(TAG, "onChanged: ApiSuccessResponse.");
97 |
98 | appExecutors.diskIO().execute(new Runnable() {
99 | @Override
100 | public void run() {
101 |
102 | // save the response to the local db
103 | saveCallResult((RequestObject) processResponse((ApiResponse.ApiSuccessResponse)requestObjectApiResponse));
104 |
105 | appExecutors.mainThread().execute(new Runnable() {
106 | @Override
107 | public void run() {
108 | results.addSource(loadFromDb(), new Observer() {
109 | @Override
110 | public void onChanged(@Nullable CacheObject cacheObject) {
111 | setValue(Resource.success(cacheObject));
112 | }
113 | });
114 | }
115 | });
116 | }
117 | });
118 | }
119 | else if(requestObjectApiResponse instanceof ApiResponse.ApiEmptyResponse){
120 | Log.d(TAG, "onChanged: ApiEmptyResponse");
121 | appExecutors.mainThread().execute(new Runnable() {
122 | @Override
123 | public void run() {
124 | results.addSource(loadFromDb(), new Observer() {
125 | @Override
126 | public void onChanged(@Nullable CacheObject cacheObject) {
127 | setValue(Resource.success(cacheObject));
128 | }
129 | });
130 | }
131 | });
132 | }
133 | else if(requestObjectApiResponse instanceof ApiResponse.ApiErrorResponse){
134 | Log.d(TAG, "onChanged: ApiErrorResponse.");
135 | results.addSource(dbSource, new Observer() {
136 | @Override
137 | public void onChanged(@Nullable CacheObject cacheObject) {
138 | setValue(
139 | Resource.error(
140 | ((ApiResponse.ApiErrorResponse) requestObjectApiResponse).getErrorMessage(),
141 | cacheObject
142 | )
143 | );
144 | }
145 | });
146 | }
147 | }
148 | });
149 | }
150 |
151 | private CacheObject processResponse(ApiResponse.ApiSuccessResponse response){
152 | return (CacheObject) response.getBody();
153 | }
154 |
155 | private void setValue(Resource newValue){
156 | if(results.getValue() != newValue){
157 | results.setValue(newValue);
158 | }
159 | }
160 |
161 | // Called to save the result of the API response into the database.
162 | @WorkerThread
163 | protected abstract void saveCallResult(@NonNull RequestObject item);
164 |
165 | // Called with the data in the database to decide whether to fetch
166 | // potentially updated data from the network.
167 | @MainThread
168 | protected abstract boolean shouldFetch(@Nullable CacheObject data);
169 |
170 | // Called to get the cached data from the database.
171 | @NonNull @MainThread
172 | protected abstract LiveData loadFromDb();
173 |
174 | // Called to create the API call.
175 | @NonNull @MainThread
176 | protected abstract LiveData> createCall();
177 |
178 | // Returns a LiveData object that represents the resource that's implemented
179 | // in the base class.
180 | public final LiveData> getAsLiveData(){
181 | return results;
182 | };
183 | }
184 |
185 |
186 |
187 |
188 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/adapters/RecipeRecyclerAdapter.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes.adapters;
2 |
3 | import android.net.Uri;
4 | import android.support.annotation.NonNull;
5 | import android.support.annotation.Nullable;
6 | import android.support.v7.widget.RecyclerView;
7 | import android.text.TextUtils;
8 | import android.view.LayoutInflater;
9 | import android.view.View;
10 | import android.view.ViewGroup;
11 |
12 | import com.bumptech.glide.Glide;
13 | import com.bumptech.glide.ListPreloader;
14 | import com.bumptech.glide.RequestBuilder;
15 | import com.bumptech.glide.RequestManager;
16 | import com.bumptech.glide.request.Request;
17 | import com.bumptech.glide.request.RequestOptions;
18 | import com.bumptech.glide.util.ViewPreloadSizeProvider;
19 | import com.codingwithmitch.foodrecipes.R;
20 | import com.codingwithmitch.foodrecipes.models.Recipe;
21 | import com.codingwithmitch.foodrecipes.util.Constants;
22 |
23 | import java.util.ArrayList;
24 | import java.util.Collections;
25 | import java.util.List;
26 |
27 | public class RecipeRecyclerAdapter extends RecyclerView.Adapter implements
28 | ListPreloader.PreloadModelProvider
29 | {
30 |
31 | private static final int RECIPE_TYPE = 1;
32 | private static final int LOADING_TYPE = 2;
33 | private static final int CATEGORY_TYPE = 3;
34 | private static final int EXHAUSTED_TYPE = 4;
35 |
36 | private List mRecipes;
37 | private OnRecipeListener mOnRecipeListener;
38 | private RequestManager requestManager;
39 | private ViewPreloadSizeProvider preloadSizeProvider;
40 |
41 | public RecipeRecyclerAdapter(OnRecipeListener mOnRecipeListener,
42 | RequestManager requestManager,
43 | ViewPreloadSizeProvider viewPreloadSizeProvider) {
44 | this.mOnRecipeListener = mOnRecipeListener;
45 | this.requestManager = requestManager;
46 | this.preloadSizeProvider = viewPreloadSizeProvider;
47 | }
48 |
49 | @NonNull
50 | @Override
51 | public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
52 |
53 | View view = null;
54 | switch (i){
55 |
56 | case RECIPE_TYPE:{
57 | view = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.layout_recipe_list_item, viewGroup, false);
58 | return new RecipeViewHolder(view, mOnRecipeListener, requestManager, preloadSizeProvider);
59 | }
60 |
61 | case LOADING_TYPE:{
62 | view = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.layout_loading_list_item, viewGroup, false);
63 | return new LoadingViewHolder(view);
64 | }
65 |
66 | case EXHAUSTED_TYPE:{
67 | view = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.layout_search_exhausted, viewGroup, false);
68 | return new SearchExhaustedViewHolder(view);
69 | }
70 |
71 | case CATEGORY_TYPE:{
72 | view = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.layout_category_list_item, viewGroup, false);
73 | return new CategoryViewHolder(view, mOnRecipeListener, requestManager);
74 | }
75 |
76 | default:{
77 | view = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.layout_recipe_list_item, viewGroup, false);
78 | return new RecipeViewHolder(view, mOnRecipeListener, requestManager, preloadSizeProvider);
79 | }
80 | }
81 |
82 |
83 | }
84 |
85 | @Override
86 | public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int i) {
87 |
88 | int itemViewType = getItemViewType(i);
89 | if(itemViewType == RECIPE_TYPE){
90 | ((RecipeViewHolder)viewHolder).onBind(mRecipes.get(i));
91 | }
92 | else if(itemViewType == CATEGORY_TYPE){
93 | ((CategoryViewHolder)viewHolder).onBind(mRecipes.get(i));
94 | }
95 |
96 | }
97 |
98 | @Override
99 | public int getItemViewType(int position) {
100 | if(mRecipes.get(position).getSocial_rank() == -1){
101 | return CATEGORY_TYPE;
102 | }
103 | else if(mRecipes.get(position).getTitle().equals("LOADING...")){
104 | return LOADING_TYPE;
105 | }
106 | else if(mRecipes.get(position).getTitle().equals("EXHAUSTED...")){
107 | return EXHAUSTED_TYPE;
108 | }
109 | else{
110 | return RECIPE_TYPE;
111 | }
112 | }
113 |
114 | // display loading during search request
115 | public void displayOnlyLoading(){
116 | clearRecipesList();
117 | Recipe recipe = new Recipe();
118 | recipe.setTitle("LOADING...");
119 | mRecipes.add(recipe);
120 | notifyDataSetChanged();
121 | }
122 |
123 | private void clearRecipesList(){
124 | if(mRecipes == null){
125 | mRecipes = new ArrayList<>();
126 | }
127 | else{
128 | mRecipes.clear();
129 | }
130 | notifyDataSetChanged();
131 | }
132 |
133 | public void setQueryExhausted(){
134 | hideLoading();
135 | Recipe exhaustedRecipe = new Recipe();
136 | exhaustedRecipe.setTitle("EXHAUSTED...");
137 | mRecipes.add(exhaustedRecipe);
138 | notifyDataSetChanged();
139 | }
140 |
141 | public void hideLoading(){
142 | if(isLoading()){
143 | if(mRecipes.get(0).getTitle().equals("LOADING...")){
144 | mRecipes.remove(0);
145 | }
146 | else if(mRecipes.get(mRecipes.size() - 1).equals("LOADING...")){
147 | mRecipes.remove(mRecipes.size() - 1);
148 | }
149 | notifyDataSetChanged();
150 | }
151 | }
152 |
153 | // pagination loading
154 | public void displayLoading(){
155 | if(mRecipes == null){
156 | mRecipes = new ArrayList<>();
157 | }
158 | if(!isLoading()){
159 | Recipe recipe = new Recipe();
160 | recipe.setTitle("LOADING...");
161 | mRecipes.add(recipe);
162 | notifyDataSetChanged();
163 | }
164 | }
165 |
166 | private boolean isLoading(){
167 | if(mRecipes != null){
168 | if(mRecipes.size() > 0){
169 | if(mRecipes.get(mRecipes.size() - 1).getTitle().equals("LOADING...")){
170 | return true;
171 | }
172 | }
173 | }
174 | return false;
175 | }
176 |
177 | public void displaySearchCategories(){
178 | List categories = new ArrayList<>();
179 | for(int i = 0; i< Constants.DEFAULT_SEARCH_CATEGORIES.length; i++){
180 | Recipe recipe = new Recipe();
181 | recipe.setTitle(Constants.DEFAULT_SEARCH_CATEGORIES[i]);
182 | recipe.setImage_url(Constants.DEFAULT_SEARCH_CATEGORY_IMAGES[i]);
183 | recipe.setSocial_rank(-1);
184 | categories.add(recipe);
185 | }
186 | mRecipes = categories;
187 | notifyDataSetChanged();
188 | }
189 |
190 | @Override
191 | public int getItemCount() {
192 | if(mRecipes != null){
193 | return mRecipes.size();
194 | }
195 | return 0;
196 | }
197 |
198 | public void setRecipes(List recipes){
199 | mRecipes = recipes;
200 | notifyDataSetChanged();
201 | }
202 |
203 | public Recipe getSelectedRecipe(int position){
204 | if(mRecipes != null){
205 | if(mRecipes.size() > 0){
206 | return mRecipes.get(position);
207 | }
208 | }
209 | return null;
210 | }
211 |
212 | @NonNull
213 | @Override
214 | public List getPreloadItems(int position) {
215 | String url = mRecipes.get(position).getImage_url();
216 | if(TextUtils.isEmpty(url)){
217 | return Collections.emptyList();
218 | }
219 | return Collections.singletonList(url);
220 | }
221 |
222 | @Nullable
223 | @Override
224 | public RequestBuilder> getPreloadRequestBuilder(@NonNull String item) {
225 | return requestManager.load(item);
226 | }
227 | }
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 |
242 |
243 |
--------------------------------------------------------------------------------
/app/src/main/java/com/codingwithmitch/foodrecipes/RecipeListActivity.java:
--------------------------------------------------------------------------------
1 | package com.codingwithmitch.foodrecipes;
2 |
3 | import android.arch.lifecycle.Observer;
4 | import android.arch.lifecycle.ViewModelProviders;
5 | import android.content.Intent;
6 | import android.os.Bundle;
7 | import android.support.annotation.NonNull;
8 | import android.support.annotation.Nullable;
9 | import android.support.v7.widget.LinearLayoutManager;
10 | import android.support.v7.widget.RecyclerView;
11 | import android.support.v7.widget.SearchView;
12 | import android.support.v7.widget.Toolbar;
13 | import android.util.Log;
14 | import android.widget.Toast;
15 |
16 |
17 | import com.bumptech.glide.Glide;
18 | import com.bumptech.glide.RequestManager;
19 | import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader;
20 | import com.bumptech.glide.request.RequestOptions;
21 | import com.bumptech.glide.util.ViewPreloadSizeProvider;
22 | import com.codingwithmitch.foodrecipes.adapters.OnRecipeListener;
23 | import com.codingwithmitch.foodrecipes.adapters.RecipeRecyclerAdapter;
24 | import com.codingwithmitch.foodrecipes.models.Recipe;
25 | import com.codingwithmitch.foodrecipes.util.Resource;
26 | import com.codingwithmitch.foodrecipes.util.Testing;
27 | import com.codingwithmitch.foodrecipes.util.VerticalSpacingItemDecorator;
28 | import com.codingwithmitch.foodrecipes.viewmodels.RecipeListViewModel;
29 |
30 | import java.util.List;
31 |
32 | import static com.codingwithmitch.foodrecipes.viewmodels.RecipeListViewModel.QUERY_EXHAUSTED;
33 |
34 |
35 | public class RecipeListActivity extends BaseActivity implements OnRecipeListener {
36 |
37 | private static final String TAG = "RecipeListActivity";
38 |
39 | private RecipeListViewModel mRecipeListViewModel;
40 | private RecyclerView mRecyclerView;
41 | private RecipeRecyclerAdapter mAdapter;
42 | private SearchView mSearchView;
43 |
44 | @Override
45 | protected void onCreate(Bundle savedInstanceState) {
46 | super.onCreate(savedInstanceState);
47 | setContentView(R.layout.activity_recipe_list);
48 | mRecyclerView = findViewById(R.id.recipe_list);
49 | mSearchView = findViewById(R.id.search_view);
50 |
51 | mRecipeListViewModel = ViewModelProviders.of(this).get(RecipeListViewModel.class);
52 |
53 | initRecyclerView();
54 | initSearchView();
55 | subscribeObservers();
56 | setSupportActionBar((Toolbar)findViewById(R.id.toolbar));
57 | }
58 |
59 | private void subscribeObservers(){
60 | mRecipeListViewModel.getRecipes().observe(this, new Observer>>() {
61 | @Override
62 | public void onChanged(@Nullable Resource> listResource) {
63 | if(listResource != null){
64 | Log.d(TAG, "onChanged: status: " + listResource.status);
65 |
66 | if(listResource.data != null){
67 | switch (listResource.status){
68 | case LOADING:{
69 | if(mRecipeListViewModel.getPageNumber() > 1){
70 | mAdapter.displayLoading();
71 | }
72 | else{
73 | mAdapter.displayOnlyLoading();
74 | }
75 | break;
76 | }
77 |
78 | case ERROR:{
79 | Log.e(TAG, "onChanged: cannot refresh the cache." );
80 | Log.e(TAG, "onChanged: ERROR message: " + listResource.message );
81 | Log.e(TAG, "onChanged: status: ERROR, #recipes: " + listResource.data.size());
82 | mAdapter.hideLoading();
83 | mAdapter.setRecipes(listResource.data);
84 | Toast.makeText(RecipeListActivity.this, listResource.message, Toast.LENGTH_SHORT).show();
85 |
86 | if(listResource.message.equals(QUERY_EXHAUSTED)){
87 | mAdapter.setQueryExhausted();
88 | }
89 | break;
90 | }
91 |
92 | case SUCCESS:{
93 | Log.d(TAG, "onChanged: cache has been refreshed.");
94 | Log.d(TAG, "onChanged: status: SUCCESS, #Recipes: " + listResource.data.size());
95 | mAdapter.hideLoading();
96 | mAdapter.setRecipes(listResource.data);
97 | break;
98 | }
99 | }
100 | }
101 | }
102 | }
103 | });
104 |
105 | mRecipeListViewModel.getViewstate().observe(this, new Observer() {
106 | @Override
107 | public void onChanged(@Nullable RecipeListViewModel.ViewState viewState) {
108 | if(viewState != null){
109 | switch (viewState){
110 |
111 | case RECIPES:{
112 | // recipes will show automatically from other observer
113 | break;
114 | }
115 |
116 | case CATEGORIES:{
117 | displaySearchCategories();
118 | break;
119 | }
120 | }
121 | }
122 | }
123 | });
124 | }
125 |
126 | private RequestManager initGlide(){
127 |
128 | RequestOptions options = new RequestOptions()
129 | .placeholder(R.drawable.white_background)
130 | .error(R.drawable.white_background);
131 |
132 | return Glide.with(this)
133 | .setDefaultRequestOptions(options);
134 | }
135 |
136 | private void searchRecipesApi(String query){
137 | mRecyclerView.smoothScrollToPosition(0);
138 | mRecipeListViewModel.searchRecipesApi(query, 1);
139 | mSearchView.clearFocus();
140 | }
141 |
142 | private void initRecyclerView(){
143 | ViewPreloadSizeProvider viewPreloader = new ViewPreloadSizeProvider<>();
144 | mAdapter = new RecipeRecyclerAdapter(this, initGlide(), viewPreloader);
145 | VerticalSpacingItemDecorator itemDecorator = new VerticalSpacingItemDecorator(30);
146 | mRecyclerView.addItemDecoration(itemDecorator);
147 | mRecyclerView.setLayoutManager(new LinearLayoutManager(this));
148 |
149 | RecyclerViewPreloader preloader = new RecyclerViewPreloader(
150 | Glide.with(this),
151 | mAdapter,
152 | viewPreloader,
153 | 30);
154 |
155 | mRecyclerView.addOnScrollListener(preloader);
156 |
157 | mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
158 | @Override
159 | public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
160 | super.onScrollStateChanged(recyclerView, newState);
161 |
162 | if(!mRecyclerView.canScrollVertically(1)
163 | && mRecipeListViewModel.getViewstate().getValue() == RecipeListViewModel.ViewState.RECIPES){
164 | mRecipeListViewModel.searchNextPage();
165 | }
166 | }
167 | });
168 |
169 | mRecyclerView.setAdapter(mAdapter);
170 | }
171 |
172 | private void initSearchView(){
173 | mSearchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
174 | @Override
175 | public boolean onQueryTextSubmit(String s) {
176 |
177 | searchRecipesApi(s);
178 | return false;
179 | }
180 |
181 | @Override
182 | public boolean onQueryTextChange(String s) {
183 | return false;
184 | }
185 | });
186 | }
187 |
188 | @Override
189 | public void onRecipeClick(int position) {
190 | Intent intent = new Intent(this, RecipeActivity.class);
191 | intent.putExtra("recipe", mAdapter.getSelectedRecipe(position));
192 | startActivity(intent);
193 | }
194 |
195 | @Override
196 | public void onCategoryClick(String category) {
197 | searchRecipesApi(category);
198 | }
199 |
200 | private void displaySearchCategories(){
201 | mAdapter.displaySearchCategories();
202 | }
203 |
204 |
205 | @Override
206 | public void onBackPressed() {
207 | if(mRecipeListViewModel.getViewstate().getValue() == RecipeListViewModel.ViewState.CATEGORIES){
208 | super.onBackPressed();
209 | }
210 | else{
211 | mRecipeListViewModel.cancelSearchRequest();
212 | mRecipeListViewModel.setViewCategories();
213 | }
214 | }
215 | }
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
--------------------------------------------------------------------------------