Final Results

The actions_roster menu resource should look like:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto">

  <item
    android:id="@+id/filter"
    android:icon="@drawable/ic_filter"
    android:title="@string/menu_filter"
    app:showAsAction="ifRoom|withText">
    <menu>

      <group
        android:id="@+id/filter_group"
        android:checkableBehavior="single" >
        <item
          android:id="@+id/all"
          android:checked="true"
          android:title="@string/menu_filter_all" />
        <item
          android:id="@+id/completed"
          android:title="@string/menu_filter_completed" />
        <item
          android:id="@+id/outstanding"
          android:title="@string/menu_filter_outstanding" />
      </group>
    </menu>
  </item>
  <item
    android:id="@+id/add"
    android:icon="@drawable/ic_add"
    android:title="@string/menu_add"
    app:showAsAction="ifRoom|withText" />
  <item
    android:id="@+id/save"
    android:icon="@drawable/ic_save"
    android:title="@string/menu_save"
    app:showAsAction="ifRoom|withText" />
</menu>

RosterListFragment, after all of our changes, should resemble:

package com.commonsware.todo.ui.roster

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.*
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.commonsware.todo.R
import com.commonsware.todo.databinding.TodoRosterBinding
import com.commonsware.todo.repo.FilterMode
import com.commonsware.todo.repo.ToDoModel
import kotlinx.coroutines.flow.collect
import org.koin.androidx.viewmodel.ext.android.viewModel

private const val TAG = "ToDo"

class RosterListFragment : Fragment() {
  private val motor: RosterMotor by viewModel()
  private val menuMap = mutableMapOf<FilterMode, MenuItem>()
  private var binding: TodoRosterBinding? = null

  private val createDoc =
    registerForActivityResult(ActivityResultContracts.CreateDocument()) {
      motor.saveReport(it)
    }

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setHasOptionsMenu(true)
  }

  override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
  ): View = TodoRosterBinding.inflate(inflater, container, false)
    .also { binding = it }
    .root

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    val adapter = RosterAdapter(
      layoutInflater,
      onCheckboxToggle = { motor.save(it.copy(isCompleted = !it.isCompleted)) },
      onRowClick = ::display
    )

    binding?.items?.apply {
      setAdapter(adapter)
      layoutManager = LinearLayoutManager(context)

      addItemDecoration(
        DividerItemDecoration(
          activity,
          DividerItemDecoration.VERTICAL
        )
      )
    }

    viewLifecycleOwner.lifecycleScope.launchWhenStarted {
      motor.states.collect { state ->
        adapter.submitList(state.items)

        binding?.apply {
          loading.visibility = if (state.isLoaded) View.GONE else View.VISIBLE

          when {
            state.items.isEmpty() && state.filterMode == FilterMode.ALL -> {
              empty.visibility = View.VISIBLE
              empty.setText(R.string.msg_empty)
            }
            state.items.isEmpty() -> {
              empty.visibility = View.VISIBLE
              empty.setText(R.string.msg_empty_filtered)
            }
            else -> empty.visibility = View.GONE
          }
        }

        menuMap[state.filterMode]?.isChecked = true
      }
    }

    viewLifecycleOwner.lifecycleScope.launchWhenStarted {
      motor.navEvents.collect { nav ->
        when (nav) {
          is Nav.ViewReport -> viewReport(nav.doc)
        }
      }
    }
  }

  override fun onDestroyView() {
    binding = null

    super.onDestroyView()
  }

  override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
    inflater.inflate(R.menu.actions_roster, menu)

    menuMap.apply {
      put(FilterMode.ALL, menu.findItem(R.id.all))
      put(FilterMode.COMPLETED, menu.findItem(R.id.completed))
      put(FilterMode.OUTSTANDING, menu.findItem(R.id.outstanding))
    }

    menuMap[motor.states.value.filterMode]?.isChecked = true

    super.onCreateOptionsMenu(menu, inflater)
  }

  override fun onOptionsItemSelected(item: MenuItem): Boolean {
    when (item.itemId) {
      R.id.add -> {
        add()
        return true
      }
      R.id.all -> {
        item.isChecked = true
        motor.load(FilterMode.ALL)
        return true
      }
      R.id.completed -> {
        item.isChecked = true
        motor.load(FilterMode.COMPLETED)
        return true
      }
      R.id.outstanding -> {
        item.isChecked = true
        motor.load(FilterMode.OUTSTANDING)
        return true
      }
      R.id.save -> {
        saveReport()
        return true
      }
    }

    return super.onOptionsItemSelected(item)
  }

  private fun display(model: ToDoModel) {
    findNavController()
      .navigate(RosterListFragmentDirections.displayModel(model.id))
  }

  private fun add() {
    findNavController().navigate(RosterListFragmentDirections.createModel(null))
  }

  private fun saveReport() {
    createDoc.launch("report.html")
  }

  private fun viewReport(uri: Uri) {
    safeStartActivity(
      Intent(Intent.ACTION_VIEW, uri)
        .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
    )
  }

  private fun safeStartActivity(intent: Intent) {
    try {
      startActivity(intent)
    } catch (t: Throwable) {
      Log.e(TAG, "Exception starting $intent", t)
      Toast.makeText(requireActivity(), R.string.oops, Toast.LENGTH_LONG).show()
    }
  }
}

The strings value resource should contain:

<resources>
  <string name="app_name">ToDo</string>
  <string name="msg_empty">Click the + icon to add a todo item!</string>
  <string name="msg_empty_filtered">Click the + icon to add a todo item, or change your filter to show other items</string>
  <string name="menu_about">About</string>
  <string name="is_completed">Item is completed</string>
  <string name="created_on">Created on:</string>
  <string name="menu_edit">Edit</string>
  <string name="desc">Description</string>
  <string name="notes">Notes</string>
  <string name="menu_save">Save</string>
  <string name="menu_add">Add</string>
  <string name="menu_delete">Delete</string>
  <string name="menu_filter">Filter</string>
  <string name="menu_filter_all">All</string>
  <string name="menu_filter_completed">Completed</string>
  <string name="menu_filter_outstanding">Outstanding</string>
  <string name="oops">Sorry! Something went wrong!</string>
  <string name="report_template"><![CDATA[<h1>To-Do Items</h1>

<h2></h2>
<p><b>COMPLETED</b> &mdash; Created on: </p>
<p></p>

]]></string>
</resources>

Our module’s build.gradle file should resemble:

plugins {
  id 'com.android.application'
  id 'kotlin-android'
  id 'androidx.navigation.safeargs.kotlin'
  id 'kotlin-kapt'
}

android {
  compileSdk 31

  defaultConfig {
    applicationId "com.commonsware.todo"
    minSdk 21
    targetSdk 31
    versionCode 1
    versionName "1.0"

    testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
  }

  buildTypes {
    release {
      minifyEnabled false
      proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    }
  }

  buildFeatures {
    viewBinding true
  }

  compileOptions {
    coreLibraryDesugaringEnabled true
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }

  kotlinOptions {
    jvmTarget = '1.8'
  }

  packagingOptions {
    exclude 'META-INF/AL2.0'
    exclude 'META-INF/LGPL2.1'
  }
}

dependencies {
  implementation 'androidx.core:core-ktx:1.6.0'
  implementation 'androidx.appcompat:appcompat:1.3.1'
  implementation 'androidx.constraintlayout:constraintlayout:2.1.0'
  implementation "androidx.recyclerview:recyclerview:1.2.1"
  implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
  implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
  implementation 'com.google.android.material:material:1.4.0'
  implementation "io.insert-koin:koin-android:$koin_version"
  implementation "com.github.jknack:handlebars:4.1.2"
  implementation "androidx.room:room-runtime:$room_version"
  implementation "androidx.room:room-ktx:$room_version"
  kapt "androidx.room:room-compiler:$room_version"
  coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
  testImplementation 'junit:junit:4.13.2'
  testImplementation "org.mockito:mockito-inline:3.12.1"
  testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
  testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.1'
  androidTestImplementation 'androidx.test.ext:junit:1.1.3'
  androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
  androidTestImplementation "androidx.arch.core:core-testing:2.1.0"
  androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.1'
}

ToDoApp, after a few revisions, should resemble:

package com.commonsware.todo

import android.app.Application
import android.text.format.DateUtils
import com.commonsware.todo.repo.ToDoDatabase
import com.commonsware.todo.repo.ToDoRepository
import com.commonsware.todo.report.RosterReport
import com.commonsware.todo.ui.SingleModelMotor
import com.commonsware.todo.ui.roster.RosterMotor
import com.github.jknack.handlebars.Handlebars
import com.github.jknack.handlebars.Helper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.qualifier.named
import org.koin.dsl.module
import java.time.Instant

class ToDoApp : Application() {
  private val koinModule = module {
    single(named("appScope")) { CoroutineScope(SupervisorJob()) }
    single { ToDoDatabase.newInstance(androidContext()) }
    single {
      ToDoRepository(
        get<ToDoDatabase>().todoStore(),
        get(named("appScope"))
      )
    }
    single {
      Handlebars().apply {
        registerHelper("dateFormat", Helper<Instant> { value, _ ->
          DateUtils.getRelativeDateTimeString(
            androidContext(),
            value.toEpochMilli(),
            DateUtils.MINUTE_IN_MILLIS,
            DateUtils.WEEK_IN_MILLIS, 0
          )
        })
      }
    }
    single { RosterReport(androidContext(), get(), get(named("appScope"))) }
    viewModel { RosterMotor(get(), get()) }
    viewModel { (modelId: String) -> SingleModelMotor(get(), modelId) }
  }

  override fun onCreate() {
    super.onCreate()

    startKoin {
      androidLogger()
      androidContext(this@ToDoApp)
      modules(koinModule)
    }
  }
}

Our new RosterReport class should look like:

package com.commonsware.todo.report

import android.content.Context
import android.net.Uri
import com.commonsware.todo.R
import com.commonsware.todo.repo.ToDoModel
import com.github.jknack.handlebars.Handlebars
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class RosterReport(
  private val context: Context,
  engine: Handlebars,
  private val appScope: CoroutineScope
) {
  private val template =
    engine.compileInline(context.getString(R.string.report_template))

  suspend fun generate(content: List<ToDoModel>, doc: Uri) {
    withContext(Dispatchers.IO + appScope.coroutineContext) {
      context.contentResolver.openOutputStream(doc, "rwt")?.writer()?.use { osw ->
        osw.write(template.apply(content))
        osw.flush()
      }
    }
  }
}

And, our updated RosterMotor should contain:

package com.commonsware.todo.ui.roster

import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.commonsware.todo.repo.FilterMode
import com.commonsware.todo.repo.ToDoModel
import com.commonsware.todo.repo.ToDoRepository
import com.commonsware.todo.report.RosterReport
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

data class RosterViewState(
  val items: List<ToDoModel> = listOf(),
  val isLoaded: Boolean = false,
  val filterMode: FilterMode = FilterMode.ALL
)

sealed class Nav {
  data class ViewReport(val doc: Uri) : Nav()
}

class RosterMotor(
  private val repo: ToDoRepository,
  private val report: RosterReport
) : ViewModel() {
  private val _states = MutableStateFlow(RosterViewState())
  val states = _states.asStateFlow()
  private val _navEvents = MutableSharedFlow<Nav>()
  val navEvents = _navEvents.asSharedFlow()
  private var job: Job? = null

  init {
    load(FilterMode.ALL)
  }

  fun load(filterMode: FilterMode) {
    job?.cancel()

    job = viewModelScope.launch {
      repo.items(filterMode).collect {
        _states.emit(RosterViewState(it, true, filterMode))
      }
    }
  }

  fun save(model: ToDoModel) {
    viewModelScope.launch {
      repo.save(model)
    }
  }

  fun saveReport(doc: Uri) {
    viewModelScope.launch {
      report.generate(_states.value.items, doc)
      _navEvents.emit(Nav.ViewReport(doc))
    }
  }
}

Prev Table of Contents Next

This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.