musiQ last.fm scrobbler 1.753 release/tutorial - what was changed & how I changed it

in #utopian-io7 years ago (edited)

Hello everybody, I have added a bunch of new features and fixed others for my app musiQ . I will save you the trivial details which are some updated libraries and small UI tweaks and get on with the good stuff. I have included links to the changed/added classes for your reading convenience :) Just a quick reminder: I use the MVP pattern with Dagger2 for DI in this app, in which the views only load the information or provide the containers for the information, and the presenters are responsible for fetching the information via RxJava2. I will skip the boilerplate Dagger2 code, if you want to see how the dagger code is implemented, leave a comment and I will make a new article with the boilerplate code or make a separate tutorial how to integrate dagger with MVP. This is a slightly long read ahead, but if you have an android app or plan on making one, feel free to learn from me/borrow my design. The major changes in the new version are:

1) Adding a header to the drawer layout with the user's profile picture, name and playcount:

Screenshot_1522760971.png

2) Adding a whole new Activity that has two tabs that display the user information and the user friends. The friend's tab allows the user to limit the displayed friends and search them as well. Screenshot of the new layout:

Screenshot_1522761006.png

Screenshot_2018-04-04-14-56-52.png

So I will be going over the two major changes and how I implemented them.

Drawer layout header change

So let's start with the layout file. The drawer layout looks like this:
drawer_layout_main.xml

<android.support.v4.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/drawer_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:descendantFocusability="blocksDescendants"
    android:focusable="false"
    android:focusableInTouchMode="false">
    (html comment removed:  The main content view )

    <include
        layout="@layout/activity_main" />

    <FrameLayout
        android:id="@+id/content_frame"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
    (html comment removed:  The navigation drawer )
    <android.support.design.widget.NavigationView
        android:id="@+id/navigation"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        app:headerLayout="@layout/drawer_header"
        app:menu="@menu/drawer_menu">

    </android.support.design.widget.NavigationView>


</android.support.v4.widget.DrawerLayout>

Main thing to take note of here is the declaration of the header:

app:headerLayout="@layout/drawer_header"

So naturally we have to define a layout for the header. The one I made contains an image of the user profile pic, playcount of his total scrobbles, and username:

drawer_header.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/nav_username"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/nav_header_image"
        android:layout_marginStart="20dp"
        android:layout_marginTop="10dp"
        android:breakStrategy="balanced"
        android:visibility="invisible"
        android:text="Logged in as: KINDALONGUSERNAAAMEEEE"
        style="@style/TextAppearance.AppCompat.Title"/>
    <TextView
        android:id="@+id/nav_playcount"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Scrobbles: 24332"
        android:layout_marginStart="20dp"
        android:layout_marginTop="5dp"
        android:visibility="gone"
        android:layout_below="@id/nav_username"
        style="@style/TextAppearance.AppCompat.Body2"/>
    <ImageView
        android:id="@+id/nav_header_image"
        android:layout_width="150dp"
        android:layout_height="150dp"
        android:src="@drawable/ic_account_circle_black_24dp"
        android:layout_marginTop="80dp"
        android:layout_marginLeft="20dp"/>

</RelativeLayout>

As you can see I just added some hardcoded text in there so I can preview it in the activity editor. Important thing to take out here is that their visibility is INVISIBLE. This is done because the user profile pic, playcount and username won't be displayed if the user is not logged in, so naturally I want them to be INVISIBLE. Note: they are INVISIBLE, not GONE, because I still want them to preserve space. If the user is not logged in the default image displayed will be this one @drawable/ic_account_circle_black_24dp - which is basically a vector asset of an icon that looks like this: ic_account_circle_black_24dp.png

So we are done with the layout and we move on to the actual code. First of all I added a method to my Api Client to fetch the user information:

LastFmApiService.java

and the method:

    @GET(METHOD_CALL+"user.getinfo")
    Observable<UserInfo> getUserInfo(@Query("user") String user);

I will spare you the model class because it's too long but you can take a look at it if you want:

UserInfo.java

Basically it returns a wrapper User.java with all the user info.

