From cefe2909425b2de6ca5de3931ba860c0eee37683 Mon Sep 17 00:00:00 2001
From: Paul Woitaschek <woitaschek@gmail.com>
Date: Mon, 4 Jul 2022 08:50:12 +0200
Subject: [PATCH] Refactor the logic out of the SleepTimerDialogController into
 it's own viewmodel. Ensure that the creation of the bookmark is not cancelled
 through the lifecycle scope moving the coroutine in a dedicated scope.

---
 .../sleepTimer/SleepTimerDialogController.kt  | 107 ++++++------------
 .../sleepTimer/SleepTimerDialogViewModel.kt   |  79 +++++++++++++
 2 files changed, 111 insertions(+), 75 deletions(-)
 create mode 100644 sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerDialogViewModel.kt

diff --git a/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerDialogController.kt b/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerDialogController.kt
index 72e9b0f6f5..594aefb90a 100644
--- a/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerDialogController.kt
+++ b/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerDialogController.kt
@@ -1,6 +1,5 @@
 package voice.sleepTimer
 
-import android.annotation.SuppressLint
 import android.app.Dialog
 import android.os.Bundle
 import android.view.View
@@ -8,27 +7,19 @@ import android.widget.FrameLayout
 import com.google.android.material.bottomsheet.BottomSheetBehavior
 import com.google.android.material.bottomsheet.BottomSheetDialog
 import com.squareup.anvil.annotations.ContributesTo
-import de.paulwoitaschek.flowpref.Pref
+import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
-import voice.common.conductor.DialogController
-import voice.common.pref.PrefKeys
 import voice.common.AppScope
+import voice.common.conductor.DialogController
 import voice.common.rootComponentAs
 import voice.data.Book
 import voice.data.getBookId
 import voice.data.putBookId
-import voice.data.repo.BookRepository
-import voice.data.repo.BookmarkRepo
 import voice.sleepTimer.databinding.DialogSleepBinding
 import javax.inject.Inject
-import javax.inject.Named
 
 private const val NI_BOOK_ID = "ni#bookId"
-private const val SI_MINUTES = "si#time"
 
