Nightmare Project: Baking App
yummio - by Ak?n DEM?R

Nightmare Project: Baking App

Are you stuck on Baking App project? You don’t know what to do, how to start, how to set up Exoplayer..?

I was stuck on Baking App. Tried to understand how Exoplayer works. I watched course videos many times but couldn’t understand very well. So, I decided on something:

  1. Do not try to understand every piece of code!
  2. Start from easiest and meaningful parts.

Let me show you how I did it..

I will write down step by step. Hope you can follow it easily and start coding Baking App.

1. Do NOT try to understand every piece of code! You don’t have to know how Exoplayer works at the background or how to customize a widget from zero to hero. I tried it and lost so much time. So, if you are not trying to learn exactly ‘Exoplayer’ or any other library, just read the documents and get what you can get(an overview) then paste the minimum required code. For Exoplayer, all we may need to know is, we will give it a Uri of a video and it will play it. It has a layout which you can customize but we won’t do it. Don’t get stuck here, just move on. We will come to that step where we will paste the code of Exoplayer and give minimum required info about it. Keep reading…

2. That’s how I started. I copied baking.json. Go here, and copy it.

3. Go to jsonschema2pojo and paste baking.json there. Change settings like this or as you wish.


Here one point is important. As you can see in our json, quantity is not an integer but here it generated a POJO with int quantity field. So don’t forget to change it to double after you paste the POJOs.

4. Create POJOs in Android Studio. Recipe.javaStep.java and Ingredient.java and paste generated classes inside corresponding classes. (Dont copy package name or just edit it later).

5. Implement Parcelable in your POJOs. If you have Android Studio Parcelable plugin, you can use Alt+Insert > Parcelable to generate parcelable class or you can use https://www.parcelabler.com.

6. Add Retrofit and Gson converter dependencies in your build.gradle(app).

implementation 'com.squareup.retrofit2:retrofit:2.3.0'
implementation 'com.squareup.retrofit2:converter-gson:2.3.0'

7. Create Retrofit client class and RecipeService interface just like how I did.

RecipeClient.java

public class RecipeClient {

    private final static String BASE_URL = "https://d17h27t6h515a5.cloudfront.net/topher/2017/May/59121517_baking/";
    public final RecipeService mRecipeService;

    public RecipeClient() {
        Retrofit retrofit = new Retrofit.Builder()
                .baseUrl(BASE_URL)
                .addConverterFactory(GsonConverterFactory.create())
                .build();

        mRecipeService = retrofit.create(RecipeService.class);
    }
}

RecipeService.java

public interface RecipeService {

    //Get list of recipes
    @GET("baking.json")
    Call<ArrayList<Recipe>> getRecipes();
}

8. Now let’s test if our Retrofit setup is working. Go to MainActivity or whatever you named it. Create a global variable for RecipeService:

RecipeService mRecipeService;

Instantiate it inside onCreate():

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_recipe);

    mRecipeService = new RecipeClient().mRecipeService;
}

Now, create a method where we will make our API calls.

// Fetch recipes
private void fetchRecipes() {
    Call<ArrayList<Recipe>> call = mRecipeService.getRecipes();

    call.enqueue(new Callback<ArrayList<Recipe>>() {
        @Override
        public void onResponse(Call<ArrayList<Recipe>> call, Response<ArrayList<Recipe>> response) {
        //Test if response is succesfull
        ArrayList<Recipe> recipe = response.body();
        Log.d("BAKING_APP", recipe.get(0).getName());
        }

        @Override
        public void onFailure(Call<ArrayList<Recipe>> call, Throwable t) {
            Log.d("FAILURE", t.toString());
        }
    });
}

Now call this method inside onCreate().

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_recipe);

    mRecipeService = new RecipeClient().mRecipeService;
    fetchRecipes();
}

Run the app and check the Logcat:

And here it is. It’s working. We got a Nutella Pie; first recipe’s name. Now let’s move further…

9. Create a RecyclerView inside our Activity (in my project, it’s RecipeActivity) which we run the fetchRecipes() method in - to show recipe names on launch of the app.

10. Create a custom adapter for our RecyclerView. Inside constructor of adapter, get an ArrayList of Recipes (ArrayList<Recipe>) as parameter.

Context mContext;
ArrayList<Recipe> mRecipeList;
public RecipeAdapter(Context context, ArrayList<Recipe> recipeList) {
    this.mContext = context;
    this.mRecipeList = recipeList;
}

After you set the values of views inside onBindViewHolder with the response of API call, set an onClickListener for each item(recipe) and send that clicked Recipe to RecipeDetails(or whatever you name it) Activity.

