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" />
<item
android:id="@+id/share"
android:icon="@drawable/ic_share"
android:title="@string/menu_share"
app:showAsAction="ifRoom|withText" />
</menu>
The overall AndroidManifest.xml
file should now look like:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.commonsware.todo">
<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 provider_paths
XML resource should contain:
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="shared" path="shared" />
</paths>
At this point, RosterMotor
should resemble:
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.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
) : 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()
}
}
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))
}
}
}
Also, ToDoApp
should look like:
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.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"))
)
}
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(), androidApplication(), get(named("appScope"))) }
viewModel { (modelId: String) -> SingleModelMotor(get(), modelId) }
}
override fun onCreate() {
super.onCreate()
startKoin {
androidLogger()
androidContext(this@ToDoApp)
modules(koinModule)
}
}
}
Finally, our updated RosterListFragment
should look like:
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
}
}
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.