So now that saw how we load the user info, let's see when we load it. We move on to my two activities which both have the DrawerLayout. The first activity is the MainActivity.java and the second is the ArtistDetails.java

So both of these incorporate DrawerLayout, and I want every time that I open the DrawerLayout is opened - the user info gets downloaded and the image/playcount/username loaded into the drawer header. So in both activities I will add the following code:

presenter.setOnDrawerOpenedListener(this);

What this does is to delegate fetching of the information to the presenter of the activity, since my Api Client is there. Here is the actual method that loads the information. The method is the same in both MainPresenter and ArtistDetailsPresenter:

Method is called

public void setOnDrawerOpenedListener(ArtistDetailsContract.View detailsActivity)

I will spare you the overridden methods we don't use. The core is here, note the onNext method:

@Override
            public void onDrawerOpened(View drawerView) {
                String username = App.getSharedPreferences().getString(Constants.USERNAME, "");
                lastFmApiClient.getLastFmApiService()
                        .getUserInfo(username)
                        .observeOn(AndroidSchedulers.mainThread())
                        .subscribeOn(Schedulers.io())
                        .subscribe(new Observer<UserInfo>() {
                            @Override
                            public void onSubscribe(Disposable d) {
                                compositeDisposable.add(d);
                                detailsActivity.showProgressBar();
                            }

                            @Override
                            public void onNext(UserInfo userInfo) {
                                String profilePicUrl = userInfo.getUser().getImage().get(Constants.IMAGE_LARGE).getText();
                                String playcount = userInfo.getUser().getPlaycount();

                                setUserInfo(profilePicUrl, playcount, navigationView, detailsActivity, username);
                            }

                            @Override
                            public void onError(Throwable e) {
                                detailsActivity.hideProgressBar();
                                AppLog.log(TAG, e.getMessage());
                                compositeDisposable.clear();
                            }

                            @Override
                            public void onComplete() {
                                compositeDisposable.clear();
                                detailsActivity.hideProgressBar();
                            }
                        });
            }
private void setUserInfo(String profilePicUrl, String playcount, NavigationView navigationView, ArtistDetailsContract.View detailsActivity, String username) {
        RelativeLayout drawerLayout = (RelativeLayout) navigationView.getHeaderView(0);

        TextView usernameTextView = (TextView) drawerLayout.getChildAt(0);
        TextView scrobbleCount = (TextView) drawerLayout.getChildAt(1);
        ImageView userAvatar = (ImageView) drawerLayout.getChildAt(2);

        usernameTextView.setVisibility(View.VISIBLE);
        scrobbleCount.setVisibility(View.VISIBLE);

        Glide.with(detailsActivity.getContext())
                .load(profilePicUrl)
                .apply(RequestOptions.circleCropTransform()).into(userAvatar);
        usernameTextView.setText(detailsActivity.getContext().getString(R.string.logged_in_as) + " " + username);
        scrobbleCount.setText(detailsActivity.getContext().getString(R.string.scrobbles) + " " + playcount);

        App.getSharedPreferences().edit().putString(Constants.PROFILE_PIC, profilePicUrl).apply();
    }

This code gets the drawer layout, gets the three things needed from it(ImageView for the profile pic, TextView for the playcount and TextView for the username), and when the information is fetched, it just loads them in place. Afterwards I put the link for the image URL into the SharedPrefetences, which I will use for my next big change, and that is:

Adding a Profile Info Activity

We will use the same template as the last change. First we begin with the layout. Since the layout is a bit more complex here I will do my best to give you the short version. Just a quick reminder what we want to achieve here: Screenshot_1522761006.png

Screenshot_2018-04-04-14-56-52.png

And the first page of the layout:
Screenshot_2018-04-04-13-43-59.png

We will begin with the first page of the ViewPager, namely the "bio" fragment.
So for this purpose the layout will consist of a collapsing toolbar layout, along with a View Pager and two tabs which are for the User Info and the User Friends.

Here is the main layout:

activity_profile.xml