holder.itemView.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        Recipe recipe = mRecipeList.get(position);
        Intent intent = new Intent(mContext, RecipeDetailsActivity.class);
        ArrayList<Recipe> recipeArrayList = new ArrayList<>();
        recipeArrayList.add(recipe);
        intent.putParcelableArrayListExtra("WHATEVER_KEY", recipeArrayList);
        mContext.startActivity(intent);
    }
});

Right now, we can fetch the recipes from API endpoint and list them in our RecyclerView and on a click we send details of recipe to another activity.

11. Create RecipeDetails activity to get our clicked Recipe details.

// Global variable
ArrayList<Recipe> mRecipeArrayList;
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_recipe_details);

    // Get recipe from intent extra
    Intent recipeIntent = getIntent();
    mRecipeArrayList = recipeIntent.getParcelableArrayListExtra("WHATEVER_KEY");
    //...
}

12. Now we have all the details of a selected recipe (ingredients, cooking steps). We need to get video urls from each Step (getVideoURL()) and pass that url to Exoplayer to be able to play it. I did this a little differently by adding another Activity(let’s say StepActivity) to which I pass Step info with an Intent again. There, I have a FrameLayout. I also created a Fragment(let’s say VideoFragment) in which I have a PlayerView (exoplayer2.ui.PlayerView) and TextView for description of the step. Not to forget, we will also add 2 Buttons here to trigger Next and Previous Steps. Here, the design is up to you.

Create an Activity, StepActivity. Drop a FrameLayout inside its xml with a unique ID. That frame layout will be a container for our fragment. 

(Here width and height is zero because I use ConstraintLayout as parent layout. You change it as you need…)

<FrameLayout
    android:id="@+id/fl_player_container"
    android:layout_width="0dp"
    android:layout_height="0dp"/>
<Button
    android:id="@+id/btn_next_step"
    android:layout_width="0dp"
    android:layout_height="wrap_content"/>
<Button
    android:id="@+id/btn_previous_step"
    android:layout_width="0dp"
    android:layout_height="wrap_content"/>

We will create a Bundle inside StepActivity and put all needed info of Step(videoUrl, thumbnailUrl, videoDescription etc). Then we will set this bundle to our VideoFragment instance and start transaction to show that fragment inside our container FrameLayout.

// We get required info from getIntent()
// and pass it to our global Bundle instance.
// videoNumber=0 is a global variable which increases
// on Next button click and decreases on Previous button
// click
public void playVideo(int videoNumber){
mVideoUri = mStepArrayList.get(videoNumber).getVideoURL();
//and others...
VideoFragment videoFragment = new VideoFragment();
bundle.putString("VIDEO_URI", mVideoUri);
bundle.putString("VIDEO_DESC", mVideoDescription);
bundle.putString("VIDEO_THUMB", mVideoThumbnail);
bundle.putString("VIDEO_SHORTDESC", mVideoShortDescription);
videoFragment.setArguments(bundle);
FragmentManager fragmentManager = getSupportFragmentManager();
fragmentManager.beginTransaction()
        .replace(R.id.fl_player_container, videoFragment)
        .commit();
}

13. Now time for ExoPlayer. Add gradle dependency:

implementation 'com.google.android.exoplayer:exoplayer:2.8.1'

14. Create a fragment, VideoFragment. Inside it drop a PlayerView (with id: player_view) and a TextView for description. The layout design is up to you.

<com.google.android.exoplayer2.ui.PlayerView
    android:id="@+id/player_view"
    android:layout_width="0dp"
    android:layout_height="0dp"/>
<TextView
    android:id="@+id/tv_step_description"
    android:layout_width="0dp"
    android:layout_height="wrap_content"/>

15. Go to VideoFragment and create a method to initialize ExoPlayer. (You can just copy/paste or check documents or other examples)

