Skip to content

Instantly share code, notes, and snippets.

@naltynbekkz
Created January 26, 2021 14:00
Show Gist options
  • Save naltynbekkz/3f168d4164fac4102580503ecbb5656f to your computer and use it in GitHub Desktop.
Save naltynbekkz/3f168d4164fac4102580503ecbb5656f to your computer and use it in GitHub Desktop.

DealerPro

Welcome to the DealerPro Android app walk-through. Please read this file very carefully before doing any work.

This project uses a lot of good libraries designed specifically for android, like Hilt for dependency injection, Data Binding, Retrofit, Room, Paging, etc. and one library made specifically for TELE2 for recognizing text on National Ids, passports, and SIM-cards. It is based on clean architecture principles, meaning it separates View classes (fragments and activities), ViewModels (contains logic for the screen), repositories (decides whether to query from local database or from remote service), DAOs (local database queries), and services (network requests).

We will go through the project structure, some base classes, and then I'll show you how to create screens.

This is a multi-module project. It has 8 modules: :core module contains most of base classes, and classes that are used in many other modules. :buildSrc is a module that is compiled before any other module, thus the values in classes in this module can be used in gradle files. Mostly it contains classes that define what dependencies to use, :auth contains everything related to authentication. :home is a feature module that is responsible for everything that happens in the home part of the app. It is also the largest module of the app. :profile doesn't have many features. It's only purpose for now is to provide logout functionality :news has even less responsibility. It's only there to satisfy the design requirements. :veridoc is an aar library that recognizes text on KZ national ID, passport, and sim-cards. :app ties everything together.

Let's go through all modules one by one.

  1. :core If you have a class that is used in multiple modules, this is the best place to keep it. If you look at the build.gradle file of any other module, you can see they depend on :core. Now, let's go through some classes in this module: - Access and AccessDao When user logs in, the app makes a network request to get access that are available to the user. Then in the home page, the accesses define what feature is shown to the user. - Cards In the design of the app, there are many screens that have a similar layout and functionality. I decided to generalize them, and put everything into cards package. There are two types of cards in this app: static and live. Static cards can navigate you to the next screen when clicked. LiveCards, on the other hand, behave like a TabLayout for the ViewPager: they show fragments at the bottom of the same screen. For example, Services fragment uses static cards, because it navigates to other screens, while ClientCard fragment uses liveCards and shows the data under the cards. To use LiveCards, first create an array of LiveCard objects, that defines icon, title and the destination fragment that extends LiveCardFragment. Then create a fragment that extends LiveCardHostFragment, and provide the array by overriding certain method. Check out the ClientCardFragment for more details. If you want to use Static cards, it would be easy because there is not much to learn. First create an array of StaticCard objects. Then create an adapter for a recyclerView in a host fragment, and pass the array, and handle the click. That's it! Congratulations! - Connectivity Only used in the BaseRepository class for checking the current state of internet connection, and to subscribe to updates. - CustomView Read more about each custom view in the corresponding class. - InfoCards Classes that make it much easier to populate recyclerViews with data in form of cards. Use the OwnerHistoryFragment as an example - Navigation these classes handle navigation. You will only be using the landingFragment interface, so take your time to read that. - Network Auth interceptor takes care of putting auth token into all the requests that you make. BaseRepository handles making all the requests. it converts all the suspend functions from retrofit service interface into liveData of Result objects. All the other classes are just data/envelope/object classes.
    - Preferences Technically, connectivity also belongs here. I don't know why its not here. Other than that it has SessionManager, that provides you with authentication stuff. - BaseActivity parent class for all the activities. it contains usefull utility methods. - BaseFragment parent class for all the fragments. it contains usefull utility methods. - Constants it contains some constants, and utility methods that can be used throughtout the app. - RefreshLiveData it wrapper class for liveData that gives you refresh functionality - Subscriber just a data class - ViewBindingAdapters it contains viewBindingAdapters and extension functions for the views.
    You can also visit individual classes to learn more.

  2. :buildSrc Here are all the dependency classes. This module is build before any other, so it can be used in build.gradle files. It keeps track of most of dependencies in the app. It is a good practice to do to have a single place to track your dependencies, because you might end up with different versions of the same dependency, which then takes twice the memory, even after the obfuscation

  3. :auth First things first, let's talk about the auth state of the app. 2 main activities in this app are MainActivity in the :app module, and the AuthActivity in the :auth module. The SplashActivity is just a launcher activity, that decides whether to open Auth or Main activity. In the AuthActivity you can see that it observes the tokenLiveData from SessionManager, and navigates to MainActivity whenever it's not null. MainActivity does the same thing: it observes tokenLiveData and navigates to AuthActivity when null. Let's take a look at the SessionManager class. There are 3 fields that actually could have been used as the auth state indicator: token, msisdn, subscriberId. All of them are saved when user logs in and deleted when user logs out. Actually, those are the ways to log in and log out. The only different thing to mind in this module, is that LoginFragment, SmsFragment and AuthActivity share the same viewModel. That's why you can see that the viewModels in fragments are provided by activityViewModels().

  4. :home This is the larges module in the app, and most of your work will be concentrated here.

  5. :profile This is another screen in the MainActivity. It is one of the most simplest modules in the app. it only has one fragment.

  6. :news This also in just another screen in the MainActivity. It also is on of the simplest modules in the app. It also only has one fragment.

  7. :app This module ties everything together, provides dependencies to the Hilt dependency injector, creates a database, and is the entering point of the app.

