From 4168308a185f3a33b3e2c6176b00bb7d2aa58a58 Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 15 Feb 2024 14:50:29 +0100 Subject: [PATCH 1/3] cache some complex operations (#3494) Signed-off-by: Pablo --- .../SearchRepositoryImpl.java | 170 +++++++++++------- .../dhis2/commons/data/SearchTeiModel.java | 5 - 2 files changed, 102 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java index 2c89d5d569..2e548590ef 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java @@ -4,15 +4,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.Transformations; -import androidx.paging.DataSource; -import androidx.paging.LivePagedListBuilder; -import androidx.paging.PagedList; -import androidx.paging.Pager; -import androidx.paging.PagingData; -import androidx.paging.PagingDataTransforms; -import androidx.paging.PagingLiveData; import org.dhis2.R; import org.dhis2.bindings.ExtensionsKt; @@ -31,6 +22,7 @@ import org.dhis2.commons.filters.data.FilterPresenter; import org.dhis2.commons.filters.sorting.SortingItem; import org.dhis2.commons.network.NetworkUtils; +import org.dhis2.commons.prefs.PreferenceProvider; import org.dhis2.commons.reporting.CrashReportController; import org.dhis2.commons.resources.ResourceManager; import org.dhis2.data.dhislogic.DhisEnrollmentUtils; @@ -102,11 +94,6 @@ import io.reactivex.Flowable; import io.reactivex.Observable; import io.reactivex.Single; -import kotlin.Unit; -import kotlin.coroutines.Continuation; -import kotlinx.coroutines.ExecutorsKt; -import kotlinx.coroutines.flow.Flow; -import kotlinx.coroutines.flow.FlowCollector; public class SearchRepositoryImpl implements SearchRepository { @@ -128,6 +115,14 @@ public class SearchRepositoryImpl implements SearchRepository { private ThemeManager themeManager; private HashSet fetchedTeiUids = new HashSet<>(); private TeiDownloader teiDownloader; + private HashMap programCache = new HashMap<>(); + private HashMap orgUnitNameCache = new HashMap<>(); + + private HashMap profilePictureCache = new HashMap<>(); + + private HashMap> attributesUidsCache = new HashMap(); + + private HashMap> trackedEntityTypeAttributesUidsCache = new HashMap(); SearchRepositoryImpl(String teiType, @Nullable String initialProgram, @@ -162,6 +157,7 @@ public class SearchRepositoryImpl implements SearchRepository { resources); } + @Override public Observable> programsWithRegistration(String programTypeId) { return d2.organisationUnitModule().organisationUnits().byOrganisationUnitScope(OrganisationUnit.Scope.SCOPE_DATA_CAPTURE).get() @@ -302,7 +298,7 @@ public Observable> saveToEnroll(@NonNull String teiType, .organisationUnit(orgUnit) .build()) .map(enrollmentUid -> { - boolean displayIncidentDate = d2.programModule().programs().uid(programUid).blockingGet().displayIncidentDate(); + boolean displayIncidentDate = getProgram(programUid).displayIncidentDate(); Date enrollmentDateNoTime = DateUtils.getInstance().getNextPeriod(PeriodType.Daily, enrollmentDate, 0); d2.enrollmentModule().enrollments().uid(enrollmentUid).setEnrollmentDate(enrollmentDateNoTime); if (displayIncidentDate) { @@ -338,7 +334,7 @@ private void setEnrollmentInfo(SearchTeiModel searchTei) { if (enrollments.indexOf(enrollment) == 0) searchTei.resetEnrollments(); searchTei.addEnrollment(enrollment); - Program program = d2.programModule().programs().byUid().eq(enrollment.program()).one().blockingGet(); + Program program = getProgram(enrollment.program()); if (program.displayFrontPageList()) { searchTei.addProgramInfo(program); } @@ -480,8 +476,8 @@ private void setRelationshipsInfo(@NonNull SearchTeiModel searchTeiModel, Progra RelationshipOwnerType.TEI, fromValues, toValues, - ExtensionsKt.profilePicturePath(fromTei, d2, selectedProgram.uid()), - ExtensionsKt.profilePicturePath(toTei, d2, selectedProgram.uid()), + profilePicturePath(fromTei, selectedProgram.uid()), + profilePicturePath(toTei, selectedProgram.uid()), getTeiDefaultRes(fromTei), getTeiDefaultRes(toTei), -1, @@ -493,6 +489,45 @@ private void setRelationshipsInfo(@NonNull SearchTeiModel searchTeiModel, Progra searchTeiModel.setRelationships(relationshipViewModels); } + private String profilePicturePath(TrackedEntityInstance tei, String programUid){ + if(!profilePictureCache.containsKey(tei.uid())){ + profilePictureCache.put(tei.uid(),ExtensionsKt.profilePicturePath(tei, d2, programUid)); + } + return profilePictureCache.get(tei.uid()); + } + + private List getProgramAttributeUids(String programUid) { + if(!attributesUidsCache.containsKey(programUid)){ + List attributeUids = new ArrayList<>(); + List programTrackedEntityAttributes = d2.programModule().programTrackedEntityAttributes() + .byProgram().eq(programUid) + .byDisplayInList().isTrue() + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) + .blockingGet(); + for (ProgramTrackedEntityAttribute programAttribute : programTrackedEntityAttributes) { + attributeUids.add(programAttribute.trackedEntityAttribute().uid()); + } + attributesUidsCache.put(programUid, attributeUids); + } + + return attributesUidsCache.get(programUid); + } + + private List getTETypeAttributeUids(String teTypeUid){ + if(!trackedEntityTypeAttributesUidsCache.containsKey(teTypeUid)){ + List attributeUids = new ArrayList<>(); + List typeAttributes = d2.trackedEntityModule().trackedEntityTypeAttributes() + .byTrackedEntityTypeUid().eq(teTypeUid) + .byDisplayInList().isTrue() + .blockingGet(); + + for (TrackedEntityTypeAttribute typeAttribute : typeAttributes) { + attributeUids.add(typeAttribute.trackedEntityAttribute().uid()); + } + } + return trackedEntityTypeAttributesUidsCache.get(teTypeUid); + } + private int getTeiDefaultRes(TrackedEntityInstance tei) { TrackedEntityType teiType = d2.trackedEntityModule().trackedEntityTypes().uid(tei.trackedEntityType()).blockingGet(); return resources.getObjectStyleDrawableResource(teiType.style().icon(), R.drawable.photo_temp_gray); @@ -501,32 +536,14 @@ private int getTeiDefaultRes(TrackedEntityInstance tei) { private List getTrackedEntityAttributesForRelationship(TrackedEntityInstance tei, Program selectedProgram) { List values; - List attributeUids = new ArrayList<>(); - List programTrackedEntityAttributes = d2.programModule().programTrackedEntityAttributes() - .byProgram().eq(selectedProgram.uid()) - .byDisplayInList().isTrue() - .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) - .blockingGet(); - for (ProgramTrackedEntityAttribute programAttribute : programTrackedEntityAttributes) { - attributeUids.add(programAttribute.trackedEntityAttribute().uid()); - } values = d2.trackedEntityModule().trackedEntityAttributeValues() .byTrackedEntityInstance().eq(tei.uid()) - .byTrackedEntityAttribute().in(attributeUids).blockingGet(); + .byTrackedEntityAttribute().in(getProgramAttributeUids(selectedProgram.uid())).blockingGet(); if (values.isEmpty()) { - attributeUids.clear(); - List typeAttributes = d2.trackedEntityModule().trackedEntityTypeAttributes() - .byTrackedEntityTypeUid().eq(tei.trackedEntityType()) - .byDisplayInList().isTrue() - .blockingGet(); - - for (TrackedEntityTypeAttribute typeAttribute : typeAttributes) { - attributeUids.add(typeAttribute.trackedEntityAttribute().uid()); - } values = d2.trackedEntityModule().trackedEntityAttributeValues() .byTrackedEntityInstance().eq(tei.uid()) - .byTrackedEntityAttribute().in(attributeUids).blockingGet(); + .byTrackedEntityAttribute().in(getTETypeAttributeUids(tei.trackedEntityType())).blockingGet(); } return values; @@ -534,7 +551,8 @@ private List getTrackedEntityAttributesForRelations @Override public String getProgramColor(@NonNull String programUid) { - Program program = d2.programModule().programs().byUid().eq(programUid).one().blockingGet(); + Program program = getProgram(programUid); + if(program == null) return ""; return program.style() != null ? program.style().color() != null ? program.style().color() : @@ -607,33 +625,33 @@ public List getEventsForMap(List teis) { .byDeleted().isFalse() .blockingGet(); - for (Event event : events) { - ProgramStage stage = d2.programModule().programStages() - .uid(event.programStage()) - .blockingGet(); + HashMap cacheStages = new HashMap<>(); - OrganisationUnit organisationUnit = d2.organisationUnitModule() - .organisationUnits() - .uid(event.organisationUnit()) - .blockingGet(); + for (Event event : events) { + if(!cacheStages.containsKey(event.programStage())){ + ProgramStage stage = d2.programModule().programStages() + .uid(event.programStage()) + .blockingGet(); + cacheStages.put(event.programStage(), stage); + } eventViewModels.add( new EventViewModel( EventViewModelType.EVENT, - stage, + cacheStages.get(event.programStage()), event, 0, null, true, true, - organisationUnit.displayName(), + orgUnitName(event.organisationUnit()), null, null, false, false, false, false, - periodUtils.getPeriodUIString(stage.periodType(), event.eventDate() != null ? event.eventDate() : event.dueDate(), Locale.getDefault()), + periodUtils.getPeriodUIString(cacheStages.get(event.programStage()).periodType(), event.eventDate() != null ? event.eventDate() : event.dueDate(), Locale.getDefault()), null )); } @@ -641,6 +659,17 @@ public List getEventsForMap(List teis) { return eventViewModels; } + private String orgUnitName(String orgUnitUid){ + if(!orgUnitNameCache.containsKey(orgUnitUid)){ + OrganisationUnit organisationUnit = d2.organisationUnitModule() + .organisationUnits() + .uid(orgUnitUid) + .blockingGet(); + orgUnitNameCache.put(orgUnitUid, organisationUnit.displayName()); + } + return orgUnitNameCache.get(orgUnitUid); + } + @Override public SearchTeiModel getTrackedEntityInfo(String teiUid, Program selectedProgram, SortingItem sortingItem) { return transform( @@ -727,20 +756,21 @@ public SearchTeiModel transform(TrackedEntitySearchItem searchItem, @Nullable Pr SearchTeiModel searchTei = new SearchTeiModel(); if (dbTei != null && dbTei.aggregatedSyncState() != State.RELATIONSHIP) { searchTei.setTei(dbTei); - if (selectedProgram != null && d2.enrollmentModule().enrollments().byTrackedEntityInstance().eq(dbTei.uid()).byProgram().eq(selectedProgram.uid()).one().blockingExists()) { - List possibleEnrollments = d2.enrollmentModule().enrollments() - .byTrackedEntityInstance().eq(dbTei.uid()) - .byProgram().eq(selectedProgram.uid()) - .orderByEnrollmentDate(RepositoryScope.OrderByDirection.DESC) - .blockingGet(); - for (Enrollment enrollment : possibleEnrollments) { + List enrollmentsInProgram = d2.enrollmentModule().enrollments() + .byTrackedEntityInstance().eq(dbTei.uid()) + .byProgram().eq(selectedProgram.uid()) + .orderByEnrollmentDate(RepositoryScope.OrderByDirection.DESC) + .blockingGet(); + + if (!enrollmentsInProgram.isEmpty()) { + for (Enrollment enrollment : enrollmentsInProgram) { if (enrollment.status() == EnrollmentStatus.ACTIVE) { searchTei.setCurrentEnrollment(enrollment); break; } } if (searchTei.getSelectedEnrollment() == null) { - searchTei.setCurrentEnrollment(possibleEnrollments.get(0)); + searchTei.setCurrentEnrollment(enrollmentsInProgram.get(0)); } } @@ -749,7 +779,7 @@ public SearchTeiModel transform(TrackedEntitySearchItem searchItem, @Nullable Pr if (offlineOnly) searchTei.setOnline(!offlineOnly); - if (dbTei.deleted() != null && dbTei.deleted()) { + if (Boolean.TRUE.equals(dbTei.deleted())) { searchTei.setOnline(true); } @@ -760,14 +790,14 @@ public SearchTeiModel transform(TrackedEntitySearchItem searchItem, @Nullable Pr setRelationshipsInfo(searchTei, selectedProgram); } if (searchTei.getSelectedEnrollment() != null) { - searchTei.setEnrolledOrgUnit(d2.organisationUnitModule().organisationUnits().uid(searchTei.getSelectedEnrollment().organisationUnit()).blockingGet().name()); + searchTei.setEnrolledOrgUnit(orgUnitName(searchTei.getSelectedEnrollment().organisationUnit())); } else { - searchTei.setEnrolledOrgUnit(d2.organisationUnitModule().organisationUnits().uid(searchTei.getTei().organisationUnit()).blockingGet().name()); + searchTei.setEnrolledOrgUnit(orgUnitName(searchTei.getTei().organisationUnit())); } searchTei.setProfilePicture(profilePicturePath(dbTei, selectedProgram)); } else { searchTei.setTei(teiFromItem); - searchTei.setEnrolledOrgUnit(d2.organisationUnitModule().organisationUnits().uid(searchTei.getTei().organisationUnit()).blockingGet().name()); + searchTei.setEnrolledOrgUnit(orgUnitName(searchTei.getTei().organisationUnit())); for (TrackedEntitySearchItemAttribute attribute : searchItem.getAttributeValues()) { if (attribute.getDisplayInList()) { @@ -882,9 +912,13 @@ public boolean eventsHaveCoordinates(String programUid) { @Nullable @Override public Program getProgram(@Nullable String programUid) { - if (programUid == null) - return null; - return d2.programModule().programs().uid(programUid).blockingGet(); + if (programUid == null) return null; + + if (!programCache.containsKey(programUid)) { + Program program = d2.programModule().programs().uid(programUid).blockingGet(); + programCache.put(program.uid(), program); + } + return programCache.get(programUid); } @Override @@ -929,8 +963,8 @@ public boolean canCreateInProgramWithoutSearch() { } private boolean displayOrgUnit() { - return d2.organisationUnitModule().organisationUnits() - .byProgramUids(Collections.singletonList(currentProgram)) - .blockingGet().size() > 1; + return d2.organisationUnitModule().organisationUnits() + .byProgramUids(Collections.singletonList(currentProgram)) + .blockingGet().size() > 1; } } diff --git a/commons/src/main/java/org/dhis2/commons/data/SearchTeiModel.java b/commons/src/main/java/org/dhis2/commons/data/SearchTeiModel.java index 96c4990542..7c96df5d79 100644 --- a/commons/src/main/java/org/dhis2/commons/data/SearchTeiModel.java +++ b/commons/src/main/java/org/dhis2/commons/data/SearchTeiModel.java @@ -111,11 +111,6 @@ public void resetEnrollments() { this.enrollmentsInfo.clear(); } - public List> getEnrollmentInfo() { - Collections.sort(enrollmentsInfo, (enrollment1, enrollment2) -> enrollment1.val0().compareToIgnoreCase(enrollment2.val0())); - return enrollmentsInfo; - } - public void setAttributeValues(LinkedHashMap attributeValues) { this.attributeValues = attributeValues; } From 5fc8fb9834deea84596d27369cd3ddee9749cdbe Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 15 Feb 2024 15:42:34 +0100 Subject: [PATCH 2/3] [ANDROAPP-5857] image download implementation (#3498) * download image files to external directory Signed-off-by: Pablo * download qr/barcode files to external directory Signed-off-by: Pablo * show message when file is downloaded Signed-off-by: Pablo --------- Signed-off-by: Pablo --- .../org/dhis2/commons/bindings/Permissions.kt | 11 ++++ .../org/dhis2/commons/data/FileHandler.kt | 52 +++++++++++++++++++ .../imagedetail/ImageDetailActivity.kt | 34 ++++++++---- commons/src/main/res/values/strings.xml | 2 + .../main/java/org/dhis2/form/ui/FormView.kt | 27 +++++----- .../form/ui/dialog/QRDetailBottomDialog.kt | 38 +++++++++----- 6 files changed, 131 insertions(+), 33 deletions(-) create mode 100644 commons/src/main/java/org/dhis2/commons/bindings/Permissions.kt create mode 100644 commons/src/main/java/org/dhis2/commons/data/FileHandler.kt diff --git a/commons/src/main/java/org/dhis2/commons/bindings/Permissions.kt b/commons/src/main/java/org/dhis2/commons/bindings/Permissions.kt new file mode 100644 index 0000000000..a8619662c1 --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/bindings/Permissions.kt @@ -0,0 +1,11 @@ +package org.dhis2.commons.bindings + +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat + +fun Context.hasPermissions(permissions: Array): Boolean { + return permissions.all { permission -> + ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED + } +} diff --git a/commons/src/main/java/org/dhis2/commons/data/FileHandler.kt b/commons/src/main/java/org/dhis2/commons/data/FileHandler.kt new file mode 100644 index 0000000000..4be787d393 --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/data/FileHandler.kt @@ -0,0 +1,52 @@ +package org.dhis2.commons.data + +import android.graphics.Bitmap +import android.os.Environment +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import java.io.File +import java.io.FileOutputStream + +class FileHandler { + + private val destinationResult = MutableLiveData() + + fun saveBitmapAndOpen( + bitmap: Bitmap, + outputFileName: String, + fileCallback: (LiveData) -> Unit, + ) { + fileCallback(destinationResult) + + val imagesFolder = getDownloadDirectory(outputFileName) + destinationResult.value = saveBitmapAndOpen(bitmap, imagesFolder) + } + + fun copyAndOpen( + sourceFile: File, + fileCallback: (LiveData) -> Unit, + ) { + fileCallback(destinationResult) + + val imagesFolder = getDownloadDirectory(sourceFile.name) + destinationResult.value = copyFile(sourceFile, imagesFolder) + } + + private fun saveBitmapAndOpen(bitmap: Bitmap, destinationFolder: File): File { + val os = FileOutputStream(destinationFolder) + bitmap.compress(Bitmap.CompressFormat.PNG, 100, os) + os.close() + return destinationFolder + } + + private fun copyFile(sourceFile: File, destinationDirectory: File): File { + return sourceFile.copyTo(destinationDirectory, true) + } + + private fun getDownloadDirectory(outputFileName: String) = File( + Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS, + ), + "dhis2" + File.separator + outputFileName, + ) +} diff --git a/commons/src/main/java/org/dhis2/commons/dialogs/imagedetail/ImageDetailActivity.kt b/commons/src/main/java/org/dhis2/commons/dialogs/imagedetail/ImageDetailActivity.kt index 5d0a0e9594..f624a2bc5e 100644 --- a/commons/src/main/java/org/dhis2/commons/dialogs/imagedetail/ImageDetailActivity.kt +++ b/commons/src/main/java/org/dhis2/commons/dialogs/imagedetail/ImageDetailActivity.kt @@ -3,6 +3,7 @@ package org.dhis2.commons.dialogs.imagedetail import android.content.Context import android.content.Intent import android.os.Bundle +import android.widget.Toast import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.remember @@ -10,6 +11,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.core.content.FileProvider import org.dhis2.commons.R +import org.dhis2.commons.data.FileHandler import org.dhis2.commons.data.FormFileProvider import org.dhis2.commons.extensions.getBitmap import org.hisp.dhis.mobile.ui.designsystem.component.FullScreenImage @@ -32,6 +34,8 @@ class ImageDetailActivity : AppCompatActivity() { } } + private val fileHandler = FileHandler() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val title = intent.getStringExtra(ARG_IMAGE_TITLE) @@ -46,14 +50,26 @@ class ImageDetailActivity : AppCompatActivity() { painter = painter!!, title = title.orEmpty(), onDismiss = { finish() }, - onDownloadButtonClick = { }, + onDownloadButtonClick = { + fileHandler.copyAndOpen( + File(imagePath), + ) { file -> + file.observe(this) { + Toast.makeText( + this, + getString(R.string.file_downladed), + Toast.LENGTH_SHORT, + ).show() + } + } + }, onShareButtonClick = { shareImage(imagePath) }, ) } } private fun shareImage(image: String) { - val intent = Intent(Intent.ACTION_SEND).apply { + with(Intent(Intent.ACTION_SEND)) { val contentUri = FileProvider.getUriForFile( this@ImageDetailActivity, FormFileProvider.fileProviderAuthority, @@ -62,14 +78,14 @@ class ImageDetailActivity : AppCompatActivity() { setDataAndType(contentUri, contentResolver.getType(contentUri)) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) putExtra(Intent.EXTRA_STREAM, contentUri) - } - val title = resources.getString(R.string.open_with) - val chooser = Intent.createChooser(intent, title) - try { - startActivity(chooser) - } catch (e: IOException) { - Timber.e(e) + val title = resources.getString(R.string.open_with) + val chooser = Intent.createChooser(intent, title) + try { + startActivity(chooser) + } catch (e: IOException) { + Timber.e(e) + } } } } diff --git a/commons/src/main/res/values/strings.xml b/commons/src/main/res/values/strings.xml index 4aaa25beca..1b21f6fdfe 100644 --- a/commons/src/main/res/values/strings.xml +++ b/commons/src/main/res/values/strings.xml @@ -236,6 +236,8 @@ Open with + File downladed successfully + diff --git a/form/src/main/java/org/dhis2/form/ui/FormView.kt b/form/src/main/java/org/dhis2/form/ui/FormView.kt index c63c8a9799..dbb727030c 100644 --- a/form/src/main/java/org/dhis2/form/ui/FormView.kt +++ b/form/src/main/java/org/dhis2/form/ui/FormView.kt @@ -50,6 +50,7 @@ import org.dhis2.commons.Constants import org.dhis2.commons.bindings.getFileFrom import org.dhis2.commons.bindings.getFileFromGallery import org.dhis2.commons.bindings.rotateImage +import org.dhis2.commons.data.FileHandler import org.dhis2.commons.data.FormFileProvider import org.dhis2.commons.dialogs.AlertBottomDialog import org.dhis2.commons.dialogs.CustomDialog @@ -324,6 +325,8 @@ class FormView : Fragment() { Manifest.permission.READ_MEDIA_VIDEO, ) + private val fileHandler = FileHandler() + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -1004,18 +1007,18 @@ class FormView : Fragment() { } private fun openFile(event: RecyclerViewUiEvents.OpenFile) { - event.field.displayName?.let { filePath -> - val file = File(filePath) - val fileUri = FileProvider.getUriForFile( - requireContext(), - FormFileProvider.fileProviderAuthority, - file, - ) - startActivity( - Intent(Intent.ACTION_VIEW) - .setDataAndType(fileUri, "*/*") - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION), - ) + activity?.activityResultRegistry?.let { + event.field.displayName?.let { filePath -> + fileHandler.copyAndOpen(File(filePath)) { file -> + file.observe(viewLifecycleOwner) { + Toast.makeText( + requireContext(), + getString(R.string.file_downladed), + Toast.LENGTH_SHORT, + ).show() + } + } + } } } diff --git a/form/src/main/java/org/dhis2/form/ui/dialog/QRDetailBottomDialog.kt b/form/src/main/java/org/dhis2/form/ui/dialog/QRDetailBottomDialog.kt index ba11ce614c..5095aaa05b 100644 --- a/form/src/main/java/org/dhis2/form/ui/dialog/QRDetailBottomDialog.kt +++ b/form/src/main/java/org/dhis2/form/ui/dialog/QRDetailBottomDialog.kt @@ -8,6 +8,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.material.Icon @@ -27,6 +28,7 @@ import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.FileProvider import androidx.fragment.app.viewModels import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.dhis2.commons.data.FileHandler import org.dhis2.commons.data.FormFileProvider import org.dhis2.commons.resources.ColorType import org.dhis2.commons.resources.ColorUtils @@ -67,6 +69,8 @@ QRDetailBottomDialog( } private var showBottomSheet: Boolean = true + private val fileHandler = FileHandler() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setStyle(STYLE_NORMAL, R.style.CustomBottomSheetDialogTheme) @@ -127,10 +131,12 @@ QRDetailBottomDialog( Row(horizontalArrangement = Arrangement.Center) { when (renderingType) { UiRenderType.QR_CODE, UiRenderType.GS1_DATAMATRIX -> { - val isGS1Matrix = value.startsWith(GS1Elements.GS1_d2_IDENTIFIER.element) + val isGS1Matrix = + value.startsWith(GS1Elements.GS1_d2_IDENTIFIER.element) val content = formattedContent(value) QrCodeBlock(data = content, isDataMatrix = isGS1Matrix) } + else -> { BarcodeBlock(data = value) } @@ -185,7 +191,12 @@ QRDetailBottomDialog( addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) setDataAndType(uri, context?.contentResolver?.getType(uri)) putExtra(Intent.EXTRA_STREAM, uri) - startActivity(Intent.createChooser(this, context?.getString(R.string.share))) + startActivity( + Intent.createChooser( + this, + context?.getString(R.string.share), + ), + ) } } }, @@ -202,16 +213,19 @@ QRDetailBottomDialog( enabled = true, text = resources.getString(R.string.download), onClick = { - qrContentUri?.let { uri -> - startActivity( - Intent().apply { - action = Intent.ACTION_VIEW - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - setDataAndType(uri, context?.contentResolver?.getType(uri)) - putExtra(Intent.EXTRA_STREAM, uri) - }, - ) - // implement download action here + viewModel.qrBitmap.value?.onSuccess { bitmap -> + fileHandler.saveBitmapAndOpen( + bitmap, + "$label.png", + ) { file -> + file.observe(viewLifecycleOwner) { + Toast.makeText( + requireContext(), + getString(R.string.file_downladed), + Toast.LENGTH_SHORT, + ).show() + } + } } }, ), 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 3/3] 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,