//Global variables
SimpleExoPlayer mSimpleExoPlayer;
DefaultBandwidthMeter bandwidthMeter;
TrackSelection.Factory videoTrackSelectionFactory;
TrackSelector trackSelector;
DataSource.Factory dataSourceFactory;
MediaSource videoSource;
public void initializeVideoPlayer(Uri videoUri){
    if(mSimpleExoPlayer == null){
        // 1. Create a default TrackSelector
        bandwidthMeter = new DefaultBandwidthMeter();
        videoTrackSelectionFactory =
                new AdaptiveTrackSelection.Factory(bandwidthMeter);
        trackSelector =
                new DefaultTrackSelector(videoTrackSelectionFactory);

        // 2. Create the player
        mSimpleExoPlayer =
                ExoPlayerFactory.newSimpleInstance(getContext(), trackSelector);

        // Bind the player to the view.
        mPlayerView.setPlayer(mSimpleExoPlayer);

        // Produces DataSource instances through which media data is loaded.
        dataSourceFactory = new DefaultDataSourceFactory(getContext(),
                Util.getUserAgent(getContext(), "YOUR_APP_NAME"), bandwidthMeter);

        // This is the MediaSource representing the media to be played.
        videoSource = new ExtractorMediaSource.Factory(dataSourceFactory)
                .createMediaSource(videoUri);
        // Prepare the player with the source.
        mSimpleExoPlayer.prepare(videoSource);
    }
}

That’s it. When we call initializeVideoPlayer(mVideoUri) method in onCreateView() and pass video Uri as a parameter, it will show the video inside the PlayerView with id player_view.

But we need to release the player when we don’t need it anymore. To do so, we will create another method and call at appropriate places.

// Release player
private void releasePlayer() {
    if (mSimpleExoPlayer != null) {
        mSimpleExoPlayer.stop();
        mSimpleExoPlayer.release();
        mSimpleExoPlayer = null;
        dataSourceFactory = null;
        videoSource = null;
        trackSelector = null;
    }
}

And call this method in:

@Override
public void onPause() {
    super.onPause();
    if (Util.SDK_INT <= 23) {
        releasePlayer();
    }
}

@Override
public void onStop() {
    super.onStop();
    if (Util.SDK_INT > 23) {
        releasePlayer();
    }
}
//I also added in
@Override
public void onStop() {
    super.onStop();
    if (Util.SDK_INT > 23) {
        releasePlayer();
    }
}

@Override
public void onDetach() {
    super.onDetach();
    if (mSimpleExoPlayer!=null) {
       releasePlayer();
    }
}

@Override
public void onDestroyView() {
    super.onDestroyView();
    releasePlayer();
}

You can also override onStart() and onResume() to initialize the player as in ExoPlayer github demo.

@Override
public void onStart() {
    super.onStart();
    if (Util.SDK_INT > 23) {
        initializeVideoPlayer(mp4VideoUri);
    }
}

@Override
public void onResume() {
    super.onResume();
    if (Util.SDK_INT <= 23 || mSimpleExoPlayer == null) {
        initializeVideoPlayer(mp4VideoUri);
    }
}

16. Now our Player can play the video. It’s now time to design landscape and tablet views. This step is so easy.

Right click on res directory > New > Android Resource Directory. In the window popped up, select Resource type: layout, select Smallest Screen Width from qualifiers list, click on >> button in the middle and type 600 in Smallest screen width box as shown in the image below. Click OK.

Now we have that directory. You can do the same for landscape design by selecting Orientation qualifier. To be able to see these directories, switch to Project view.


We will just right click > copy our XML layout files of our activities that we want to change for tablet view and paste into sw600dp and land directories. And then design the layouts as we want.

You can simply hide the views that you don’t want to see in tablet views. So you won’t have any problems with findViewById() or you won’t need to add additional checks for findViewById() if that view exists in tablet layout or not. But if you add additional views for tablet view, then you need to control if the device is tablet or not.

How to do that check? It’s easy. If you watched Fragments course videos you may have already seen that. We set an id for parent layouts of each tablet layouts.

As you can see above, ConstraintLayout has an id. So, if the app is running on a tablet, this view will be called and we will have a layout with id recipe_tablet. If app is not running on a tablet, then findViewById(R.id.recipe_tablet) will return null. This is explained in Fragments lesson, Step: 24. Exercise: Two Pane Layout.

17. Saving states.

When we rotate the phone we need to save the minimum info that we want to keep. Here I just save the Step id(video number) and StepList to get the video from with that number.

@Override
protected void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    outState.putParcelableArrayList(STEP_LIST_STATE,mStepArrayList);
    outState.putInt(STEP_NUMBER_STATE, mVideoNumber);
}

And in onCreate() check if savedInstanceState is null:

if(savedInstanceState != null){
    int stepNo = savedInstanceState.getInt(STEP_NUMBER_STATE,0);
    if(mStepArrayList.isEmpty())
        mStepArrayList = savedInstanceState.getParcelableArrayList(STEP_LIST_STATE);
    if(!isTablet){
        mStepperIndicator.setCurrentStep(stepNo);
    }
    playVideo(stepNo);
}
else{
    playVideo(mVideoNumber);
}

