diff --git a/Jenkinsfile b/Jenkinsfile index 11bc1f51bf..d5c9d40f61 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -6,6 +6,7 @@ pipeline { options { buildDiscarder(logRotator(daysToKeepStr: '5')) timeout(time: 50) + disableConcurrentBuilds(abortPrevious: true) } stages { diff --git a/RELEASE.md b/RELEASE.md index 02e37d5735..2bd2f8fe94 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,56 +1,44 @@ -Android Capture App for DHIS 2 (v2.8.2) - Patch version +# DHIS2 Android App version 2.9 Release Notes
-This is a patch version of the DHIS2 Android App It builds upon the last version including bug fixes that couldn't wait to the next version. -It includes no functional improvements neither changes in the User Interface. It means that yours users can update without experiencing any change in the UI. +The new DHIS2 Android App allows offline data capture across all DHIS2 data models. Data and metadata are automatically synchronized whenever there is internet access, always keeping the most relevant data for the logged user in the device. +The app is compatible and we support 2.38, 2.39, 40. And has no breaking changes with 2.37, 2.36, 2.35 and 2.34.
+## USER EXPERIENCE -## Bugs fixed -* [ANDROAPP-5463](https://dhis2.atlassian.net/browse/ANDROAPP-5463) Wrong password in already authenticated account throws invalid url -* [ANDROAPP-5452](https://dhis2.atlassian.net/browse/ANDROAPP-5452) Notifications not displaying on devices with android 13 -* [ANDROAPP-5426](https://dhis2.atlassian.net/browse/ANDROAPP-5426) DataSet tables not saving text values -* [ANDROAPP-5425](https://dhis2.atlassian.net/browse/ANDROAPP-5425) App crashing when opening orgUnit field in event creation on android 5 and 6 devices -* [ANDROAPP-5424](https://dhis2.atlassian.net/browse/ANDROAPP-5424) App not functional in Android 5.0 and 6.0 devices due to expression-parser library -* [ANDROAPP-5403](https://dhis2.atlassian.net/browse/ANDROAPP-5403) The app displays the keyboard over the selecting menu when the user has multiple apps to use the email or phone number -* [ANDROAPP-5401](https://dhis2.atlassian.net/browse/ANDROAPP-5401) Infinite loading when applying date filters in tracker program -* [ANDROAPP-5399](https://dhis2.atlassian.net/browse/ANDROAPP-5399) Improve recomposition on input field in tables -* [ANDROAPP-5396](https://dhis2.atlassian.net/browse/ANDROAPP-5396) SDK BC: handle d2ErrorCode SERVER_CONNECTION_ERROR -* [ANDROAPP-5389](https://dhis2.atlassian.net/browse/ANDROAPP-5389) Validation Strategy - Errors -* [ANDROAPP-5385](https://dhis2.atlassian.net/browse/ANDROAPP-5385) Program dashboard: filter by EventDate includes all EventStatus as side effect -* [ANDROAPP-5384](https://dhis2.atlassian.net/browse/ANDROAPP-5384) Persist dataset column size when adjusted by the user and revert to default -* [ANDROAPP-5380](https://dhis2.atlassian.net/browse/ANDROAPP-5380) Sync button crashes app after rotating device in search screen -* [ANDROAPP-5377](https://dhis2.atlassian.net/browse/ANDROAPP-5377) A TEI enrolled in many programs display to many icons blocking the TEI info card -* [ANDROAPP-5376](https://dhis2.atlassian.net/browse/ANDROAPP-5376) Login button doesn't work and doesn't present any error -* [ANDROAPP-5375](https://dhis2.atlassian.net/browse/ANDROAPP-5375) App crashes with some icons -* [ANDROAPP-5370](https://dhis2.atlassian.net/browse/ANDROAPP-5370) Navigation button does not open in some programs. -* [ANDROAPP-5369](https://dhis2.atlassian.net/browse/ANDROAPP-5369) NullPointerException: Attempt to invoke virtual method 'java.lang.String java.lang.String.replaceAll(java.lang.String, ... -* [ANDROAPP-5368](https://dhis2.atlassian.net/browse/ANDROAPP-5368) Org Unit value type opens the hierarchy incorrectly -* [ANDROAPP-5363](https://dhis2.atlassian.net/browse/ANDROAPP-5363) Wrong label displayed while navigating an error or warning -* [ANDROAPP-5348](https://dhis2.atlassian.net/browse/ANDROAPP-5348) Errors in program rules are not shown after they have been displayed once despite the program rule being reexecuted -* [ANDROAPP-5343](https://dhis2.atlassian.net/browse/ANDROAPP-5343) Sync flow backwards -* [ANDROAPP-5342](https://dhis2.atlassian.net/browse/ANDROAPP-5342) Form actionable icons launch action from stored value -* [ANDROAPP-5340](https://dhis2.atlassian.net/browse/ANDROAPP-5340) Store image and files before value type validation -* [ANDROAPP-5335](https://dhis2.atlassian.net/browse/ANDROAPP-5335) In TEI dashboard filters appears items related to TEI -* [ANDROAPP-5334](https://dhis2.atlassian.net/browse/ANDROAPP-5334) "All enrollments" cards show incident date even when not configured -* [ANDROAPP-5330](https://dhis2.atlassian.net/browse/ANDROAPP-5330) App crash when deleting quantities in "Review" stage -* [ANDROAPP-5329](https://dhis2.atlassian.net/browse/ANDROAPP-5329) The selected cell is hidden in RTStock program (and datasets) table if the first cell is selected after scroll -* [ANDROAPP-5328](https://dhis2.atlassian.net/browse/ANDROAPP-5328) Default language not respected (or inconsistent) when changing between servers. -* [ANDROAPP-5323](https://dhis2.atlassian.net/browse/ANDROAPP-5323) IllegalStateException: Attempting to launch an unregistered ActivityResultLauncher with contract androidx.activity.resul... -* [ANDROAPP-5255](https://dhis2.atlassian.net/browse/ANDROAPP-5255) [LANDSCAPE] Loading bar never hides in overview screen -* [ANDROAPP-5253](https://dhis2.atlassian.net/browse/ANDROAPP-5253) Event status filter doesn't remove checkmarks after the reset -* [ANDROAPP-4710](https://dhis2.atlassian.net/browse/ANDROAPP-4710) Validation Strategy - Mandatory Fields -* [ANDROAPP-4322](https://dhis2.atlassian.net/browse/ANDROAPP-4322) Analytics legends don't show event's exact date -* [ANDROAPP-3106](https://dhis2.atlassian.net/browse/ANDROAPP-3106) [Bug]Error when searching with comma char in the values -* This patch release updates the [Android SDK](https://github.com/dhis2/dhis2-android-sdk) to version 1.8.2. - -You can find in Jira details on the [bugs fixed](https://dhis2.atlassian.net/issues/?filter=10461) in this version. +**Disable referral in tracker programs:** When users add events in a tracker program, the DHIS2 Android Capture app offers three options: Add (for new events), Schedule (for planning future evetns) and Refer (for referrals or transfers). As this third option is not used in many implementations, this new feature enables the admin user to remove that option from the menu to simplify the user experience. The referral option can be hidden using the Android Settings Web App for all programs or for each specific program. [Jira](https://dhis2.atlassian.net/browse/ANDROAPP-4445) | [Documentation App](https://docs.dhis2.org/en/use/android-app/program-features.html#capture_app_programs_referrals) | [Documentation Webapp](https://docs.dhis2.org/en/use/android-app/settings-configuration.html#capture_app_android_settings_webapp_appearance_program) | [Screenshot](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/dhis2-android/release+notes+2.9/release+feature+cards/Android-2-9-Disable-referrals.png) + +**Skip home screen if users only have access to one program:** The home screen of the DHIS2 Android App shows the list of programs and datasets available for the user. The first thing a user must do when using the app is to select the program or dataset to work with. In some implementations, users have access to only one program or dataset. To reduce the number of clicks and streamline the process of data entry, the App will now skip the home screen in the cases where the user has access to only one program or dataset, and will instead open directly to the program or dataset screen with the event, TEI or dataset list. [Jira](https://dhis2.atlassian.net/browse/ANDROAPP-5148) | [Documentation](https://docs.dhis2.org/en/use/android-app/android-specific-features.html#capture_app_home) | [Screenshot](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/dhis2-android/release+notes+2.9/release+feature+cards/Android-2-9-Skip-home-screen.png) + +**Display program stage description:** The description for program stage sections was not available to the end user in previous versions of the App. To provide more context and information at the moment of data collection, the description has now been brought to the user interface and will be displayed below the section name. [Jira](https://dhis2.atlassian.net/browse/ANDROAPP-5151) | [Documentation](https://docs.dhis2.org/en/use/android-app/program-features.html#capture_app_programs_common_features_data_entry_form_program_stage_description) | [Screenshot](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/dhis2-android/release+notes+2.9/release+feature+cards/Android-2-9-Program-stage-description.png) + +**Disable collapsible sections in forms:** Stage sections in the Android App are displayed with collapsible menus that enable the user to open one section at a time. The purpose of this accordion-like implementation is to help the user navigate very long forms. However, some implementations would prefer to list the sections one after the other. This new version of the application enables the admin user to decide if the sections should appear in extended mode. This configuration is made through the Android Settings Web App and will display the sections one after the other with the section name acting as a separator. [Jira](https://dhis2.atlassian.net/browse/ANDROAPP-5393) | [Documentation App](https://docs.dhis2.org/en/use/android-app/program-features.html#capture_app_programs_common_features_data_entry_form_collapsible_sections) | [Documentation Webapp](https://docs.dhis2.org/en/use/android-app/settings-configuration.html#capture_app_android_settings_webapp_appearance_program) | [Screenshot](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/dhis2-android/release+notes+2.9/release+feature+cards/Android-2-9-Not-collapse-sections.png) + +**Move working lists under the search bar:** The working lists have been moved from the filters section to the main program screen. In earlier versions, the user had to open the filters to be able to see and select a working list. From this version, the working lists are always visible under the search bar, facilitating their use for filtering out Tracked Entity Instances. [Jira](https://dhis2.atlassian.net/browse/ANDROAPP-5453) | [Documentation](https://docs.dhis2.org/en/use/android-app/program-features.html#capture_app_programs_common_features_working_lists) | [Screenshot](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/dhis2-android/release+notes+2.9/release+feature+cards/Android-2-9-Working-list-under-search-bar.png) + +**New design for Dataset, Event and TEI cards:** Cards are used for listing datasets, events and TEIs. The new design offers a cleaner and more intuitive layout, replacing the use of colored icons by descriptive text when relevant. [Jira](https://dhis2.atlassian.net/browse/ANDROAPP-5485) | [Documentation datasets](https://docs.dhis2.org/en/use/android-app/datasets-features.html#capture_app_datsets_cards_design) | [Documentation events](https://docs.dhis2.org/en/use/android-app/program-features.html#capture_app_programs_common_features_cards_design) | [Documentation TEI](https://docs.dhis2.org/en/use/android-app/program-features.html#capture_app_programs_tei_design) | [Screenshot](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/dhis2-android/release+notes+2.9/release+feature+cards/Android-2-9-New-cards-design.png) + +**Implement changes in TEI Dashboard details:** The TEI Dashboard has been redesigned for both portrait and landscape view. The new design offers a cleaner and more intuitive layout, replacing the use of colored icons by text when relevant and moving some secondary actions to the hidden menus. [Jira](https://dhis2.atlassian.net/browse/ANDROAPP-4019) | [Screenshot](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/dhis2-android/release+notes+2.9/release+feature+cards/Android-2-9-TEI-dashboard.png) | [Documentation](https://docs.dhis2.org/en/use/android-app/program-features.html#capture_app_programs_tei_design) + +**Data entry forms - New inputs per value type:** The inputs for all value types have been redesigned. Tappable areas and texts have been increased and selection modes are improved to offer a cleaner and more intuitive user experience. By default, the Android App will display the previous forms. Admin users are able to opt-in to use the new forms through the Android Settings Web App. [Jira](https://dhis2.atlassian.net/browse/ANDROAPP-5408) | [Documentation App](https://docs.dhis2.org/en/use/android-app/program-features.html#capture_app_programs_common_features_data_entry_form_new_inputs) | [Documentation Webapp](https://docs.dhis2.org/en/use/android-app/settings-configuration.html#capture_app_android_settings_webapp_appearance_program) | [Screenshot](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/dhis2-android/release+notes+2.9/release+feature+cards/Android-2-9-New-inputs.png) + +**[EXPERIMENTAL] TEI Header:** The TEI Header is a title that can be added to the TEI cards and dashboards in the app. The title helps identify a TEI by displaying a summary of key information. It is formed by a concatenation of Tracked Entity Attributes and fixed text. The title is configured through a Program Indicator in the Maintenance app and is assigned to the tracker program in the Android Settings web app. This feature is experimental, and depending on feedback and adoption it will be refined and incorporated in the web Capture app. [Jira](https://dhis2.atlassian.net/browse/ANDROAPP-5402) | [Documentation App](https://docs.dhis2.org/en/use/android-app/program-features.html#capture_app_programs_tei_header) | [Documentation Webapp](https://docs.dhis2.org/en/use/android-app/settings-configuration.html#capture_app_android_settings_webapp_appearance_program_specific) | [Screenshot](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/dhis2-android/release+notes+2.9/release+feature+cards/Android-2-9-TEI-Header.png) + +**Other improvements for User Experience** +- Smaller improvements focussing on user experience like a new org unit selector [Jira](https://dhis2.atlassian.net/browse/ANDROAPP-4566) | | [Screenshot](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/dhis2-android/release+notes+2.9/release+feature+cards/Android-2-9-Org-unit-selector.png), or adding a loading spinner during the deletion of big databases. [Jira](https://dhis2.atlassian.net/browse/ANDROAPP-4768) | [Screenshot](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/dhis2-android/release+notes+2.9/release+feature+cards/Android-2-9-loading-when-deleting-data.png) + +## MAINTENANCE + +**Bug fixing:** You can find the list of bugs fixed [here](https://dhis2.atlassian.net/issues/?filter=10510). + +You can find in Jira details on the [new features](https://dhis2.atlassian.net/issues/?filter=10513) in this version. Remember to check the [documentation](https://www.dhis2.org/android-documentation) for detailed information of the features included in the App and how to configure DHIS2 to use it. diff --git a/app/src/androidTest/java/org/dhis2/usescases/BaseTest.kt b/app/src/androidTest/java/org/dhis2/usescases/BaseTest.kt index fc2f2bb63c..f1d8e26584 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/BaseTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/BaseTest.kt @@ -2,11 +2,8 @@ package org.dhis2.usescases import android.content.Context import android.os.Build -import androidx.test.espresso.Espresso import androidx.test.espresso.IdlingRegistry -import androidx.test.espresso.action.ViewActions import androidx.test.espresso.intent.Intents -import androidx.test.espresso.matcher.ViewMatchers import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import org.dhis2.AppTest @@ -21,6 +18,7 @@ import org.dhis2.common.keystore.KeyStoreRobot.Companion.USERNAME import org.dhis2.common.mockwebserver.MockWebServerRobot import org.dhis2.common.preferences.PreferencesRobot import org.dhis2.common.rules.DisableAnimations +import org.dhis2.commons.featureconfig.model.Feature import org.dhis2.commons.idlingresource.CountingIdlingResourceSingleton import org.dhis2.commons.idlingresource.SearchIdlingResourceSingleton import org.dhis2.commons.prefs.Preference @@ -74,6 +72,7 @@ open class BaseTest { keyStoreRobot = providesKeyStoreRobot(context) preferencesRobot = providesPreferencesRobot(context) mockWebServerRobot = providesMockWebserverRobot(context) + disableComposeForms() } } @@ -160,6 +159,10 @@ open class BaseTest { (context.applicationContext as AppTest).deleteDatabase(DB_TO_IMPORT) } + private fun disableComposeForms() { + preferencesRobot.saveValue(Feature.COMPOSE_FORMS.name, false) + } + companion object { @ClassRule @JvmField diff --git a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetInitialRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetInitialRobot.kt index fa4cd4e771..17bd65dd4d 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetInitialRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetInitialRobot.kt @@ -1,18 +1,14 @@ package org.dhis2.usescases.datasets -import androidx.compose.ui.test.junit4.ComposeTestRule -import androidx.compose.ui.test.onNodeWithTag import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.matcher.ViewMatchers.hasDescendant -import androidx.test.espresso.matcher.ViewMatchers.hasSibling import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import org.dhis2.R import org.dhis2.common.BaseRobot import org.dhis2.utils.customviews.DateViewHolder -import org.hamcrest.CoreMatchers.allOf fun dataSetInitialRobot(dataSetInitialRobot: DataSetInitialRobot.() -> Unit) { diff --git a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTest.kt b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTest.kt index f0ed9ee295..659b9d2bd8 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTest.kt @@ -48,7 +48,7 @@ class DataSetTest : BaseTest() { @Test fun shouldCreateNewDataSet() { - val period = "Dec 2022" + val period = "Oct 2023" val orgUnit = "Ngelehun CHC" startDataSetDetailActivity("ZOV1a5R4gqH", "DS EXTRA TEST", ruleDataSetDetail) @@ -57,7 +57,7 @@ class DataSetTest : BaseTest() { } dataSetInitialRobot { clickOnInputOrgUnit() - orgUnitSelectorRobot(composeTestRule){ + orgUnitSelectorRobot(composeTestRule) { selectTreeOrgUnit(orgUnit) } clickOnInputPeriod() diff --git a/app/src/androidTest/java/org/dhis2/usescases/event/EventRegistrationRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/event/EventRegistrationRobot.kt index 5df4772583..56cb29fa1d 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/event/EventRegistrationRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/event/EventRegistrationRobot.kt @@ -1,10 +1,11 @@ package org.dhis2.usescases.event +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithText import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.hasDescendant -import androidx.test.espresso.matcher.ViewMatchers.isEnabled import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withSubstring import androidx.test.espresso.matcher.ViewMatchers.withText @@ -13,7 +14,6 @@ import org.dhis2.common.BaseRobot import org.dhis2.common.matchers.hasCompletedPercentage import org.dhis2.usescases.event.entity.EventDetailsUIModel import org.hamcrest.CoreMatchers.allOf -import org.hamcrest.CoreMatchers.not fun eventRegistrationRobot(eventRegistrationRobot: EventRegistrationRobot.() -> Unit) { EventRegistrationRobot().apply { @@ -48,25 +48,12 @@ class EventRegistrationRobot : BaseRobot() { onView(withId(R.id.navigation_details)).perform(click()) } - fun checkEventDetails(eventDetails: EventDetailsUIModel) { + fun checkEventDetails(eventDetails: EventDetailsUIModel, composeTestRule: ComposeTestRule) { onView(withId(R.id.detailsStageName)).check(matches(withText(eventDetails.programStage))) onView(withId(R.id.completion)).check(matches(hasCompletedPercentage(eventDetails.completedPercentage))) - onView(withId(R.id.date_layout)).check( - matches( - allOf( - isEnabled(), - hasDescendant(allOf(withId(R.id.date), withText(eventDetails.eventDate))) - ) - ) - ) - onView(withId(R.id.org_unit_layout)).check( - matches( - allOf( - not(isEnabled()), - hasDescendant(allOf(withId(R.id.org_unit), withText(eventDetails.orgUnit))) - ) - ) - ) + + composeTestRule.onNodeWithText(formatStoredDateToUI(eventDetails.eventDate)).assertIsDisplayed() + composeTestRule.onNodeWithText(eventDetails.orgUnit).assertIsDisplayed() } fun clickOnShare() { @@ -102,4 +89,22 @@ class EventRegistrationRobot : BaseRobot() { fun clickNextButton() { waitForView(withId(R.id.action_button)).perform(click()) } + + private fun formatStoredDateToUI(dateValue: String): String { + val components = dateValue.split("/") + + val year = components[2] + val month = if (components[1].length == 1) { + "0${components[1]}" + } else { + components[1] + } + val day = if (components[0].length == 1) { + "0${components[0]}" + } else { + components[0] + } + + return "$day/$month/$year" + } } \ No newline at end of file diff --git a/app/src/androidTest/java/org/dhis2/usescases/event/EventTest.kt b/app/src/androidTest/java/org/dhis2/usescases/event/EventTest.kt index 67b8161f46..02c8bc8ba3 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/event/EventTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/event/EventTest.kt @@ -11,7 +11,6 @@ import org.dhis2.usescases.event.entity.ProgramStageUIModel import org.dhis2.usescases.event.entity.TEIProgramStagesUIModel import org.dhis2.usescases.eventsWithoutRegistration.eventCapture.EventCaptureActivity import org.dhis2.usescases.eventsWithoutRegistration.eventInitial.EventInitialActivity -import org.dhis2.usescases.form.formRobot import org.dhis2.usescases.programEventDetail.ProgramEventDetailActivity import org.dhis2.usescases.programEventDetail.eventList.EventListFragment import org.dhis2.usescases.programevent.robot.programEventsRobot @@ -74,7 +73,7 @@ class EventTest: BaseTest() { eventRegistrationRobot { checkEventFormDetails(eventDetails) clickOnDetails() - checkEventDetails(eventDetails) + checkEventDetails(eventDetails, composeTestRule) } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/flow/teiFlow/TeiFlowRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/flow/teiFlow/TeiFlowRobot.kt index b49f96b27c..0f0ef7320a 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/flow/teiFlow/TeiFlowRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/flow/teiFlow/TeiFlowRobot.kt @@ -93,14 +93,14 @@ class TeiFlowRobot : BaseRobot() { } } - fun changeDueDate(date: DateRegistrationUIModel, programStage: String, orgUnit: String) { + fun changeDueDate(date: DateRegistrationUIModel, programStage: String, orgUnit: String, composeTestRule: ComposeTestRule) { teiDashboardRobot { clickOnStageGroup(programStage) clickOnEventGroupByStageUsingOU(orgUnit) } eventRobot { - clickOnEventDueDate() + clickOnEventDueDate(composeTestRule) selectSpecificDate(date.year, date.month, date.day) acceptUpdateEventDate() } diff --git a/app/src/androidTest/java/org/dhis2/usescases/programevent/ProgramEventTest.kt b/app/src/androidTest/java/org/dhis2/usescases/programevent/ProgramEventTest.kt index 428944489a..6ec2122d91 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/programevent/ProgramEventTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/programevent/ProgramEventTest.kt @@ -11,7 +11,6 @@ import org.dhis2.usescases.programEventDetail.ProgramEventDetailActivity import org.dhis2.usescases.programEventDetail.eventList.EventListFragment import org.dhis2.usescases.programevent.robot.programEventsRobot import org.dhis2.usescases.teidashboard.robot.eventRobot -import org.junit.Ignore import org.junit.Rule import org.junit.Test @@ -30,7 +29,6 @@ class ProgramEventTest : BaseTest() { return arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) } - @Ignore("Nondeterministic") @Test fun shouldCreateNewEventAndCompleteIt() { val eventOrgUnit = "Ngelehun CHC" @@ -124,7 +122,7 @@ class ProgramEventTest : BaseTest() { } eventRobot { clickOnDetails() - checkEventDetails(eventDate, eventOrgUnit) + checkEventDetails(eventDate, eventOrgUnit, composeTestRule) } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/searchte/SearchTETest.kt b/app/src/androidTest/java/org/dhis2/usescases/searchte/SearchTETest.kt index 3f605485fb..b7cf9e5d5f 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/searchte/SearchTETest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/searchte/SearchTETest.kt @@ -1,6 +1,8 @@ package org.dhis2.usescases.searchte import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick import androidx.test.espresso.IdlingRegistry import androidx.test.espresso.IdlingResourceTimeoutException import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -9,12 +11,12 @@ import androidx.test.rule.ActivityTestRule import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until -import com.mapbox.mapboxsdk.maps.MapboxMap import dispatch.android.espresso.IdlingDispatcherProvider import dispatch.android.espresso.IdlingDispatcherProviderRule import org.dhis2.R import org.dhis2.bindings.app import org.dhis2.common.idlingresources.MapIdlingResource +import org.dhis2.ui.dialogs.bottomsheet.SECONDARY_BUTTON_TAG import org.dhis2.usescases.BaseTest import org.dhis2.usescases.flow.teiFlow.TeiFlowTest import org.dhis2.usescases.flow.teiFlow.entity.DateRegistrationUIModel @@ -40,7 +42,6 @@ class SearchTETest : BaseTest() { val rule = ActivityTestRule(SearchTEActivity::class.java, false, false) private var mapIdlingResource: MapIdlingResource? = null - private var map: MapboxMap? = null private val customDispatcherProvider = context.applicationContext.app().appComponent().customDispatcherProvider() @@ -58,7 +59,6 @@ class SearchTETest : BaseTest() { fun shouldSuccessfullySearchByName() { val firstName = "Tim" val firstNamePosition = 0 - val filterCount = "1" val orgUnit = "Ngelehun CHC" prepareChildProgrammeIntentAndLaunchActivity(rule) @@ -92,7 +92,6 @@ class SearchTETest : BaseTest() { val firstNamePosition = 0 val lastName = "Jones" val lastNamePosition = 1 - val filterCount = "2" prepareChildProgrammeIntentAndLaunchActivity(rule) @@ -125,7 +124,6 @@ class SearchTETest : BaseTest() { val displayInListData = createDisplayListFields() val namePosition = 0 val lastNamePosition = 1 - val filterCount = "3" setDatePicker() prepareTestAdultWomanProgrammeIntentAndLaunchActivity(rule) @@ -179,12 +177,13 @@ class SearchTETest : BaseTest() { teiFlowRobot { registerTEI(registerTeiDetails) - changeDueDate(overdueDate, programStage, orgUnit) + changeDueDate(overdueDate, programStage, orgUnit, composeTestRule) + pressBack() + composeTestRule.onNodeWithTag(SECONDARY_BUTTON_TAG).performClick() pressBack() } filterRobot { - waitToDebounce(5000) clickOnFilter() clickOnFilterBy(eventStatusFilter) clickOnFilterOverdueOption() diff --git a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/TeiDashboardTest.kt b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/TeiDashboardTest.kt index a5f8a0a7cc..90370f5491 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/TeiDashboardTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/TeiDashboardTest.kt @@ -16,7 +16,6 @@ import org.dhis2.usescases.teidashboard.robot.eventRobot import org.dhis2.usescases.teidashboard.robot.indicatorsRobot import org.dhis2.usescases.teidashboard.robot.noteRobot import org.dhis2.usescases.teidashboard.robot.teiDashboardRobot -import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -145,7 +144,10 @@ class TeiDashboardTest : BaseTest() { clickOnFab() clickOnReferral() clickOnFirstReferralEvent() - clickOnReferralOption() + clickOnReferralOption( + composeTestRule, + context.getString(R.string.one_time) + ) clickOnReferralNextButton() checkEventWasCreated(LAB_MONITORING) } diff --git a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EventRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EventRobot.kt index c6b882a480..625f39c53a 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EventRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EventRobot.kt @@ -1,15 +1,15 @@ package org.dhis2.usescases.teidashboard.robot +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.contrib.PickerActions import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition -import androidx.test.espresso.matcher.ViewMatchers.hasDescendant -import androidx.test.espresso.matcher.ViewMatchers.isEnabled import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withSubstring import androidx.test.espresso.matcher.ViewMatchers.withText @@ -25,7 +25,6 @@ import org.dhis2.ui.dialogs.bottomsheet.MAIN_BUTTON_TAG import org.dhis2.ui.dialogs.bottomsheet.SECONDARY_BUTTON_TAG import org.dhis2.usescases.teiDashboard.dashboardfragments.teidata.DashboardProgramViewHolder import org.hamcrest.CoreMatchers.allOf -import org.hamcrest.CoreMatchers.not fun eventRobot(eventRobot: EventRobot.() -> Unit) { EventRobot().apply { @@ -134,17 +133,37 @@ class EventRobot : BaseRobot() { onView(withId(R.id.possitive)).perform(click()) } - fun clickOnEventDueDate() { - onView(withId(R.id.due_date)).perform(click()) + fun clickOnEventDueDate(composeTestRule: ComposeTestRule) { + composeTestRule.onNodeWithTag("INPUT_DATE_TIME_TEXT").assertIsDisplayed() + composeTestRule.onNodeWithTag("INPUT_DATE_TIME_TEXT").performClick() + } fun selectSpecificDate(year: Int, monthOfYear: Int, dayOfMonth: Int) { onView(withId(R.id.datePicker)).perform(PickerActions.setDate(year, monthOfYear, dayOfMonth)) } - fun checkEventDetails(eventDate: String, eventOrgUnit: String) { + fun checkEventDetails(eventDate: String, eventOrgUnit: String, composeTestRule: ComposeTestRule) { onView(withId(R.id.completion)).check(matches(hasCompletedPercentage(100))) - onView(withId(R.id.date_layout)).check(matches(allOf(isEnabled(),hasDescendant(allOf(withId(R.id.date), withText(eventDate)))))) - onView(withId(R.id.org_unit_layout)).check(matches(allOf(not(isEnabled()), hasDescendant(allOf(withId(R.id.org_unit), withText(eventOrgUnit)))))) + composeTestRule.onNodeWithText(formatStoredDateToUI(eventDate)).assertIsDisplayed() + composeTestRule.onNodeWithText(eventOrgUnit).assertIsDisplayed() + } + + private fun formatStoredDateToUI(dateValue: String): String { + val components = dateValue.split("/") + + val year = components[2] + val month = if (components[1].length == 1) { + "0${components[1]}" + } else { + components[1] + } + val day = if (components[0].length == 1) { + "0${components[0]}" + } else { + components[0] + } + + return "$day/$month/$year" } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/TeiDashboardRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/TeiDashboardRobot.kt index ac270d0eed..3c0f598942 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/TeiDashboardRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/TeiDashboardRobot.kt @@ -2,10 +2,10 @@ package org.dhis2.usescases.teidashboard.robot import android.content.Context import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches @@ -151,14 +151,13 @@ class TeiDashboardRobot : BaseRobot() { .perform(actionOnItemAtPosition(0, click())) } - fun clickOnReferralOption() { - onView(withId(R.id.one_time)).perform(click()) + fun clickOnReferralOption(composeTestRule: ComposeTestRule, oneTime: String) { + composeTestRule.onNodeWithText(oneTime).performClick() } fun clickOnReferralNextButton() { waitForView(withId(R.id.action_button)).perform(click()) } - fun checkEventWasCreated(eventName: String) { onView(withId(R.id.tei_recycler)) diff --git a/app/src/dhisPlayServices/java/org/dhis2/usescases/main/program/ProgramAnimation.kt b/app/src/dhisPlayServices/java/org/dhis2/usescases/main/program/ProgramAnimation.kt index cc51bf6f12..fb722c430e 100644 --- a/app/src/dhisPlayServices/java/org/dhis2/usescases/main/program/ProgramAnimation.kt +++ b/app/src/dhisPlayServices/java/org/dhis2/usescases/main/program/ProgramAnimation.kt @@ -3,7 +3,7 @@ package org.dhis2.usescases.main.program import android.animation.ValueAnimator import android.graphics.drawable.GradientDrawable import android.view.animation.OvershootInterpolator -import org.dhis2.Bindings.dp +import org.dhis2.bindings.dp class ProgramAnimation { diff --git a/app/src/dhisPlayServices/java/org/dhis2/utils/granularsync/GranularSyncModule.kt b/app/src/dhisPlayServices/java/org/dhis2/utils/granularsync/GranularSyncModule.kt index 06688cac7a..6ed0cf8816 100644 --- a/app/src/dhisPlayServices/java/org/dhis2/utils/granularsync/GranularSyncModule.kt +++ b/app/src/dhisPlayServices/java/org/dhis2/utils/granularsync/GranularSyncModule.kt @@ -30,6 +30,7 @@ import dagger.Module import dagger.Provides import kotlinx.coroutines.Dispatchers import org.dhis2.commons.prefs.PreferenceProvider +import org.dhis2.commons.resources.ColorUtils import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.commons.sync.SyncContext diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8d8b9ebd04..786c96c1fd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,6 +20,7 @@ + + + eventDate()!! - EventStatus.COMPLETED -> eventDate()!! - EventStatus.SCHEDULE -> dueDate()!! - EventStatus.SKIPPED -> dueDate()!! - EventStatus.VISITED -> eventDate()!! - EventStatus.OVERDUE -> dueDate()!! - null -> Date() - } -} - -fun EventCollectionRepository.applyFilters( - periodFilters: MutableList, - orgUnitFilters: MutableList, - stateFilters: MutableList, - assignedUser: String?, - eventStatusFilters: MutableList, - catOptComboFilters: MutableList, -): EventCollectionRepository { - var eventRepo = this - - if (periodFilters.isNotEmpty()) { - eventRepo = eventRepo.byEventDate().inDatePeriods(periodFilters) - } - if (orgUnitFilters.isNotEmpty()) { - eventRepo = eventRepo.byOrganisationUnitUid().`in`(orgUnitFilters) - } - if (catOptComboFilters.isNotEmpty()) { - eventRepo = eventRepo.byAttributeOptionComboUid() - .`in`(UidsHelper.getUids(catOptComboFilters)) - } - if (eventStatusFilters.isNotEmpty()) { - eventRepo = eventRepo.byStatus().`in`(eventStatusFilters) - } - if (stateFilters.isNotEmpty()) { - eventRepo = eventRepo.byAggregatedSyncState().`in`(stateFilters) - } - if (assignedUser != null) { - eventRepo = eventRepo.byAssignedUser().eq(assignedUser) - } - - return eventRepo -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncDataWorker.java b/app/src/main/java/org/dhis2/data/service/SyncDataWorker.java index fb473a701e..d1c6b97306 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncDataWorker.java +++ b/app/src/main/java/org/dhis2/data/service/SyncDataWorker.java @@ -5,6 +5,7 @@ import android.app.NotificationChannel; import android.app.NotificationManager; import android.content.Context; +import android.content.pm.ServiceInfo; import android.os.Build; import androidx.annotation.NonNull; @@ -176,7 +177,11 @@ private void triggerNotification(String title, String content, int progress) { .setProgress(100, progress, false) .setPriority(NotificationCompat.PRIORITY_DEFAULT); - setForegroundAsync(new ForegroundInfo(SyncDataWorker.SYNC_DATA_ID, notificationBuilder.build())); + setForegroundAsync(new ForegroundInfo( + SyncDataWorker.SYNC_DATA_ID, + notificationBuilder.build(), + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC : 0 + )); } diff --git a/app/src/main/java/org/dhis2/data/service/SyncMetadataWorker.java b/app/src/main/java/org/dhis2/data/service/SyncMetadataWorker.java index 5677d602c7..b0b066eb22 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncMetadataWorker.java +++ b/app/src/main/java/org/dhis2/data/service/SyncMetadataWorker.java @@ -7,6 +7,7 @@ import android.app.NotificationChannel; import android.app.NotificationManager; import android.content.Context; +import android.content.pm.ServiceInfo; import android.os.Build; import androidx.annotation.NonNull; @@ -192,7 +193,11 @@ private void triggerNotification(String title, String content, int progress) { .setProgress(100, progress, false) .setPriority(NotificationCompat.PRIORITY_DEFAULT); - setForegroundAsync(new ForegroundInfo(SyncMetadataWorker.SYNC_METADATA_ID, notificationBuilder.build())); + setForegroundAsync(new ForegroundInfo( + SyncMetadataWorker.SYNC_METADATA_ID, + notificationBuilder.build(), + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC : 0 + )); } private void cancelNotification() { diff --git a/app/src/main/java/org/dhis2/data/service/SyncStatusController.kt b/app/src/main/java/org/dhis2/data/service/SyncStatusController.kt index c349446a43..712b04f075 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncStatusController.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncStatusController.kt @@ -8,7 +8,7 @@ import timber.log.Timber class SyncStatusController { private var progressStatusMap: Map = emptyMap() - private val downloadStatus = MutableLiveData(SyncStatusData(false)) + private val downloadStatus = MutableLiveData(SyncStatusData(isInitialSync = true)) fun observeDownloadProcess(): LiveData = downloadStatus @@ -98,4 +98,8 @@ class SyncStatusController { SyncStatusData(true, true, progressStatusMap), ) } + + fun restore() { + downloadStatus.postValue(SyncStatusData()) + } } diff --git a/app/src/main/java/org/dhis2/data/service/SyncStatusData.kt b/app/src/main/java/org/dhis2/data/service/SyncStatusData.kt index 9710fe34bb..245d514153 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncStatusData.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncStatusData.kt @@ -4,9 +4,10 @@ import org.hisp.dhis.android.core.arch.call.D2ProgressStatus import org.hisp.dhis.android.core.arch.call.D2ProgressSyncStatus data class SyncStatusData( - val running: Boolean, + val running: Boolean? = null, val downloadingMedia: Boolean = false, val programSyncStatusMap: Map = emptyMap(), + val isInitialSync: Boolean = false, ) { fun isProgramDownloading(uid: String): Boolean { diff --git a/app/src/main/java/org/dhis2/usescases/datasets/dataSetTable/dataSetSection/DataValuePresenter.kt b/app/src/main/java/org/dhis2/usescases/datasets/dataSetTable/dataSetSection/DataValuePresenter.kt index a0ed56a271..73c2066e5a 100644 --- a/app/src/main/java/org/dhis2/usescases/datasets/dataSetTable/dataSetSection/DataValuePresenter.kt +++ b/app/src/main/java/org/dhis2/usescases/datasets/dataSetTable/dataSetSection/DataValuePresenter.kt @@ -4,6 +4,7 @@ import androidx.annotation.VisibleForTesting import io.reactivex.Flowable import io.reactivex.disposables.CompositeDisposable import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -209,7 +210,10 @@ class DataValuePresenter( } fun onSaveValueChange(cell: TableCell) { - launch(dispatcherProvider.io()) { + launch( + dispatcherProvider.io(), + start = CoroutineStart.ATOMIC, + ) { saveValue(cell) view.onValueProcessed() } diff --git a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailModel.java b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailModel.java index df8433e4c4..d97501be74 100644 --- a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailModel.java +++ b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailModel.java @@ -12,6 +12,10 @@ @AutoValue public abstract class DataSetDetailModel { + + @NonNull + public abstract String datasetUid(); + @NonNull public abstract String orgUnitUid(); @@ -49,7 +53,7 @@ public abstract class DataSetDetailModel { public abstract String nameCategoryOptionCombo(); @NonNull - public static DataSetDetailModel create(@NonNull String orgUnitUid, @NonNull String catOptionComboUid, @NonNull String periodId, @NonNull String orgUnitName, String nameCatCombo, String namePeriod, State state, String periodType, Boolean displayOrgUnitName, Boolean isComplete, Date lastUpdated, String nameCategoryOptionCombo) { - return new AutoValue_DataSetDetailModel(orgUnitUid, catOptionComboUid, periodId, orgUnitName, nameCatCombo, namePeriod, state, periodType, displayOrgUnitName, isComplete, lastUpdated, nameCategoryOptionCombo); + public static DataSetDetailModel create(@NonNull String datasetUid, @NonNull String orgUnitUid, @NonNull String catOptionComboUid, @NonNull String periodId, @NonNull String orgUnitName, String nameCatCombo, String namePeriod, State state, String periodType, Boolean displayOrgUnitName, Boolean isComplete, Date lastUpdated, String nameCategoryOptionCombo) { + return new AutoValue_DataSetDetailModel(datasetUid, orgUnitUid, catOptionComboUid, periodId, orgUnitName, nameCatCombo, namePeriod, state, periodType, displayOrgUnitName, isComplete, lastUpdated, nameCategoryOptionCombo); } } diff --git a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailRepository.java b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailRepository.java index c6ce7e3add..ea56821bd7 100644 --- a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailRepository.java +++ b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailRepository.java @@ -19,4 +19,11 @@ public interface DataSetDetailRepository { CategoryOptionCombo getCatOptCombo(String selectedCatOptionCombo); boolean dataSetHasAnalytics(); + + boolean dataSetIsEditable( + String datasetUid, + String periodId, + String organisationUnitUid, + String attributeOptionComboUid + ); } diff --git a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailRepositoryImpl.java b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailRepositoryImpl.java index 55d21e10c8..8a58a9d8c3 100644 --- a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailRepositoryImpl.java +++ b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailRepositoryImpl.java @@ -11,7 +11,9 @@ import org.hisp.dhis.android.core.category.CategoryOptionCombo; import org.hisp.dhis.android.core.common.State; import org.hisp.dhis.android.core.dataset.DataSetCompleteRegistration; +import org.hisp.dhis.android.core.dataset.DataSetEditableStatus; import org.hisp.dhis.android.core.dataset.DataSetInstanceCollectionRepository; +import org.hisp.dhis.android.core.event.EventEditableStatus; import org.hisp.dhis.android.core.organisationunit.OrganisationUnit; import org.hisp.dhis.android.core.period.DatePeriod; import org.hisp.dhis.android.core.period.Period; @@ -83,6 +85,7 @@ public Flowable> dataSetGroups(List orgUnits, L //"Category Combination Name" + "Category option selected" return DataSetDetailModel.create( + dataSetReport.dataSetUid(), dataSetReport.organisationUnitUid(), dataSetReport.attributeOptionComboUid(), //catComboUid dataSetReport.period(), @@ -166,6 +169,22 @@ public boolean dataSetHasAnalytics() { } + @Override + public boolean dataSetIsEditable( + String datasetUid, + String periodId, + String organisationUnitUid, + String attributeOptionComboUid) { + return d2.dataSetModule() + .dataSetInstanceService() + .blockingGetEditableStatus( + datasetUid, + periodId, + organisationUnitUid, + attributeOptionComboUid + ) instanceof DataSetEditableStatus.Editable; + } + private CategoryCombo getCategoryComboFromOptionCombo(String categoryOptionComboUid) { CategoryOptionCombo catOptionCombo = d2.categoryModule() .categoryOptionCombos() diff --git a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/datasetList/DataSetListAdapter.kt b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/datasetList/DataSetListAdapter.kt index 7a05a097ab..ba611acf5e 100644 --- a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/datasetList/DataSetListAdapter.kt +++ b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/datasetList/DataSetListAdapter.kt @@ -34,6 +34,12 @@ class DataSetListAdapter( composeView.setContent { val card = cardMapper.map( dataset = it, + editable = viewModel.isEditable( + datasetUid = it.datasetUid(), + periodId = it.periodId(), + organisationUnitUid = it.orgUnitUid(), + attributeOptionComboUid = it.catOptionComboUid(), + ), onSyncIconClick = { viewModel.syncDataSet(it) }, diff --git a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/datasetList/DataSetListViewModel.kt b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/datasetList/DataSetListViewModel.kt index ee42a60763..4cb863670c 100644 --- a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/datasetList/DataSetListViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/datasetList/DataSetListViewModel.kt @@ -16,7 +16,7 @@ import org.dhis2.utils.Action import timber.log.Timber class DataSetListViewModel( - dataSetDetailRepository: DataSetDetailRepository, + val dataSetDetailRepository: DataSetDetailRepository, schedulerProvider: SchedulerProvider, val filterManager: FilterManager, val matomoAnalyticsController: MatomoAnalyticsController, @@ -74,4 +74,18 @@ class DataSetListViewModel( fun updateData() { filterManager.publishData() } + + fun isEditable( + datasetUid: String, + periodId: String, + organisationUnitUid: String, + attributeOptionComboUid: String, + ): Boolean { + return dataSetDetailRepository.dataSetIsEditable( + datasetUid, + periodId, + organisationUnitUid, + attributeOptionComboUid, + ) + } } diff --git a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/datasetList/mapper/DatasetCardMapper.kt b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/datasetList/mapper/DatasetCardMapper.kt index 3777b593a0..0a21fb02e6 100644 --- a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/datasetList/mapper/DatasetCardMapper.kt +++ b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/datasetList/mapper/DatasetCardMapper.kt @@ -8,6 +8,7 @@ import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.SyncDisabled import androidx.compose.material.icons.outlined.SyncProblem +import androidx.compose.material.icons.outlined.Visibility import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import org.dhis2.R @@ -30,13 +31,14 @@ class DatasetCardMapper( fun map( dataset: DataSetDetailModel, + editable: Boolean, onSyncIconClick: () -> Unit, onCardCLick: () -> Unit, ): ListCardUiModel { return ListCardUiModel( title = dataset.namePeriod(), lastUpdated = dataset.lastUpdated().toDateSpan(context), - additionalInfo = getAdditionalInfoList(dataset), + additionalInfo = getAdditionalInfoList(dataset, editable), actionButton = { ProvideSyncButton( state = dataset.state(), @@ -51,6 +53,7 @@ class DatasetCardMapper( private fun getAdditionalInfoList( dataset: DataSetDetailModel, + editable: Boolean, ): List { val list = mutableListOf() @@ -74,11 +77,36 @@ class DatasetCardMapper( state = dataset.state(), ) - // checkViewOnly() + checkViewOnly( + list = list, + editable = editable, + ) return list } + private fun checkViewOnly( + list: MutableList, + editable: Boolean, + ) { + if (!editable) { + list.add( + AdditionalInfoItem( + icon = { + Icon( + imageVector = Icons.Outlined.Visibility, + contentDescription = resourceManager.getString(R.string.view_only), + tint = AdditionalInfoItemColor.DISABLED.color, + ) + }, + value = resourceManager.getString(R.string.view_only), + isConstantItem = true, + color = AdditionalInfoItemColor.DISABLED.color, + ), + ) + } + } + private fun checkCategoryCombination( list: MutableList, dataset: DataSetDetailModel, diff --git a/app/src/main/java/org/dhis2/usescases/events/ScheduledEventActivity.kt b/app/src/main/java/org/dhis2/usescases/events/ScheduledEventActivity.kt index a3e5be3493..1a68faac56 100644 --- a/app/src/main/java/org/dhis2/usescases/events/ScheduledEventActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/events/ScheduledEventActivity.kt @@ -5,6 +5,12 @@ import android.content.Intent import android.os.Bundle import android.view.View import android.widget.DatePicker +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp import androidx.databinding.DataBindingUtil import org.dhis2.App import org.dhis2.R @@ -13,6 +19,8 @@ import org.dhis2.commons.dialogs.calendarpicker.CalendarPicker import org.dhis2.commons.dialogs.calendarpicker.OnDatePickerListener import org.dhis2.databinding.ActivityEventScheduledBinding import org.dhis2.usescases.eventsWithoutRegistration.eventCapture.EventCaptureActivity +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventDate +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.providers.ProvideInputDate import org.dhis2.usescases.eventsWithoutRegistration.eventInitial.EventInitialActivity import org.dhis2.usescases.general.ActivityGlobalAbstract import org.dhis2.utils.DateUtils @@ -76,14 +84,13 @@ class ScheduledEventActivity : ActivityGlobalAbstract(), ScheduledEventContract. override fun setEvent(event: Event) { this.event = event - binding.dueDate.setText(DateUtils.uiDateFormat().format(event.dueDate())) - when (event.status()) { EventStatus.OVERDUE, EventStatus.SCHEDULE -> { binding.actionButton.visibility = View.VISIBLE binding.actionButton.text = getString(R.string.skip) binding.actionButton.setOnClickListener { presenter.skipEvent() } } + else -> { binding.actionButton.visibility = View.GONE binding.actionButton.setOnClickListener(null) @@ -91,18 +98,46 @@ class ScheduledEventActivity : ActivityGlobalAbstract(), ScheduledEventContract. } } - override fun setStage(programStage: ProgramStage) { + override fun setStage(programStage: ProgramStage, event: Event) { this.stage = programStage binding.programStage = programStage - binding.dateLayout.hint = - programStage.executionDateLabel() ?: getString(R.string.report_date) - binding.dueDateLayout.hint = programStage.dueDateLabel() ?: getString(R.string.due_date) - if (programStage.hideDueDate() == true) { - binding.dueDateLayout.visibility = View.GONE - } + binding.scheduledEventFieldContainer.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + Column { + val eventDate = EventDate( + label = programStage.executionDateLabel() + ?: getString(R.string.report_date), + dateValue = "", + ) + Spacer(modifier = Modifier.height(16.dp)) - setEventDateClickListener(programStage.periodType()) + ProvideInputDate( + eventDate = eventDate, + allowsManualInput = false, + detailsEnabled = true, + onDateClick = { setEvenDateListener(programStage.periodType()) }, + onDateSet = {}, + onClear = {}, + ) + if (programStage.hideDueDate() == false) { + val dueDate = EventDate( + label = programStage.dueDateLabel() ?: getString(R.string.due_date), + dateValue = DateUtils.uiDateFormat().format(event.dueDate() ?: ""), + ) + Spacer(modifier = Modifier.height(16.dp)) + ProvideInputDate( + eventDate = dueDate, + detailsEnabled = true, + onDateClick = { setDueDateListener(programStage.periodType()) }, + onDateSet = {}, + onClear = {}, + ) + } + } + } + } } override fun setProgram(program: Program) { @@ -110,63 +145,61 @@ class ScheduledEventActivity : ActivityGlobalAbstract(), ScheduledEventContract. binding.name = program.displayName() } - fun setEventDateClickListener(periodType: PeriodType?) { - binding.date.setOnClickListener { - if (periodType == null) { - showCustomCalendar(false) - } else { - var minDate = - DateUtils.getInstance().expDate(null, program.expiryDays()!!, periodType) - val lastPeriodDate = - DateUtils.getInstance().getNextPeriod(periodType, minDate, -1, true) - - if (lastPeriodDate.after( - DateUtils.getInstance().getNextPeriod( - program.expiryPeriodType(), - minDate, - 0, - ), - ) - ) { - minDate = DateUtils.getInstance().getNextPeriod(periodType, lastPeriodDate, 0) - } + private fun setEvenDateListener(periodType: PeriodType?) { + if (periodType == null) { + showCustomCalendar(false) + } else { + var minDate = + DateUtils.getInstance().expDate(null, program.expiryDays()!!, periodType) + val lastPeriodDate = + DateUtils.getInstance().getNextPeriod(periodType, minDate, -1, true) - PeriodDialog() - .setPeriod(periodType) - .setMinDate(minDate) - .setMaxDate(DateUtils.getInstance().today) - .setPossitiveListener { selectedDate -> presenter.setEventDate(selectedDate) } - .show(supportFragmentManager, PeriodDialog::class.java.simpleName) + if (lastPeriodDate.after( + DateUtils.getInstance().getNextPeriod( + program.expiryPeriodType(), + minDate, + 0, + ), + ) + ) { + minDate = DateUtils.getInstance().getNextPeriod(periodType, lastPeriodDate, 0) } + + PeriodDialog() + .setPeriod(periodType) + .setMinDate(minDate) + .setMaxDate(DateUtils.getInstance().today) + .setPossitiveListener { selectedDate -> presenter.setEventDate(selectedDate) } + .show(supportFragmentManager, PeriodDialog::class.java.simpleName) } + } - binding.dueDate.setOnClickListener { - if (periodType == null) { - showCustomCalendar(true) - } else { - var minDate = - DateUtils.getInstance().expDate(null, program.expiryDays()!!, periodType) - val lastPeriodDate = - DateUtils.getInstance().getNextPeriod(periodType, minDate, -1, true) - - if (lastPeriodDate.after( - DateUtils.getInstance().getNextPeriod( - program.expiryPeriodType(), - minDate, - 0, - ), - ) - ) { - minDate = DateUtils.getInstance().getNextPeriod(periodType, lastPeriodDate, 0) - } + private fun setDueDateListener(periodType: PeriodType?) { + if (periodType == null) { + showCustomCalendar(true) + } else { + var minDate = + DateUtils.getInstance().expDate(null, program.expiryDays()!!, periodType) + val lastPeriodDate = + DateUtils.getInstance().getNextPeriod(periodType, minDate, -1, true) - PeriodDialog() - .setPeriod(periodType) - .setMinDate(minDate) - .setMaxDate(DateUtils.getInstance().today) - .setPossitiveListener { selectedDate -> presenter.setDueDate(selectedDate) } - .show(supportFragmentManager, PeriodDialog::class.java.simpleName) + if (lastPeriodDate.after( + DateUtils.getInstance().getNextPeriod( + program.expiryPeriodType(), + minDate, + 0, + ), + ) + ) { + minDate = DateUtils.getInstance().getNextPeriod(periodType, lastPeriodDate, 0) } + + PeriodDialog() + .setPeriod(periodType) + .setMinDate(minDate) + .setMaxDate(DateUtils.getInstance().today) + .setPossitiveListener { selectedDate -> presenter.setDueDate(selectedDate) } + .show(supportFragmentManager, PeriodDialog::class.java.simpleName) } } diff --git a/app/src/main/java/org/dhis2/usescases/events/ScheduledEventContract.kt b/app/src/main/java/org/dhis2/usescases/events/ScheduledEventContract.kt index 387c59d5fd..0bf7b898a4 100644 --- a/app/src/main/java/org/dhis2/usescases/events/ScheduledEventContract.kt +++ b/app/src/main/java/org/dhis2/usescases/events/ScheduledEventContract.kt @@ -12,7 +12,7 @@ class ScheduledEventContract { interface View : AbstractActivityContracts.View { fun setEvent(event: Event) - fun setStage(programStage: ProgramStage) + fun setStage(programStage: ProgramStage, event: Event) fun setProgram(program: Program) fun openInitialActivity() fun openFormActivity() diff --git a/app/src/main/java/org/dhis2/usescases/events/ScheduledEventPresenterImpl.kt b/app/src/main/java/org/dhis2/usescases/events/ScheduledEventPresenterImpl.kt index 091c67249f..407a2991c5 100644 --- a/app/src/main/java/org/dhis2/usescases/events/ScheduledEventPresenterImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/events/ScheduledEventPresenterImpl.kt @@ -39,7 +39,7 @@ class ScheduledEventPresenterImpl( { stageProgramEventData -> val (stage, program, event) = stageProgramEventData view.setProgram(program) - view.setStage(stage) + view.setStage(stage, event) view.setEvent(event) }, { Timber.e(it) }, 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 bcbd0bb3b2..7388e7890c 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 @@ -188,7 +188,7 @@ class EventDetailsRepository( return d2.categoryModule().categoryOptionCombos() .byCategoryComboUid().eq(categoryComboUid) .byCategoryOptions(categoryOptionsUid) - .one()?.blockingGet()?.uid() + .one().blockingGet()?.uid() } fun getCatOption(selectedOption: String?): CategoryOption? { @@ -207,7 +207,7 @@ class EventDetailsRepository( .categoryOptions() .withOrganisationUnits() .byCategoryUid(categoryUid) - .blockingGet() ?: emptyList() + .blockingGet() } fun getOptionsFromCatOptionCombo(): Map? { @@ -277,7 +277,9 @@ class EventDetailsRepository( FeatureType.POLYGON, FeatureType.MULTI_POLYGON, -> eventRepository.setGeometry(geometry) + else -> { + // no-op } } } 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 2923d6cd88..f66d01c6a9 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 @@ -16,16 +16,18 @@ class ConfigureEventCatCombo( private var selectedCategoryOptions = mapOf() operator fun invoke(categoryOption: Pair? = null): Flow { - categoryOption?.let { - updateSelectedOptions(it) - } repository.catCombo().apply { + val categories = getCategories(this?.categories()) + val categoryOptions = getCategoryOptions() + + updateSelectedOptions(categoryOption, categories, categoryOptions) + return flowOf( EventCatCombo( uid = getCatComboUid(this?.uid() ?: "", this?.isDefault ?: false), isDefault = this?.isDefault ?: false, - categories = getCategories(this?.categories()), - categoryOptions = getCategoryOptions(), + categories = categories, + categoryOptions = categoryOptions, selectedCategoryOptions = selectedCategoryOptions, isCompleted = isCompleted( isDefault = this?.isDefault ?: true, @@ -73,14 +75,27 @@ class ConfigureEventCatCombo( private fun updateSelectedOptions( categoryOption: Pair?, + categories: List, + categoryOptions: Map?, ): Map { - categoryOption?.let { pair -> - val copy = selectedCategoryOptions.toMutableMap() - copy[pair.first] = pair.second?.let { categoryOptionId -> - repository.getCatOption(categoryOptionId) + if (categoryOption == null) { + categories.forEach { category -> + categoryOptions?.get(category.uid)?.let { categoryOption -> + val copy = selectedCategoryOptions.toMutableMap() + copy[category.uid] = categoryOption + selectedCategoryOptions = copy + } + } + } else { + categoryOption.let { pair -> + val copy = selectedCategoryOptions.toMutableMap() + copy[pair.first] = pair.second?.let { categoryOptionId -> + repository.getCatOption(categoryOptionId) + } + selectedCategoryOptions = copy } - selectedCategoryOptions = copy } + return selectedCategoryOptions } diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventDetails.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventDetails.kt index 9f3e4f2b86..e2ad0490f2 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventDetails.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventDetails.kt @@ -69,10 +69,14 @@ class ConfigureEventDetails( } private fun isActionButtonVisible(isEventCompleted: Boolean, storedEvent: Event?): Boolean { - return storedEvent?.let { - !(it.status() == OVERDUE && enrollmentStatus == CANCELLED) && - repository.getEditableStatus() !is NonEditable - } ?: isEventCompleted + return if (!isEventCompleted) { + false + } else { + storedEvent?.let { + !(it.status() == OVERDUE && enrollmentStatus == CANCELLED) && + repository.getEditableStatus() !is NonEditable + } ?: true + } } fun reopenEvent() = repository.reopenEvent() 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 new file mode 100644 index 0000000000..cc4f28bd4e --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/providers/InputFieldsProvider.kt @@ -0,0 +1,382 @@ +package org.dhis2.usescases.eventsWithoutRegistration.eventDetails.providers + +import androidx.compose.foundation.background +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ExposedDropdownMenuBox +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +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.graphics.Color +import org.dhis2.R +import org.dhis2.commons.resources.ResourceManager +import org.dhis2.data.dhislogic.inDateRange +import org.dhis2.data.dhislogic.inOrgUnit +import org.dhis2.form.model.UiEventType +import org.dhis2.form.model.UiRenderType +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventCatCombo +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventCategory +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventCoordinates +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventDate +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventOrgUnit +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventTemp +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventTempStatus +import org.dhis2.utils.category.CategoryDialog.Companion.DEFAULT_COUNT_LIMIT +import org.hisp.dhis.android.core.arch.helpers.GeometryHelper +import org.hisp.dhis.android.core.arch.helpers.Result +import org.hisp.dhis.android.core.category.CategoryOption +import org.hisp.dhis.android.core.common.FeatureType +import org.hisp.dhis.android.core.common.Geometry +import org.hisp.dhis.android.core.common.ValueType +import org.hisp.dhis.mobile.ui.designsystem.component.Coordinates +import org.hisp.dhis.mobile.ui.designsystem.component.DateTimeActionIconType +import org.hisp.dhis.mobile.ui.designsystem.component.InputCoordinate +import org.hisp.dhis.mobile.ui.designsystem.component.InputDateTime +import org.hisp.dhis.mobile.ui.designsystem.component.InputDropDown +import org.hisp.dhis.mobile.ui.designsystem.component.InputOrgUnit +import org.hisp.dhis.mobile.ui.designsystem.component.InputPolygon +import org.hisp.dhis.mobile.ui.designsystem.component.InputRadioButton +import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState +import org.hisp.dhis.mobile.ui.designsystem.component.Orientation +import org.hisp.dhis.mobile.ui.designsystem.component.RadioButtonData +import org.hisp.dhis.mobile.ui.designsystem.component.internal.DateTransformation +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import java.util.Date + +@Composable +fun ProvideInputDate( + eventDate: EventDate, + detailsEnabled: Boolean, + onDateClick: () -> Unit, + allowsManualInput: Boolean = true, + onDateSet: (InputDateValues) -> Unit, + onClear: () -> Unit, + required: Boolean = false, +) { + var value by remember(eventDate.dateValue) { + mutableStateOf(eventDate.dateValue?.let { formatStoredDateToUI(it) }) + } + + var state by remember { + mutableStateOf(getInputState(detailsEnabled)) + } + + InputDateTime( + title = eventDate.label ?: "", + allowsManualInput = allowsManualInput, + value = value, + actionIconType = DateTimeActionIconType.DATE, + onActionClicked = onDateClick, + state = state, + visualTransformation = DateTransformation(), + onValueChanged = { + value = it + if (it.isEmpty()) { + onClear() + } else if (isValid(it)) { + if (isValidDateFormat(it)) { + state = InputShellState.FOCUSED + formatUIDateToStored(it)?.let { dateValues -> + onDateSet(dateValues) + } + } else { + state = InputShellState.ERROR + } + } else { + state = InputShellState.FOCUSED + } + }, + isRequired = required, + ) +} + +fun isValidDateFormat(dateString: String): Boolean { + val year = dateString.substring(4, 8) + val month = dateString.substring(2, 4) + val day = dateString.substring(0, 2) + + val formattedDate = "$year-$month-$day" + + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + + return try { + LocalDate.parse(formattedDate, formatter) + when (ValueType.DATE.validator.validate(formattedDate)) { + is Result.Failure -> false + is Result.Success -> true + } + } catch (e: DateTimeParseException) { + false + } +} + +private fun isValid(valueString: String) = valueString.length == 8 + +private fun formatStoredDateToUI(dateValue: String): String? { + val components = dateValue.split("/") + if (components.size != 3) { + return null + } + + val year = components[2] + val month = if (components[1].length == 1) { + "0${components[1]}" + } else { + components[1] + } + val day = if (components[0].length == 1) { + "0${components[0]}" + } else { + components[0] + } + + return "$day$month$year" +} + +fun formatUIDateToStored(dateValue: String?): InputDateValues? { + if (dateValue?.length != 8) { + return null + } + + val year = dateValue.substring(4, 8).toInt() + val month = dateValue.substring(2, 4).toInt() + val day = dateValue.substring(0, 2).toInt() + + return (InputDateValues(day, month, year)) +} + +data class InputDateValues(val day: Int, val month: Int, val year: Int) + +@Composable +fun ProvideOrgUnit( + orgUnit: EventOrgUnit, + detailsEnabled: Boolean, + onOrgUnitClick: () -> Unit, + resources: ResourceManager, + onClear: () -> Unit, + required: Boolean = false, +) { + val state = getInputState(detailsEnabled && orgUnit.enable && orgUnit.orgUnits.size > 1) + + var inputFieldValue by remember(orgUnit.selectedOrgUnit) { + mutableStateOf(orgUnit.selectedOrgUnit?.displayName()) + } + + InputOrgUnit( + title = resources.getString(R.string.org_unit), + state = state, + inputText = inputFieldValue ?: "", + onValueChanged = { + inputFieldValue = it + if (it.isNullOrEmpty()) { + onClear() + } + }, + onOrgUnitActionCLicked = onOrgUnitClick, + isRequiredField = required, + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun ProvideCategorySelector( + modifier: Modifier = Modifier, + category: EventCategory, + eventCatCombo: EventCatCombo, + detailsEnabled: Boolean, + currentDate: Date?, + selectedOrgUnit: String?, + onShowCategoryDialog: (EventCategory) -> Unit, + onClearCatCombo: (EventCategory) -> Unit, + onOptionSelected: (CategoryOption?) -> Unit, + required: Boolean = false, +) { + var selectedItem by remember { + mutableStateOf( + eventCatCombo.selectedCategoryOptions[category.uid]?.displayName() + ?: eventCatCombo.categoryOptions?.get(category.uid)?.displayName(), + ) + } + + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = {}, + ) { + InputDropDown( + modifier = modifier, + title = category.name, + state = getInputState(detailsEnabled), + selectedItem = selectedItem, + onResetButtonClicked = { + selectedItem = null + onClearCatCombo(category) + }, + onArrowDropDownButtonClicked = { + expanded = !expanded + }, + isRequiredField = required, + ) + + if (expanded) { + if (category.optionsSize > DEFAULT_COUNT_LIMIT) { + onShowCategoryDialog(category) + expanded = false + } else { + DropdownMenu( + modifier = modifier.exposedDropdownSize(), + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + val selectableOptions = category.options + .filter { option -> + option.access().data().write() + }.filter { option -> + option.inDateRange(currentDate) + }.filter { option -> + option.inOrgUnit(selectedOrgUnit) + } + selectableOptions.forEach { option -> + val isSelected = option.displayName() == selectedItem + DropdownMenuItem( + modifier = Modifier.background( + when { + isSelected -> SurfaceColor.PrimaryContainer + else -> Color.Transparent + }, + ), + content = { + Text( + text = option.displayName() ?: option.code() ?: "", + color = when { + isSelected -> TextColor.OnPrimaryContainer + else -> TextColor.OnSurface + }, + ) + }, + onClick = { + expanded = false + selectedItem = option.displayName() + onOptionSelected(option) + }, + ) + } + } + } + } + } +} + +private fun getInputState(enabled: Boolean) = if (enabled) { + InputShellState.UNFOCUSED +} else { + InputShellState.DISABLED +} + +@Composable +fun ProvideCoordinates( + coordinates: EventCoordinates, + detailsEnabled: Boolean, + resources: ResourceManager, +) { + when (coordinates.model?.renderingType) { + UiRenderType.POLYGON, UiRenderType.MULTI_POLYGON -> { + InputPolygon( + title = resources.getString(R.string.polygon), + state = getInputState(detailsEnabled && coordinates.model.editable), + polygonAdded = !coordinates.model.value.isNullOrEmpty(), + onResetButtonClicked = { coordinates.model.onClear() }, + onUpdateButtonClicked = { + coordinates.model.invokeUiEvent(UiEventType.REQUEST_LOCATION_BY_MAP) + }, + ) + } + + else -> { + InputCoordinate( + title = resources.getString(R.string.coordinates), + state = getInputState(detailsEnabled && coordinates.model?.editable == true), + coordinates = mapGeometry(coordinates.model?.value, FeatureType.POINT), + latitudeText = resources.getString(R.string.latitude), + longitudeText = resources.getString(R.string.longitude), + addLocationBtnText = resources.getString(R.string.add_location), + onResetButtonClicked = { + coordinates.model?.onClear() + }, + onUpdateButtonClicked = { + coordinates.model?.invokeUiEvent(UiEventType.REQUEST_LOCATION_BY_MAP) + }, + ) + } + } +} + +fun mapGeometry(value: String?, featureType: FeatureType): Coordinates? { + return value?.let { + val geometry = Geometry.builder() + .coordinates(it) + .type(featureType) + .build() + + Coordinates( + latitude = GeometryHelper.getPoint(geometry)[1], + longitude = GeometryHelper.getPoint(geometry)[0], + ) + } +} + +@Composable +fun ProvideRadioButtons( + eventTemp: EventTemp, + detailsEnabled: Boolean, + resources: ResourceManager, + onEventTempSelected: (status: EventTempStatus?) -> Unit, +) { + val radioButtonData = listOf( + RadioButtonData( + uid = EventTempStatus.ONE_TIME.name, + selected = eventTemp.status == EventTempStatus.ONE_TIME, + enabled = true, + textInput = resources.getString(R.string.one_time), + ), + RadioButtonData( + uid = EventTempStatus.PERMANENT.name, + selected = eventTemp.status == EventTempStatus.PERMANENT, + enabled = true, + textInput = resources.getString(R.string.permanent), + ), + ) + + InputRadioButton( + title = resources.getString(R.string.referral), + radioButtonData = radioButtonData, + orientation = Orientation.HORIZONTAL, + state = getInputState(detailsEnabled), + itemSelected = radioButtonData.find { it.selected }, + onItemChange = { data -> + when (data?.uid) { + EventTempStatus.ONE_TIME.name -> { + onEventTempSelected(EventTempStatus.ONE_TIME) + } + + EventTempStatus.PERMANENT.name -> { + onEventTempSelected(EventTempStatus.PERMANENT) + } + + else -> { + onEventTempSelected(null) + } + } + }, + ) +} diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/ui/EventDetailsFragment.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/ui/EventDetailsFragment.kt index f49ccc1438..171e6fa43c 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/ui/EventDetailsFragment.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/ui/EventDetailsFragment.kt @@ -8,6 +8,13 @@ import android.view.View import android.view.ViewGroup import android.widget.DatePicker import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.databinding.DataBindingUtil import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope @@ -27,16 +34,21 @@ import org.dhis2.commons.dialogs.calendarpicker.OnDatePickerListener import org.dhis2.commons.locationprovider.LocationSettingLauncher import org.dhis2.commons.orgunitselector.OUTreeFragment import org.dhis2.commons.orgunitselector.OrgUnitSelectorScope +import org.dhis2.commons.resources.ResourceManager import org.dhis2.databinding.EventDetailsFragmentBinding import org.dhis2.maps.views.MapSelectorActivity import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.injection.EventDetailsComponentProvider import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.injection.EventDetailsModule import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventCategory import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventDetails +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.providers.ProvideCategorySelector +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.providers.ProvideCoordinates +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.providers.ProvideInputDate +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.providers.ProvideOrgUnit +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.providers.ProvideRadioButtons import org.dhis2.usescases.general.FragmentGlobalAbstract import org.dhis2.utils.category.CategoryDialog import org.dhis2.utils.category.CategoryDialog.Companion.TAG -import org.dhis2.utils.customviews.CatOptionPopUp import org.dhis2.utils.customviews.PeriodDialog import org.hisp.dhis.android.core.common.FeatureType import org.hisp.dhis.android.core.enrollment.EnrollmentStatus @@ -49,6 +61,9 @@ class EventDetailsFragment : FragmentGlobalAbstract() { @Inject lateinit var factory: EventDetailsViewModelFactory + @Inject + lateinit var resourceManager: ResourceManager + private val requestLocationPermissions = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), @@ -119,6 +134,89 @@ class EventDetailsFragment : FragmentGlobalAbstract() { ) binding.lifecycleOwner = viewLifecycleOwner binding.viewModel = viewModel + binding.fieldsContainer.setContent { + val date by viewModel.eventDate.collectAsState() + val details by viewModel.eventDetails.collectAsState() + val orgUnit by viewModel.eventOrgUnit.collectAsState() + val catCombo by viewModel.eventCatCombo.collectAsState() + val coordinates by viewModel.eventCoordinates.collectAsState() + val eventTemp by viewModel.eventTemp.collectAsState() + + Column { + if (date.active) { + Spacer(modifier = Modifier.height(16.dp)) + ProvideInputDate( + eventDate = date, + detailsEnabled = details.enabled, + onDateClick = { viewModel.onDateClick() }, + onDateSet = { dateValues -> + viewModel.onDateSet(dateValues.year, dateValues.month, dateValues.day) + }, + onClear = { viewModel.onClearEventReportDate() }, + required = true, + ) + } + if (orgUnit.visible) { + Spacer(modifier = Modifier.height(16.dp)) + ProvideOrgUnit( + orgUnit = orgUnit, + detailsEnabled = details.enabled, + onOrgUnitClick = { viewModel.onOrgUnitClick() }, + resources = resourceManager, + onClear = { + viewModel.onClearOrgUnit() + }, + required = true, + ) + } + + if (!catCombo.isDefault) { + catCombo.categories.forEach { category -> + Spacer(modifier = Modifier.height(16.dp)) + ProvideCategorySelector( + category = category, + eventCatCombo = catCombo, + detailsEnabled = details.enabled, + currentDate = date.currentDate, + selectedOrgUnit = details.selectedOrgUnit, + onShowCategoryDialog = { + showCategoryDialog(it) + }, + onClearCatCombo = { + viewModel.onClearCatCombo() + }, + onOptionSelected = { + val selectedOption = Pair(category.uid, it?.uid()) + viewModel.setUpCategoryCombo(selectedOption) + }, + + required = true, + ) + } + } + + if (coordinates.active) { + Spacer(modifier = Modifier.height(16.dp)) + ProvideCoordinates( + coordinates = coordinates, + detailsEnabled = details.enabled, + resources = resourceManager, + ) + } + + if (eventTemp.active) { + Spacer(modifier = Modifier.height(16.dp)) + ProvideRadioButtons( + eventTemp = eventTemp, + detailsEnabled = details.enabled, + resources = resourceManager, + onEventTempSelected = { + viewModel.setUpEventTemp(it) + }, + ) + } + } + } return binding.root } @@ -147,14 +245,6 @@ class EventDetailsFragment : FragmentGlobalAbstract() { showNoOrgUnitsDialog() } - viewModel.showCategoryDialog = { category -> - showCategoryDialog(category) - } - - viewModel.showCategoryPopUp = { category -> - showCategoryPopUp(category) - } - viewModel.requestLocationPermissions = { requestLocationPermissions.launch( arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), @@ -247,7 +337,7 @@ class EventDetailsFragment : FragmentGlobalAbstract() { OrgUnitSelectorScope.ProgramCaptureScope(viewModel.eventOrgUnit.value.programUid!!), ) .onSelection { selectedOrgUnits -> - viewModel.setUpOrgUnit(selectedOrgUnit = selectedOrgUnits.first().uid()) + viewModel.setUpOrgUnit(selectedOrgUnit = selectedOrgUnits.firstOrNull()?.uid()) } .build() .show(childFragmentManager, "ORG_UNIT_DIALOG") @@ -257,19 +347,6 @@ class EventDetailsFragment : FragmentGlobalAbstract() { showInfoDialog(getString(R.string.error), getString(R.string.no_org_units)) } - private fun showCategoryPopUp(category: EventCategory) { - CatOptionPopUp( - context = requireContext(), - anchor = binding.catComboLayout, - options = category.options, - date = viewModel.eventDate.value.currentDate, - orgUnitUid = viewModel.eventDetails.value.selectedOrgUnit, - ) { categoryOption -> - val selectedOption = Pair(category.uid, categoryOption?.uid()) - viewModel.setUpCategoryCombo(selectedOption) - }.show() - } - private fun showCategoryDialog(category: EventCategory) { CategoryDialog( CategoryDialog.Type.CATEGORY_OPTIONS, diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/ui/EventDetailsViewBindings.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/ui/EventDetailsViewBindings.kt index f06c04c41b..a740d4c70d 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/ui/EventDetailsViewBindings.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/ui/EventDetailsViewBindings.kt @@ -1,7 +1,5 @@ package org.dhis2.usescases.eventsWithoutRegistration.eventDetails.ui -import android.view.LayoutInflater -import android.widget.LinearLayout import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -9,36 +7,6 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.databinding.BindingAdapter import com.google.android.material.composethemeadapter.MdcTheme -import org.dhis2.databinding.CategorySelectorBinding -import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventCatCombo - -@BindingAdapter(value = ["setViewModel", "setCatCombo", "enabled"]) -fun LinearLayout.setCatCombo( - viewModel: EventDetailsViewModel, - eventCatCombo: EventCatCombo, - enabled: Boolean?, -) { - if (!eventCatCombo.isDefault) { - this@setCatCombo.removeAllViews() - eventCatCombo.categories.forEach { category -> - val catSelectorBinding: CategorySelectorBinding = - CategorySelectorBinding.inflate(LayoutInflater.from(context)) - catSelectorBinding.catCombLayout.hint = category.name - catSelectorBinding.catCombo.isEnabled = enabled ?: true - catSelectorBinding.catCombo.setOnClickListener { - viewModel.onCatComboClick(category) - } - - val selectorDisplay = - eventCatCombo.selectedCategoryOptions[category.uid]?.displayName() - ?: eventCatCombo.categoryOptions?.get(category.uid)?.displayName() - - catSelectorBinding.catCombo.setText(selectorDisplay) - - this@setCatCombo.addView(catSelectorBinding.root) - } - } -} @ExperimentalAnimationApi @BindingAdapter("setReopen") diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/ui/EventDetailsViewModel.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/ui/EventDetailsViewModel.kt index 290ad4e1dc..614e1232a8 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/ui/EventDetailsViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/ui/EventDetailsViewModel.kt @@ -18,7 +18,6 @@ import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.domain.Configu import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.domain.ConfigureOrgUnit import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.domain.CreateOrUpdateEventDetails import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventCatCombo -import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventCategory import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventCoordinates import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventDate import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventDetails @@ -26,7 +25,6 @@ import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventOr import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventTemp import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.EventTempStatus import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.providers.EventDetailResourcesProvider -import org.dhis2.utils.category.CategoryDialog.Companion.DEFAULT_COUNT_LIMIT import org.hisp.dhis.android.core.arch.helpers.GeometryHelper import org.hisp.dhis.android.core.common.FeatureType import org.hisp.dhis.android.core.common.Geometry @@ -53,8 +51,6 @@ class EventDetailsViewModel( var showPeriods: (() -> Unit)? = null var showOrgUnits: (() -> Unit)? = null var showNoOrgUnits: (() -> Unit)? = null - var showCategoryDialog: ((category: EventCategory) -> Unit)? = null - var showCategoryPopUp: ((category: EventCategory) -> Unit)? = null var requestLocationPermissions: (() -> Unit)? = null var showEnableLocationMessage: (() -> Unit)? = null var requestLocationByMap: ((featureType: String, initCoordinate: String?) -> Unit)? = null @@ -171,6 +167,11 @@ class EventDetailsViewModel( } } + fun onClearEventReportDate() { + _eventDate.value = eventDate.value.copy(currentDate = null) + setUpEventDetails() + } + fun setUpOrgUnit(selectedDate: Date? = null, selectedOrgUnit: String? = null) { viewModelScope.launch { configureOrgUnit(selectedDate, selectedOrgUnit) @@ -182,6 +183,11 @@ class EventDetailsViewModel( } } + fun onClearOrgUnit() { + _eventOrgUnit.value = eventOrgUnit.value.copy(selectedOrgUnit = null) + setUpEventDetails() + } + fun setUpCategoryCombo(categoryOption: Pair? = null) { EventDetailIdlingResourceSingleton.increment() viewModelScope.launch { @@ -195,6 +201,11 @@ class EventDetailsViewModel( } } + fun onClearCatCombo() { + _eventCatCombo.value = eventCatCombo.value.copy(isCompleted = false) + setUpEventDetails() + } + private fun setUpCoordinates(value: String? = "") { EventDetailIdlingResourceSingleton.increment() viewModelScope.launch { @@ -256,14 +267,6 @@ class EventDetailsViewModel( } } - fun onCatComboClick(category: EventCategory) { - if (category.optionsSize > DEFAULT_COUNT_LIMIT) { - showCategoryDialog?.invoke(category) - } else { - showCategoryPopUp?.invoke(category) - } - } - fun requestCurrentLocation() { locationProvider.getLastKnownLocation( onNewLocation = { location -> @@ -365,5 +368,6 @@ inline fun Result.mockSafeFold( } } } + else -> onFailure(exceptionOrNull() ?: Exception()) } diff --git a/app/src/main/java/org/dhis2/usescases/main/MainActivity.kt b/app/src/main/java/org/dhis2/usescases/main/MainActivity.kt index 4304569c38..a4f8ab5539 100644 --- a/app/src/main/java/org/dhis2/usescases/main/MainActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/main/MainActivity.kt @@ -49,6 +49,7 @@ import java.io.File import javax.inject.Inject private const val FRAGMENT = "Fragment" +private const val SINGLE_PROGRAM_NAVIGATION = "SINGLE_PROGRAM_NAVIGATION" private const val INIT_DATA_SYNC = "INIT_DATA_SYNC" private const val WIPE_NOTIFICATION = "wipe_notification" private const val RESTART = "Restart" @@ -73,6 +74,7 @@ class MainActivity : var notification: Boolean = false var forceToNotSynced = false + private var singleProgramNavigationDone = false private val getDevActivityContent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { @@ -184,6 +186,7 @@ class MainActivity : elevation = ViewCompat.getElevation(binding.toolbar) val restoreScreenName = savedInstanceState?.getString(FRAGMENT) + singleProgramNavigationDone = savedInstanceState?.getBoolean(SINGLE_PROGRAM_NAVIGATION) ?: false val openScreen = intent.getStringExtra(FRAGMENT) when { @@ -212,6 +215,8 @@ class MainActivity : if (!presenter.wasSyncAlreadyDone()) { presenter.launchInitialDataSync() + } else if (!singleProgramNavigationDone && presenter.hasOneHomeItem()) { + navigateToSingleProgram() } checkNotificationPermission() @@ -225,6 +230,7 @@ class MainActivity : override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) + outState.putBoolean(SINGLE_PROGRAM_NAVIGATION, singleProgramNavigationDone) outState.putString(FRAGMENT, mainNavigator.currentScreenName()) } @@ -245,15 +251,21 @@ class MainActivity : private fun observeSyncState() { presenter.observeDataSync().observe(this) { - if (it.running) { - setFilterButtonVisibility(false) - setBottomNavigationVisibility(false) - } else { - setFilterButtonVisibility(true) - setBottomNavigationVisibility(true) - presenter.onDataSuccess() - if (presenter.hasOneHomeItem()) { - navigateToSingleProgram() + when (it.running) { + true -> { + setFilterButtonVisibility(false) + setBottomNavigationVisibility(false) + } + false -> { + setFilterButtonVisibility(true) + setBottomNavigationVisibility(true) + presenter.onDataSuccess() + if (presenter.hasOneHomeItem()) { + navigateToSingleProgram() + } + } + else -> { + // no action } } } @@ -261,6 +273,7 @@ class MainActivity : private fun navigateToSingleProgram() { presenter.getSingleItemData()?.let { homeItemData -> + singleProgramNavigationDone = true navigationLauncher.navigateTo(this, homeItemData) } } diff --git a/app/src/main/java/org/dhis2/usescases/main/MainPresenter.kt b/app/src/main/java/org/dhis2/usescases/main/MainPresenter.kt index 8bb4959877..80861f6011 100644 --- a/app/src/main/java/org/dhis2/usescases/main/MainPresenter.kt +++ b/app/src/main/java/org/dhis2/usescases/main/MainPresenter.kt @@ -197,6 +197,7 @@ class MainPresenter( disposable.add( Completable.fromCallable { workManagerController.cancelAllWork() + syncStatusController.restore() FilterManager.getInstance().clearAllFilters() preferences.setValue(Preference.SESSION_LOCKED, false) userManager.d2.dataStoreModule().localDataStore().value(PIN).blockingDeleteIfExist() @@ -218,6 +219,7 @@ class MainPresenter( view.showProgressDeleteNotification() try { workManagerController.cancelAllWork() + syncStatusController.restore() deleteUserData.wipeCacheAndPreferences(view.obtainFileView()) userManager.d2?.wipeModule()?.wipeEverything() userManager.d2?.userModule()?.accountManager()?.deleteCurrentAccount() diff --git a/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepositoryImpl.kt b/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepositoryImpl.kt index 9498a55577..8bc25cf6f1 100644 --- a/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepositoryImpl.kt @@ -166,7 +166,7 @@ internal class ProgramRepositoryImpl( else -> ProgramDownloadState.NONE }, - downloadActive = syncStatusData.running, + downloadActive = syncStatusData.running ?: false, ) } } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchPageConfigurator.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchPageConfigurator.kt index 9e3ae7172a..7454e811b9 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchPageConfigurator.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchPageConfigurator.kt @@ -1,6 +1,7 @@ package org.dhis2.usescases.searchTrackEntity import org.dhis2.utils.customviews.navigationbar.NavigationPageConfigurator +import org.hisp.dhis.android.core.common.FeatureType class SearchPageConfigurator( val searchRepository: SearchRepository, @@ -10,8 +11,8 @@ class SearchPageConfigurator( private var canDisplayAnalytics: Boolean = false fun initVariables(): SearchPageConfigurator { - canDisplayMap = searchRepository.programHasCoordinates() - canDisplayAnalytics = searchRepository.programHasAnalytics() + canDisplayMap = programHasCoordinates() + canDisplayAnalytics = programHasAnalytics() return this } @@ -30,4 +31,47 @@ class SearchPageConfigurator( override fun displayAnalytics(): Boolean { return canDisplayAnalytics } + + internal fun programHasCoordinates(): Boolean { + val program = searchRepository.currentProgram()?.let { programId -> + searchRepository.getProgram(programId) + } ?: return false + + val programHasCoordinates = program.featureType() != null && program.featureType() != FeatureType.NONE + + val programStagesHaveCoordinates by lazy { + searchRepository.programStagesHaveCoordinates(program.uid()) + } + + val programAttributesHaveCoordinates by lazy { + searchRepository.programAttributesHaveCoordinates(program.uid()) + } + + val teTypeHasCoordinates by lazy { + searchRepository.trackedEntityType?.let { teType -> + teType.featureType() != null && teType.featureType() != FeatureType.NONE + } ?: false + } + + val teAttributesHaveCoordinates by lazy { + searchRepository.teTypeAttributesHaveCoordinates(program.trackedEntityType()?.uid()) + } + + val eventsHaveCoordinates by lazy { + searchRepository.eventsHaveCoordinates(program.uid()) + } + + return programHasCoordinates || + programStagesHaveCoordinates || + programAttributesHaveCoordinates || + teTypeHasCoordinates || + teAttributesHaveCoordinates || + eventsHaveCoordinates + } + + internal fun programHasAnalytics(): Boolean { + val programUid = searchRepository.currentProgram() ?: return false + + return searchRepository.getProgramVisualizationGroups(programUid).isNotEmpty() + } } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepository.java b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepository.java index 11df1c23b5..a1dc2103a4 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepository.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepository.java @@ -13,6 +13,7 @@ import org.hisp.dhis.android.core.arch.call.D2Progress; import org.hisp.dhis.android.core.organisationunit.OrganisationUnit; import org.hisp.dhis.android.core.program.Program; +import org.hisp.dhis.android.core.settings.AnalyticsDhisVisualizationsGroup; import org.hisp.dhis.android.core.trackedentity.TrackedEntityType; import org.jetbrains.annotations.NotNull; @@ -58,10 +59,15 @@ public interface SearchRepository { TeiDownloadResult download(String teiUid, @Nullable String enrollmentUid, String reason); void setCurrentProgram(@Nullable String currentProgram); - boolean programHasAnalytics(); - boolean programHasCoordinates(); + boolean programStagesHaveCoordinates(String programUid); + boolean teTypeAttributesHaveCoordinates(String typeId); + boolean programAttributesHaveCoordinates(String programUid); + boolean eventsHaveCoordinates(String programUid); + + List getProgramVisualizationGroups(String programUid); @Nullable Program getProgram(@Nullable String programUid); + @Nullable String currentProgram(); @NotNull Map filterQueryForProgram(@NotNull Map queryData, @org.jetbrains.annotations.Nullable String programUid); 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 4ebecef374..967ffa2c3a 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java @@ -9,9 +9,9 @@ import androidx.paging.LivePagedListBuilder; import androidx.paging.PagedList; -import org.dhis2.bindings.ValueExtensionsKt; import org.dhis2.R; import org.dhis2.bindings.ExtensionsKt; +import org.dhis2.bindings.ValueExtensionsKt; import org.dhis2.commons.Constants; import org.dhis2.commons.data.EntryMode; import org.dhis2.commons.data.EventViewModel; @@ -69,6 +69,7 @@ import org.hisp.dhis.android.core.relationship.RelationshipItem; import org.hisp.dhis.android.core.relationship.RelationshipItemTrackedEntityInstance; import org.hisp.dhis.android.core.relationship.RelationshipType; +import org.hisp.dhis.android.core.settings.AnalyticsDhisVisualizationsGroup; import org.hisp.dhis.android.core.settings.ProgramConfigurationSetting; import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttribute; import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValue; @@ -765,7 +766,7 @@ private SearchTeiModel transform(TrackedEntitySearchItem searchItem, @Nullable P } } - searchTei.setOnline(false); + searchTei.setOnline(!searchItem.isOnline()); if (offlineOnly) searchTei.setOnline(!offlineOnly); @@ -836,103 +837,67 @@ public void setCurrentProgram(String currentProgram) { this.currentProgram = currentProgram; } - private String currentProgram() { + @Override + public String currentProgram() { return currentProgram; } @Override - public boolean programHasAnalytics() { - String programUid = currentProgram(); - if (programUid != null) { - boolean hasCharts = charts != null && !charts.getProgramVisualizations(null, programUid).isEmpty(); - return hasCharts; + public List getProgramVisualizationGroups(String programUid) { + if (charts != null) { + return charts.getVisualizationGroups(programUid); } else { - return false; + return Collections.emptyList(); } } @Override - public boolean programHasCoordinates() { - String programUid = currentProgram(); - - if (programUid == null) return false; - - boolean teTypeHasCoordinates = false; - FeatureType teTypeFeatureType = d2.trackedEntityModule().trackedEntityTypes() - .uid(teiType) - .blockingGet() - .featureType(); - - if (teTypeFeatureType != null && teTypeFeatureType != FeatureType.NONE) { - teTypeHasCoordinates = true; - } - - boolean enrollmentHasCoordinates = false; - FeatureType enrollmentFeatureType = d2.programModule().programs() - .uid(programUid) - .blockingGet() - .featureType(); - - if (enrollmentFeatureType != null && enrollmentFeatureType != FeatureType.NONE) { - enrollmentHasCoordinates = true; - } + public boolean programStagesHaveCoordinates(String programUid) { + return !d2.programModule().programStages() + .byProgramUid().eq(programUid) + .byFeatureType().notIn(FeatureType.NONE) + .blockingIsEmpty(); + } + @Override + public boolean teTypeAttributesHaveCoordinates(String typeId) { List teAttributes = d2.trackedEntityModule().trackedEntityTypeAttributes() - .byTrackedEntityTypeUid().eq(teiType) + .byTrackedEntityTypeUid().eq(typeId) .blockingGet(); List teAttributeUids = new ArrayList<>(); for (TrackedEntityTypeAttribute teTypeAttr : teAttributes) { teAttributeUids.add(teTypeAttr.trackedEntityAttribute().uid()); } - boolean teAttributeHasCoordinates = !d2.trackedEntityModule().trackedEntityAttributes() + return !d2.trackedEntityModule().trackedEntityAttributes() .byUid().in(teAttributeUids) - .byValueType().eq(ValueType.COORDINATE) + .byValueType().in(ValueType.COORDINATE, ValueType.GEOJSON) .blockingIsEmpty(); + } - boolean programAttributeHasCoordinates = false; - boolean eventHasCoordinates = false; - boolean eventDataElementHasCoordinates = false; - if (programUid != null) { - List programAttributes = d2.programModule().programTrackedEntityAttributes() - .byProgram().eq(programUid) - .blockingGet(); - List programAttributeUids = new ArrayList<>(); - for (ProgramTrackedEntityAttribute programAttr : programAttributes) { - programAttributeUids.add(programAttr.trackedEntityAttribute().uid()); - } - - programAttributeHasCoordinates = !d2.trackedEntityModule().trackedEntityAttributes() - .byUid().in(programAttributeUids) - .byValueType().eq(ValueType.COORDINATE) - .blockingIsEmpty(); - - eventHasCoordinates = !d2.programModule().programStages() - .byProgramUid().eq(programUid) - .byFeatureType().notIn(FeatureType.NONE) - .blockingIsEmpty(); - - - List events = d2.eventModule().eventQuery().byIncludeDeleted() - .eq(false) - .byProgram() - .eq(programUid) - .blockingGet(); - for (Event event : events) { - if (event.geometry() != null) { - eventDataElementHasCoordinates = true; - break; - } - } - + @Override + public boolean programAttributesHaveCoordinates(String programUid) { + List programAttributes = d2.programModule().programTrackedEntityAttributes() + .byProgram().eq(programUid) + .blockingGet(); + List programAttributeUids = new ArrayList<>(); + for (ProgramTrackedEntityAttribute programAttr : programAttributes) { + programAttributeUids.add(programAttr.trackedEntityAttribute().uid()); } - return teTypeHasCoordinates || - enrollmentHasCoordinates || - teAttributeHasCoordinates || - programAttributeHasCoordinates || - eventHasCoordinates || - eventDataElementHasCoordinates; + return !d2.trackedEntityModule().trackedEntityAttributes() + .byUid().in(programAttributeUids) + .byValueType().in(ValueType.COORDINATE, ValueType.GEOJSON) + .blockingIsEmpty(); + } + + @Override + public boolean eventsHaveCoordinates(String programUid) { + return !d2.eventModule().events() + .byDeleted().isFalse() + .byProgramUid().eq(programUid) + .byGeometryCoordinates().isNotNull() + .blockingIsEmpty(); } @Nullable diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchTeiLiveAdapter.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchTeiLiveAdapter.kt index e1b6e5375c..29e5b86bb3 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchTeiLiveAdapter.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchTeiLiveAdapter.kt @@ -85,12 +85,12 @@ class SearchTeiLiveAdapter( if (it.isOnline) { onDownloadTei.invoke( it.tei.uid(), - it.selectedEnrollment.uid(), + it.selectedEnrollment?.uid(), ) } else { onTeiClick.invoke( it.tei.uid(), - it.selectedEnrollment.uid(), + it.selectedEnrollment?.uid(), it.isOnline, ) } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt index 9ba650f305..ba4837b7e9 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt @@ -128,7 +128,7 @@ class TEICardMapper( ) checkEnrollmentStatus( list = list, - status = searchTEIModel.selectedEnrollment.status(), + status = searchTEIModel.selectedEnrollment?.status(), ) checkOverdue( @@ -218,7 +218,7 @@ class TEICardMapper( ) { val programNames = enrolledPrograms.map { it.name() } - if (programNames.size > 1) { + if (programNames.isNotEmpty()) { list.add( AdditionalInfoItem( key = resourceManager.getString(R.string.programs), diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt index e1acc90837..fda6a377e7 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt @@ -182,7 +182,6 @@ class TeiDashboardMobileActivity : getString(R.string.msg_network_connection_maps), ) } - binding.syncButton.visibility = if (programUid != null) View.VISIBLE else View.GONE binding.syncButton.setOnClickListener { openSyncDialog() } if (intent.shouldLaunchSyncDialog()) { openSyncDialog() 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 83eb34b1d5..78d3e1941b 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 @@ -268,6 +268,12 @@ class TEIDataFragment : FragmentGlobalAbstract(), TEIDataContracts.View { ) val card = teiDashboardCardMapper.map( dashboardModel = dashboardModel, + onImageClick = { fileToShow -> + ImageDetailBottomDialog( + null, + fileToShow, + ).show(childFragmentManager, ImageDetailBottomDialog.TAG) + }, phoneCallback = { openChooser(it, Intent.ACTION_DIAL) }, emailCallback = { openChooser(it, Intent.ACTION_SENDTO) }, programsCallback = { 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 3b58f97416..2feff90014 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 @@ -17,6 +17,7 @@ import org.dhis2.commons.bindings.event import org.dhis2.commons.bindings.program 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.filters.FilterManager import org.dhis2.commons.filters.data.FilterRepository @@ -144,13 +145,6 @@ class TEIDataPresenter( teiDataRepository.getTEIEnrollmentEvents( stageAndGrouping.first, stageAndGrouping.second, - filterManager.periodFilters, - filterManager.orgUnitUidsFilters, - filterManager.stateFilters, - filterManager.assignedFilter, - filterManager.eventStatusFilters, - filterManager.catOptComboFilters, - filterManager.sortingItem, ).toFlowable(), ruleEngineRepository.updateRuleEngine() .flatMap { ruleEngineRepository.reCalculate() }, @@ -266,7 +260,12 @@ class TEIDataPresenter( valueStore, ) stagesToHide = stagesToHide1 - return events + return events.filter { + when (it.type) { + EventViewModelType.STAGE -> !stagesToHide.contains(it.stage?.uid()) + EventViewModelType.EVENT -> !stagesToHide.contains(it.event?.programStage()) + } + } } @VisibleForTesting diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepository.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepository.kt index 649a208e49..59947a21d9 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepository.kt @@ -3,13 +3,8 @@ package org.dhis2.usescases.teiDashboard.dashboardfragments.teidata import io.reactivex.Single import org.dhis2.commons.data.EventViewModel import org.dhis2.commons.data.StageSection -import org.dhis2.commons.filters.sorting.SortingItem -import org.hisp.dhis.android.core.category.CategoryOptionCombo -import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.enrollment.Enrollment -import org.hisp.dhis.android.core.event.EventStatus import org.hisp.dhis.android.core.organisationunit.OrganisationUnit -import org.hisp.dhis.android.core.period.DatePeriod import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance @@ -17,13 +12,6 @@ interface TeiDataRepository { fun getTEIEnrollmentEvents( selectedStage: StageSection, groupedByStage: Boolean, - periodFilters: MutableList, - orgUnitFilters: MutableList, - stateFilters: MutableList, - assignedToMe: Boolean, - eventStatusFilters: MutableList, - catOptComboFilters: MutableList, - sortingItem: SortingItem?, ): Single> fun getEnrollment(): Single diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt index 9f7c259366..cd3e730370 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt @@ -1,29 +1,22 @@ package org.dhis2.usescases.teiDashboard.dashboardfragments.teidata import io.reactivex.Single -import org.dhis2.bindings.applyFilters import org.dhis2.bindings.profilePicturePath import org.dhis2.bindings.userFriendlyValue import org.dhis2.commons.data.EventViewModel import org.dhis2.commons.data.EventViewModelType import org.dhis2.commons.data.StageSection -import org.dhis2.commons.filters.Filters -import org.dhis2.commons.filters.sorting.SortingItem -import org.dhis2.commons.filters.sorting.SortingStatus import org.dhis2.data.dhislogic.DhisPeriodUtils import org.dhis2.utils.DateUtils import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope import org.hisp.dhis.android.core.category.CategoryCombo -import org.hisp.dhis.android.core.category.CategoryOptionCombo -import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.event.Event import org.hisp.dhis.android.core.event.EventCollectionRepository import org.hisp.dhis.android.core.event.EventStatus import org.hisp.dhis.android.core.organisationunit.OrganisationUnit -import org.hisp.dhis.android.core.period.DatePeriod import org.hisp.dhis.android.core.period.PeriodType import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance @@ -40,33 +33,13 @@ class TeiDataRepositoryImpl( override fun getTEIEnrollmentEvents( selectedStage: StageSection, groupedByStage: Boolean, - periodFilters: MutableList, - orgUnitFilters: MutableList, - stateFilters: MutableList, - assignedToMe: Boolean, - eventStatusFilters: MutableList, - catOptComboFilters: MutableList, - sortingItem: SortingItem?, ): Single> { - var eventRepo = d2.eventModule().events().byEnrollmentUid().eq(enrollmentUid) - - eventRepo = eventRepo.applyFilters( - periodFilters, - orgUnitFilters, - stateFilters, - if (assignedToMe) { - d2.userModule().user().blockingGet()?.uid() - } else { - null - }, - eventStatusFilters, - catOptComboFilters, - ) + val eventRepo = d2.eventModule().events().byEnrollmentUid().eq(enrollmentUid) return if (groupedByStage) { - getGroupedEvents(eventRepo, selectedStage, sortingItem) + getGroupedEvents(eventRepo, selectedStage) } else { - getTimelineEvents(eventRepo, sortingItem) + getTimelineEvents(eventRepo) } } @@ -161,13 +134,14 @@ class TeiDataRepositoryImpl( } override fun getTeiHeader(): String? { - return d2.trackedEntityModule().trackedEntitySearch().uid(teiUid).blockingGet()?.header + return d2.trackedEntityModule().trackedEntitySearch() + .byProgram().eq(programUid) + .uid(teiUid).blockingGet()?.header } private fun getGroupedEvents( eventRepository: EventCollectionRepository, selectedStage: StageSection, - sortingItem: SortingItem?, ): Single> { val eventViewModels = mutableListOf() var eventRepo: EventCollectionRepository @@ -181,8 +155,9 @@ class TeiDataRepositoryImpl( eventRepo = eventRepository.byDeleted().isFalse .byProgramStageUid().eq(programStage.uid()) - eventRepo = eventRepoSorting(sortingItem, eventRepo) - val eventList = eventRepo.blockingGet() + val eventList = eventRepo + .orderByTimeline(RepositoryScope.OrderByDirection.DESC) + .blockingGet() val isSelected = programStage.uid() == selectedStage.stageUid @@ -253,14 +228,11 @@ class TeiDataRepositoryImpl( private fun getTimelineEvents( eventRepository: EventCollectionRepository, - sortingItem: SortingItem?, ): Single> { val eventViewModels = mutableListOf() - var eventRepo = eventRepository - - eventRepo = eventRepoSorting(sortingItem, eventRepo) - return eventRepo + return eventRepository + .orderByTimeline(RepositoryScope.OrderByDirection.DESC) .byDeleted().isFalse .get() .map { eventList -> @@ -313,39 +285,6 @@ class TeiDataRepositoryImpl( } } - private fun eventRepoSorting( - sortingItem: SortingItem?, - eventRepo: EventCollectionRepository, - ): EventCollectionRepository { - return if (sortingItem != null) { - when (sortingItem.filterSelectedForSorting) { - Filters.ORG_UNIT -> - if (sortingItem.sortingStatus == SortingStatus.ASC) { - eventRepo.orderByOrganisationUnitName(RepositoryScope.OrderByDirection.ASC) - } else { - eventRepo.orderByOrganisationUnitName(RepositoryScope.OrderByDirection.DESC) - } - - Filters.PERIOD -> { - if (sortingItem.sortingStatus === SortingStatus.ASC) { - eventRepo - .orderByTimeline(RepositoryScope.OrderByDirection.ASC) - } else { - eventRepo - .orderByTimeline(RepositoryScope.OrderByDirection.DESC) - } - } - - else -> { - eventRepo - } - } - } else { - eventRepo - .orderByTimeline(RepositoryScope.OrderByDirection.DESC) - } - } - private fun checkEventStatus(events: List): List { return events.mapNotNull { event -> if (event.status() == EventStatus.SCHEDULE && diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TeiDetailDashboard.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TeiDetailDashboard.kt index 908a9f9d7e..5133a5cd43 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TeiDetailDashboard.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TeiDetailDashboard.kt @@ -1,5 +1,7 @@ package org.dhis2.usescases.teiDashboard.ui +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -22,53 +24,67 @@ fun TeiDetailDashboard( ) { LazyColumn( modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), + .fillMaxWidth(), ) { item { - if (syncData.showInfoBar) { - InfoBar( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) - .testTag(SYNC_INFO_BAR_TEST_TAG), - infoBarData = - InfoBarData( - text = syncData.text, - icon = syncData.icon, - color = syncData.textColor, - backgroundColor = syncData.backgroundColor, - actionText = syncData.actionText, - onClick = syncData.onActionClick, - ), - ) - } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + ) { + if (syncData.showInfoBar) { + InfoBar( + modifier = Modifier + .padding(start = 8.dp, end = 8.dp) + .testTag(SYNC_INFO_BAR_TEST_TAG), + infoBarData = + InfoBarData( + text = syncData.text, + icon = syncData.icon, + color = syncData.textColor, + backgroundColor = syncData.backgroundColor, + actionText = syncData.actionText, + onClick = syncData.onActionClick, + ), + ) + if (followUpData.showInfoBar || enrollmentData.showInfoBar) { + Spacer(modifier = Modifier.padding(top = 8.dp)) + } + } - if (followUpData.showInfoBar) { - InfoBar( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) - .testTag(FOLLOWUP_INFO_BAR_TEST_TAG), - infoBarData = InfoBarData( - text = followUpData.text, - icon = followUpData.icon, - color = followUpData.textColor, - backgroundColor = followUpData.backgroundColor, - actionText = followUpData.actionText, - onClick = followUpData.onActionClick, - ), - ) - } + if (followUpData.showInfoBar) { + InfoBar( + modifier = Modifier + .padding(start = 8.dp, end = 8.dp) + .testTag(FOLLOWUP_INFO_BAR_TEST_TAG), + infoBarData = InfoBarData( + text = followUpData.text, + icon = followUpData.icon, + color = followUpData.textColor, + backgroundColor = followUpData.backgroundColor, + actionText = followUpData.actionText, + onClick = followUpData.onActionClick, + ), + ) + if (enrollmentData.showInfoBar) { + Spacer(modifier = Modifier.padding(top = 8.dp)) + } + } - if (enrollmentData.showInfoBar) { - InfoBar( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 0.dp) - .testTag(STATE_INFO_BAR_TEST_TAG), - infoBarData = InfoBarData( - text = enrollmentData.text, - icon = enrollmentData.icon, - color = enrollmentData.textColor, - backgroundColor = enrollmentData.backgroundColor, - actionText = enrollmentData.actionText, - ), - ) + if (enrollmentData.showInfoBar) { + InfoBar( + modifier = Modifier + .padding(start = 8.dp, end = 8.dp) + .testTag(STATE_INFO_BAR_TEST_TAG), + infoBarData = InfoBarData( + text = enrollmentData.text, + icon = enrollmentData.icon, + color = enrollmentData.textColor, + backgroundColor = enrollmentData.backgroundColor, + actionText = enrollmentData.actionText, + ), + ) + } } } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/mapper/TeiDashboardCardMapper.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/mapper/TeiDashboardCardMapper.kt index c9f042ea4a..cc7f2784f0 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/mapper/TeiDashboardCardMapper.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/mapper/TeiDashboardCardMapper.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import org.dhis2.R +import org.dhis2.commons.data.tuples.Pair import org.dhis2.commons.date.toUi import org.dhis2.commons.resources.ResourceManager import org.dhis2.usescases.teiDashboard.DashboardProgramModel @@ -16,6 +17,8 @@ import org.dhis2.usescases.teiDashboard.ui.model.TeiCardUiModel import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.organisationunit.OrganisationUnit import org.hisp.dhis.android.core.program.Program +import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttribute +import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValue import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItem import org.hisp.dhis.mobile.ui.designsystem.component.Avatar import org.hisp.dhis.mobile.ui.designsystem.component.AvatarStyle @@ -29,12 +32,13 @@ class TeiDashboardCardMapper( fun map( dashboardModel: DashboardProgramModel, + onImageClick: (File) -> Unit, phoneCallback: (String) -> Unit, emailCallback: (String) -> Unit, programsCallback: () -> Unit, ): TeiCardUiModel { val avatar: @Composable (() -> Unit)? = if (dashboardModel.avatarPath.isNotEmpty()) { - { ProvideAvatar(item = dashboardModel) } + { ProvideAvatar(item = dashboardModel, onImageClick) } } else { null } @@ -42,7 +46,12 @@ class TeiDashboardCardMapper( return TeiCardUiModel( avatar = avatar, title = getTitle(dashboardModel), - additionalInfo = getAdditionalInfo(dashboardModel, phoneCallback, emailCallback, programsCallback), + additionalInfo = getAdditionalInfo( + dashboardModel, + phoneCallback, + emailCallback, + programsCallback, + ), actionButton = {}, expandLabelText = resourceManager.getString(R.string.show_more), shrinkLabelText = resourceManager.getString(R.string.show_less), @@ -51,13 +60,14 @@ class TeiDashboardCardMapper( } @Composable - private fun ProvideAvatar(item: DashboardProgramModel) { + private fun ProvideAvatar(item: DashboardProgramModel, onImageClick: (File) -> Unit) { val file = File(item.avatarPath) val bitmap = BitmapFactory.decodeFile(file.absolutePath).asImageBitmap() val painter = BitmapPainter(bitmap) Avatar( imagePainter = painter, + onImageClick = { onImageClick.invoke(file) }, style = AvatarStyle.IMAGE, ) } @@ -68,8 +78,9 @@ class TeiDashboardCardMapper( } else if (item.attributes.isEmpty()) { "-" } else { - val key = item.attributes.firstOrNull()?.val0()?.displayFormName() - val value = item.attributes.firstOrNull()?.val1()?.value() + val attribute = item.attributes.filterAttributes().firstOrNull() + val key = attribute?.val0()?.displayFormName() + val value = attribute?.val1()?.value() "$key: $value" } } @@ -81,11 +92,7 @@ class TeiDashboardCardMapper( programsCallback: () -> Unit, ): List { val attributesList = item.attributes - .asSequence() - .filter { it.val0().valueType() != ValueType.IMAGE } - .filter { it.val0().valueType() != ValueType.COORDINATE } - .filter { it.val0().valueType() != ValueType.FILE_RESOURCE } - .filter { it.val1().value()?.isNotEmpty() == true } + .filterAttributes() .map { if (it.val0().valueType() == ValueType.PHONE_NUMBER) { AdditionalInfoItem( @@ -149,7 +156,7 @@ class TeiDashboardCardMapper( ) } }.also { list -> - if (item.enrollmentActivePrograms.size > 1) { + if (item.enrollmentActivePrograms.isNotEmpty()) { addEnrollPrograms( list, item.enrollmentActivePrograms, @@ -216,4 +223,10 @@ class TeiDashboardCardMapper( ), ) } + + private fun List>.filterAttributes() = + this.filter { it.val0().valueType() != ValueType.IMAGE } + .filter { it.val0().valueType() != ValueType.COORDINATE } + .filter { it.val0().valueType() != ValueType.FILE_RESOURCE } + .filter { it.val1().value()?.isNotEmpty() == true } } diff --git a/app/src/main/java/org/dhis2/utils/analytics/AnalyticsInterceptor.kt b/app/src/main/java/org/dhis2/utils/analytics/AnalyticsInterceptor.kt index 109e64ab2f..7f3dde9e55 100644 --- a/app/src/main/java/org/dhis2/utils/analytics/AnalyticsInterceptor.kt +++ b/app/src/main/java/org/dhis2/utils/analytics/AnalyticsInterceptor.kt @@ -25,7 +25,11 @@ package org.dhis2.utils.analytics +import io.reactivex.Single +import io.reactivex.observers.DisposableSingleObserver +import io.reactivex.schedulers.Schedulers import okhttp3.Interceptor +import okhttp3.Request import okhttp3.Response import org.dhis2.BuildConfig import org.hisp.dhis.android.core.D2Manager @@ -39,17 +43,32 @@ class AnalyticsInterceptor(private val analyticHelper: AnalyticsHelper) : Interc val response = chain.proceed(request) if (response.code >= 400 && isLogged()) { - analyticHelper.trackMatomoEvent( - API_CALL, - "${request.method}_${request.url}", - "${response.code}_${appVersionName}_${getDhis2Version()}", - ) + trackMatomoEvent(request, response) } return response } - private fun getDhis2Version(): String? { - return D2Manager.getD2().systemInfoModule().systemInfo().blockingGet()?.version() + private fun trackMatomoEvent(request: Request, response: Response) { + getDhis2Version() + .subscribeOn(Schedulers.io()) + .subscribe(object : DisposableSingleObserver() { + override fun onSuccess(version: String) { + analyticHelper.trackMatomoEvent( + API_CALL, + "${request.method}_${request.url}", + "${response.code}_${appVersionName}_$version", + ) + dispose() + } + + override fun onError(e: Throwable) { + dispose() + } + }) + } + + private fun getDhis2Version(): Single { + return D2Manager.getD2().systemInfoModule().systemInfo().get().map { it.version() } } private fun isLogged(): Boolean { diff --git a/app/src/main/java/org/dhis2/utils/customviews/BreakTheGlassBottomDialog.kt b/app/src/main/java/org/dhis2/utils/customviews/BreakTheGlassBottomDialog.kt index d6fb3bafd5..2217d01c52 100644 --- a/app/src/main/java/org/dhis2/utils/customviews/BreakTheGlassBottomDialog.kt +++ b/app/src/main/java/org/dhis2/utils/customviews/BreakTheGlassBottomDialog.kt @@ -14,12 +14,10 @@ import org.dhis2.R import org.dhis2.commons.resources.ColorType import org.dhis2.commons.resources.ColorUtils import org.dhis2.databinding.BreakTheGlassBottomDialogBindingImpl -import javax.inject.Inject class BreakTheGlassBottomDialog : BottomSheetDialogFragment() { - @Inject - lateinit var colorUtils: ColorUtils + val colorUtils: ColorUtils = ColorUtils() fun setPositiveButton(onClick: ((String) -> Unit)? = null) = apply { this.positiveOnclick = onClick diff --git a/app/src/main/res/layout-land/activity_dashboard_mobile.xml b/app/src/main/res/layout-land/activity_dashboard_mobile.xml index 08dae35d84..71ed810355 100644 --- a/app/src/main/res/layout-land/activity_dashboard_mobile.xml +++ b/app/src/main/res/layout-land/activity_dashboard_mobile.xml @@ -73,6 +73,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="wrap_content" /> - - - - - - - - - - - + app:layout_constraintBottom_toBottomOf="parent" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="wrap_content" /> El campo es obligatorio Por razones de seguridad, esta versión de la app no permite el uso de adb. Por favor use la versión de formación. - ¡Vaya! Algo no ha ido como estaba previsto.\nDéjenos por todo en marcha de nuevo. + ¡Vaya! Algo no ha ido como estaba previsto.\nDéjenos poner todo en marcha de nuevo. Ir atrás Cerrar app Más detalles diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 311ca3be72..765818b11e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -972,4 +972,5 @@ Sync Error Mark for follow-up Remove + Coordinates diff --git a/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/datasetlist/DataSetListViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/datasetlist/DataSetListViewModelTest.kt index e56dc950a1..b618e7c74c 100644 --- a/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/datasetlist/DataSetListViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/datasetlist/DataSetListViewModelTest.kt @@ -102,6 +102,7 @@ class DataSetListViewModelTest { "", "", "", + "", State.SYNCED, "", true, diff --git a/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/datasetlist/mapper/DatasetCardMapperTest.kt b/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/datasetlist/mapper/DatasetCardMapperTest.kt index 51f353e37b..c8b802a156 100644 --- a/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/datasetlist/mapper/DatasetCardMapperTest.kt +++ b/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/datasetlist/mapper/DatasetCardMapperTest.kt @@ -40,6 +40,7 @@ class DatasetCardMapperTest { "", "", "", + "", "orgUnitName", "nameCatCombo", "Dataset PeriodName", @@ -54,6 +55,7 @@ class DatasetCardMapperTest { // When dataset is mapped to card item val result = mapper.map( dataset = datasetModel, + editable = true, onSyncIconClick = {}, onCardCLick = {}, ) diff --git a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventDetailsTest.kt b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventDetailsTest.kt index c1040ea221..62c84f257c 100644 --- a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventDetailsTest.kt +++ b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventDetailsTest.kt @@ -111,17 +111,22 @@ class ConfigureEventDetailsTest { whenever(event.status()) doReturn EventStatus.ACTIVE whenever(repository.getEditableStatus()) doReturn Editable() + // And event creation should be completed + val selectedDate = Date() + val selectedOrgUnit = ORG_UNIT_UID + val isCatComboCompleted = true + // When button is checked val eventDetails = configureEventDetails.invoke( - selectedDate = null, - selectedOrgUnit = null, + selectedDate = selectedDate, + selectedOrgUnit = selectedOrgUnit, catOptionComboUid = null, - isCatComboCompleted = false, + isCatComboCompleted = isCatComboCompleted, coordinates = null, tempCreate = null, ).first() - // Then action button should be invisible + // Then action button should be visible assertTrue(eventDetails.isActionButtonVisible) assert(eventDetails.actionButtonText.equals(UPDATE)) } diff --git a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchPageConfiguratorTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchPageConfiguratorTest.kt new file mode 100644 index 0000000000..88ef4cf2f4 --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchPageConfiguratorTest.kt @@ -0,0 +1,93 @@ +package org.dhis2.usescases.searchTrackEntity + +import com.google.common.truth.Truth.assertThat +import org.hisp.dhis.android.core.common.FeatureType +import org.hisp.dhis.android.core.program.Program +import org.hisp.dhis.android.core.settings.AnalyticsDhisVisualizationsGroup +import org.hisp.dhis.android.core.trackedentity.TrackedEntityType +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +class SearchPageConfiguratorTest { + + private val searchRepository: SearchRepository = mock() + private val program: Program = mock() + private val teType: TrackedEntityType = mock() + private val visualizationGroup: AnalyticsDhisVisualizationsGroup = mock() + + private val programUid: String = "programUid" + private val teTypeUid: String = "teTypeUid" + + private lateinit var configurator: SearchPageConfigurator + + @Before + fun setup() { + whenever(searchRepository.currentProgram()).doReturn(programUid) + whenever(searchRepository.getProgram(programUid)).doReturn(program) + whenever(program.featureType()).doReturn(FeatureType.POINT) + whenever(program.uid()).doReturn(programUid) + whenever(program.trackedEntityType()).doReturn(teType) + whenever(teType.uid()).doReturn(teTypeUid) + + configurator = SearchPageConfigurator(searchRepository) + } + + @Test + fun display_map_shortcut_to_false_if_no_program() { + whenever(searchRepository.currentProgram()).doReturn(null) + + val hasCoordinates = configurator.programHasCoordinates() + + assertThat(hasCoordinates).isFalse() + + verify(searchRepository).currentProgram() + verifyNoMoreInteractions(searchRepository) + } + + @Test + fun display_map_shortcut_to_true_if_program_has_coordinates() { + val hasCoordinates = configurator.programHasCoordinates() + + assertThat(hasCoordinates).isTrue() + + verify(searchRepository).currentProgram() + verify(searchRepository).getProgram(programUid) + verifyNoMoreInteractions(searchRepository) + } + + @Test + fun display_map_do_not_shortcut_if_no_coordinates() { + whenever(program.featureType()).doReturn(FeatureType.NONE) + + val hasCoordinates = configurator.programHasCoordinates() + + assertThat(hasCoordinates).isFalse() + + verify(searchRepository).currentProgram() + verify(searchRepository).getProgram(programUid) + verify(searchRepository).programStagesHaveCoordinates(programUid) + verify(searchRepository).programAttributesHaveCoordinates(programUid) + verify(searchRepository).trackedEntityType + verify(searchRepository).teTypeAttributesHaveCoordinates(any()) + verify(searchRepository).eventsHaveCoordinates(programUid) + verifyNoMoreInteractions(searchRepository) + } + + @Test + fun display_analytics_shortcut_to_false_if_no_program() { + whenever(searchRepository.currentProgram()).doReturn(null) + + val hasAnalytics = configurator.programHasAnalytics() + + assertThat(hasAnalytics).isFalse() + + verify(searchRepository).currentProgram() + verifyNoMoreInteractions(searchRepository) + } +} diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/TEIDetailMapperTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/TEIDetailMapperTest.kt index a674bce5fa..e007fe46dd 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/TEIDetailMapperTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/TEIDetailMapperTest.kt @@ -43,6 +43,7 @@ class TEIDetailMapperTest { phoneCallback = {}, emailCallback = {}, programsCallback = {}, + onImageClick = {}, ) diff --git a/commons/src/main/java/org/dhis2/commons/bindings/TEICardExtensions.kt b/commons/src/main/java/org/dhis2/commons/bindings/TEICardExtensions.kt index 614900cdff..05e26bc37f 100644 --- a/commons/src/main/java/org/dhis2/commons/bindings/TEICardExtensions.kt +++ b/commons/src/main/java/org/dhis2/commons/bindings/TEICardExtensions.kt @@ -246,7 +246,7 @@ fun SearchTeiModel.setTeiImage( teiImageView.setImageDrawable(null) teiTextImageView.visibility = View.VISIBLE val valueToShow = ArrayList(textAttributeValues.values) - if (valueToShow[0] == null) { + if (valueToShow[0]?.value()?.isEmpty() != false) { teiTextImageView.text = "?" } else { teiTextImageView.text = valueToShow[0].value()?.first().toString().toUpperCase() 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 242d3aba73..475f12f888 100644 --- a/commons/src/main/java/org/dhis2/commons/data/SearchTeiModel.java +++ b/commons/src/main/java/org/dhis2/commons/data/SearchTeiModel.java @@ -14,6 +14,7 @@ import java.util.Date; import java.util.LinkedHashMap; import java.util.List; +import java.util.Objects; public class SearchTeiModel implements CarouselItemModel { @@ -170,7 +171,17 @@ public List getEnrollments() { public List getProgramInfo() { Collections.sort(programInfo, (program1, program2) -> program1.displayName().compareToIgnoreCase(program2.displayName())); - return programInfo; + if (selectedEnrollment != null) { + List programs = new ArrayList<>(); + for (Program program : programInfo) { + if (!Objects.equals(selectedEnrollment.program(), program.uid())) { + programs.add(program); + } + } + return programs; + } else { + return programInfo; + } } public void setOverdueDate(Date dateToShow) { diff --git a/commons/src/main/java/org/dhis2/commons/featureconfig/data/FeatureConfigRepositoryImpl.kt b/commons/src/main/java/org/dhis2/commons/featureconfig/data/FeatureConfigRepositoryImpl.kt index 5ec79ce3ab..617c3c4049 100644 --- a/commons/src/main/java/org/dhis2/commons/featureconfig/data/FeatureConfigRepositoryImpl.kt +++ b/commons/src/main/java/org/dhis2/commons/featureconfig/data/FeatureConfigRepositoryImpl.kt @@ -3,10 +3,14 @@ package org.dhis2.commons.featureconfig.data import org.dhis2.commons.featureconfig.model.Feature import org.dhis2.commons.featureconfig.model.FeatureState import org.dhis2.commons.prefs.PreferenceProvider +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.settings.ExperimentalFeature import javax.inject.Inject -class FeatureConfigRepositoryImpl @Inject constructor(val preferences: PreferenceProvider) : - FeatureConfigRepository { +class FeatureConfigRepositoryImpl @Inject constructor( + val preferences: PreferenceProvider, + val d2: D2, +) : FeatureConfigRepository { override val featuresList: List get() = Feature.entries.map { @@ -17,5 +21,12 @@ class FeatureConfigRepositoryImpl @Inject constructor(val preferences: Preferenc preferences.setValue(featureState.feature.name, !featureState.enable) } - override fun isFeatureEnable(feature: Feature) = preferences.getBoolean(feature.name, false) + override fun isFeatureEnable(feature: Feature): Boolean { + return if (preferences.contains(feature.name)) { + preferences.getBoolean(feature.name, false) + } else { + d2.settingModule().generalSetting() + .hasExperimentalFeature(ExperimentalFeature.NewFormLayout).blockingGet() + } + } } diff --git a/commons/src/main/java/org/dhis2/commons/featureconfig/di/FeatureConfigModule.kt b/commons/src/main/java/org/dhis2/commons/featureconfig/di/FeatureConfigModule.kt index 9293dce433..81059726b7 100644 --- a/commons/src/main/java/org/dhis2/commons/featureconfig/di/FeatureConfigModule.kt +++ b/commons/src/main/java/org/dhis2/commons/featureconfig/di/FeatureConfigModule.kt @@ -5,12 +5,15 @@ import dagger.Provides import org.dhis2.commons.featureconfig.data.FeatureConfigRepository import org.dhis2.commons.featureconfig.data.FeatureConfigRepositoryImpl import org.dhis2.commons.prefs.PreferenceProvider +import org.hisp.dhis.android.core.D2Manager @Module class FeatureConfigModule { @Provides fun provideRepository(preferenceProvider: PreferenceProvider): FeatureConfigRepository { - return FeatureConfigRepositoryImpl(preferenceProvider) + return FeatureConfigRepositoryImpl(preferenceProvider, provideD2()) } + + private fun provideD2() = D2Manager.getD2() } diff --git a/commons/src/main/java/org/dhis2/commons/featureconfig/ui/FeatureConfigViewModel.kt b/commons/src/main/java/org/dhis2/commons/featureconfig/ui/FeatureConfigViewModel.kt index cf79ebb43b..6482b81f4a 100644 --- a/commons/src/main/java/org/dhis2/commons/featureconfig/ui/FeatureConfigViewModel.kt +++ b/commons/src/main/java/org/dhis2/commons/featureconfig/ui/FeatureConfigViewModel.kt @@ -6,7 +6,7 @@ import androidx.lifecycle.ViewModel import org.dhis2.commons.featureconfig.data.FeatureConfigRepository import org.dhis2.commons.featureconfig.model.FeatureState -class FeatureConfigViewModel constructor( +class FeatureConfigViewModel( private val repository: FeatureConfigRepository, ) : ViewModel() { diff --git a/commons/src/main/java/org/dhis2/commons/filters/workingLists/WorkingListChipGroup.kt b/commons/src/main/java/org/dhis2/commons/filters/workingLists/WorkingListChipGroup.kt index b5f3e5bba0..176adc638f 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/workingLists/WorkingListChipGroup.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/workingLists/WorkingListChipGroup.kt @@ -79,7 +79,11 @@ fun WorkingListChipGroup( workingListViewModel: WorkingListViewModel, ) { val workingListFilterState = workingListViewModel.workingListFilter.observeAsState() - var selectedWorkingList by remember { mutableStateOf(null) } + var selectedWorkingList by remember { + mutableStateOf( + FilterManager.getInstance().currentWorkingList(), + ) + } workingListFilterState.value?.let { workingListFilter -> LazyRow(modifier) { @@ -95,15 +99,12 @@ fun WorkingListChipGroup( ), label = workingList.label, selected = selectedWorkingList == workingList, - onSelected = { _ -> workingListFilter.onChecked(workingList.id()) }, + onSelected = { _ -> + workingListFilter.onChecked(workingList.id()) + selectedWorkingList = FilterManager.getInstance().currentWorkingList() + }, ) } } - workingListFilter.observeScope() - .addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() { - override fun onPropertyChanged(sender: Observable?, propertyId: Int) { - selectedWorkingList = FilterManager.getInstance().currentWorkingList() - } - }) } } diff --git a/compose-table/src/main/java/org/dhis2/composetable/ui/DataTable.kt b/compose-table/src/main/java/org/dhis2/composetable/ui/DataTable.kt index fdc6519d60..365b5ca459 100644 --- a/compose-table/src/main/java/org/dhis2/composetable/ui/DataTable.kt +++ b/compose-table/src/main/java/org/dhis2/composetable/ui/DataTable.kt @@ -28,13 +28,14 @@ fun DataTable(tableList: List, bottomContent: @Composable (() -> Uni Table( tableList = tableList, tableHeaderRow = { index, tableModel -> + val isSingleValue = tableModel.tableRows.firstOrNull()?.values?.size == 1 TableHeaderRow( modifier = Modifier .background(Color.White), cornerUiState = TableCornerUiState( isSelected = tableSelection.isCornerSelected(tableModel.id ?: ""), onTableResize = { - if (tableModel.tableRows.first().values.size == 1) { + if (isSingleValue) { tableResizeActions.onRowHeaderResize( tableModel.id ?: "", it, @@ -47,7 +48,7 @@ fun DataTable(tableList: List, bottomContent: @Composable (() -> Uni } }, onResizing = { resizingCell = it }, - singleValueTable = tableModel.tableRows.first().values.size == 1, + singleValueTable = isSingleValue, ), tableModel = tableModel, horizontalScrollState = horizontalScrollStates[index], diff --git a/dhis2_android_maps/build.gradle.kts b/dhis2_android_maps/build.gradle.kts index df3e71b938..9f56ae1cc9 100644 --- a/dhis2_android_maps/build.gradle.kts +++ b/dhis2_android_maps/build.gradle.kts @@ -35,6 +35,7 @@ android { } buildFeatures { + compose = true dataBinding = true } @@ -43,6 +44,10 @@ android { kotlinOptions { jvmTarget = "17" } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.kotlinCompilerExtensionVersion.get() + } } dependencies { diff --git a/dhis2_android_maps/src/main/java/org/dhis2/maps/geometry/point/PointAdapter.kt b/dhis2_android_maps/src/main/java/org/dhis2/maps/geometry/point/PointAdapter.kt deleted file mode 100644 index 7adeb777b7..0000000000 --- a/dhis2_android_maps/src/main/java/org/dhis2/maps/geometry/point/PointAdapter.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.dhis2.maps.geometry.point - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.databinding.DataBindingUtil -import androidx.recyclerview.widget.RecyclerView -import org.dhis2.maps.R -import org.dhis2.maps.databinding.ItemPointGeoBinding - -class PointAdapter( - val viewModel: PointViewModel, -) : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { - val binding: ItemPointGeoBinding = DataBindingUtil.inflate( - LayoutInflater.from(parent.context), - R.layout.item_point_geo, - parent, - false, - ) - return Holder(binding) - } - - override fun getItemCount(): Int { - return 1 - } - - override fun onBindViewHolder(holder: Holder, position: Int) { - holder.bind() - } - - inner class Holder(val binding: ItemPointGeoBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind() { - binding.viewModel = viewModel - } - } -} diff --git a/dhis2_android_maps/src/main/java/org/dhis2/maps/views/MapSelectorActivity.kt b/dhis2_android_maps/src/main/java/org/dhis2/maps/views/MapSelectorActivity.kt index 83b2b391db..bfeeb6bf57 100644 --- a/dhis2_android_maps/src/main/java/org/dhis2/maps/views/MapSelectorActivity.kt +++ b/dhis2_android_maps/src/main/java/org/dhis2/maps/views/MapSelectorActivity.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle +import android.view.View import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat @@ -14,7 +15,6 @@ import androidx.core.graphics.drawable.toBitmap import androidx.databinding.DataBindingUtil import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager import com.mapbox.geojson.Feature import com.mapbox.geojson.Point import com.mapbox.geojson.Polygon @@ -35,13 +35,11 @@ import com.mapbox.mapboxsdk.style.sources.GeoJsonSource import org.dhis2.commons.extensions.truncate import org.dhis2.maps.R import org.dhis2.maps.camera.initCameraToViewAllElements -import org.dhis2.maps.camera.moveCameraToDevicePosition import org.dhis2.maps.camera.moveCameraToPosition import org.dhis2.maps.databinding.ActivityMapSelectorBinding import org.dhis2.maps.extensions.polygonToLatLngBounds import org.dhis2.maps.extensions.toLatLng import org.dhis2.maps.geometry.bound.GetBoundingBox -import org.dhis2.maps.geometry.point.PointAdapter import org.dhis2.maps.geometry.point.PointViewModel import org.dhis2.maps.geometry.polygon.PolygonAdapter import org.dhis2.maps.geometry.polygon.PolygonViewModel @@ -50,6 +48,8 @@ import org.dhis2.maps.location.MapActivityLocationCallback import org.hisp.dhis.android.core.arch.helpers.GeometryHelper import org.hisp.dhis.android.core.common.FeatureType import org.hisp.dhis.android.core.common.Geometry +import org.hisp.dhis.mobile.ui.designsystem.component.Button +import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle class MapSelectorActivity : AppCompatActivity(), @@ -60,6 +60,14 @@ class MapSelectorActivity : init = true if (initialCoordinates == null) { map.moveCameraToPosition(latLng) + getPointViewModel()?.let { + val point = + Point.fromLngLat( + latLng.longitude.truncate(), + latLng.latitude.truncate(), + ) + setPointToViewModel(point, it) + } } } } @@ -79,6 +87,10 @@ class MapSelectorActivity : BaseMapManager(this, emptyList()) } + private var onSaveButtonClick: (() -> Unit)? = null + + private var pointViewModel: PointViewModel? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = DataBindingUtil.setContentView(this, R.layout.activity_map_selector) @@ -112,19 +124,29 @@ class MapSelectorActivity : binding.mapPositionButton.setOnClickListener { centerCameraOnMyPosition() } + + binding.saveButton.setContent { + Button( + style = ButtonStyle.FILLED, + text = resources.getString(R.string.done), + onClick = { onSaveButtonClick?.invoke() }, + ) + } } private fun centerCameraOnMyPosition() { - val isLocationActivated = - map.locationComponent.isLocationComponentActivated - if (isLocationActivated) { + if (ActivityCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_FINE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED + ) { + val isLocationActivated = map.locationComponent.isLocationComponentActivated val isLocationEnabled = map.locationComponent.isLocationComponentEnabled - if (isLocationEnabled) { + if (isLocationActivated && isLocationEnabled) { map.locationComponent.lastKnownLocation?.let { val latLong = LatLng(it) - map.moveCameraToDevicePosition(latLong) - if (locationType == FeatureType.POINT) { - val viewModel = ViewModelProvider(this)[PointViewModel::class.java] + map.moveCameraToPosition(latLong) + getPointViewModel()?.let { viewModel -> val point = Point.fromLngLat( latLong.longitude.truncate(), @@ -133,7 +155,19 @@ class MapSelectorActivity : setPointToViewModel(point, viewModel) } } + } else { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), + CENTER_MY_POSITION, + ) } + } else { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), + CENTER_MY_POSITION, + ) } } @@ -160,31 +194,38 @@ class MapSelectorActivity : } } - private fun bindPoint(initialCoordinates: String?) { - val viewModel = ViewModelProvider(this)[PointViewModel::class.java] - binding.recycler.layoutManager = LinearLayoutManager(this) - binding.recycler.adapter = PointAdapter(viewModel) - map.addOnMapClickListener { - val point = Point.fromLngLat(it.longitude.truncate(), it.latitude.truncate()) - setPointToViewModel(point, viewModel) - true - } - binding.saveButton.setOnClickListener { - val value = viewModel.getPointAsString() - value?.let { - finishResult(it) - } + private fun getPointViewModel(): PointViewModel? { + if (locationType == FeatureType.POINT && pointViewModel == null) { + pointViewModel = ViewModelProvider(this)[PointViewModel::class.java] } + return pointViewModel + } - if (initialCoordinates != null) { - val initGeometry = - Geometry.builder().coordinates(initialCoordinates).type(locationType).build() - val pointGeometry = GeometryHelper.getPoint(initGeometry) - pointGeometry.let { sdkPoint -> - val point = Point.fromLngLat(sdkPoint[0], sdkPoint[1]) + private fun bindPoint(initialCoordinates: String?) { + getPointViewModel()?.let { viewModel -> + binding.recycler.visibility = View.GONE + map.addOnMapClickListener { + val point = Point.fromLngLat(it.longitude.truncate(), it.latitude.truncate()) setPointToViewModel(point, viewModel) + true + } + onSaveButtonClick = { + val value = viewModel.getPointAsString() + value?.let { + finishResult(it) + } + } + + if (initialCoordinates != null) { + val initGeometry = + Geometry.builder().coordinates(initialCoordinates).type(locationType).build() + val pointGeometry = GeometryHelper.getPoint(initGeometry) + pointGeometry.let { sdkPoint -> + val point = Point.fromLngLat(sdkPoint[0], sdkPoint[1]) + setPointToViewModel(point, viewModel) + } + map.moveCameraToPosition(pointGeometry.toLatLng()) } - map.moveCameraToPosition(pointGeometry.toLatLng()) } } @@ -275,7 +316,7 @@ class MapSelectorActivity : viewModel.add(polygonPoint) true } - binding.saveButton.setOnClickListener { + onSaveButtonClick = { val value = viewModel.getPointAsString() value?.let { finishResult(it) @@ -415,11 +456,21 @@ class MapSelectorActivity : centerMapOnCurrentLocation() } } + + CENTER_MY_POSITION -> { + if (grantResults.isNotEmpty() && + grantResults[0] == PackageManager.PERMISSION_GRANTED + ) { + enableLocationComponent() + centerCameraOnMyPosition() + } + } } } companion object { private const val ACCESS_LOCATION_PERMISSION_REQUEST = 102 + private const val CENTER_MY_POSITION = 103 const val DATA_EXTRA = "data_extra" const val LOCATION_TYPE_EXTRA = "LOCATION_TYPE_EXTRA" const val INITIAL_GEOMETRY_COORDINATES = "INITIAL_DATA" diff --git a/dhis2_android_maps/src/main/res/layout/activity_map_selector.xml b/dhis2_android_maps/src/main/res/layout/activity_map_selector.xml index 8b414e0808..94353f4f54 100644 --- a/dhis2_android_maps/src/main/res/layout/activity_map_selector.xml +++ b/dhis2_android_maps/src/main/res/layout/activity_map_selector.xml @@ -38,24 +38,6 @@ android:text="@string/select_location" android:textSize="20sp" app:layout_constraintStart_toEndOf="@id/back" /> - - - - - - - + app:layout_constraintTop_toBottomOf="@id/mapContainer"> -