I won't paste the whole code, but the main thing to take out is :

 <ImageView
                    android:id="@+id/profile_image"
                    android:layout_width="200dp"
                    android:layout_height="200dp"
                    android:layout_marginBottom="0dp"
                    android:layout_marginTop="0dp"
                    android:adjustViewBounds="true"
                    android:clickable="false"
                    app:layout_constraintBottom_toBottomOf="parent"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toTopOf="parent"
                    app:layout_constraintVertical_bias="0.35" />

and the ViewPager:

<include
        android:id="@+id/profile_include"
        layout="@layout/content_profile" />

This is the image that appears on the activity, and this is why we saved the profile pic URL in the SharedPreferences. So how do we open this activity? Well, from the drawer layout. I added a small language SettingsManager which I instantiate in both my MainActivity and ArtistDetails. In this case we use

 private void openProfile() {
        String username = App.getSharedPreferences().getString(Constants.USERNAME, "");

        if (username.isEmpty() || username == "") {
            alertDialogBuilder.setCancelable(true);
            alertDialogBuilder.setTitle(activity.getString(R.string.note))
                    .setMessage(activity.getString(R.string.log_in_to_use_feature))
                    .setNeutralButton(activity.getString(R.string.dialog_action_dismiss), (dialog, which) -> {
                        dialog.dismiss();
                    });
            alertDialogBuilder.create().show();
            return;
        }

        activity.startActivity(new Intent(activity, Profile.java));
    }

This checks if the user is logged in(if an username is saved) and if the username is there, it opens the Profile activity. So on to the code of the Profile activity.

As we use the MVP pattern so we create a ProfileContract.java

public interface ProfileContract {
    interface View extends BaseView<Presenter>, MainViewFunctionable {
        void initViewPager();

        void setUserImage(String url);
    }

    interface Presenter extends BasePresenter<View> {
    }
}

There are two main methods that define the functionality of this class. I will skip the boilerplate code on loading the collapsing toolbar and etc, since it's the same in every activity. So on to the main functionality. The two main things the Profile.java will do are to load the ViewPager and set the image:

@Override
    public void initViewPager() {
        ProfileViewPagerAdapter viewPagerAdapter = new ProfileViewPagerAdapter(getSupportFragmentManager());
        viewPager.setAdapter(viewPagerAdapter);
        tabLayout.setupWithViewPager(viewPager);

        //need to call this as calligraphy doesnt change the fonts of the tablayout, since there is no exposed property,
        //in the xml, and the fonts are set programatically
        HelperMethods.changeTabsFont(this, tabLayout);
    }

And we load the image like this:

@Override
    public void setUserImage(String url) {
        Glide.with(this).load(url).apply(RequestOptions.circleCropTransform()).transition(withCrossFade(2000)).into(profileImage);
    }

Pretty easy right? Now we move on to the ViewPager.java

switch (position) {
            case 0:
                return ProfileUserInfo.newInstance();
            case 1:
                return ProfileUserFriendsInfo.newInstance();
        }
So we have two fragments, which the ViewPager will load. 

The first one is for the user biography.

Screenshot_2018-04-04-13-43-59.png

The layout for this fragment is profile_info_layout.xml

This layout basically has a few labels and a few containers for the information that we will be getting from the user. His real name from the site, his name, country, age, and date registered. Note that all of this is in a NestedScrollView to allow our Profile.java collapsed layout to be scrollable.

This fragment is called ProfileUserInfo.java. And here is the [contract]