-/**
- * Simple dialog for activating the sleep timer
- */
 class SleepTimerDialogController(bundle: Bundle) : DialogController(bundle) {
 
   constructor(bookId: Book.Id) : this(
@@ -38,93 +29,59 @@ class SleepTimerDialogController(bundle: Bundle) : DialogController(bundle) {
   )
 
   @Inject
-  lateinit var bookmarkRepo: BookmarkRepo
-
-  @Inject
-  lateinit var sleepTimer: SleepTimer
-
-  @Inject
-  lateinit var bookRepo: BookRepository
+  lateinit var viewModel: SleepTimerDialogViewModel
 
   init {
     rootComponentAs<Component>().inject(this)
   }
 
-  @field:[Inject Named(PrefKeys.SLEEP_TIME)]
-  lateinit var sleepTimePref: Pref<Int>
-
-  private var selectedMinutes = 0
-
-  override fun onSaveInstanceState(outState: Bundle) {
-    super.onSaveInstanceState(outState)
-    outState.putInt(SI_MINUTES, selectedMinutes)
-  }
-
   override fun onCreateDialog(savedViewState: Bundle?): Dialog {
     val binding = DialogSleepBinding.inflate(activity!!.layoutInflater)
 
-    fun updateTimeState() {
-      binding.time.text = activity!!.getString(R.string.min, selectedMinutes.toString())
-
-      if (selectedMinutes > 0) binding.fab.show()
-      else binding.fab.hide()
-    }
-
-    @SuppressLint("SetTextI18n")
-    fun appendNumber(number: Int) {
-      val newNumber = selectedMinutes * 10 + number
-      if (newNumber > 999) return
-      selectedMinutes = newNumber
-      updateTimeState()
+    listOf(
+      binding.zero,
+      binding.one,
+      binding.two,
+      binding.three,
+      binding.four,
+      binding.five,
+      binding.six,
+      binding.seven,
+      binding.eight,
+      binding.nine,
+    ).forEachIndexed { index, textView ->
+      textView.setOnClickListener {
+        viewModel.onNumberClicked(index)
+      }
     }
 
-    selectedMinutes = savedViewState?.getInt(SI_MINUTES) ?: sleepTimePref.value
-    updateTimeState()
-
-    // find views and prepare clicks
-    binding.one.setOnClickListener { appendNumber(1) }
-    binding.two.setOnClickListener { appendNumber(2) }
-    binding.three.setOnClickListener { appendNumber(3) }
-    binding.four.setOnClickListener { appendNumber(4) }
-    binding.five.setOnClickListener { appendNumber(5) }
-    binding.six.setOnClickListener { appendNumber(6) }
-    binding.seven.setOnClickListener { appendNumber(7) }
-    binding.eight.setOnClickListener { appendNumber(8) }
-    binding.nine.setOnClickListener { appendNumber(9) }
-    binding.zero.setOnClickListener { appendNumber(0) }
-    // upon delete remove the last number
     binding.delete.setOnClickListener {
-      selectedMinutes /= 10
-      updateTimeState()
+      viewModel.onNumberDeleteClicked()
     }
-    // upon long click remove all numbers
     binding.delete.setOnLongClickListener {
-      selectedMinutes = 0
-      updateTimeState()
+      viewModel.onNumberDeleteLongClicked()
       true
     }
 
+    lifecycleScope.launch {
+      viewModel.viewState().collectLatest { viewState ->
+        binding.time.text = activity!!.getString(R.string.min, viewState.selectedMinutes.toString())
 
-    binding.fab.setOnClickListener {
-      require(selectedMinutes > 0) { "fab should be hidden when time is invalid" }
-      sleepTimePref.value = selectedMinutes
-
-      lifecycleScope.launch {
-        val book = bookRepo.get(args.getBookId(NI_BOOK_ID)!!) ?: return@launch
-        bookmarkRepo.addBookmarkAtBookPosition(
-          book = book,
-          setBySleepTimer = true,
-          title = null
-        )
+        if (viewState.showFab) {
+          binding.fab.show()
+        } else {
+          binding.fab.hide()
+        }
       }
-
-      sleepTimer.setActive(true)
+    }
+    binding.fab.setOnClickListener {
+      viewModel.onConfirmButtonClicked(args.getBookId(NI_BOOK_ID)!!)
       dismissDialog()
     }
 
     return BottomSheetDialog(activity!!).apply {
       setContentView(binding.root)
-      // hide the background so the fab looks overlapping
+      // hide the background so the fab appears overlapping
       setOnShowListener {
         val parentView = binding.root.parent as View
         parentView.background = null
diff --git a/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerDialogViewModel.kt b/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerDialogViewModel.kt
new file mode 100644
index 0000000000..ff66533cd3
--- /dev/null
+++ b/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerDialogViewModel.kt
@@ -0,0 +1,79 @@
+package voice.sleepTimer
+
+import de.paulwoitaschek.flowpref.Pref
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import voice.common.pref.PrefKeys
+import voice.data.Book
+import voice.data.repo.BookRepository
+import voice.data.repo.BookmarkRepo
+import javax.inject.Inject
+import javax.inject.Named
+
+class SleepTimerDialogViewModel
+@Inject constructor(
+  private val bookmarkRepo: BookmarkRepo,
+  private val sleepTimer: SleepTimer,
+  private val bookRepo: BookRepository,
+  @Named(PrefKeys.SLEEP_TIME)
+  private val sleepTimePref: Pref<Int>
+) {
+
+  private val scope = MainScope()
+
+  private val selectedMinutes = MutableStateFlow(sleepTimePref.value)
+
+  fun viewState(): Flow<SleepTimerDialogViewState> {
+    return selectedMinutes
+      .map { selectedMinutes ->
+        SleepTimerDialogViewState(
+          selectedMinutes = selectedMinutes,
+          showFab = selectedMinutes > 0
+        )
+      }
+  }
+
+  fun onNumberClicked(number: Int) {
+    require(number in 0..9)
+    selectedMinutes.update { oldValue ->
+      val newValue = (oldValue * 10 + number)
+      if (newValue > 999) {
+        oldValue
+      } else {
+        sleepTimePref.value = newValue
+        newValue
+      }
+    }
+  }
+
+  fun onNumberDeleteClicked() {
+    selectedMinutes.update { it / 10 }
+  }
+
+  fun onNumberDeleteLongClicked() {
+    selectedMinutes.update { 0 }
+  }
+
+  fun onConfirmButtonClicked(bookId: Book.Id) {
+    check(selectedMinutes.value > 0)
+
+    scope.launch {
+      val book = bookRepo.get(bookId) ?: return@launch
+      bookmarkRepo.addBookmarkAtBookPosition(
+        book = book,
+        setBySleepTimer = true,
+        title = null
+      )
+    }
+    sleepTimer.setActive(true)
+  }
+}
+
+data class SleepTimerDialogViewState(
+  val selectedMinutes: Int,
+  val showFab: Boolean,
+)