18. Widgets. Finally the widget part comes…

Add a Widget. Right click on the app directory > New > Widget > App Widget. We will now customize this widget as we want. I added an ImageView to show the recipe icon and a TextView to show ingredients. (To watch the video, check: Lesson 7: Widgets — 5. Creating Your First App Widget)

Our generated widget provider class already includes some methods like updateAppWidget(), onUpdate()onEnabled() and onDisabled() . We will just use updateAppWidget() and onUpdate().

Let’s assign a click event to our TextView(Ingredients) or ImageView in our widget which opens our application. Some of the code already exists. We can only assign a click event thru a PendingIntent. PendingIntent wraps an Intent.

//YummioWidgetProvider class
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.yummio_widget_provider);
// Create the intent
Intent intent = new Intent(context, RecipeActivity.class);
intent.putExtra(ConstantsUtil.WIDGET_EXTRA,"CAME_FROM_WIDGET");
// Create the pending intent that will wrap our intent
PendingIntent pendingIntent = PendingIntent.getActivity(context,0,intent,0);

// OnClick intent for textview
views.setOnClickPendingIntent(R.id.widget_ingredients, pendingIntent);

// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, views);

Run the app. Add a widget on the home screen and tap on the textview. It works, right? I hope so :)

Now, we need our Widget to show ingredients of our latest chosen Recipe (that’s how I decided to use). I handled this by saving chosen Recipe in the SharedPreferences. I convert response of Retrofit into json string. And pass it to RecipeAdapter. OnClick, I get clicked Recipe as json String and send it as Extra to RecipeDetailsActivity and from there to Activity where I show video and save into SharedPref.

// Retrofit onResponse()
String mJsonResult = new Gson().toJson(response.body());

Getting that Recipe from SharedPref is done with a Service. We will now create a Widget service that extends IntentService. That service class gets data from SharedPreferences and by calling updateWidgetRecipe() method of our widget provider and passing Recipe details to that.

To do that let’s update our updateWidgetRecipe() method to handle passed info and set text of widget.

// Here I added a String parameter which we will pass the json Recipe and imgResId to show Recipe icon. You can just show ingredients for simplicity
static void updateAppWidget(Context context, String jsonRecipeIngredients, int imgResId, AppWidgetManager appWidgetManager, int appWidgetId) {

    RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.yummio_widget_provider);
// Create the intent that we will start. You can directly start the activity which contains the video.
    Intent intent = new Intent(context, RecipeActivity.class);
    intent.putExtra(ConstantsUtil.WIDGET_EXTRA,"CAME_FROM_WIDGET");
   
// Create the pending intent that will wrap our intent
    PendingIntent pendingIntent = PendingIntent.getActivity(context,0,intent,0);

    if(jsonRecipeIngredients.equals("")){
        jsonRecipeIngredients = "No ingredients yet!";
    }

    views.setTextViewText(R.id.widget_ingredients, jsonRecipeIngredients);
    views.setImageViewResource(R.id.ivWidgetRecipeIcon, imgResId);

    // OnClick intent for textview
    views.setOnClickPendingIntent(R.id.widget_ingredients, pendingIntent);

    // Instruct the widget manager to update the widget
    appWidgetManager.updateAppWidget(appWidgetId, views);
}

And onUpdate() method calls our service:

// Gets called once created and on every update period
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
    YummioWidgetService.startActionOpenRecipe(context);
}

And here is our service class: We save Recipe as Json and get it with the help of Gson to map with Recipe.class(POJO).

@Override
protected void onHandleIntent(@Nullable Intent intent) {
    if (intent != null) {
        final String action = intent.getAction();
        if (ACTION_OPEN_RECIPE.equals(action)) {
            handleActionOpenRecipe();
        }
    }
}
private void handleActionOpenRecipe() {

    //Get data from shared pref
SharedPreferences sharedpreferences =
            getSharedPreferences(ConstantsUtil.YUMMIO_SHARED_PREF,MODE_PRIVATE);
    String jsonRecipe = sharedpreferences.getString(ConstantsUtil.JSON_RESULT_EXTRA, "");
    
    //
    StringBuilder stringBuilder = new StringBuilder();
    Gson gson = new Gson();
    Recipe recipe = gson.fromJson(jsonRecipe, Recipe.class);
    int id= recipe.getId();
    int imgResId = ConstantsUtil.recipeIcons[id-1];
    List<Ingredient> ingredientList = recipe.getIngredients();
    for(Ingredient ingredient : ingredientList){
        String quantity = String.valueOf(ingredient.getQuantity());
        String measure = ingredient.getMeasure();
        String ingredientName = ingredient.getIngredient();
        String line = quantity + " " + measure + " " + ingredientName;
        stringBuilder.append( line + "\n");
    }
    String ingredientsString = stringBuilder.toString();
    
    //Here we create AppWidgetManager to update AppWidget state 
    AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(this);
    int[] appWidgetIds = appWidgetManager.getAppWidgetIds(new ComponentName(this, YummioWidgetProvider.class));
    //Pass recipe info into the widget provider
    YummioWidgetProvider.updateWidgetRecipe(this, ingredientsString, imgResId, appWidgetManager, appWidgetIds);
}


