Commit 524ea163 authored by Joey's avatar Joey

glucose: editor: rewrite as fragment

Signed-off-by: Joey's avatarJoey <bevilacquajoey@gmail.com>
Change-Id: I4e4d6ef0ccdf64c7ef2f587d41184831222c8ca0
parent 59eac2eb
......@@ -49,9 +49,10 @@ object Activities {
object Glucose {
object Editor : AddressableActivity {
override val className = "$PACKAGE_NAME.glucose.ui.EditorActivity"
override val className = "$PACKAGE_NAME.glucose.ui.GlucoseActivity"
const val EXTRA_UID = "glucose_uid"
const val EXTRA_INSULIN_BASAL = "glucose_insulin_basal"
}
}
......
......@@ -8,9 +8,10 @@
*/
dependencies {
androidTestImplementation 'androidx.arch.core:core-testing:2.0.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.1.1'
androidTestImplementation 'androidx.arch.core:core-testing:2.0.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.2.0'
androidTestImplementation 'org.mockito:mockito-android:2.19.0'
testImplementation 'junit:junit:4.12'
}
\ No newline at end of file
......@@ -10,6 +10,10 @@ package it.diab.glucose.viewmodels
import android.content.Context
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.Observer
import androidx.test.core.app.ApplicationProvider
import it.diab.data.entities.Glucose
import it.diab.data.entities.TimeFrame
......@@ -18,14 +22,18 @@ import it.diab.data.extensions.insulin
import it.diab.data.plugin.PluginManager
import it.diab.data.repositories.GlucoseRepository
import it.diab.data.repositories.InsulinRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.mockito.junit.MockitoJUnitRunner
import kotlin.math.absoluteValue
@RunWith(MockitoJUnitRunner::class)
class EditorViewModelTest {
private lateinit var glucoseRepo: GlucoseRepository
......@@ -54,6 +62,8 @@ class EditorViewModelTest {
isBasal = true
}
private lateinit var lifecycle: LifecycleOwner
@Before
fun setup() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
......@@ -62,7 +72,6 @@ class EditorViewModelTest {
InsulinRepository.getInstance(context)
)
pluginManager = PluginManager(context)
glucoseRepo = GlucoseRepository.getInstance(context).apply {
setDebugMode()
insert(testGlucose)
......@@ -74,43 +83,51 @@ class EditorViewModelTest {
insert(testBasal)
}
val lifecycleRegistry = LifecycleRegistry(mock(LifecycleOwner::class.java))
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
lifecycle = LifecycleOwner { lifecycleRegistry }
// setup is required to be "void"
Unit
}
@Test
fun setGlucose() = runBlocking {
viewModel.runSetGlucose(testGlucose.uid)
viewModel.glucose.run {
assertEquals(testGlucose.uid, uid)
assertEquals(testGlucose, this)
}
viewModel.runPrepare(testGlucose.uid, pluginManager)
viewModel.glucose.observe(lifecycle, Observer {
assertEquals(testGlucose.value, viewModel.value)
assertEquals(testGlucose.date, viewModel.date)
assertEquals(testGlucose.value, it.value)
// Account for time diff due to save operations
assertTrue((testGlucose.date.time - it.date.time).absoluteValue < 1000)
})
}
@Test
fun save() = runBlocking {
viewModel.runSetGlucose(-1)
val initialSize = glucoseRepo.getInDateRange(0, System.currentTimeMillis()).size
viewModel.glucose.apply {
value = 81
insulinId0 = 0
insulinValue0 = 10.5f
eatLevel = Glucose.MAX
}
val glucose = glucose { value = 71 }
glucoseRepo.insert(glucose)
viewModel.runPrepare(glucose.uid, pluginManager)
viewModel.runSave()
viewModel.glucose.observe(lifecycle, blockingObserver {
viewModel.runSave()
delay(500)
val finalSize = glucoseRepo.getInDateRange(0, System.currentTimeMillis()).size
assertTrue(finalSize > initialSize)
val finalSize = glucoseRepo.getInDateRange(0, System.currentTimeMillis()).size
assertTrue(finalSize > initialSize)
// Stop observing or we end up looping forever
viewModel.glucose.removeObservers(lifecycle)
})
}
@Test
fun getInsulin() = runBlocking {
viewModel.runPrepare(this, pluginManager)
viewModel.runPrepare(-1L, pluginManager)
viewModel.getInsulin(testInsulin.uid).run {
assertEquals(testInsulin.uid, uid)
assertEquals(testInsulin, this)
......@@ -119,28 +136,50 @@ class EditorViewModelTest {
@Test
fun hasPotentialBasal() = runBlocking {
viewModel.runPrepare(this, pluginManager)
viewModel.glucose.timeFrame = testBasal.timeFrame
assertTrue(viewModel.hasPotentialBasal())
val glucose = glucose {
uid = 71
timeFrame = testBasal.timeFrame
}
glucoseRepo.insert(glucose)
viewModel.runPrepare(glucose.uid, pluginManager)
viewModel.glucose.observe(lifecycle, Observer {
assertTrue(viewModel.hasPotentialBasal())
})
}
@Test
fun getInsulinByTimeFrame() = runBlocking {
viewModel.runPrepare(this, pluginManager)
viewModel.glucose.timeFrame = testInsulin.timeFrame
assertEquals(viewModel.glucose.timeFrame, viewModel.getInsulinByTimeFrame().timeFrame)
val glucose = glucose {
uid = 91
timeFrame = testInsulin.timeFrame
}
glucoseRepo.insert(glucose)
viewModel.runPrepare(glucose.uid, pluginManager)
viewModel.glucose.observe(lifecycle, Observer {
assertEquals(glucose.timeFrame, viewModel.getInsulinByTimeFrame().timeFrame)
})
}
@Test
fun applyInsulinSuggestion() = runBlocking {
val test = 6.5f
viewModel.runApplySuggestion(test, testInsulin)
viewModel.glucose.run {
assertEquals(test, insulinValue0)
assertEquals(testInsulin.uid, insulinId0)
val glucose = glucose {
uid = 42
}
glucoseRepo.insert(glucose)
viewModel.runPrepare(glucose.uid, pluginManager)
viewModel.glucose.observe(lifecycle, blockingObserver {
viewModel.runApplySuggestion(test, testInsulin)
val fromDb = glucoseRepo.getById(glucose.uid)
assertEquals(test, fromDb.insulinValue0)
assertEquals(testInsulin.uid, fromDb.insulinId0)
// Stop observing or we end up looping forever
viewModel.glucose.removeObservers(lifecycle)
})
}
private fun <T> blockingObserver(block: suspend (T) -> Unit) = Observer<T> { runBlocking { block(it) } }
}
\ No newline at end of file
......@@ -14,7 +14,7 @@
<application>
<activity
android:name=".ui.EditorActivity"
android:name=".ui.GlucoseActivity"
android:configChanges="screenSize|orientation"
android:theme="@style/AppTheme.NoActionBar"
android:windowSoftInputMode="stateHidden">
......
/*
* Copyright (c) 2019 Bevilacqua Joey
*
* Licensed under the GNU GPLv3 license
*
* The text of the license can be found in the LICENSE file
* or at https://www.gnu.org/licenses/gpl.txt
*/
package it.diab.glucose.fragments
import android.os.Bundle
import android.os.Handler
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.ImageView
import androidx.appcompat.widget.AppCompatSpinner
import androidx.lifecycle.ViewModelProviders
import com.google.android.material.button.MaterialButton
import it.diab.core.util.Activities
import it.diab.core.util.intentTo
import it.diab.data.repositories.GlucoseRepository
import it.diab.data.repositories.InsulinRepository
import it.diab.glucose.R
import it.diab.glucose.ui.models.InsulinDialogUiModel
import it.diab.glucose.util.InsulinSelector
import it.diab.glucose.viewmodels.InsulinDialogViewModel
import it.diab.glucose.viewmodels.InsulinDialogViewModelFactory
import it.diab.ui.widgets.BottomSheetDialogFragmentExt
class InsulinDialogFragment : BottomSheetDialogFragmentExt() {
private lateinit var nameSpinner: AppCompatSpinner
private lateinit var valueEditText: EditText
private lateinit var addButton: MaterialButton
private lateinit var removeButton: MaterialButton
private lateinit var editorIcon: ImageView
private lateinit var viewModel: InsulinDialogViewModel
private var wantsBasal = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val context = context ?: return
val factory = InsulinDialogViewModelFactory(
GlucoseRepository.getInstance(context),
InsulinRepository.getInstance(context)
)
viewModel = ViewModelProviders.of(this, factory)[InsulinDialogViewModel::class.java]
}
override fun onCreateDialogView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = inflater.inflate(R.layout.fragment_add_insulin, container, false)
nameSpinner = view.findViewById(R.id.glucose_editor_insulin_spinner)
valueEditText = view.findViewById(R.id.glucose_editor_insulin_value)
addButton = view.findViewById(R.id.glucose_editor_insulin_add)
removeButton = view.findViewById(R.id.glucose_editor_insulin_remove)
editorIcon = view.findViewById(R.id.glucose_editor_insulin_editor)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val uid = arguments?.getLong(Activities.Glucose.Editor.EXTRA_UID, -1L) ?: -1L
wantsBasal = arguments?.getBoolean(Activities.Glucose.Editor.EXTRA_INSULIN_BASAL) ?: false
viewModel.prepare(uid, wantsBasal, this::setup)
}
private fun setup(model: InsulinDialogUiModel) {
val context = context ?: return
if (model.insulinValue > 0f) {
val formattedValue = "%.1f".format(model.insulinValue)
valueEditText.setText(formattedValue)
}
nameSpinner.adapter = ArrayAdapter(
context,
androidx.appcompat.R.layout.support_simple_spinner_dropdown_item,
model.insulins.map { "${it.name} (${getString(it.timeFrame.string)})" }
)
val spinnerIndex = InsulinSelector(model.targetTimeFrame).run {
if (wantsBasal) suggestBasal(model.insulins.toTypedArray(), model.currentInsulinId)
else suggestInsulin(model.insulins.toTypedArray(), model.currentInsulinId)
}
nameSpinner.setSelection(spinnerIndex)
editorIcon.setOnClickListener { startActivity(intentTo(Activities.Insulin)) }
addButton.setOnClickListener { onSave() }
removeButton.setOnClickListener { onRemove() }
if (model.currentInsulinId < 1L) {
removeButton.visibility = View.GONE
}
}
private fun onSave() {
val currentValue = valueEditText.text.toString().toFloatOrNull() ?: 0f
viewModel.apply {
if (wantsBasal) {
setBasal(nameSpinner.selectedItemPosition, currentValue)
} else {
setInsulin(nameSpinner.selectedItemPosition, currentValue)
}
}
Handler().postDelayed(this::dismiss, 350)
}
private fun onRemove() {
viewModel.run {
if (wantsBasal) {
removeBasal()
} else {
removeInsulin()
}
}
Handler().postDelayed(this::dismiss, 350)
}
}
\ No newline at end of file
/*
* Copyright (c) 2019 Bevilacqua Joey
*
* Licensed under the GNU GPLv3 license
*
* The text of the license can be found in the LICENSE file
* or at https://www.gnu.org/licenses/gpl.txt
*/
package it.diab.glucose.ui
import android.annotation.SuppressLint
import android.app.Activity
import android.view.LayoutInflater
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.ImageView
import android.widget.Toast
import androidx.appcompat.widget.AppCompatSpinner
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.button.MaterialButton
import it.diab.core.util.Activities
import it.diab.core.util.intentTo
import it.diab.data.entities.Glucose
import it.diab.data.entities.Insulin
import it.diab.glucose.R
import it.diab.glucose.util.InsulinSelector
import it.diab.glucose.util.VibrationUtil
import it.diab.ui.util.UIUtils
class AddInsulinDialog(
private val activity: Activity,
private val glucose: Glucose,
private val isBasal: Boolean
) {
private val dialog = BottomSheetDialog(activity)
private val nameSpinner: AppCompatSpinner
private val valueEditText: EditText
private val addButton: MaterialButton
private val removeButton: MaterialButton
private val editorIcon: ImageView
private lateinit var insulins: Array<Insulin>
init {
val inflater = activity.getSystemService(LayoutInflater::class.java)
@SuppressLint("InflateParams")
val view = inflater.inflate(R.layout.dialog_insulin_to_glucose, null)
nameSpinner = view.findViewById(R.id.glucose_editor_insulin_spinner)
valueEditText = view.findViewById(R.id.glucose_editor_insulin_value)
addButton = view.findViewById(R.id.glucose_editor_insulin_add)
removeButton = view.findViewById(R.id.glucose_editor_insulin_remove)
editorIcon = view.findViewById(R.id.glucose_editor_insulin_editor)
dialog.setContentView(view)
}
fun setInsulins(list: List<Insulin>) {
insulins = list.toTypedArray()
val currentValue = if (isBasal) glucose.insulinValue1 else glucose.insulinValue0
if (currentValue != 0f) {
valueEditText.setText(currentValue.toString())
}
val names = Array(list.size) { i ->
"${insulins[i].name} (${activity.getString(insulins[i].timeFrame.string)})"
}
nameSpinner.adapter = ArrayAdapter<String>(
activity,
androidx.appcompat.R.layout.support_simple_spinner_dropdown_item, names
)
val spinnerPosition = InsulinSelector(glucose.timeFrame).run {
if (isBasal) {
suggestBasal(insulins, glucose.insulinId1)
} else {
suggestInsulin(insulins, glucose.insulinId0)
}
}
nameSpinner.setSelection(if (spinnerPosition == -1) 0 else spinnerPosition)
editorIcon.setOnClickListener {
activity.startActivity(intentTo(Activities.Insulin))
}
}
fun show(onAdd: (Insulin, Float) -> Unit, onRemove: () -> Unit) {
if (nameSpinner.adapter.isEmpty) {
VibrationUtil.vibrateForError(activity)
activity.startActivity(intentTo(Activities.Insulin))
Toast.makeText(activity, R.string.glucose_editor_no_insulin, Toast.LENGTH_LONG).show()
return
}
addButton.setOnClickListener {
val selected = insulins[nameSpinner.selectedItemPosition]
val value = valueEditText.text.toString().toFloatOrNull() ?: 0F
onAdd(selected, value)
dialog.dismiss()
}
removeButton.setOnClickListener {
onRemove()
dialog.dismiss()
}
UIUtils.setWhiteNavBarIfNeeded(activity, dialog)
dialog.show()
}
}
/*
* Copyright (c) 2019 Bevilacqua Joey
*
* Licensed under the GNU GPLv3 license
*
* The text of the license can be found in the LICENSE file
* or at https://www.gnu.org/licenses/gpl.txt
*/
package it.diab.glucose.ui
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import it.diab.core.util.Activities
import it.diab.glucose.fragments.EditorFragment
class GlucoseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val uid = intent.getLongExtra(Activities.Glucose.Editor.EXTRA_UID, -1L)
val detailFragment = EditorFragment().apply {
arguments = Bundle().apply {
putLong(Activities.Glucose.Editor.EXTRA_UID, uid)
}
}
supportFragmentManager.beginTransaction()
.replace(android.R.id.content, detailFragment)
.commit()
}
}
/*
* Copyright (c) 2019 Bevilacqua Joey
*
* Licensed under the GNU GPLv3 license
*
* The text of the license can be found in the LICENSE file
* or at https://www.gnu.org/licenses/gpl.txt
*/
package it.diab.glucose.ui.models
import it.diab.data.entities.Insulin
import it.diab.data.entities.TimeFrame
data class InsulinDialogUiModel(
val targetTimeFrame: TimeFrame,
val insulinValue: Float,
val currentInsulinId: Long,
val insulins: List<Insulin>
)
\ No newline at end of file
......@@ -9,44 +9,47 @@
package it.diab.glucose.viewmodels
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import it.diab.data.entities.Glucose
import it.diab.data.entities.Insulin
import it.diab.data.entities.TimeFrame
import it.diab.data.extensions.asTimeFrame
import it.diab.data.plugin.PluginManager
import it.diab.data.repositories.GlucoseRepository
import it.diab.data.repositories.InsulinRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Date
class EditorViewModel internal constructor(
private val glucoseRepository: GlucoseRepository,
private val insulinRepository: InsulinRepository
) : ViewModel() {
var glucose = Glucose()
private set
var isEditMode = false
private val _uid = MutableLiveData<Long>()
private var _glucose = Transformations.switchMap(_uid) { uid ->
glucoseRepository.getByIdLive(uid)
}
val glucose = Transformations.map(_glucose) { glucose ->
glucose?.firstOrNull() ?: Glucose()
}
lateinit var insulins: List<Insulin>
var isEditMode = false
var date = Date()
var value = 0
var eatLevel = 1
private lateinit var insulins: List<Insulin>
private lateinit var basalInsulins: List<Insulin>
private lateinit var pluginManager: PluginManager
fun prepare(pManager: PluginManager, block: () -> Unit) {
viewModelScope.launch {
runPrepare(this, pManager)
block()
}
}
fun setGlucose(uid: Long, block: () -> Unit) {
fun prepare(uid: Long, pManager: PluginManager, block: () -> Unit) {
viewModelScope.launch {
runSetGlucose(uid)
runPrepare(uid, pManager)
block()
}
}
......@@ -57,14 +60,20 @@ class EditorViewModel internal constructor(
fun getInsulin(uid: Long) = insulins.firstOrNull { it.uid == uid } ?: Insulin()
fun hasPotentialBasal() = basalInsulins.any { it.timeFrame == glucose.timeFrame }
fun hasPotentialBasal(): Boolean {
val targetTimeFrame = glucose.value?.timeFrame ?: TimeFrame.EXTRA
return basalInsulins.any { it.timeFrame == targetTimeFrame }
}
fun getInsulinByTimeFrame() =
insulins.firstOrNull { it.timeFrame == glucose.timeFrame } ?: Insulin()
fun getInsulinByTimeFrame(): Insulin {
val targetTimeFrame = glucose.value?.timeFrame ?: TimeFrame.EXTRA
return insulins.firstOrNull { it.timeFrame == targetTimeFrame } ?: Insulin()
}
fun getInsulinSuggestion(block: (Float) -> Unit) {
val target = glucose.value ?: Glucose()
if (pluginManager.isInstalled()) {
viewModelScope.launch { pluginManager.fetchSuggestion(glucose, block) }
viewModelScope.launch { pluginManager.fetchSuggestion(target, block) }
} else {
block(PluginManager.NO_MODEL)
}
......@@ -73,14 +82,22 @@ class EditorViewModel internal constructor(
fun applyInsulinSuggestion(value: Float, insulin: Insulin, block: () -> Unit) {
viewModelScope.launch {
runApplySuggestion(value, insulin)
withContext(Dispatchers.Main) { block() }
block()
}
}
@VisibleForTesting
suspend fun runPrepare(scope: CoroutineScope, pManager: PluginManager) {
val defAll = scope.async(IO) { insulinRepository.getInsulins() }
val defBasal = scope.async(IO) { insulinRepository.getBasals() }
suspend fun runPrepare(uid: Long, pManager: PluginManager) {
val defAll = viewModelScope.async(IO) { insulinRepository.getInsulins() }
val defBasal = viewModelScope.async(IO) { insulinRepository.getBasals() }
_uid.value = uid
val staticGlucose = glucoseRepository.getById(uid)
isEditMode = uid <= 0L
value = staticGlucose.value
date = staticGlucose.date
eatLevel = staticGlucose.eatLevel
pluginManager = pManager
insulins = defAll.await()
......@@ -88,19 +105,21 @@ class EditorViewModel internal constructor(
}
@VisibleForTesting
suspend fun runSetGlucose(uid: Long) = withContext(IO) {
glucose = glucoseRepository.getById(uid)
suspend fun runSave() {
val toSave = glucose.value ?: return
toSave.value = value
toSave.date = date
toSave.timeFrame = date.asTimeFrame()
toSave.eatLevel = eatLevel
glucoseRepository.insert(toSave)
}
@VisibleForTesting
suspend fun runSave() = withContext(IO) {
glucoseRepository.insert(glucose)
}
suspend fun runApplySuggestion(value: Float, insulin: Insulin) {
val toSave = glucose.value ?: return
@VisibleForTesting
suspend fun runApplySuggestion(value: Float, insulin: Insulin) = withContext(IO) {
glucose.insulinId0 = insulin.uid
glucose.insulinValue0 = value
glucoseRepository.insert(glucose)
toSave.insulinId0 = insulin.uid
toSave.insulinValue0 = value
glucoseRepository.insert(toSave)
}
}
\ No newline at end of file
/*
* Copyright (c) 2019 Bevilacqua Joey
*
* Licensed under the GNU GPLv3 license
*