From 80b307d7f19c46fbbad3c9e86287d73d57bd5a45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manu=20Mu=C3=B1oz?= Date: Fri, 16 Feb 2024 08:15:36 +0100 Subject: [PATCH] feat: [ANDROAPP-5802] schedule events after completion (#3483) --- .../org/dhis2/usescases/UseCaseTestsSuite.kt | 5 +- .../scheduling/SchedulingDialogUiTest.kt | 134 +++++++++++++++ .../org/dhis2/data/user/UserComponent.java | 5 + .../data/EventDetailsRepository.kt | 37 +++- .../domain/ConfigureEventCatCombo.kt | 2 +- .../providers/InputFieldsProvider.kt | 12 +- .../teidata/TEIDataContracts.kt | 3 +- .../teidata/TEIDataFragment.kt | 46 +++-- .../teidata/TEIDataPresenter.kt | 12 +- .../dialogs/scheduling/SchedulingComponent.kt | 10 ++ .../dialogs/scheduling/SchedulingDialog.kt | 117 +++++++++++++ .../dialogs/scheduling/SchedulingDialogUi.kt | 154 +++++++++++++++++ .../dialogs/scheduling/SchedulingModule.kt | 25 +++ .../dialogs/scheduling/SchedulingViewModel.kt | 159 ++++++++++++++++++ .../scheduling/SchedulingViewModelFactory.kt | 29 ++++ app/src/main/res/values/strings.xml | 4 + .../data/TeiDataPresenterTest.kt | 31 ++++ 17 files changed, 747 insertions(+), 38 deletions(-) create mode 100644 app/src/androidTest/java/org/dhis2/usescases/teidashboard/dialogs/scheduling/SchedulingDialogUiTest.kt create mode 100644 app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingComponent.kt create mode 100644 app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingDialog.kt create mode 100644 app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingDialogUi.kt create mode 100644 app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingModule.kt create mode 100644 app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModel.kt create mode 100644 app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModelFactory.kt diff --git a/app/src/androidTest/java/org/dhis2/usescases/UseCaseTestsSuite.kt b/app/src/androidTest/java/org/dhis2/usescases/UseCaseTestsSuite.kt index 0b60fdfb25..a0e85ac6b7 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/UseCaseTestsSuite.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/UseCaseTestsSuite.kt @@ -5,7 +5,6 @@ import org.dhis2.usescases.datasets.DataSetTest import org.dhis2.usescases.enrollment.EnrollmentTest import org.dhis2.usescases.event.EventTest import org.dhis2.usescases.filters.FilterTest -import org.dhis2.usescases.form.FormTest import org.dhis2.usescases.jira.JiraTest import org.dhis2.usescases.login.LoginTest import org.dhis2.usescases.main.MainTest @@ -15,6 +14,7 @@ import org.dhis2.usescases.searchte.SearchTETest import org.dhis2.usescases.settings.SettingsTest import org.dhis2.usescases.sync.SyncActivityTest import org.dhis2.usescases.teidashboard.TeiDashboardTest +import org.dhis2.usescases.teidashboard.dialogs.scheduling.SchedulingDialogUiTest import org.junit.runner.RunWith import org.junit.runners.Suite @@ -33,6 +33,7 @@ import org.junit.runners.Suite SearchTETest::class, SettingsTest::class, SyncActivityTest::class, - TeiDashboardTest::class + TeiDashboardTest::class, + SchedulingDialogUiTest::class, ) class UseCaseTestsSuite diff --git a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/dialogs/scheduling/SchedulingDialogUiTest.kt b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/dialogs/scheduling/SchedulingDialogUiTest.kt new file mode 100644 index 0000000000..4aa70eb56a --- /dev/null +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/dialogs/scheduling/SchedulingDialogUiTest.kt @@ -0,0 +1,134 @@ +package org.dhis2.usescases.teidashboard.dialogs.scheduling + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import kotlinx.coroutines.flow.MutableStateFlow +import org.dhis2.composetable.test.TestActivity +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventCatCombo +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventCategory +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventDate +import org.dhis2.usescases.teiDashboard.dialogs.scheduling.SchedulingDialogUi +import org.dhis2.usescases.teiDashboard.dialogs.scheduling.SchedulingViewModel +import org.hisp.dhis.android.core.category.CategoryOption +import org.hisp.dhis.android.core.program.ProgramStage +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SchedulingDialogUiTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val viewModel: SchedulingViewModel = mock() + + @Before + fun setUp() { + whenever(viewModel.eventDate).thenReturn(MutableStateFlow(EventDate(label = "Date"))) + whenever(viewModel.eventCatCombo).thenReturn( + MutableStateFlow( + EventCatCombo( + categories = listOf( + EventCategory( + uid = "uid", + name = "CatCombo", + optionsSize = 2, + options = listOf( + CategoryOption.builder().uid("uidA").displayName("optionA").build(), + CategoryOption.builder().uid("uidB").displayName("optionB").build(), + ), + ), + ), + ), + ), + ) + } + + @Test + fun programStageInputNotDisplayedForOneStage() { + val programStages = listOf(ProgramStage.builder().uid("stageUid").displayName("PS A").build()) + whenever(viewModel.programStage).thenReturn(MutableStateFlow(programStages.first())) + composeTestRule.setContent { + SchedulingDialogUi( + programStages = programStages, + viewModel = viewModel, + orgUnitUid = "orgUnitUid", + ) { + } + } + composeTestRule.onNodeWithText("Schedule next " + programStages.first().displayName() + "?").assertExists() + composeTestRule.onNodeWithText("Program stage").assertDoesNotExist() + composeTestRule.onNodeWithText("Date").assertExists() + composeTestRule.onNodeWithText("CatCombo *").assertExists() + composeTestRule.onNodeWithText("Schedule").assertExists() + } + + @Test + fun programStageInputDisplayedForMoreThanOneStages() { + val programStages = listOf( + ProgramStage.builder().uid("stageUidA").displayName("PS A").build(), + ProgramStage.builder().uid("stageUidB").displayName("PS B").build(), + ) + whenever(viewModel.programStage).thenReturn(MutableStateFlow(programStages.first())) + composeTestRule.setContent { + SchedulingDialogUi( + programStages = programStages, + viewModel = viewModel, + orgUnitUid = "orgUnitUid", + ) { + } + } + composeTestRule.onNodeWithText("Schedule next event?").assertExists() + composeTestRule.onNodeWithText("Program stage").assertExists() + } + + @Test + fun inputFieldsShouldNotBeDisplayedWhenAnsweringNo() { + val programStages = listOf( + ProgramStage.builder().uid("stageUidA").displayName("PS A").build(), + ProgramStage.builder().uid("stageUidB").displayName("PS B").build(), + ) + whenever(viewModel.programStage).thenReturn(MutableStateFlow(programStages.first())) + composeTestRule.setContent { + SchedulingDialogUi( + programStages = programStages, + viewModel = viewModel, + orgUnitUid = "orgUnitUid", + ) { + } + } + composeTestRule.onNodeWithText("No").performClick() + + composeTestRule.onNodeWithText("Program stage").assertDoesNotExist() + composeTestRule.onNodeWithText("Date").assertDoesNotExist() + composeTestRule.onNodeWithText("CatCombo *").assertDoesNotExist() + composeTestRule.onNodeWithText("Done").assertExists() + } + + @Test + fun selectProgramStage() { + val programStages = listOf( + ProgramStage.builder().uid("stageUidA").displayName("PS A").build(), + ProgramStage.builder().uid("stageUidB").displayName("PS B").build(), + ) + whenever(viewModel.programStage).thenReturn(MutableStateFlow(programStages.first())) + composeTestRule.setContent { + SchedulingDialogUi( + programStages = programStages, + viewModel = viewModel, + orgUnitUid = "orgUnitUid", + ) { + } + } + + composeTestRule.onNodeWithText("Program stage").performClick() + composeTestRule.onNodeWithTag("INPUT_DROPDOWN_MENU_ITEM_1").performClick() + + verify(viewModel).updateStage(programStages[1]) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dhis2/data/user/UserComponent.java b/app/src/main/java/org/dhis2/data/user/UserComponent.java index 4aa6e247e2..b562c2e0ce 100644 --- a/app/src/main/java/org/dhis2/data/user/UserComponent.java +++ b/app/src/main/java/org/dhis2/data/user/UserComponent.java @@ -72,6 +72,8 @@ import org.dhis2.usescases.teiDashboard.TeiDashboardModule; import org.dhis2.usescases.teiDashboard.dashboardfragments.relationships.RelationshipComponent; import org.dhis2.usescases.teiDashboard.dashboardfragments.relationships.RelationshipModule; +import org.dhis2.usescases.teiDashboard.dialogs.scheduling.SchedulingComponent; +import org.dhis2.usescases.teiDashboard.dialogs.scheduling.SchedulingModule; import org.dhis2.usescases.teiDashboard.teiProgramList.TeiProgramListComponent; import org.dhis2.usescases.teiDashboard.teiProgramList.TeiProgramListModule; import org.dhis2.utils.optionset.OptionSetComponent; @@ -204,4 +206,7 @@ public interface UserComponent { @NonNull SessionComponent plus(PinModule pinModule); + + @NonNull + SchedulingComponent plus(SchedulingModule schedulingModule); } diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/data/EventDetailsRepository.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/data/EventDetailsRepository.kt index 0029f6ac3d..1c2efdf873 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/data/EventDetailsRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/data/EventDetailsRepository.kt @@ -1,6 +1,8 @@ package org.dhis2.usescases.eventsWithoutRegistration.eventDetails.data import io.reactivex.Observable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import org.dhis2.data.dhislogic.AUTH_ALL import org.dhis2.data.dhislogic.AUTH_UNCOMPLETE_EVENT import org.dhis2.form.model.FieldUiModel @@ -17,6 +19,7 @@ import org.hisp.dhis.android.core.common.ObjectStyle import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.enrollment.EnrollmentStatus import org.hisp.dhis.android.core.event.Event +import org.hisp.dhis.android.core.event.EventCreateProjection import org.hisp.dhis.android.core.event.EventEditableStatus import org.hisp.dhis.android.core.event.EventObjectRepository import org.hisp.dhis.android.core.event.EventStatus @@ -24,6 +27,7 @@ import org.hisp.dhis.android.core.maintenance.D2Error import org.hisp.dhis.android.core.organisationunit.OrganisationUnit import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.program.ProgramStage +import java.util.Calendar import java.util.Date class EventDetailsRepository( @@ -31,7 +35,7 @@ class EventDetailsRepository( private val programUid: String, private val eventUid: String?, private val programStageUid: String?, - private val fieldFactory: FieldViewModelFactory, + private val fieldFactory: FieldViewModelFactory?, private val onError: (Throwable) -> String?, ) { @@ -173,7 +177,7 @@ class EventDetailsRepository( d2.eventModule().events().uid(eventUid).blockingGet()?.geometry()?.coordinates() } - return fieldFactory.create( + return fieldFactory!!.create( id = "", label = "", valueType = ValueType.COORDINATE, @@ -325,4 +329,33 @@ class EventDetailsRepository( ), ) } + + fun scheduleEvent( + enrollmentUid: String?, + dueDate: Date, + orgUnitUid: String?, + categoryOptionComboUid: String?, + ): Flow = flow { + val cal = Calendar.getInstance() + cal.time = dueDate + cal[Calendar.HOUR_OF_DAY] = 0 + cal[Calendar.MINUTE] = 0 + cal[Calendar.SECOND] = 0 + cal[Calendar.MILLISECOND] = 0 + + val uid = d2.eventModule().events().blockingAdd( + EventCreateProjection.builder() + .enrollment(enrollmentUid) + .program(programUid) + .programStage(programStageUid) + .organisationUnit(orgUnitUid) + .attributeOptionCombo(categoryOptionComboUid) + .build(), + ) + val eventRepository = d2.eventModule().events().uid(uid) + eventRepository.setDueDate(cal.time) + eventRepository.setStatus(EventStatus.SCHEDULE) + + emit(uid) + } } diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventCatCombo.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventCatCombo.kt index 093f8a971b..c84ac29857 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventCatCombo.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventCatCombo.kt @@ -19,9 +19,9 @@ class ConfigureEventCatCombo( repository.catCombo().apply { val categories = getCategories(this?.categories()) val categoryOptions = getCategoryOptions() + updateSelectedOptions(categoryOption, categories, categoryOptions) val catComboUid = getCatComboUid(this?.uid() ?: "", this?.isDefault ?: false) val catComboDisplayName = getCatComboDisplayName(this?.uid() ?: "") - updateSelectedOptions(categoryOption, categories, categoryOptions) return flowOf( EventCatCombo( diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/providers/InputFieldsProvider.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/providers/InputFieldsProvider.kt index 4dd1678f0e..cd213c3d87 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/providers/InputFieldsProvider.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/providers/InputFieldsProvider.kt @@ -207,11 +207,13 @@ fun ProvideCategorySelector( modifier: Modifier = Modifier, eventCatComboUiModel: EventCatComboUiModel, ) { - var selectedItem by remember { - mutableStateOf( - eventCatComboUiModel.eventCatCombo.selectedCategoryOptions[eventCatComboUiModel.category.uid]?.displayName() - ?: eventCatComboUiModel.eventCatCombo.categoryOptions?.get(eventCatComboUiModel.category.uid)?.displayName(), - ) + var selectedItem by with(eventCatComboUiModel) { + remember(this) { + mutableStateOf( + eventCatCombo.selectedCategoryOptions[category.uid]?.displayName() + ?: eventCatCombo.categoryOptions?.get(category.uid)?.displayName(), + ) + } } val selectableOptions = eventCatComboUiModel.category.options diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataContracts.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataContracts.kt index 3a81eb28c7..88dc489b73 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataContracts.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataContracts.kt @@ -23,7 +23,8 @@ class TEIDataContracts { interface View : AbstractActivityContracts.View { fun viewLifecycleOwner(): LifecycleOwner fun setEvents(events: List) - fun displayGenerateEvent(): Consumer + fun displayScheduleEvent() + fun showDialogCloseProgram() fun areEventsCompleted(): Consumer> fun enrollmentCompleted(): Consumer fun switchFollowUp(followUp: Boolean) diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataFragment.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataFragment.kt index 1525783853..caad0e43c2 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataFragment.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataFragment.kt @@ -29,6 +29,7 @@ import org.dhis2.commons.animations.collapse import org.dhis2.commons.animations.expand import org.dhis2.commons.data.EventCreationType import org.dhis2.commons.data.EventViewModel +import org.dhis2.commons.data.EventViewModelType import org.dhis2.commons.data.StageSection import org.dhis2.commons.dialogs.CustomDialog import org.dhis2.commons.dialogs.DialogClickListener @@ -53,6 +54,8 @@ import org.dhis2.usescases.teiDashboard.TeiDashboardMobileActivity import org.dhis2.usescases.teiDashboard.dashboardfragments.teidata.teievents.CategoryDialogInteractions import org.dhis2.usescases.teiDashboard.dashboardfragments.teidata.teievents.EventAdapter import org.dhis2.usescases.teiDashboard.dashboardfragments.teidata.teievents.EventCatComboOptionSelector +import org.dhis2.usescases.teiDashboard.dialogs.scheduling.SchedulingDialog +import org.dhis2.usescases.teiDashboard.dialogs.scheduling.SchedulingDialog.Companion.SCHEDULING_DIALOG import org.dhis2.usescases.teiDashboard.ui.TeiDetailDashboard import org.dhis2.usescases.teiDashboard.ui.mapper.InfoBarMapper import org.dhis2.usescases.teiDashboard.ui.mapper.TeiDashboardCardMapper @@ -373,35 +376,26 @@ class TEIDataFragment : FragmentGlobalAbstract(), TEIDataContracts.View { } } - override fun displayGenerateEvent(): Consumer { - return Consumer { programStageModel: ProgramStage -> - programStageFromEvent = programStageModel - if (programStageModel.displayGenerateEventBox() == true || programStageModel.allowGenerateNextVisit() == true) { - dialog = CustomDialog( - requireContext(), - getString(R.string.dialog_generate_new_event), - getString(R.string.message_generate_new_event), - getString(R.string.button_ok), - getString(R.string.cancel), - RC_GENERATE_EVENT, - object : DialogClickListener { - override fun onPositive() { - presenter.onAcceptScheduleNewEvent( - programStageModel.standardInterval() ?: 0, - ) - } - - override fun onNegative() { - if (programStageFromEvent?.remindCompleted() == true) presenter.areEventsCompleted() - } - }, + override fun displayScheduleEvent() { + SchedulingDialog( + enrollment = dashboardModel.currentEnrollment, + programStages = eventAdapter?.currentList + ?.filter { it.type == EventViewModelType.STAGE && it.canAddNewEvent } + ?.mapNotNull { it.stage } + ?: emptyList(), + onScheduled = { programStageUid -> + showToast( + resourceManager.formatWithEventLabel( + R.string.event_label_created, + programStageUid, + ), ) - dialog?.show() - } else if (java.lang.Boolean.TRUE == programStageModel.remindCompleted()) showDialogCloseProgram() - } + presenter.updateEventList() + }, + ).show(childFragmentManager, SCHEDULING_DIALOG) } - private fun showDialogCloseProgram() { + override fun showDialogCloseProgram() { dialog = CustomDialog( requireContext(), resourceManager.formatWithEventLabel( diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataPresenter.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataPresenter.kt index a8e3aac6f0..7aa74aac51 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataPresenter.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataPresenter.kt @@ -339,7 +339,13 @@ class TEIDataPresenter( dashboardRepository.displayGenerateEvent(eventUid) .subscribeOn(schedulerProvider.io()) .observeOn(schedulerProvider.ui()) - .subscribe(view.displayGenerateEvent(), Timber.Forest::d), + .subscribe({ programStage -> + if (programStage.displayGenerateEventBox() == true || programStage.allowGenerateNextVisit() == true) { + view.displayScheduleEvent() + } else if (programStage.remindCompleted() == true) { + view.showDialogCloseProgram() + } + }, Timber.Forest::d), ) } @@ -579,4 +585,8 @@ class TEIDataPresenter( fun getTeiHeader(): String? { return teiDataRepository.getTeiHeader() } + + fun updateEventList() { + groupingProcessor.onNext(_groupEvents.value) + } } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingComponent.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingComponent.kt new file mode 100644 index 0000000000..32729e81d0 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingComponent.kt @@ -0,0 +1,10 @@ +package org.dhis2.usescases.teiDashboard.dialogs.scheduling + +import dagger.Subcomponent +import org.dhis2.commons.di.dagger.PerFragment + +@PerFragment +@Subcomponent(modules = [SchedulingModule::class]) +fun interface SchedulingComponent { + fun inject(schedulingDialog: SchedulingDialog) +} diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingDialog.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingDialog.kt new file mode 100644 index 0000000000..a5a8db124f --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingDialog.kt @@ -0,0 +1,117 @@ +package org.dhis2.usescases.teiDashboard.dialogs.scheduling + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.DatePicker +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.viewModels +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.dhis2.bindings.app +import org.dhis2.commons.dialogs.calendarpicker.CalendarPicker +import org.dhis2.commons.dialogs.calendarpicker.OnDatePickerListener +import org.dhis2.form.R +import org.dhis2.utils.customviews.PeriodDialog +import org.hisp.dhis.android.core.enrollment.Enrollment +import org.hisp.dhis.android.core.program.ProgramStage +import java.util.Date +import javax.inject.Inject + +class SchedulingDialog( + val enrollment: Enrollment, + val programStages: List, + val onScheduled: (String) -> Unit, +) : BottomSheetDialogFragment() { + companion object { + const val SCHEDULING_DIALOG = "SCHEDULING_DIALOG" + } + + @Inject + lateinit var factory: SchedulingViewModelFactory + val viewModel: SchedulingViewModel by viewModels { factory } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.CustomBottomSheetDialogTheme) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + app().userComponent()?.plus( + SchedulingModule( + enrollment, + programStages, + ), + )?.inject(this) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + viewModel.showCalendar = { + showCalendarDialog() + } + + viewModel.showPeriods = { + showPeriodDialog() + } + + viewModel.onEventScheduled = { + dismiss() + onScheduled(viewModel.programStage.value.uid()) + } + + return ComposeView(requireContext()).apply { + setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed, + ) + setContent { + SchedulingDialogUi( + viewModel = viewModel, + programStages = programStages, + orgUnitUid = enrollment.organisationUnit(), + onDismiss = { dismiss() }, + ) + } + } + } + + private fun showCalendarDialog() { + val dialog = CalendarPicker(requireContext()) + dialog.setInitialDate(viewModel.eventDate.value.currentDate) + dialog.setMinDate(viewModel.eventDate.value.minDate) + dialog.setMaxDate(viewModel.eventDate.value.maxDate) + dialog.isFutureDatesAllowed(viewModel.eventDate.value.allowFutureDates) + dialog.setListener( + object : OnDatePickerListener { + override fun onNegativeClick() { + // Unused + } + override fun onPositiveClick(datePicker: DatePicker) { + viewModel.onDateSet( + datePicker.year, + datePicker.month, + datePicker.dayOfMonth, + ) + } + }, + ) + dialog.show() + } + + private fun showPeriodDialog() { + PeriodDialog() + .setPeriod(viewModel.eventDate.value.periodType) + .setMinDate(viewModel.eventDate.value.minDate) + .setMaxDate(viewModel.eventDate.value.maxDate) + .setPossitiveListener { selectedDate: Date -> + viewModel.setUpEventReportDate(selectedDate) + } + .show(requireActivity().supportFragmentManager, PeriodDialog::class.java.simpleName) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingDialogUi.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingDialogUi.kt new file mode 100644 index 0000000000..0b9923f697 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingDialogUi.kt @@ -0,0 +1,154 @@ +package org.dhis2.usescases.teiDashboard.dialogs.scheduling + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import org.dhis2.R +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventCatComboUiModel +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventInputDateUiModel +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.providers.ProvideCategorySelector +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.providers.ProvideInputDate +import org.hisp.dhis.android.core.program.ProgramStage +import org.hisp.dhis.mobile.ui.designsystem.component.BottomSheetShell +import org.hisp.dhis.mobile.ui.designsystem.component.Button +import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle +import org.hisp.dhis.mobile.ui.designsystem.component.DropdownItem +import org.hisp.dhis.mobile.ui.designsystem.component.InputDropDown +import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState +import org.hisp.dhis.mobile.ui.designsystem.component.InputYesNoFieldValues +import org.hisp.dhis.mobile.ui.designsystem.component.Orientation +import org.hisp.dhis.mobile.ui.designsystem.component.RadioButtonBlock +import org.hisp.dhis.mobile.ui.designsystem.component.RadioButtonData +import org.hisp.dhis.mobile.ui.designsystem.resource.provideStringResource +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing + +@Composable +fun SchedulingDialogUi( + programStages: List, + viewModel: SchedulingViewModel, + orgUnitUid: String?, + onDismiss: () -> Unit, +) { + val date by viewModel.eventDate.collectAsState() + val catCombo by viewModel.eventCatCombo.collectAsState() + val selectedProgramStage by viewModel.programStage.collectAsState() + + val yesNoOptions = InputYesNoFieldValues.entries.map { + RadioButtonData( + it.value, + selected = false, + enabled = true, + textInput = provideStringResource(it.value), + ) + } + var optionSelected by remember { mutableStateOf(yesNoOptions.first()) } + val scheduleNew by remember(optionSelected) { + derivedStateOf { optionSelected == yesNoOptions.first() } + } + + val onButtonClick = { + when { + scheduleNew -> viewModel.scheduleEvent() + else -> onDismiss() + } + } + BottomSheetShell( + title = bottomSheetTitle(programStages), + buttonBlock = { + Button( + modifier = Modifier.fillMaxWidth(), + style = ButtonStyle.FILLED, + enabled = !scheduleNew || + !date.dateValue.isNullOrEmpty() && + catCombo.isCompleted, + text = buttonTitle(scheduleNew), + onClick = onButtonClick, + ) + }, + showSectionDivider = false, + content = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + RadioButtonBlock( + modifier = Modifier.padding(bottom = Spacing.Spacing8), + orientation = Orientation.HORIZONTAL, + content = yesNoOptions, + itemSelected = optionSelected, + onItemChange = { + optionSelected = it + }, + ) + if (scheduleNew) { + if (programStages.size > 1) { + InputDropDown( + title = stringResource(id = R.string.program_stage), + state = InputShellState.UNFOCUSED, + dropdownItems = programStages.map { DropdownItem(it.displayName().orEmpty()) }, + selectedItem = DropdownItem(selectedProgramStage.displayName().orEmpty()), + onResetButtonClicked = {}, + onItemSelected = { item -> + programStages.find { it.displayName() == item.label } + ?.let { viewModel.updateStage(it) } + }, + ) + } + ProvideInputDate( + EventInputDateUiModel( + eventDate = date, + detailsEnabled = true, + onDateClick = { viewModel.onDateClick() }, + onDateSet = { viewModel.onDateSet(it.year, it.month, it.day) }, + onClear = { viewModel.onClearEventReportDate() }, + ), + ) + if (!catCombo.isDefault) { + catCombo.categories.forEach { category -> + ProvideCategorySelector( + eventCatComboUiModel = EventCatComboUiModel( + category = category, + eventCatCombo = catCombo, + detailsEnabled = true, + currentDate = date.currentDate, + selectedOrgUnit = orgUnitUid, + onClearCatCombo = { viewModel.onClearCatCombo() }, + onOptionSelected = { + val selectedOption = Pair(category.uid, it?.uid()) + viewModel.setUpCategoryCombo(selectedOption) + }, + required = true, + noOptionsText = stringResource(R.string.no_options), + catComboText = stringResource(R.string.cat_combo), + ), + ) + } + } + } + } + }, + onDismiss = onDismiss, + ) +} + +@Composable +fun bottomSheetTitle(programStages: List): String = + stringResource(id = R.string.schedule_next) + " " + + when (programStages.size) { + 1 -> programStages.first().displayName() + else -> stringResource(id = R.string.event) + } + "?" + +@Composable +fun buttonTitle(scheduleNew: Boolean): String = when (scheduleNew) { + true -> stringResource(id = R.string.schedule) + false -> stringResource(id = R.string.done) +} diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingModule.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingModule.kt new file mode 100644 index 0000000000..e4013c5b2a --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingModule.kt @@ -0,0 +1,25 @@ +package org.dhis2.usescases.teiDashboard.dialogs.scheduling + +import dagger.Module +import dagger.Provides +import org.dhis2.commons.di.dagger.PerFragment +import org.dhis2.commons.resources.ResourceManager +import org.dhis2.data.dhislogic.DhisPeriodUtils +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.enrollment.Enrollment +import org.hisp.dhis.android.core.program.ProgramStage + +@Module +class SchedulingModule( + val enrollment: Enrollment, + val programStages: List, +) { + @Provides + @PerFragment + fun provideSchedulingViewModelFactory( + d2: D2, + resourceManager: ResourceManager, + periodUtils: DhisPeriodUtils, + ): SchedulingViewModelFactory = + SchedulingViewModelFactory(enrollment, programStages, d2, resourceManager, periodUtils) +} diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModel.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModel.kt new file mode 100644 index 0000000000..202836159d --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModel.kt @@ -0,0 +1,159 @@ +package org.dhis2.usescases.teiDashboard.dialogs.scheduling + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import org.dhis2.commons.data.EventCreationType +import org.dhis2.commons.resources.ResourceManager +import org.dhis2.data.dhislogic.DhisPeriodUtils +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.data.EventDetailsRepository +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.domain.ConfigureEventCatCombo +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.domain.ConfigureEventReportDate +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventCatCombo +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventDate +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.providers.EventDetailResourcesProvider +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.enrollment.Enrollment +import org.hisp.dhis.android.core.program.ProgramStage +import java.util.Calendar +import java.util.Date + +class SchedulingViewModel( + val enrollment: Enrollment, + val programStages: List, + val d2: D2, + val resourceManager: ResourceManager, + val periodUtils: DhisPeriodUtils, +) : ViewModel() { + + lateinit var repository: EventDetailsRepository + lateinit var configureEventReportDate: ConfigureEventReportDate + lateinit var configureEventCatCombo: ConfigureEventCatCombo + + private val _programStage: MutableStateFlow = MutableStateFlow(programStages.first()) + val programStage: StateFlow get() = _programStage + + var showCalendar: (() -> Unit)? = null + var showPeriods: (() -> Unit)? = null + var onEventScheduled: (() -> Unit)? = null + + private val _eventDate: MutableStateFlow = MutableStateFlow(EventDate()) + val eventDate: StateFlow get() = _eventDate + + private val _eventCatCombo: MutableStateFlow = MutableStateFlow(EventCatCombo()) + val eventCatCombo: StateFlow get() = _eventCatCombo + + init { + loadConfiguration() + } + + private fun loadConfiguration() { + repository = EventDetailsRepository( + d2 = d2, + programUid = enrollment.program().orEmpty(), + eventUid = null, + programStageUid = programStage.value.uid(), + fieldFactory = null, + onError = resourceManager::parseD2Error, + ) + configureEventReportDate = ConfigureEventReportDate( + creationType = EventCreationType.SCHEDULE, + resourceProvider = EventDetailResourcesProvider( + enrollment.program().orEmpty(), + programStage.value.uid(), + resourceManager, + ), + repository = repository, + periodType = programStage.value.periodType(), + periodUtils = periodUtils, + enrollmentId = enrollment.uid(), + scheduleInterval = programStage.value.standardInterval() ?: 0, + ) + configureEventCatCombo = ConfigureEventCatCombo( + repository = repository, + ) + loadProgramStage() + } + + private fun loadProgramStage() { + viewModelScope.launch { + configureEventReportDate().collect { + _eventDate.value = it + } + + configureEventCatCombo() + .collect { + _eventCatCombo.value = it + } + } + } + + fun setUpEventReportDate(selectedDate: Date? = null) { + viewModelScope.launch { + configureEventReportDate(selectedDate) + .flowOn(Dispatchers.IO) + .collect { + _eventDate.value = it + } + } + } + + fun onClearEventReportDate() { + _eventDate.value = eventDate.value.copy(currentDate = null) + } + + fun setUpCategoryCombo(categoryOption: Pair? = null) { + viewModelScope.launch { + configureEventCatCombo(categoryOption) + .flowOn(Dispatchers.IO) + .collect { + _eventCatCombo.value = it + } + } + } + + fun onClearCatCombo() { + _eventCatCombo.value = eventCatCombo.value.copy(isCompleted = false) + } + + fun onDateClick() { + programStage.value.periodType()?.let { + showPeriods?.invoke() + } ?: showCalendar?.invoke() + } + + fun onDateSet(year: Int, month: Int, day: Int) { + val calendar = Calendar.getInstance() + calendar[year, month, day, 0, 0] = 0 + calendar[Calendar.MILLISECOND] = 0 + val selectedDate = calendar.time + setUpEventReportDate(selectedDate) + } + + fun updateStage(stage: ProgramStage) { + _programStage.value = stage + loadConfiguration() + } + + fun scheduleEvent() { + viewModelScope.launch { + eventDate.value.currentDate?.let { date -> + repository.scheduleEvent( + enrollmentUid = enrollment.uid(), + dueDate = date, + orgUnitUid = enrollment.organisationUnit(), + categoryOptionComboUid = eventCatCombo.value.uid, + ).flowOn(Dispatchers.IO) + .collect { + if (it != null) { + onEventScheduled?.invoke() + } + } + } + } + } +} diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModelFactory.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModelFactory.kt new file mode 100644 index 0000000000..b2f2f2b841 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModelFactory.kt @@ -0,0 +1,29 @@ +package org.dhis2.usescases.teiDashboard.dialogs.scheduling + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import org.dhis2.commons.resources.ResourceManager +import org.dhis2.data.dhislogic.DhisPeriodUtils +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.enrollment.Enrollment +import org.hisp.dhis.android.core.program.ProgramStage + +@Suppress("UNCHECKED_CAST") +class SchedulingViewModelFactory( + private val enrollment: Enrollment, + private val programStages: List, + private val d2: D2, + private val resourceManager: ResourceManager, + private val periodUtils: DhisPeriodUtils, +) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + return SchedulingViewModel( + enrollment, + programStages, + d2, + resourceManager, + periodUtils, + ) as T + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 86bdc2d661..72d8a0f87f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -946,4 +946,8 @@ This %s and all its data across all programs will be deleted. This action cannot be undone. Remove from %s? Data from %s will de deleted. This action cannot be undone. + Schedule next + Schedule + Program stage + event diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/data/TeiDataPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/data/TeiDataPresenterTest.kt index 968de083d0..f018f00cb1 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/data/TeiDataPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/data/TeiDataPresenterTest.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.MutableLiveData +import io.reactivex.Observable import io.reactivex.Single import org.dhis2.commons.bindings.canCreateEventInEnrollment import org.dhis2.commons.bindings.enrollment @@ -199,6 +200,36 @@ class TeiDataPresenterTest { assertTrue(teiDataPresenter.shouldDisplayEventCreationButton.value == false) } + @Test + fun `Should display schedule events dialogs when configured`() { + val programStage = ProgramStage.builder() + .uid("programStage") + .allowGenerateNextVisit(true) + .displayGenerateEventBox(true) + .remindCompleted(false) + .build() + whenever( + dashboardRepository.displayGenerateEvent("eventUid"), + ) doReturn Observable.just(programStage) + teiDataPresenter.displayGenerateEvent("eventUid") + verify(view).displayScheduleEvent() + } + + @Test + fun `Should display close program dialogs when configured`() { + val programStage = ProgramStage.builder() + .uid("programStage") + .allowGenerateNextVisit(false) + .displayGenerateEventBox(false) + .remindCompleted(true) + .build() + whenever( + dashboardRepository.displayGenerateEvent("eventUid"), + ) doReturn Observable.just(programStage) + teiDataPresenter.displayGenerateEvent("eventUid") + verify(view).showDialogCloseProgram() + } + private fun fakeModel( eventCount: Int = 0, type: EventViewModelType = EventViewModelType.STAGE,