(https://github.com/DDihanov/musiQ/blob/master/app/src/main/java/com/dihanov/musiq/ui/settings/profile/user_bio/ProfileUserInfoContract.java):

public interface ProfileUserInfoContract {
    interface View extends BaseView<Presenter> {
        void loadUserBio(String realName, String profileUrl, String country, String age, String playcount, String unixRegistrationDate);

        void showProgressBar();

        void hideProgressBar();
    }

    interface Presenter extends BasePresenter<View> {
        void fetchUserInfo();
    }
}

The chain of command is like this:

Fragment is loaded -> presenter takes the fragment via takeView method -> fragmentcalls presenter.fetchUserInfo() method -> after info is fetched presenter calls loadUserBio(...) method

Here is our fragment ProfileUserInfo.java
First we call the fetchUserInfo() method:

In the onCreateView method we call the methods in this following order:

        presenter.takeView(this);
        presenter.fetchUserInfo();

After presenter.FetcHuserInfo() is called the following happens in our ProfileUserInfoPresenter.java :

 @Override
    public void fetchUserInfo() {
        String username = App.getSharedPreferences().getString(Constants.USERNAME, "");

        lastFmApiClient
                .getLastFmApiService()
                .getUserInfo(username)
                .observeOn(AndroidSchedulers.mainThread())
                .subscribeOn(Schedulers.io())
                .subscribe(new Observer<UserInfo>() {
                    @Override
                    public void onSubscribe(Disposable d) {
                        compositeDisposable.add(d);
                        view.showProgressBar();
                    }

                    @Override
                    public void onNext(UserInfo userInfo) {
                        view.loadUserBio(userInfo.getUser().getRealname(),
                                userInfo.getUser().getUrl(),
                                userInfo.getUser().getCountry(),
                                userInfo.getUser().getAge(),
                                userInfo.getUser().getPlaycount(),
                                userInfo.getUser().getRegistered().getUnixtime());
                    }

                    @Override
                    public void onError(Throwable e) {
                        view.hideProgressBar();
                        AppLog.log(TAG, e.getMessage());
                        compositeDisposable.clear();
                    }

                    @Override
                    public void onComplete() {
                        view.hideProgressBar();
                        compositeDisposable.clear();
                    }
                });
    }

So using the method getUserInfo from the Api Client, we get the information from our model class, and we call

  view.loadUserBio(userInfo.getUser().getRealname(),
                                userInfo.getUser().getUrl(),
                                userInfo.getUser().getCountry(),
                                userInfo.getUser().getAge(),
                                userInfo.getUser().getPlaycount(),
                                userInfo.getUser().getRegistered().getUnixtime());

So loadUserBio in ProfileUserInfo.java:

@Override
    public void loadUserBio(String realName, String profileUrl, String country, String age, String playcount, String unixRegistrationDate) {
        this.realNameTextView.setText(realName);
        this.profileUrlTextView.setText(profileUrl);
        this.countryTextView.setText(country);
        if(Integer.parseInt(age) <= 0){
            this.ageTextView.setVisibility(View.GONE);
            this.ageLabel.setVisibility(View.GONE);
        } else {
            this.ageTextView.setText(age);
        }
        this.playcountTextView.setText(playcount);
        this.dateTextView.setText(DateUtils.formatDateTime(getContext(), Long.parseLong(unixRegistrationDate) * 1000L, 0));
    }

At the end we just load the information in our containers. Easy as that.

The second fragment is the friends fragment

Screenshot_1522761006.png

Screenshot_2018-04-04-14-56-52.png

This part is a bit tricky so bare with me, I will try to explain everything.

FIrst we have our method in the LastFmApiService.java that fetches the user friends:

@GET(METHOD_CALL+"user.getfriends")
    Observable<UserFriends> getUserFriends(@Query("user") String user, @Query("recenttracks") int recentTracks, @Query("limit") int limit);

This method requires the username, a boolean flag whether each user's most recently scrobbled track should be included(yes in our case), and the limit of friends to fetch(defaults to 50).

Ok so we know how to get our information from the last.fm api. Next up is the layout:

profile_friends_layout.xml

So what we have here is an EditText which will act as a search bar, a Spinner which will allow the user to select the limit of friends fetched, and finally a RecyclerView which will hold the results of the friends.

And of course we need the viewholder that will be every entry in our recycler view.

profile_friends_viewholder.xml

We basically have the same things here as we had in our previous UserInfo fragment. The only difference is the recently scrobbled track TextView.

So how do we connect all this together?

Let's take a look at our contract again:

ProfileUserFriendsContract.java

public interface ProfileUserFriendsContract {
    interface View extends BaseView<Presenter> {
        void loadFriends(List<User> friends);

        void showProgressBar();

        void hideProgressBar();
    }

    interface Presenter extends BasePresenter<View> {
        void fetchFriends(int limit);
    }
}

So the main functionality is the mostly the same as our previous fragment:

We get load the information from last.fm with the presenter -> load it into the recycler view

in a bit more detail:

fragment is created -> presenter takes the view via .takeView method ->fragment uses presenter.fetchFriends to fetch the info -> after the api call is complete the presenter calls the fragment's loadFriends method -> fragment loads the friends in the RecyclerView

So let's see how the order is executed:

ProfileUserFriendsInfo.java

First the presenter takes the view in the onCreateView method:

presenter.takeView(this);

But notice we don't immediately call the fetchFriends method. This is because of our spinner. We want to load as many friends as the spinner tells us. Android is designed in such a fashion, that when the spinner is created, it's onSpinnerOptionSelected method is automatically executed. So we use this to our advantage:

@OnItemSelected(R.id.friends_limit_spinner)
    void onSpinnerItemSelected(Spinner spinner, int position){
        int limit = Integer.parseInt((String) spinner.getSelectedItem());

        presenter.fetchFriends(limit);
    }

The moment the spinner is created, it will call the fetchFriends with the first value of the spinner, which is 20.

    <string-array name="friends_limit">
        <item>20</item>
        <item>50</item>
        <item>100</item>
        <item>200</item>
    </string-array>

So ProfileUserFriendsPresenter.java fetchFriends method:

@Override
    public void fetchFriends(int limit) {
        lastFmApiClient.getLastFmApiService()
                .getUserFriends(App.getSharedPreferences().getString(Constants.USERNAME, ""), 1, limit)
                .observeOn(AndroidSchedulers.mainThread())
                .subscribeOn(Schedulers.io())
                .subscribe(new Observer<UserFriends>() {
                    @Override
                    public void onSubscribe(Disposable d) {
                        compositeDisposable.add(d);
                        view.showProgressBar();
                    }

                    @Override
                    public void onNext(UserFriends userFriends) {
                        view.loadFriends(userFriends.getFriends().getUser());
                    }

                    @Override
                    public void onError(Throwable e) {
                        AppLog.log(TAG, e.getMessage());
                        view.hideProgressBar();
                        compositeDisposable.clear();
                    }

                    @Override
                    public void onComplete() {
                        view.hideProgressBar();
                        compositeDisposable.clear();
                    }
                });
    }

We just call the API and it fetches the information from the site, after that we call the loadFriends method in our fragment:

 @Override
    public void loadFriends(List<User> friends) {
        friendRecyclerView.setAdapter(new ProfileFriendsAdapter(getContext(), friends));
        RecyclerView.LayoutManager layoutManager =
                new GridLayoutManager(this.getContext(), 2,GridLayoutManager.VERTICAL, false);
        friendRecyclerView.setLayoutManager(layoutManager);
    }

So some boilerplate code to create a RecyclerView - we set the adapter, we set the layout manager to be a GridLayout with two columns.

For this purpose, I have created a custom ProfileFriendsAdapter.java

Remember, this class contains our ViewHolder

The magic here happens first in the constructor, which receives the information and then in our onBindViewHolder method, which uses the information which was passed to the constructor and creates the single entry in the RecyclerView:

the constructor:

public ProfileFriendsAdapter(Context context, List<User> friends) {
        this.friends = friends;
        friendsOriginal.addAll(friends);
        this.context = context;
    }
@Override
    public void onBindViewHolder(ViewHolder holder, int position) {
        User friend = friends.get(position);

        holder.friendName.setText(friend.getName());
        if (friend.getCountry().trim().isEmpty() || friend.getCountry().trim() == "") {
            holder.friendCountry.setVisibility(View.GONE);
        } else {
            holder.friendCountry.setText(friend.getCountry());
        }
        Glide.with(context)
                .load(friend.getImage().get(3).getText())
                .apply(RequestOptions.circleCropTransform()).transition(withCrossFade(2000))
                .into(holder.friendImage);
        holder.friendPlaycount.setText(context.getString(R.string.playcount) + " " + friend.getPlaycount());
        holder.friendUrl.setText(friend.getUrl());
        if (friend.getRecenttrack() != null && friend.getRecenttrack().getArtist() != null && friend.getRecenttrack().getName() != null) {
            holder.friendLastTrack.setText(context.getString(R.string.last_track) + " " + friend.getRecenttrack().getArtist().getName() + " - " + friend.getRecenttrack().getName());
        }
    }

So basically the recycler view requests a ViewHolder in a specific position, and the onBindViewHolder gets the specified position from the List(the fetched friends) and sets the information in the ViewHolder's container.

So if you were observant so far you will notice a few things:

Why do we have a friendsOriginal ArrayList in our Adapter class? Why do we add the friends information in the constructor to friendsOriginal ? Why does the class implement the Filterable interface? How do we perform searching?

Well glad you asked, this is the last point:

Searching for friends:

So back to our ProfileUserFriendsInfo.java

The search is started from the method:

@OnTextChanged(R.id.profile_friends_search)
    void searchForFriend(CharSequence charSequence){
        ((ProfileFriendsAdapter)friendRecyclerView.getAdapter()).getFilter().filter(charSequence);
    }

This is a ButterKnife listener that acts the same way as we were to manually add it with searchBar.addOnTextChangedListener.

This method gets the filter from the adapter and the filter performs the search:

the getFilter method:

@Override
    public Filter getFilter() {
        if(friendFilter == null) {
            friendFilter = new FriendFilter();
        }
        return friendFilter;
    }

This returns the filter, and if the filter is null ,we create one. The filter is a nested static class in the Adapter:

private class FriendFilter extends Filter {
        @Override
        protected FilterResults performFiltering(CharSequence constraint) {
            String userString = constraint.toString().toLowerCase();

            FilterResults filterResults = new FilterResults();

            if(constraint == null || constraint.length() == 0 || constraint.toString().trim() == ""){
                filterResults.values = friendsOriginal;
                filterResults.count = friendsOriginal.size();
            } else {
                ArrayList<User> resultList = new ArrayList<>();
                for (User friend : friends) {
                    if (friend.getName().toLowerCase().contains(userString)) {
                        resultList.add(friend);
                    }
                }

                filterResults.values = resultList;
                filterResults.count = resultList.size();
            }

            return filterResults;
        }

We have two lists. friends and friendsOriginal. friendsOriginal is our backup list.
So every time the text in the searchBar changes, we go through a check. We go through our List(friends list) and we search if their name contains the String that is searched. If it contains it, we simply add the User to a new results list, and at the end set the result list as the primary source of information of the RecylerView. If the user deletes the search query and the search bar is empty, we want to revert back to the original collection, so that's why we keep a separate list with the original collection(friendsOriginal), and we go through a check to see if the searched String is empty, if it's empty, we just revert the RecyclerView's and Adapter's data source back to the original collection.

Yay we are finished!

Kind of a long article, but I went through everything, so that it might serve another person in their app development quest.
Thank you for taking your time to read this, be sure to check out the app on GitHub and on the Google PlayStore. I hope I helped someone with this, feel free to use it in your app. If you have any questions/suggestions add them in the comments. Until next time!



Posted on Utopian.io - Rewarding Open Source Contributors

Sort:  

Congratulations @j4zz! You have completed some achievement on Steemit and have been rewarded with new badge(s) :

Award for the number of upvotes received

Click on any badge to view your own Board of Honor on SteemitBoard.

To support your work, I also upvoted your post!
For more information about SteemitBoard, click here

If you no longer want to receive notifications, reply to this comment with the word STOP

Upvote this notification to help all Steemit users. Learn why here!

Do not miss the last announcement from @steemitboard!

Thank you for the contribution. It has been approved.

You can contact us on Discord.
[utopian-moderator]

Hey @j4zz I am @utopian-io. I have just upvoted you!

Achievements

  • You have less than 500 followers. Just gave you a gift to help you succeed!
  • Seems like you contribute quite often. AMAZING!

Community-Driven Witness!

I am the first and only Steem Community-Driven Witness. Participate on Discord. Lets GROW TOGETHER!

mooncryption-utopian-witness-gif

Up-vote this comment to grow my power and help Open Source contributions like this one. Want to chat? Join me on Discord https://discord.gg/Pc8HG9x