Final Results

Our completed ErrorDialogFragment should look like:

package com.commonsware.todo.ui

import android.app.Dialog
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.commonsware.todo.R

class ErrorDialogFragment : DialogFragment() {
  companion object {
    const val KEY_RETRY = "retryRequested"
  }

  private val args: ErrorDialogFragmentArgs by navArgs()

  override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
    return AlertDialog.Builder(requireActivity())
      .setTitle(args.title)
      .setMessage(args.message)
      .setPositiveButton(R.string.retry) { _, _ -> onRetryRequest() }
      .setNegativeButton(R.string.cancel) { _, _ ->  }
      .create()
  }

  private fun onRetryRequest() {
    findNavController()
      .previousBackStackEntry?.savedStateHandle?.set(KEY_RETRY, args.scenario)
  }
}

enum class ErrorScenario { Import, None }

The updated navigation graph (nav_graph.xml) should resemble:

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:id="@+id/nav_graph.xml"
  app:startDestination="@id/rosterListFragment">

  <fragment
    android:id="@+id/rosterListFragment"
    android:name="com.commonsware.todo.ui.roster.RosterListFragment"
    android:label="@string/app_name">
    <action
      android:id="@+id/displayModel"
      app:destination="@id/displayFragment" />
    <action
      android:id="@+id/createModel"
      app:destination="@id/editFragment" >
      <argument
        android:name="modelId"
        android:defaultValue="@null" />
    </action>
    <action
      android:id="@+id/showError"
      app:destination="@id/errorDialogFragment" />
  </fragment>
  <fragment
    android:id="@+id/displayFragment"
    android:name="com.commonsware.todo.ui.display.DisplayFragment"
    android:label="@string/app_name" >
    <argument
      android:name="modelId"
      app:argType="string" />
    <action
      android:id="@+id/editModel"
      app:destination="@id/editFragment" />
  </fragment>
  <fragment
    android:id="@+id/editFragment"
    android:name="com.commonsware.todo.ui.edit.EditFragment"
    android:label="@string/app_name" >
    <argument
      android:name="modelId"
      app:argType="string"
      app:nullable="true" />
  </fragment>
  <fragment
    android:id="@+id/prefsFragment"
    android:name="com.commonsware.todo.ui.prefs.PrefsFragment"
    android:label="@string/settings" />
  <action android:id="@+id/editPrefs" app:destination="@id/prefsFragment" />
  <dialog
    android:id="@+id/errorDialogFragment"
    android:name="com.commonsware.todo.ui.ErrorDialogFragment"
    android:label="ErrorDialogFragment" >
    <argument
      android:name="title"
      app:argType="string" />
    <argument
      android:name="message"
      app:argType="string" />
    <argument
      android:name="scenario"
      app:argType="com.commonsware.todo.ui.ErrorScenario" />
  </dialog>
</navigation>

The strings.xml resource should now 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>
  <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>
  <string name="cancel">Cancel</string>
  <string name="retry">Retry</string>
  <string name="import_error_title">Import Failure</string>
  <string name="import_error_message">Something went wrong with the import!</string>
</resources>

Our RosterMotor should now resemble:

package com.commonsware.todo.ui.roster

import android.app.Application
import android.content.Context
import android.net.Uri
import android.util.Log
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 com.commonsware.todo.ui.ErrorScenario
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 val _errorEvents = MutableSharedFlow<ErrorScenario>()
  val errorEvents = _errorEvents.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 {
      try {
        repo.importItems(prefs.loadWebServiceUrl())
      } catch (ex: Exception) {
        Log.e("ToDo", "Exception importing items", ex)
        _errorEvents.emit(ErrorScenario.Import)
      }
    }
  }

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

Finally, our revised RosterListFragment should include:

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 com.commonsware.todo.ui.ErrorDialogFragment
import com.commonsware.todo.ui.ErrorScenario
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)
        }
      }
    }

    viewLifecycleOwner.lifecycleScope.launchWhenStarted {
      motor.errorEvents.collect { error ->
        when (error) {
          ErrorScenario.Import -> handleImportError()
        }
      }
    }

    findNavController()
      .getBackStackEntry(R.id.rosterListFragment)
      .savedStateHandle
      .getLiveData<ErrorScenario>(ErrorDialogFragment.KEY_RETRY)
      .observe(viewLifecycleOwner) { retryScenario ->
        when (retryScenario) {
          ErrorScenario.Import -> {
            clearImportError()
            motor.importItems()
          }
        }
      }
  }

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

  private fun handleImportError() {
    findNavController().navigate(
      RosterListFragmentDirections.showError(
        getString(R.string.import_error_title),
        getString(R.string.import_error_message),
        ErrorScenario.Import
      )
    )
  }

  private fun clearImportError() {
    findNavController()
      .getBackStackEntry(R.id.rosterListFragment)
      .savedStateHandle
      .set(ErrorDialogFragment.KEY_RETRY, ErrorScenario.None)
  }
}

Prev Table of Contents Next

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