Let's now learn how to create a fragment. I will be assuming that you have very little knowledge about software engineering, so please bare with me.

  1. Task definition. Let's say you are given a task to make a new fragment that get's some data from certain endpoint, and sends some other data to some other endpoint. Given: a. get data from GET: https://api.tele2.kz/v1/dealerpro/get-example-object request parameters: msisdn: String, currentTimestamp: Long response body: { "code": 0, "message": "Example has been exampled", "details": { "exampleId": 0, "exampleName": "string" } } b. send data to POST: https://api.tele2.kz/v1/dealerpro/post-example-object request body: { "exampleString": "string", "exampleNumber": 0 } response body: { "code": 0, "message": "Successful example", "details": null } c. You should also ask Aleksei whether you should go to back, or stay on the same page after sending the data.

  2. Defining the Data objects and business logic. Next you will have to define the data objects. we will need 2 data objects here: one for the GET response body, and another for POST request body. What you do is create a new kotlin file. You can name it whatever you think fits best, but here it will be called ExampleGetResponse:

    @Keep
    data class ExampleGetResponse (
        @SerializedName("exampleId")
        val exampleId: Int,
        @SerializedName("exampleName")
        val exampleName: String
    )

    Please don't forget to add @Keep and @SerializedName annotations. They will be very important when the app eventually go through code obfuscation. Next let's do the ExamplePostRequest:

    @Keep
    data class ExamplePostRequest (
        @SerializedName("exampleString")
        val exampleString: String,
        @SerializedName("exampleNumber")
        val exampleNumber: Int
    )

    Next let's do some business logic. As you can see both requests have the same baseUrl: https://api.tele2.kz/v1/dealerpro/. You will almost always have a url that start with https://api.tele2.kz/v1/. The second part is the service name. If you take a look at kz.mtelecom.home.api package, you will find services that have their
    service name commented at the top. What we need is the DealerProService. There we just have to define what the request looks like.

    /**
     * https://api.tele2.kz/v1/dealerpro/get-example-object?msisdn=7072110955&currentTimestamp=1607853444000
     */
    @GET("get-example-object")
    suspend fun getExampleObject(
        @Query("msisdn") msisdn: String, 
        @Query("currentTimestamp") currentTimestamp: Long
    ): Response<Envelope<ExampleGetResponse>>

    Don't forget to add the comment with the endpoint. Now let's do the POST request:

    /**
     * https://api.tele2.kz/v1/dealerpro/post-example-object
     */
    @POST("post-example-object")
    suspend fun postExampleObject(@Body examplePostRequest: ExamplePostRequest): Response<Envelope<Empty>>

    Here, if you know that the response is null, please use the Empty class. It lets you avoid null object. Note that if the endpoint is not defined among the existing services, you will have to create your own. To do that, you first have to create a new file. Let's say your base Url is https://api.tele2.kz/v1/example/, then create a file called ExampleService:

    /**
     * baseUrl:    https://api.tele2.kz/v1/example/
     */
    interface ExampleService {
        // here you write the code for your requests
    }

    Next you provide the instance of this interface built by retrofit object in the RetrofitModule.kt file.

    @Provides
    @ActivityScoped
    fun provideExampleService(
        okhttpClient: OkHttpClient,
        gsonConverterFactory: GsonConverterFactory
    ): ImeiService {
        return Retrofit.Builder()
            .baseUrl("https://api.tele2.kz/v1/example/")
            .addConverterFactory(gsonConverterFactory)
            .client(okhttpClient)
            .build()
            .create(ExampleService::class.java)
    }

    Alternatively you could define the baseUrl in the BaseUrl file, and then use BaseUrl.example instead of hardcoding the baseUrl. It's a good practice because that way you'll have a single place to keep all your base urls. Navigate to the package where you want a new package to be, and create a new package. In our case it will be called 'example', but you can call it whatever you want. Here you will be creating the repository. Create a file ExampleRepository.kt

    @ActivityScoped
    class ExampleRepository @Inject constructor(
        private val service: ExampleService,
        connectivity: Connectivity
    ) : BaseRepository(connectivity) {
    
        fun getExampleObject(msisdn: String, currentTimestamp: Long) =
            networkRequest { service.getExampleObject(msisdn, currentTimestamp) }
           
        fun postExampleObject(examplePostRequest: ExamplePostRequest) =
            networkRequest { service.postExampleObject(examplePostRequest) }
            
    }

    Don't forget to write the annotation. That annotation provides you the constructor parameters. Connectivity is needed to construct the BaseRepository class. You can see that the functions use networkRequest functions. You could also use consecutiveRequests function for making consecutive requests. Or you could also write your own strategy. You probably will write one, when you need to interact with the database.

  3. Creating Fragment and ViewModel At this point you have all the logic to make a request. Now it's time to create the screen itself. Right-click on the example package and select New -> Fragment -> Fragment (Blank). Call it ExampleFragment, and it will automatically generate fragment_example layout file. First go to the layout file and convert it to dataBinding layout. Add the title to the string file and include the appBar in the root of the layout file. You should have something like this:

    // strings.xml 
    <string name="example_title">Example Title</string>
    
    // strings.xml (ru-rKZ)
    <string name="example_title">Название Примера</string>
    
    // fragment_example.xml
    <?xml version="1.0" encoding="utf-8"?>
    <layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools">
    
        <data>
    
        </data>
    
        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent">
    
            <include
                android:id="@+id/appbar"
                layout="@layout/layout_app_bar"
                app:layout_constraintTop_toTopOf="parent"
                app:title="@{@string/example_title}" />
    
            <!--    The rest of your layout here    -->
    
        </androidx.constraintlayout.widget.ConstraintLayout>
    </layout>

    Surely, you need to add all your views there too. Next, go to the package where ExampleFragment.kt is located and create ExampleViewModel.kt file. Inside that paste:

    class ExampleViewModel @ViewModelInject constructor(
        @Assisted savedStateHandle: SavedStateHandle,
        private val repository: ExampleRepository
    ) : ViewModel() {
    
        val msisdn: String = savedStateHandle["msisdn"]!!
        
        val exampleObject = repository.getExampleObject(msisdn, System.currentTimeMillis())
           
        fun postExampleObject(examplePostRequest: ExamplePostRequest) = 
            repository.postExampleObject(examplePostRequest)
        
    }

    In the viewModel class constructor you should have the savedStateHandle, that gives you all the navigation arguments. Here we are only retrieving msisdn. Next initialize the exampleObject, and expose the postExampleObject function, because we want to keep repository private. Next go to the ExampleFragment.kz file and delete everything (it's always easier to start from scratch). Paste the following:

    @AndroidEntryPoint
    class ExampleFragment : BaseFragment<FragmentExampleBinding>() {
    
        private val viewModel: ExampleViewModel by viewModels()
    
        override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?
        ): View {
            _binding = FragmentExampleBinding.inflate(inflater, container, false)
            setupToolbar(binding.appbar.toolbar)
    
            // logic here
    
            return binding.root
        }
    
    } 

    The annotation is there to provide the instance of the viewModel. Your Fragment should extend the BaseFragment class to use all the great functions I wrote, that make it easy to show errors, successMessages, and handle the network responses. When extending BaseFragment, you have to provide the layout binding class. Read the official documentation to find out more, but generally you just have to write the layout filename in camel case and add 'binding' at the end. Next get the viewModel 'by viewModels()'. The dependency injection framework provides the instance of the viewModel. Then override onCreateView, and inside that initialize _binding as shown in the example, and setup the toolbar. At the end of the onCreateView return binding.root. One rule to keep in mind is to never override onViewCreated, and if you really have to, call super.

    Now you have to write the logic. Let's say you want to make a request to get the exampleObject. You will have to write:

    viewModel.exampleObject.observe("Success!!!") {
        // bind exampleObject here
    }  

    'observe' is an extension function that handles success message, error message. You only need to provide how you want to bind the object to the layout.

    Now, let's say you need to send some data to the cloud, then navigate up if you get success, and do something else if you get error. You'll have to use observeResult:

    viewModel.postExampleObject(ExamplePostRequest("string", 0)).observeResult(
        successMessage = "Success message",
        bind = {
            findNavController().navigateUp()
        },
        fail = {
            // do something else.. (respond to fail)
        }
    )
  4. Add this fragment to the App Now let's add the newly created fresh fragment to the app navigation. First go to the navigation file of the module (99% of the time it's home.xml, also you will see that I left it in a mess), open the design editor, click on New Destination, and choose your fragment from the list. Next go to the fragment from which you want to navigate to the exampleFragment. In order to navigate to the exampleFragment, you will have to write the following:

    findNavController().navigate(
        R.id.exampleFragment,
        bundleOf("msisdn" to "7077050967")
    )  

    Optionally you can add the setPopUpTo parameter:

    findNavController().navigate(
        R.id.exampleFragment,
        bundleOf("msisdn" to "7077050967"),
        NavOptions.Builder().setPopUpTo(R.id.homeFragment, true).build()
    )  

    Again read the Android Navigation Component documentation to learn more.

    And that's it. Now you have a new screen in the project. Don't forget to commit and push the updates.

There are some extra features that I wrote by myself with no supervision. You can find them in the Constants.kt file. If you want to make them work, open the local.properties file, and write: "features=true" on a new line at the bottom. You will have them, but no one else will.

Please keep a consistent coding style, and don't forget to review all the other files, e.g. colors.xml which contains all the colors needed in the app. Many of other files in the project contain somethings I didn't cover in here.

Contact Aleksei if you have questions about design, contact Daniyar if you have questions about backend, and contact Artem if you feel like something is wrong with any of those. Finally, contact me at http://t.me/naltynbekkz if you have questions about all this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment