Final Results

The top-level build.gradle file should look a bit like:

buildscript {
  ext.nav_version = '2.3.5'

  repositories {
    google()
    mavenCentral()
  }

  dependencies {
    classpath 'com.android.tools.build:gradle:7.0.2'
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.21"
    classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
  }
}

task clean(type: Delete) {
  delete rootProject.buildDir
}

ext {
  koin_version = "3.1.2"
  moshi_version = "1.12.0"
  room_version = "2.3.0"
}

app/build.gradle should contain:

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 "androidx.preference:preference-ktx:1.1.1"
  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"
  implementation "com.squareup.okhttp3:okhttp:4.9.1"
  implementation "com.squareup.moshi:moshi:$moshi_version"
  kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_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'
}

The manifest overall should resemble:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  package="com.commonsware.todo">

  <uses-permission android:name="android.permission.INTERNET" />

  <supports-screens
    android:largeScreens="true"
    android:normalScreens="true"
    android:smallScreens="true"
    android:xlargeScreens="true" />

  <application
    android:name=".ToDoApp"
    android:allowBackup="false"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:supportsRtl="true"
    android:theme="@style/Theme.ToDo">
    <activity
      android:name=".ui.AboutActivity"
      android:exported="true" />
    <activity
      android:name=".ui.MainActivity"
      android:exported="true">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
    <provider
      android:name="androidx.core.content.FileProvider"
      android:authorities="${applicationId}.provider"
      android:exported="false"
      android:grantUriPermissions="true">
      <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/provider_paths" />
    </provider>
  </application>

</manifest>

Our new ToDoServerItem.kt should contain:

package com.commonsware.todo.repo

import android.annotation.SuppressLint
import com.squareup.moshi.FromJson
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.ToJson
import java.time.Instant
import java.time.format.DateTimeFormatter

@JsonClass(generateAdapter = true)
data class ToDoServerItem(
  val description: String,
  val id: String,
  val completed: Boolean,
  val notes: String,
  @Json(name = "created_on") val createdOn: Instant
) {
  fun toEntity(): ToDoEntity {
    return ToDoEntity(
      id = id,
      description = description,
      isCompleted = completed,
      notes = notes,
      createdOn = createdOn
    )
  }
}

private val FORMATTER = DateTimeFormatter.ISO_INSTANT

class MoshiInstantAdapter {
  @ToJson
  fun toJson(date: Instant) = FORMATTER.format(date)

  @FromJson
  fun fromJson(dateString: String): Instant =
    FORMATTER.parse(dateString, Instant::from)
}

ToDoRepository should resemble:

package com.commonsware.todo.repo

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext

enum class FilterMode { ALL, OUTSTANDING, COMPLETED }

class ToDoRepository(
  private val store: ToDoEntity.Store,
  private val appScope: CoroutineScope,
  private val remoteDataSource: ToDoRemoteDataSource
) {
  fun items(filterMode: FilterMode = FilterMode.ALL): Flow<List<ToDoModel>> =
    filteredEntities(filterMode).map { all -> all.map { it.toModel() } }

  private fun filteredEntities(filterMode: FilterMode) = when (filterMode) {
    FilterMode.ALL -> store.all()
    FilterMode.OUTSTANDING -> store.filtered(isCompleted = false)
    FilterMode.COMPLETED -> store.filtered(isCompleted = true)
  }

  fun find(id: String?): Flow<ToDoModel?> = store.find(id).map { it?.toModel() }

  suspend fun save(model: ToDoModel) {
    withContext(appScope.coroutineContext) {
      store.save(ToDoEntity(model))
    }
  }

  suspend fun delete(model: ToDoModel) {
    withContext(appScope.coroutineContext) {
      store.delete(ToDoEntity(model))
    }
  }

  suspend fun importItems(url: String) {
    withContext(appScope.coroutineContext) {
      store.importItems(remoteDataSource.load(url).map { it.toEntity() })
    }
  }
}

ToDoEntity should contain:

package com.commonsware.todo.repo

import androidx.room.*
import kotlinx.coroutines.flow.Flow
import java.time.Instant
import java.util.*

