Chapter 6: Writing the Android Consumer App

Now that we've implemented the shared code, we should put it to the test. We'll start with the easier step first; that is, consuming the shared module from the Android code. This chapter will be a more concise one as teaching Android development is outside the scope of this book. With that said, I consider it important to see how that shared KMM code can be consumed by the targeted platforms.

In this chapter, we'll cover the following topics:

  • Setting up the Android module
  • Tying the Android app to the shared code
  • Implementing the UI on Android

Technical requirements

You can find the code files for this chapter in this book's GitHub repository at https://github.com/PacktPublishing/Simplifying-Application-Development-with-Kotlin-Multiplatform-Mobile.

Setting up the Android module

Since we tested part of the shared code in Chapter 5, Writing Shared Code, we have already done most of the setup. Let's go through what we need to set up before implementing the Android app.

Enabling Jetpack Compose

We'll be using Android's new UI Toolkit: Jetpack Compose. So, first, we'll need to enable it. You can find the official setup guide here: https://developer.android.com/jetpack/compose/setup#add-compose.

To enable Jetpack Compose, we'll need to add the following configurations to the build.gradle.kts file of the androidApp module, under the android{} configuration block:

  1. Enable the compose build feature:

    buildFeatures {

            compose = true

    }

  2. Make sure both the Kotlin and Java compilers target Java 8:

    compileOptions {

            sourceCompatibility = JavaVersion.VERSION_1_8

            targetCompatibility = JavaVersion.VERSION_1_8

    }

    kotlinOptions {

            jvmTarget = "1.8"

    }

  3. We also need to specify the version of the Kotlin compiler extension to be used:

    composeOptions {

            kotlinCompilerExtensionVersion =

              composeVersion

    }

The last step is to add all the dependencies we'll need to implement Dogify on Android.

Adding the necessary dependencies

Make sure that you add the following dependencies to the build.gradle.kts file of the androidApp module:

  1. Add the following dependency of our shared module:

    implementation(project(":shared"))

  2. Now, we must add AppCompat and various Kotlin extensions. Note that instead of relying on AppCompat, you could just use ComponentActivity; see https://twitter.com/joreilly/status/1364982668371329025?s=20:

    implementation("androidx.appcompat:appcompat:1.3.0")

        // Android Lifecycle

        val lifecycleVersion = "2.3.1"

    implementation("androidx.lifecycle:lifecycle-

      viewmodel- ktx:$lifecycleVersion")

    implementation("androidx.lifecycle:lifecycle-

      runtime-ktx:2.4.0-alpha02")

        // Android Kotlin extensions

    implementation("androidx.core:core-ktx:1.6.0")

    kotlinOptions {

            jvmTarget = "1.8"

    }

  3. Now, let's add the Jetpack Compose UI, Foundation, Activity, and the various tooling support that's required, such as Previews:

    implementation("androidx.activity:activity-

      compose:1.3.0-rc02")

    implementation("androidx.compose.ui:ui:$composeVersion

      ")

        // Tooling support (Previews, etc.)

    implementation("androidx.compose.ui:ui-

         tooling:1.0.0-rc02")

        // Foundation (Border, Background, Box, Image,

        Scroll, shapes, animations, etc.)

        implementation("androidx.compose.foundation:foundation

      :$composeVersion")

  4. Now, add Jetpack Compose Material Design and the necessary icons:

    // Material Design  

    implementation("androidx.compose.material:material:$co

    mposeVersion")

    // Material design icons

    implementation("androidx.compose.material:material-

      icons-core:$composeVersion")

    implementation("androidx.compose.material:material-

      icons-extended:$composeVersion")

  5. Finally, add the Accompanist coil for image loading and swipe to refresh the capabilities:

    val accompanistVersion = "0.13.0"

    implementation("com.google.accompanist:accompanist-

      coil:$accompanistVersion")

        implementation("com.google.accompanist:accompanist-

      swiperefresh:$accompanistVersion")

The full code is available at 06/01-android-module-setup.

Note

There is a common pattern for sharing dependency versions between the shared and Android modules. In most cases, this is done by storing it in a Kotlin file/object in buildSrc. I've refrained from this pattern for the following reasons:

• I wanted to keep the practical chapters as simple and to the point as possible.

• I'm not sure how I feel about the pattern and about tying the dependency versions for these modules together. It can be great in example projects, but production KMM apps are not that likely to live in the same repository. This is typically the case when the project is adopting KMM in already existing apps.

Now that we have all the dependencies in place, we are ready to consume the shared code.

Tying the Android app to the shared code

We'll be using a simple ViewModel pattern to interact with the shared code and expose the needed data and actions to our UI, based on Android's architecture ViewModel to leverage some life cycle functionality provided by the framework.

We'll create a simple MainViewModel class in the androidApp module. Let's go through the implementation step by step.

First, let's think about what dependencies this ViewModel has:

class MainViewModel(

    breedsRepository: BreedsRepository,

    private val getBreeds: GetBreedsUseCase,

    private val fetchBreeds: FetchBreedsUseCase,

    private val onToggleFavouriteState:

      ToggleFavouriteStateUseCase

) : ViewModel() {

Since we'll be communicating with the shared code, we'll make use of the three use cases for running the specific actions, and we'll listen to the stream containing the breeds from BreedsRepository.

Now, let's look at what will we expose from the ViewModel layer to View. We will do this with the help of Kotlin's Flows, exposing the whole state of the UI in multiple pieces of information:

  1. The "state" will tell the UI some information about the UI, if we have any data, if something went wrong, or whether we're currently loading the data:

    private val _state = MutableStateFlow(State.LOADING)

    val state: StateFlow<State> = _state

    private val _isRefreshing = MutableStateFlow(false)

    val isRefreshing: StateFlow<Boolean> = _isRefreshing

    enum class State {

            LOADING,

            NORMAL,

            ERROR,

            EMPTY

    }

  2. We must send out events when, for example, a certain action has failed (such as marking a breed as a favorite):

    private val _events = MutableSharedFlow<Event>()

    val events: SharedFlow<Event> = _events

    enum class Event {

        Error

    }

  3. We must also provide information about whether we are currently filtering out favorite breeds only:

    private val _shouldFilterFavourites =

      MutableStateFlow(false)

    val shouldFilterFavourites: StateFlow<Boolean> =

      _shouldFilterFavourites

  4. Finally, we need to provide the list of breeds, which depends on the breeds coming from BreedsRepository and whether the user wants to filter out favorite breeds only:

    val breeds =

          breedsRepository.breeds.combine

           (shouldFilterFavourites) { breeds,

            shouldFilterFavourites ->

                if (shouldFilterFavourites) {

                    breeds.filter { it.isFavourite }

                } else {

                    breeds

                }.also {

                    _state.value = if (it.isEmpty())

                    State.EMPTY else State.NORMAL

                }

            }.stateIn(

                viewModelScope,

                SharingStarted.WhileSubscribed(),

                emptyList()

    )

Now, let's see what actions we can expose for the UI in the form of functions:

  1. The first thing we need is a refresh action so that users can trigger a force refresh of the underlying data:

    fun refresh() {

            loadData(true)

    }

  2. We also need a way for users to switch between whether they'd like to see their favorite breeds only or not:

    fun onToggleFavouriteFilter() {

         _shouldFilterFavourites.value =

           !shouldFilterFavourites.value

    }

  3. Next, we need an action for marking or unmarking a favorite breed:

    fun onFavouriteTapped(breed: Breed) {

            viewModelScope.launch {

                try {

                    onToggleFavouriteState(breed)

                } catch (e: Exception) {

                    _events.emit(Event.Error)

                }

            }

    }

  4. We also need a trigger for getting the data when ViewModel is initialized:

    init {

            loadData()

    }

  5. At this point, our loadData() function should look like this:

    private fun loadData(isForceRefresh: Boolean = false) {

            val getData: suspend () -> List<Breed> =

                { if (isForceRefresh) fetchBreeds.invoke()

                 else getBreeds.invoke() }

            if (isForceRefresh) {

                _isRefreshing.value = true

            } else {

                _state.value = State.LOADING

            }

            viewModelScope.launch {

             _state.value = try {

                    getData()

                   State.NORMAL

                 } catch (e: Exception) {

                   State.ERROR

                 }

              _isRefreshing.value = false

            }

    }

Essentially, we're just checking if we should do a force refresh or not and calling the appropriate use case from the shared code. Based on the result of this operation, we update the "state."

This completes the implementation of our MainViewModel. The last thing we need to do is make sure that Koin knows how to inject this ViewModel. For this, we'll create an AppModule file that contains the following bean definition of our ViewModel:

val viewModelModule = module {

    viewModel { MainViewModel(get(), get(), get(), get()) }

}

We also need to make sure Koin knows about this newly declared module by adding it to our initialization:

initKoin {

            androidContext(this@DogifyApplication)

            modules(viewModelModule)

}

The full code is available at 06/02-consuming-shared-code-android.

Now that we've tied together our Android app to the shared code, let's try it out by building the UI and testing it.

Implementing the UI on Android

Before we start, I'd like to emphasize that I had conflicting thoughts when I was writing this chapter (as a matter of fact, the whole example project). I wanted to polish the UI as much as possible, try out the new Android 12 splash screen API, make it edge-to-edge, and so on. But at the same time, I didn't want to introduce things without explicitly talking about them in this book as well, and to do that felt out of scope.

So, consider this as me finding an excuse for why the UI looks so barebone.

Now, let's throw some Jetpack Compose code together and see how consuming the shared code can be presented on an Android UI:

  1. Let's create a MainScreen that will contain our small number of composable components. We'll start by creating the MainScreen composable:

    @Composable

    fun MainScreen(viewModel: MainViewModel) {

        val state by viewModel.state.collectAsState()

        val breeds by viewModel.breeds.collectAsState()

        val events by

         viewModel.events.collectAsState(Unit)

        val isRefreshing by

         viewModel.isRefreshing.collectAsState()

        val shouldFilterFavourites by

         viewModel.shouldFilterFavourites.collectAsState()

As you can see, first, we consume all the state-related information that our previously defined ViewModel exposes.

  1. We'll also need two other states to be maintained by this composable:

    val scaffoldState = rememberScaffoldState()

    val snackbarCoroutineScope = rememberCoroutineScope()

  2. Now, our root component will be a Scaffold with a SwipeRefresh so that users can trigger the refresh action with a pull-to-refresh action:

    Scaffold(scaffoldState = scaffoldState) {

          SwipeRefresh(

                state =

                 rememberSwipeRefreshState(isRefreshing =

                 isRefreshing),

                onRefresh = viewModel::refresh

            )

  3. Next, we'll split the screen into two parts – a switch for toggling the favorite breeds filter and one for the content. The latter will either contain the list of breeds, a loading indicator, or an empty/error placeholder:

    Column(

               Modifier

               .fillMaxSize()

               .padding(8.dp)

                ) {

    Row(

               Modifier

               .wrapContentWidth(Alignment.End)

               .padding(8.dp)) {

                Text(text = "Filter favourites")

    Switch(

               checked = shouldFilterFavourites,

               modifier = Modifier.padding

                (horizontal = 8.dp),

               onCheckedChange = {

               viewModel.onToggleFavouriteFilter() }

                        )

                    }

    when (state) {

      MainViewModel.State.LOADING -> {

             Spacer(Modifier.weight(1f))

             CircularProgressIndicator(Modifier.align

                (Alignment.CenterHorizontally))

                 Spacer(Modifier.weight(1f))

               }

      MainViewModel.State.NORMAL -> Breeds(

           breeds = breeds,

           onFavouriteTapped =

              viewModel::onFavouriteTapped

          )

      MainViewModel.State.ERROR -> {

           Spacer(Modifier.weight(1f))

           Text(

             text = "Oops something went wrong...",

             modifier =

             Modifier.align(Alignment.CenterHorizontally)

            )

           Spacer(Modifier.weight(1f))

    }

      MainViewModel.State.EMPTY -> {

           Spacer(Modifier.weight(1f))

           Text(

             text = "Oops looks like there are no

          ${if (shouldFilterFavourites) "favourites"

             else "dogs"}",

             modifier =

              Modifier.align(Alignment.CenterHorizontally)

             )

         Spacer(Modifier.weight(1f))

      }

    }

    if (events == MainViewModel.Event.Error) {

       snackbarCoroutineScope.launch {

        scaffoldState.snackbarHostState.apply {

          currentSnackbarData?.dismiss()

          showSnackbar("Oops something went wrong...")

                      }

                  }

              }

           }

    }

  4. The last component will be the Breeds composable, which will show the list of breeds, their images and their names, and an action for marking that breed as a favorite in a grid:

    @Composable

    fun Breeds(breeds: List<Breed>, onFavouriteTapped:

    (Breed) -> Unit = {}) {

        LazyVerticalGrid(cells = GridCells.Fixed(2)) {

            items(breeds) {

             Column(Modifier.padding(8.dp)) {

              Image(

              painter = rememberCoilPainter(request =

                it.imageUrl),

                contentDescription = "${it.name}-image",

                        modifier = Modifier

                            .aspectRatio(1f)

                            .fillMaxWidth()

                .align(Alignment.CenterHorizontally),

                        contentScale = ContentScale.Crop

                    )

      Row(Modifier.padding(vertical = 8.dp)) {

              Text(

                   text = it.name,

                   modifier = Modifier

                .align(Alignment.CenterVertically)

                    )

    Spacer(Modifier.weight(1f))

               Icon(

                 if (it.isFavourite)

                 Icons.Filled.Favorite else

                 Icons.Outlined.FavoriteBorder,

                 contentDescription = "Mark as favourite",

                  modifier = Modifier.clickable {

                              onFavouriteTapped(it)

                            }

                        )

                    }

                }

            }

        }

    }

  5. Finally, we must show these components. We'll need to update our MainActivity to the following:

    class MainActivity : AppCompatActivity() {

      private val viewModel by viewModel<MainViewModel>()

        override fun onCreate(savedInstanceState: Bundle?) {

            super.onCreate(savedInstanceState)

            setContent {

                MaterialTheme {

                    MainScreen(viewModel)

                }

            }

        }

    }

Here, we're injecting the MainViewModel component we created with Koin's ViewModel functionality and setting the MainScreen composable as the content of MainActivity. If you run this code, you should be able to see the following screen:

Figure 6.1 – Dogify on Android

Figure 6.1 – Dogify on Android

The full code is available on branch 06/ui-android.

Summary

In this chapter, we connected our shared code to our Android application and created the Dogify UI in Jetpack Compose. We also observed that consuming the shared code was as easy as consuming a regular Android library.

In the next chapter, we'll try to do the same for iOS and see if we need to make any modifications to our shared code to make it work and easier to consume via Swift.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.119.248.159