// Trigger the service to perform the action
public static void startActionOpenRecipe(Context context) {
    Intent intent = new Intent(context, YummioWidgetService.class);
    intent.setAction(ACTION_OPEN_RECIPE);
    context.startService(intent);
}

Important! : We will call our service at the right place to update the widget. I do this in the activity where I show video player, right after saving recipe details in the SharedPref. And one more thing: add this service in the manifest.

//Save the recipe info
SharedPreferences.Editor editor = getSharedPreferences(ConstantsUtil.YUMMIO_SHARED_PREF, MODE_PRIVATE).edit();
editor.putString(ConstantsUtil.JSON_RESULT_EXTRA, mJsonResult);
editor.apply();

//Start the widget service to update the widget
YummioWidgetService.startActionOpenRecipe(this);

Manifest(my service class is inside .widget directory):

<service android:name=".widget.YummioWidgetService" />

19. Lastly, Espresso Testing. You can do this at the very beginning which is better but I did this part in the end.

First add required dependencies:

testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-intents:3.0.2'
androidTestImplementation('com.android.support.test.espresso:espresso-contrib:2.2') {
    // Necessary to avoid version conflicts
    exclude group: 'com.android.support', module: 'appcompat'
    exclude group: 'com.android.support', module: 'support-v4'
    exclude group: 'com.android.support', module: 'support-annotations'
    exclude module: 'recyclerview-v7'
}

I named already existing test class as IntentTesting(Refactor). You can watch the course video about this. I won’t go into details here. Below code is same as in the video and in any other example:

@Rule
public IntentsTestRule<RecipeActivity> mActivityRule = new IntentsTestRule<>(
        RecipeActivity.class);
@Before
public void stubAllExternalIntents() {
    // By default Espresso Intents does not stub any Intents. Stubbing needs to be setup before
    // every test run. In this case all external Intents will be blocked.
    intending(not(isInternal())).respondWith(new Instrumentation.ActivityResult(Activity.RESULT_OK, null));
}

We will write our Test method to test intents. RecipeActivity has a RecyclerView. We will perform a click action for RecyclerView’s first element. With intended, we check if the Intent which will trigger after RecyclerView click event, has an Extra with name RECIPE_INTENT_EXTRA. You can here also check if new activity has a class name as RecipeDetailsActivity.class. Check Espresso cheat-sheet.

@Test
public void intentTest(){
   
    //Recyclerview click action    onView(ViewMatchers.withId(R.id.rv_recipes)).perform(RecyclerViewActions.actionOnItemAtPosition(0,ViewActions.click()));

    //Check if intent (RecipeActivity to RecipeDetailsActivity) has RECIPE_INTENT_EXTRA
    intended(hasExtraWithKey(ConstantsUtil.RECIPE_INTENT_EXTRA));
}

Rigth after this, if you right click on the IntentTesting class and Run IntentTesting we will see it’s succesful. You can add more tests to test other things.

Finally, we are done. Below, you can see the screenshots. And you can also check the source code on my github as reference in case you couldn’t understand some parts (as I couldn’t paste all the codes here:))

Hope you find this helpful!


Soyombo Soyinka, MBA.

Data Analytics | Data Science | Cybersecurity

6 å¹´

Yummio, may God bless you more. I have been stuck at the widget part, but now, you write-up gave me good insight. Thanks so much

要查看或添加评论,请登录

Ak?n Demir的更多文章

  • Graduated - Google Developer Nanodegree

    Graduated - Google Developer Nanodegree

    11 Sep 2017, I applied for Google Developer Scholarship. 10 Oct 2017, I received an email saying that I have been…

    10 条评论
  • Popvie App v2

    Popvie App v2

    Detayl? Türk?e haline buradan ula?abilirsiniz. As part of Udacity Google Developer Nanodegree - Advanced Android…

    12 条评论

其他会员也浏览了