@Entity(tableName = "todos", indices = [Index(value = ["id"])])
data class ToDoEntity(
  val description: String,
  @PrimaryKey
  val id: String = UUID.randomUUID().toString(),
  val notes: String = "",
  val createdOn: Instant = Instant.now(),
  val isCompleted: Boolean = false
) {
  constructor(model: ToDoModel) : this(
    id = model.id,
    description = model.description,
    isCompleted = model.isCompleted,
    notes = model.notes,
    createdOn = model.createdOn
  )

  fun toModel(): ToDoModel {
    return ToDoModel(
      id = id,
      description = description,
      isCompleted = isCompleted,
      notes = notes,
      createdOn = createdOn
    )
  }

  @Dao
  interface Store {
    @Query("SELECT * FROM todos ORDER BY description")
    fun all(): Flow<List<ToDoEntity>>

    @Query("SELECT * FROM todos WHERE isCompleted = :isCompleted ORDER BY description")
    fun filtered(isCompleted: Boolean): Flow<List<ToDoEntity>>

    @Query("SELECT * FROM todos WHERE id = :modelId")
    fun find(modelId: String?): Flow<ToDoEntity?>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun save(vararg entities: ToDoEntity)

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun importItems(entities: List<ToDoEntity>)

    @Delete
    suspend fun delete(vararg entities: ToDoEntity)
  }
}

The repaired RosterListFragmentTest should look like:

package com.commonsware.todo.ui.roster

import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.hasChildCount
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.platform.app.InstrumentationRegistry
import com.commonsware.todo.R
import com.commonsware.todo.repo.ToDoDatabase
import com.commonsware.todo.repo.ToDoModel
import com.commonsware.todo.repo.ToDoRemoteDataSource
import com.commonsware.todo.repo.ToDoRepository
import com.commonsware.todo.ui.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Before
import org.junit.Test
import org.koin.core.context.loadKoinModules
import org.koin.dsl.module

class RosterListFragmentTest {
  private lateinit var repo: ToDoRepository
  private val items = listOf(
    ToDoModel("this is a test"),
    ToDoModel("this is another test"),
    ToDoModel("this is... wait for it... yet another test")
  )

  @Before
  fun setUp() {
    val context = InstrumentationRegistry.getInstrumentation().targetContext
    val db = ToDoDatabase.newTestInstance(context)
    val appScope = CoroutineScope(SupervisorJob())

    repo = ToDoRepository(
      db.todoStore(),
      appScope,
      ToDoRemoteDataSource(OkHttpClient())
    )

    loadKoinModules(module {
      single { repo }
    })

    runBlocking { items.forEach { repo.save(it) } }
  }

  @Test
  fun testListContents() {
    ActivityScenario.launch(MainActivity::class.java)

    onView(withId(R.id.items)).check(matches(hasChildCount(3)))
  }
}

And the fixed ToDoRepositoryTest should contain:

package com.commonsware.todo.repo

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runBlockingTest
import okhttp3.OkHttpClient
import org.hamcrest.Matchers.empty
import org.hamcrest.Matchers.equalTo
import org.hamcrest.collection.IsIterableContainingInOrder.contains
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ToDoRepositoryTest {
  @get:Rule
  val instantTaskExecutorRule = InstantTaskExecutorRule()

  private val context = InstrumentationRegistry.getInstrumentation().targetContext
  private val db = ToDoDatabase.newTestInstance(context)
  private val remoteDataSource = ToDoRemoteDataSource(OkHttpClient())

  @Test
  fun canAddItems() = runBlockingTest {
    val underTest = ToDoRepository(db.todoStore(), this, remoteDataSource)
    val results = mutableListOf<List<ToDoModel>>()

    val itemsJob = launch {
      underTest.items().collect { results.add(it) }
    }

    assertThat(results.size, equalTo(1))
    assertThat(results[0], empty())

    val testModel = ToDoModel("test model")

    underTest.save(testModel)

    assertThat(results.size, equalTo(2))
    assertThat(results[1], contains(testModel))
    assertThat(underTest.find(testModel.id).first(), equalTo(testModel))

    itemsJob.cancel()
  }

  @Test
  fun canModifyItems() = runBlockingTest {
    val underTest = ToDoRepository(db.todoStore(), this, remoteDataSource)
    val testModel = ToDoModel("test model")
    val replacement = testModel.copy(notes = "This is the replacement")
    val results = mutableListOf<List<ToDoModel>>()

    val itemsJob = launch {
      underTest.items().collect { results.add(it) }
    }

    assertThat(results[0], empty())

    underTest.save(testModel)

    assertThat(results[1], contains(testModel))

    underTest.save(replacement)

    assertThat(results[2], contains(replacement))

    itemsJob.cancel()
  }

  @Test
  fun canRemoveItems() = runBlockingTest {
    val underTest = ToDoRepository(db.todoStore(), this, remoteDataSource)
    val testModel = ToDoModel("test model")
    val results = mutableListOf<List<ToDoModel>>()

    val itemsJob = launch {
      underTest.items().collect { results.add(it) }
    }

    assertThat(results[0], empty())

    underTest.save(testModel)

    assertThat(results[1], contains(testModel))

    underTest.delete(testModel)

    assertThat(results[2], empty())

    itemsJob.cancel()
  }
}

The new PrefsRepository should be:

package com.commonsware.todo.repo

import android.content.Context
import androidx.preference.PreferenceManager
import com.commonsware.todo.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class PrefsRepository(context: Context) {
  private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
  private val webServiceUrlKey = context.getString(R.string.web_service_url_key)
  private val defaultWebServiceUrl =
    context.getString(R.string.web_service_url_default)

  suspend fun loadWebServiceUrl(): String = withContext(Dispatchers.IO) {
    prefs.getString(webServiceUrlKey, defaultWebServiceUrl) ?: defaultWebServiceUrl
  }
}

ToDoApp should look like:

package com.commonsware.todo

import android.app.Application
import android.text.format.DateUtils
import com.commonsware.todo.repo.PrefsRepository
import com.commonsware.todo.repo.ToDoDatabase
import com.commonsware.todo.repo.ToDoRemoteDataSource
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 okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidApplication
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")),
        get()
      )
    }
    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"))) }
    single { OkHttpClient.Builder().build() }
    single { ToDoRemoteDataSource(get()) }
    single { PrefsRepository(androidContext()) }
    viewModel {
      RosterMotor(
        get(),
        get(),
        androidApplication(),
        get(named("appScope")),
        get()
      )
    }
    viewModel { (modelId: String) -> SingleModelMotor(get(), modelId) }
  }

  override fun onCreate() {
    super.onCreate()

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

RosterMotor, should contain:

package com.commonsware.todo.ui.roster

import android.app.Application
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.commonsware.todo.BuildConfig
import com.commonsware.todo.repo.FilterMode
import com.commonsware.todo.repo.PrefsRepository
import com.commonsware.todo.repo.ToDoModel
import com.commonsware.todo.repo.ToDoRepository
import com.commonsware.todo.report.RosterReport
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import java.io.File

private const val AUTHORITY = BuildConfig.APPLICATION_ID + ".provider"

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()
  data class ShareReport(val doc: Uri) : Nav()
}

class RosterMotor(
  private val repo: ToDoRepository,
  private val report: RosterReport,
  private val context: Application,
  private val appScope: CoroutineScope,
  private val prefs: PrefsRepository
) : 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))
    }
  }

  fun shareReport() {
    viewModelScope.launch {
      saveForSharing()
    }
  }

  fun importItems() {
    viewModelScope.launch {
      repo.importItems(prefs.loadWebServiceUrl())
    }
  }

  private suspend fun saveForSharing() {
    withContext(Dispatchers.IO + appScope.coroutineContext) {
      val shared = File(context.cacheDir, "shared").also { it.mkdirs() }
      val reportFile = File(shared, "report.html")
      val doc = FileProvider.getUriForFile(context, AUTHORITY, reportFile)

      _states.value.let { report.generate(it.items, doc) }
      _navEvents.emit(Nav.ShareReport(doc))
    }
  }
}

The actions_roster menu resource should resemble:

<?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" />
  <item
    android:id="@+id/share"
    android:icon="@drawable/ic_share"
    android:title="@string/menu_share"
    app:showAsAction="ifRoom|withText" />
  <item
    android:id="@+id/importItems"
    android:icon="@drawable/ic_download"
    android:title="@string/menu_import"
    app:showAsAction="never" />
</menu>

The strings resource should look like:

<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>
  <string name="menu_share">Share</string>
  <string name="pref_url_title">Web service URL</string>
  <string name="web_service_url_key">webServiceUrl</string>
  <string name="web_service_url_default">https://commonsware.com/AndExplore/2.0/items.json</string>
  <string name="settings">Settings</string>
  <string name="menu_import">Import</string>
</resources>

And RosterListFragment 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)
          is Nav.ShareReport -> shareReport(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
      }
      R.id.share -> {
        motor.shareReport()
        return true
      }
      R.id.importItems -> {
        motor.importItems()
        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 shareReport(doc: Uri) {
    safeStartActivity(
      Intent(Intent.ACTION_SEND)
        .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
        .setType("text/html")
        .putExtra(Intent.EXTRA_STREAM, doc)
    )
  }

  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()
    }
  }
}

Prev Table of Contents Next

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