diff --git a/.github/workflows/rampcapture-github-release-signed-apk.yml b/.github/workflows/rampcapture-github-release-signed-apk.yml index 1153174ad1c..09f3054595d 100644 --- a/.github/workflows/rampcapture-github-release-signed-apk.yml +++ b/.github/workflows/rampcapture-github-release-signed-apk.yml @@ -87,6 +87,7 @@ jobs: - name: Build signed release APK run: ./gradlew app:assembleDhis2Release env: + RAMP_CAPTURE_VERSION: ${{ steps.release-info.outputs.forkNumber }} SIGNING_KEY_ALIAS: ${{ secrets.KEY_ALIAS }} SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} SIGNING_STORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} diff --git a/README.md b/README.md index f2aacb4d8f4..2e3769bb7bf 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | Infra: signed APK releases | [simcapture-github-release-signed-apk.yml](.github/workflows/simcapture-github-release-signed-apk.yml) | New file | GitHub Action to create a GitHub Release on a merge to `main` | | Infra: signed APK releases | [app/build.gradle.kts#L85](app/build.gradle.kts#L85) | Code change | App's Package ID set to `com.simprints.simcapture` | | Infra: GitHub Actions filtering | [simcapture-disable-upstream-workflows.yml](.github/workflows/simcapture-disable-upstream-workflows.yml) | New file | GitHub Action to limit allowed Actions to the ones starting with `simcapture-` or `copilot-` | -| Docs: fork-specific README | [README.md#L1-L70](README.md#L1-L70) | Code change | This section in README | +| Docs: fork-specific README | [README.md#L1-L125](README.md#L1-L125) | Code change | This section in README | | Enrolment+ Possible Duplicates | [CustomIntentActivityResultContract.kt#L68](form/src/main/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContract.kt#L68) | Code addition | Treats non-identify Simprints callouts that return identification matches as `PossibleDuplicates` results | | Enrolment+ Possible Duplicates | [FormView.kt#L654](form/src/main/java/org/dhis2/form/ui/FormView.kt#L654) | Code addition | Stores the returned SID session and hands the enrolment form off to the possible duplicates search flow | | Enrolment+ Possible Duplicates | [EnrollmentActivity.kt#L281](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L281) | Code addition | Launches possible duplicates search from the enrolment form, carrying the biometric field and returned GUID matches | @@ -52,14 +52,14 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | Identification+ Enrol Last | [SimprintsRememberCustomIntentFormPresenter.kt](form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt) | New file | Builds the Simprints form presenter from Compose field state and placeholder resources | | Identification Confirm Identity | [SearchTEActivity.kt#L128](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L128) | Code addition | Registers the confirm-identity SID launcher and waits for Simprints navigation before opening dashboards | | Identification Confirm Identity | [SearchTEIViewModel.kt#L847](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L847) | Code addition | Intercepts TEI openings from biometric search to launch Confirm Identity when required | -| Identification Confirm Identity | [SearchTEModule.java#L361](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L361) | Code addition | DI for separate Simprints-specific components for Identification Confirm Identity | +| Identification Confirm Identity | [SearchTEModule.java#L374](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L374) | Code addition | DI for separate Simprints-specific components for Identification Confirm Identity | | Identification Confirm Identity | [SearchTeiViewModelFactory.kt#L48](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L48) | Code addition | Passes Simprints search state into `SearchTEIViewModel` | | Identification Confirm Identity | [SimprintsSearchViewModelFactory.kt](app/src/main/java/org/dhis2/simprints/di/SimprintsSearchViewModelFactory.kt) | New file | Creates activity-scoped Simprints search ViewModel so Confirm Identity and Enrol Last state survives configuration changes | | Identification Confirm Identity | [SimprintsSearchViewModel.kt](app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt) | New file | Coordinates biometric search handoff for Enrol Last labels and Confirm Identity navigation | | Identification Confirm Identity | [SimprintsResolveConfirmIdentityCalloutUseCase.kt](commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCase.kt) | New file | Resolves the selected TEI's biometric GUID and prepares the `CONFIRM_IDENTITY` callout | | ModuleID in Identification | [CustomIntentRepositoryImpl.kt#L105](commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt#L105) | Code addition | Overrides `moduleId` for Simprints identification intents with the current user's Org Unit value | | Identification result ordering by score | [SearchTEIViewModel.kt#L715](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L715) | Code addition | Routes biometric search loading through SID-order-aware lookup so results follow identification match score order | -| Identification result ordering by score | [SearchTEModule.java#L353](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L353) | Code change | DI for separate Simprints-specific components for Identification result ordering by score | +| Identification result ordering by score | [SearchTEModule.java#L366](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L366) | Code change | DI for separate Simprints-specific components for Identification result ordering by score | | Identification result ordering by score | [SearchTeiViewModelFactory.kt#L49](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L49) | Code addition | Passes the ordered-result use case into `SearchTEIViewModel` | | Identification result ordering by score | [SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt](commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt) | New file | Orders matching TEIs to follow the SID identification response order | | Identification with less buttons | [ParameterSelectorItemProvider.kt#L92](app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/provider/ParameterSelectorItemProvider.kt#L92) | Code addition | Starts Simprints identification callout immediately on biometric search field click instead of expanding the field | @@ -71,34 +71,56 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | MFID Auto-Open Record | [SimprintsSearchViewModel.kt#L187](app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt#L187) | Code addition | Consumes an eligible single MFID identification to open the matched record directly instead of showing the result list | | MFID Auto-Open Record | [SimprintsResolveSingleBiometricSearchNavigationUseCase.kt](app/src/main/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt) | New file | Resolves the biometric search to exactly one TEI before auto-opening the record | | RAMP Infra: signed APK releases | [rampcapture-github-release-signed-apk.yml](.github/workflows/rampcapture-github-release-signed-apk.yml) | New file | GitHub Action to create a RAMPcapture GitHub Release on a merge to `ramp-main` | +| RAMP Infra: version info | [rampcapture-github-release-signed-apk.yml#L90](.github/workflows/rampcapture-github-release-signed-apk.yml#L90) | Code addition | Passes the computed RAMPcapture fork number into the signed release APK build as `RAMP_CAPTURE_VERSION` | +| RAMP Infra: version info | [app/build.gradle.kts#L96](app/build.gradle.kts#L96) | Code addition | Reads `RAMP_CAPTURE_VERSION`, falls back to `local build`, and exposes it as `BuildConfig.RAMP_CAPTURE_VERSION` | +| RAMP Infra: version info | [ContextExtensions.kt#L17](app/src/main/java/org/dhis2/bindings/ContextExtensions.kt#L17) | Code addition | Formats the RAMPcapture build version for About, including commit SHA if a non-release build | +| RAMP Infra: version info | [fragment_about.xml#L31](app/src/main/res/layout/fragment_about.xml#L31) | Code addition | Adds a RAMPcapture version row above the upstream DHIS2 app and SDK version labels on the About screen | +| RAMP Infra: version info | [AboutFragment.kt#L45](app/src/main/java/org/dhis2/usescases/about/AboutFragment.kt#L45) | Code addition | Binds the RAMPcapture version text while keeping the DHIS2 app and SDK version | +| RAMP Infra: version info | [strings.xml#L363](app/src/main/res/values/strings.xml#L363) | Code addition | Adds the `RAMPcapture Version: %s` label for the new About screen row | | RAMP ACF-1,39,40,41: datastore config | [RampDatastoreConfig.kt#L5](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L5) | New file | Defines the RAMP datastore config schema for enabled data-element history charts and program-stage history tables | -| RAMP ACF-1,39,40,41: datastore config | [RampDatastoreRepository.kt#L13](commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt#L13) | New file | Reads, validates, caches, and downloads the `simprints/ramp` DHIS2 datastore config used by RAMP features | +| RAMP ACF-1,39,40,41: datastore config | [RampDatastoreConfig.kt#L27](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L27) | Code addition | Adds optional per-chart decimal-place limits to the datastore config | +| RAMP ACF-1,39,40,41: datastore config | [RampDatastoreRepository.kt#L15](commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt#L15) | New file | Reads, validates, caches, and downloads the `simprints/ramp` DHIS2 datastore config used by RAMP features | | RAMP ACF-1,39,40,41: datastore config | [SyncPresenterImpl.kt#L272](app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt#L272) | Code addition | Syncs the RAMP datastore config after metadata download so charts and tables use current server configuration | | RAMP ACF-1: field history charts | [EventRepository.kt#L776](form/src/main/java/org/dhis2/form/data/EventRepository.kt#L776) | Code addition | Attaches a configured RAMP history chart to matching event fields when form items load or rules update fields | | RAMP ACF-1: field history charts | [FormHistoryChartRepository.kt#L10](form/src/main/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepository.kt#L10) | New file | Builds per-field visit history chart data from the current enrollment's configured follow-up visit events | -| RAMP ACF-1: field history charts | [FormHistoryChartView.kt#L48](form/src/main/java/org/dhis2/form/simprints/ramp/ui/FormHistoryChartView.kt#L48) | New file | Renders the configured field history chart as a line chart with visit-number X-axis labels | +| RAMP ACF-1: field history charts | [FormHistoryChart.kt#L8](form/src/main/java/org/dhis2/form/simprints/ramp/model/FormHistoryChart.kt#L8) | Code addition | Carries the configured decimal-place limit with chart state | +| RAMP ACF-1: field history charts | [FormHistoryChartView.kt#L49](form/src/main/java/org/dhis2/form/simprints/ramp/ui/FormHistoryChartView.kt#L49) | New file | Renders the configured field history chart as a line chart with visit-number X-axis labels | | RAMP ACF-1: field history charts | [FieldProvider.kt#L164](form/src/main/java/org/dhis2/form/ui/provider/inputfield/FieldProvider.kt#L164) | Code change | Displays the RAMP history chart directly under the matching input field | | RAMP ACF-1: field history charts | [FormViewModel.kt#L313](form/src/main/java/org/dhis2/form/ui/FormViewModel.kt#L313) | Code change | Keeps the plotted current-event value live while the user edits a field | -| RAMP ACF-39,40,41: history tables | [EventHistoryTableRepository.kt#L19](app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt#L19) | New file | Builds visit-column history tables from configured follow-up visit events, grouped by program-stage sections | +| RAMP ACF-39,40,41: history tables | [EventHistoryTableRepository.kt#L20](app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt#L20) | New file | Builds visit-column history tables from configured follow-up visit events, grouped by program-stage sections | +| RAMP ACF-39,40,41: history tables | [EventHistoryTableRepository.kt#L212](app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt#L212) | Code change | Orders table rows to follow the configured program-stage section data-element order | | RAMP ACF-39,40,41: history tables | [EventHistoryTableScreen.kt#L41](app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableScreen.kt#L41) | New file | Renders the history table with visit columns, date row, section headers, loading, empty, and error states | | RAMP ACF-39,40,41: history tables | [EventHistoryTableFragment.kt#L23](app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableFragment.kt#L23) | New file | Shows the history table in event-capture and TEI dashboard contexts using event or enrollment arguments | | RAMP ACF-39,40,41: history tables | [EventCaptureRepositoryImpl.java#L217](app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java#L217) | Code addition | Detects whether the current event's program stage has a configured RAMP history table | -| RAMP ACF-39,40,41: history tables | [EventCapturePagerAdapter.kt#L66](app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePagerAdapter.kt#L66) | Code change | Adds the event-capture History page and maps it to the table navigation item | -| RAMP ACF-39,40,41: history tables | [EventCapturePresenterImpl.kt#L155](app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePresenterImpl.kt#L155) | Code change | Adds the RAMP History navigation item to program stage (event) screens (where configured) | -| RAMP ACF-39,40,41: history tables | [EventCaptureActivity.kt#L278](app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt#L278) | Code change | Expands the event-capture history table to full width in landscape and restores the split layout afterwards | +| RAMP ACF-39,40,41: history tables | [EventCapturePagerAdapter.kt#L69](app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePagerAdapter.kt#L69) | Code change | Adds the event-capture table page and maps it to the RAMP Chart navigation item | +| RAMP ACF-39,40,41: history tables | [EventCapturePresenterImpl.kt#L167](app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePresenterImpl.kt#L167) | Code change | Keeps the RAMP Chart navigation item visible as a single tab when no other right-pane tabs are configured | +| RAMP ACF-39,40,41: history tables | [EventPageConfigurator.kt#L13](app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfigurator.kt#L13) | Code change | Hides Analytics and Notes when a program-stage history table is configured, leaving only Form and Chart | +| RAMP ACF-39,40,41: history tables | [EventCaptureActivity.kt#L237](app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt#L237) | Code change | Expands the event-capture history table to full width in landscape and restores the split layout afterwards | | RAMP ACF-39,40,41: history tables | [DashboardRepositoryImpl.kt#L817](app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt#L817) | Code addition | Detects whether the current enrollment program has a configured RAMP history table | -| RAMP ACF-39,40,41: history tables | [DashboardViewModel.kt#L170](app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt#L170) | Code addition | Adds the RAMP History navigation item to program enrollment screens (where configured) | -| RAMP ACF-39,40,41: history tables | [TeiDashboardMobileActivity.kt#L406](app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt#L406) | Code addition | Opens the enrollment history table from the TEI dashboard and expands it to full width in landscape (to use space on tablets) | +| RAMP ACF-39,40,41: history tables | [DashboardViewModel.kt#L177](app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt#L177) | Code addition | Adds the RAMP Chart navigation item and only forces Details as the landscape fullscreen restore tab | +| RAMP ACF-39,40,41: history tables | [TeiDashboardPageConfigurator.kt#L11](app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfigurator.kt#L11) | Code change | Hides Analytics and Notes when an enrollment history table is configured, leaving only Details and Chart | +| RAMP ACF-39,40,41: history tables | [TeiDashboardMobileActivity.kt#L369](app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt#L369) | Code addition | Expands the enrollment history table only after Chart is pressed in landscape, with Details as restore tab | +| RAMP Search restriction in a program | [RampDatastoreConfig.kt#L8](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L8) | Code addition | Adds `programSpecificSettings` to the RAMP datastore schema, with per-program `isSearchEnabled` settings | +| RAMP Search restriction in a program | [SearchRepositoryImpl.java#L1020](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java#L1020) | Code addition | Resolves the current program's search-enabled flag through the RAMP datastore repository | +| RAMP Search restriction in a program | [SearchTEIViewModel.kt#L220](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L220) | Code change | Loads the restriction into screen state, prevents opening search, and suppresses initial search prompts when disabled | +| RAMP Search restriction in a program | [SearchTEUi.kt#L244](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L244) | Code change | Lets shared search/create controls hide search actions while preserving create actions where allowed | +| RAMP Search restriction in a program | [SearchTEList.kt#L226](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L226) | Code change | Applies the restriction to the list screen's search controls | +| RAMP Search restriction in a program | [SearchTEMap.kt#L145](app/src/main/java/org/dhis2/usescases/searchTrackEntity/mapView/SearchTEMap.kt#L145) | Code change | Applies the restriction to the map screen's search action | +| RAMP Search restriction in a program | [SearchJavaToCompose.kt#L22](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchJavaToCompose.kt#L22) | Code change | Hides the landscape open-search button when the selected program disables search | +| RAMP Search restriction in a program | [SearchScreenConfigurator.kt#L54](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchScreenConfigurator.kt#L54) | Code change | Keeps the landscape search panel closed when search is disabled while still allowing filters to open | +| RAMP Menu option off in a program stage | [RampDatastoreConfig.kt#L9](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L9) | Code addition | Adds `programStageSpecificSettings` to the RAMP datastore schema, with per-stage schedule and refer option flags | +| RAMP Menu option off in a program stage | [RampDatastoreRepository.kt#L56](commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt#L56) | Code addition | Resolves per-stage schedule and refer option flags from the RAMP datastore | +| RAMP Menu option off in a program stage | [GetNewEventCreationTypeOptions.kt#L21](app/src/main/java/org/dhis2/usescases/teiDashboard/domain/GetNewEventCreationTypeOptions.kt#L21) | Code change | Applies datastore-controlled stage option flags when building the tracker dashboard add-event menu | ### Releases -SimCapture [releases](https://github.com/Simprints/SimCapture/releases) have the following naming -scheme: `SimCapture-DHIS2--fork-`. -An example of a release APK name: `SimCapture-DHIS2-v3.3.1-fork-1-signed-release.apk`. +RAMPcapture [releases](https://github.com/Simprints/SimCapture/releases) have the following naming +scheme: `RAMPcapture-DHIS2--fork-`. +An example of a release APK name: `RAMPcapture-DHIS2-v3.3.1-fork-1-signed-release.apk`. ### GitHub Actions -The workflows of this fork repo should start with `simcapture-`. +The workflows of this fork repo should start with `simcapture-` or `rampcapture-`. The upstream `README` is preserved below. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index baeb1c83b85..ac791a9e9e5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,8 +93,10 @@ android { multiDexEnabled = true val bitriseSentryDSN = System.getenv("SENTRY_DSN") ?: "" + val rampCaptureVersion = System.getenv("RAMP_CAPTURE_VERSION") ?: "local build" buildConfigField("String", "SDK_VERSION", "\"" + libs.versions.dhis2sdk.get() + "\"") + buildConfigField("String", "RAMP_CAPTURE_VERSION", "\"$rampCaptureVersion\"") buildConfigField("String", "MATOMO_URL", "\"https://usage.analytics.dhis2.org/matomo.php\"") buildConfigField("long", "VERSION_CODE", "${defaultConfig.versionCode}") buildConfigField("String", "VERSION_NAME", "\"${defaultConfig.versionName}\"") diff --git a/app/src/androidTest/java/org/dhis2/usescases/about/AboutRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/about/AboutRobot.kt index 26b675b31d7..9e4df1b3d5c 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/about/AboutRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/about/AboutRobot.kt @@ -15,9 +15,9 @@ fun aboutRobot(aboutBody: AboutRobot.() -> Unit) { class AboutRobot : BaseRobot() { - fun checkVersionNames(appName: String, sdkName: String) { - val appNameText = "App Version: $appName" - onView(withId(R.id.aboutApp)).check(matches(withText(appNameText))) + fun checkVersionNames(rampCaptureName: String, appName: String, sdkName: String) { + onView(withId(R.id.rampCaptureVersion)).check(matches(withText(rampCaptureName))) + onView(withId(R.id.aboutApp)).check(matches(withText(appName))) onView(withId(R.id.appSDK)).check(matches(withText(sdkName))) } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/about/AboutTest.kt b/app/src/androidTest/java/org/dhis2/usescases/about/AboutTest.kt index 2e3261c54e1..b5beed52ddb 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/about/AboutTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/about/AboutTest.kt @@ -3,6 +3,7 @@ package org.dhis2.usescases.about import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.ActivityTestRule import org.dhis2.bindings.buildInfo +import org.dhis2.bindings.rampCaptureBuildInfo import org.dhis2.BuildConfig import org.dhis2.R import org.dhis2.usescases.BaseTest @@ -21,6 +22,7 @@ class AboutTest : BaseTest() { @Test fun shouldCheckVersionsWhenOpenAboutScreen() { startActivity() + val rampCaptureVersion = getRampCaptureVersionName() val appVersion = getAppVersionName() val sdkVersion = getSDKVersionName() @@ -30,7 +32,7 @@ class AboutTest : BaseTest() { } aboutRobot { - checkVersionNames(appVersion, sdkVersion) + checkVersionNames(rampCaptureVersion, appVersion, sdkVersion) } } @@ -38,9 +40,14 @@ class AboutTest : BaseTest() { rule.launchActivity(null) } - private fun getAppVersionName(): String { - return context.buildInfo() - } + private fun getRampCaptureVersionName() = + String.format( + context.getString(R.string.ramp_capture_version), + context.rampCaptureBuildInfo(), + ) + + private fun getAppVersionName() = + String.format(context.getString(R.string.about_app), context.buildInfo()) private fun getSDKVersionName() = String.format(context.getString(R.string.about_sdk), BuildConfig.SDK_VERSION) diff --git a/app/src/main/java/org/dhis2/bindings/ContextExtensions.kt b/app/src/main/java/org/dhis2/bindings/ContextExtensions.kt index f423efd7518..652401b5dab 100644 --- a/app/src/main/java/org/dhis2/bindings/ContextExtensions.kt +++ b/app/src/main/java/org/dhis2/bindings/ContextExtensions.kt @@ -14,6 +14,13 @@ fun Context.buildInfo(): String = "v${BuildConfig.VERSION_NAME} : ${BuildConfig.GIT_SHA} " } +fun Context.rampCaptureBuildInfo(): String = + if (BuildConfig.BUILD_TYPE == "release") { + BuildConfig.RAMP_CAPTURE_VERSION + } else { + "${BuildConfig.RAMP_CAPTURE_VERSION} : ${BuildConfig.GIT_SHA} " + } + fun Fragment.checkSMSPermission( requestPermission: Boolean, request: Int, diff --git a/app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt b/app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt index d7b2f65410c..eea9847bb74 100644 --- a/app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt +++ b/app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt @@ -209,14 +209,15 @@ class EventHistoryTableRepository( rows = followUpRows, ) } else { + val rowsByDataElementUid = followUpRows.associateBy { row -> row.dataElementUid } + sections.mapNotNull { section -> - val sectionDataElementUids = + val rows = section .dataElements() .orEmpty() - .mapNotNull { dataElement -> dataElement.uid() } - .toSet() - val rows = followUpRows.filter { row -> row.dataElementUid in sectionDataElementUids } + .mapNotNull { dataElement -> dataElement.uid()?.let(rowsByDataElementUid::get) } + .distinctBy { row -> row.dataElementUid } rows .takeIf { it.isNotEmpty() } @@ -296,6 +297,8 @@ class EventHistoryTableRepository( .eq(tableContext.enrollmentUid) .byProgramStageUid() .eq(followUpVisitProgramStageUid) + .byDeleted() + .isFalse .blockingGet() } val events = diff --git a/app/src/main/java/org/dhis2/usescases/about/AboutFragment.kt b/app/src/main/java/org/dhis2/usescases/about/AboutFragment.kt index a033100e3dc..0ddbb19c63f 100644 --- a/app/src/main/java/org/dhis2/usescases/about/AboutFragment.kt +++ b/app/src/main/java/org/dhis2/usescases/about/AboutFragment.kt @@ -11,6 +11,7 @@ import org.dhis2.BuildConfig import org.dhis2.R import org.dhis2.bindings.app import org.dhis2.bindings.buildInfo +import org.dhis2.bindings.rampCaptureBuildInfo import org.dhis2.databinding.FragmentAboutBinding import org.dhis2.usescases.general.FragmentGlobalAbstract import org.hisp.dhis.android.core.user.User @@ -41,6 +42,8 @@ class AboutFragment : aboutGit.movementMethod = LinkMovementMethod.getInstance() aboutDev.movementMethod = LinkMovementMethod.getInstance() aboutContact.movementMethod = LinkMovementMethod.getInstance() + rampCaptureVersion.text = + getString(R.string.ramp_capture_version).format(context?.rampCaptureBuildInfo()) aboutApp.text = getString(R.string.about_app).format(context?.buildInfo()) appSDK.text = getString(R.string.about_sdk).format(BuildConfig.SDK_VERSION) privacyPolicy.setOnClickListener { navigateToPrivacyPolicy() } diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt index 91a450ffd7f..f524d26accd 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt @@ -170,6 +170,7 @@ class EventCaptureActivity : pageConfigurator!!.displayAnalytics(), pageConfigurator!!.displayTableView(), pageConfigurator!!.displayRelationships(), + pageConfigurator!!.displayNotes(), intent.getBooleanExtra(OPEN_ERROR_LOCATION, false), eventMode, ) diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureModule.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureModule.kt index 95f54659e26..a44fc8f2d3a 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureModule.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureModule.kt @@ -12,6 +12,7 @@ import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.schedulers.SchedulerProvider +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository import org.dhis2.data.dhislogic.DhisEnrollmentUtils import org.dhis2.data.forms.dataentry.SearchTEIRepository import org.dhis2.data.forms.dataentry.SearchTEIRepositoryImpl @@ -62,7 +63,12 @@ class EventCaptureModule( @Provides @PerActivity - fun provideRepository(d2: D2?): EventCaptureRepository = EventCaptureRepositoryImpl(eventUid, d2) + fun provideRepository(d2: D2): EventCaptureRepository = + EventCaptureRepositoryImpl( + eventUid, + d2, + RampDatastoreRepository(d2), + ) @Provides @PerActivity diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePagerAdapter.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePagerAdapter.kt index 6c3e60f8a32..1835278e991 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePagerAdapter.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePagerAdapter.kt @@ -22,6 +22,7 @@ class EventCapturePagerAdapter( displayAnalyticScreen: Boolean, displaySimprintsRampHistoryTableScreen: Boolean, displayRelationshipScreen: Boolean, + displayNotes: Boolean, private val shouldOpenErrorSection: Boolean, private val eventMode: EventMode, ) : FragmentStateAdapter(fragmentActivity) { @@ -59,8 +60,11 @@ class EventCapturePagerAdapter( portraitPages.add(EventPageType.RELATIONSHIPS) landscapePages.add(EventPageType.RELATIONSHIPS) } - portraitPages.add(EventPageType.NOTES) - landscapePages.add(EventPageType.NOTES) + + if (displayNotes) { + portraitPages.add(EventPageType.NOTES) + landscapePages.add(EventPageType.NOTES) + } if (displaySimprintsRampHistoryTableScreen) { portraitPages.add(EventPageType.SIMPRINTS_RAMP_HISTORY_TABLE) diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePresenterImpl.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePresenterImpl.kt index 81a1ce393a6..2ec9614f384 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePresenterImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePresenterImpl.kt @@ -107,6 +107,7 @@ class EventCapturePresenterImpl( private fun loadBottomBarItems() { val navItems = mutableListOf>() + val displayTableView = pageConfigurator.displayTableView() if (pageConfigurator.displayDataEntry() || forceDisplayDataEntryNavigationItemForSimprintsRampTable) { navItems.add( @@ -152,20 +153,27 @@ class EventCapturePresenterImpl( ) } - if (pageConfigurator.displayTableView()) { + if (displayTableView) { navItems.add( NavigationBarItem( id = NavigationPage.TABLE_VIEW, icon = Icons.Outlined.TableChart, selectedIcon = Icons.Filled.TableChart, - label = resourceManager.getString(R.string.navigation_simprints_ramp_history), + label = resourceManager.getString(R.string.navigation_simprints_ramp_history_chart), ), ) } + val visibleItems = navItems.takeIf { it.size > 1 || displayTableView }.orEmpty() + val selectedItem = + navigationBarUIState.value.selectedItem + ?.takeIf { item -> visibleItems.any { it.id == item } } + ?: visibleItems.firstOrNull()?.id + navigationBarUIState.value = navigationBarUIState.value.copy( - items = navItems.takeIf { it.size > 1 }.orEmpty(), + items = visibleItems, + selectedItem = selectedItem, ) } diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java index 40a1492b71b..b8f96853cff 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java @@ -3,8 +3,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.gson.Gson; - import org.dhis2.commons.bindings.SdkExtensionsKt; import org.dhis2.commons.simprints.ramp.model.ProgramStageHistoryTableConfig; import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository; @@ -38,10 +36,10 @@ public class EventCaptureRepositoryImpl implements EventCaptureContract.EventCap private final D2 d2; private final RampDatastoreRepository rampDatastoreRepository; - public EventCaptureRepositoryImpl(String eventUid, D2 d2) { + public EventCaptureRepositoryImpl(String eventUid, D2 d2, RampDatastoreRepository rampDatastoreRepository) { this.eventUid = eventUid; this.d2 = d2; - this.rampDatastoreRepository = new RampDatastoreRepository(d2, new Gson()); + this.rampDatastoreRepository = rampDatastoreRepository; } private Event getCurrentEvent() { diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfigurator.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfigurator.kt index 4e8fd182b8b..7c7033af45e 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfigurator.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfigurator.kt @@ -10,11 +10,11 @@ class EventPageConfigurator( override fun displayDataEntry(): Boolean = isPortrait - override fun displayAnalytics(): Boolean = eventCaptureRepository.hasAnalytics() + override fun displayAnalytics(): Boolean = eventCaptureRepository.hasAnalytics() && !displayTableView() override fun displayRelationships(): Boolean = eventCaptureRepository.hasRelationships() - override fun displayNotes(): Boolean = true + override fun displayNotes(): Boolean = !displayTableView() override fun displayTableView(): Boolean = eventCaptureRepository.hasSimprintsRampProgramStageHistoryTable() } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchJavaToCompose.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchJavaToCompose.kt index ffc216171b4..df6669b71ac 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchJavaToCompose.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchJavaToCompose.kt @@ -19,6 +19,7 @@ fun ComposeView?.setLandscapeOpenSearchButton( DHIS2Theme { val screenState by searchTEIViewModel.screenState.observeAsState() val teTypeName by searchTEIViewModel.teTypeName.observeAsState() + val isSearchEnabled by searchTEIViewModel.isSearchEnabled.observeAsState(true) val visible = screenState?.let { @@ -26,7 +27,7 @@ fun ComposeView?.setLandscapeOpenSearchButton( } ?: false val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE - AnimatedVisibility(visible = isLandscape && visible && !teTypeName.isNullOrBlank()) { + AnimatedVisibility(visible = isLandscape && visible && isSearchEnabled && !teTypeName.isNullOrBlank()) { WrappedSearchButton(onClick = onClick, teTypeName = teTypeName!!) } } 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 0c199898d14..db2e4dbf7ed 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepository.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepository.java @@ -79,6 +79,8 @@ public interface SearchRepository { boolean canCreateInProgramWithoutSearch(); + boolean isSearchEnabled(); + void setCurrentTheme(@org.jetbrains.annotations.Nullable ProgramSpinnerModel selectedProgram); List trackedEntityTypeFields(); 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 cd66d341233..eee444b1643 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java @@ -18,6 +18,7 @@ import org.dhis2.commons.resources.DhisPeriodUtils; import org.dhis2.commons.resources.MetadataIconProvider; import org.dhis2.commons.resources.ResourceManager; +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository; import org.dhis2.data.dhislogic.DhisEnrollmentUtils; import org.dhis2.data.forms.dataentry.SearchTEIRepository; import org.dhis2.data.forms.dataentry.ValueStore; @@ -124,6 +125,7 @@ public class SearchRepositoryImpl implements SearchRepository { private final MetadataIconProvider metadataIconProvider; private final ProfilePictureProvider profilePictureProvider; + private final RampDatastoreRepository rampDatastoreRepository; private CustomIntentRepository customIntentRepository; SearchRepositoryImpl(String teiType, @@ -141,7 +143,8 @@ public class SearchRepositoryImpl implements SearchRepository { MetadataIconProvider metadataIconProvider, ProfilePictureProvider profilePictureProvider, DateUtils dateUtils, - CustomIntentRepository customIntentRepository + CustomIntentRepository customIntentRepository, + RampDatastoreRepository rampDatastoreRepository ) { this.teiType = teiType; this.d2 = d2; @@ -165,6 +168,7 @@ public class SearchRepositoryImpl implements SearchRepository { this.metadataIconProvider = metadataIconProvider; this.profilePictureProvider = profilePictureProvider; this.customIntentRepository = customIntentRepository; + this.rampDatastoreRepository = rampDatastoreRepository; } @@ -1012,6 +1016,11 @@ public boolean canCreateInProgramWithoutSearch() { } } + @Override + public boolean isSearchEnabled() { + return rampDatastoreRepository.isSearchEnabled(currentProgram); + } + private boolean displayOrgUnit() { return d2.organisationUnitModule().organisationUnits() .byProgramUids(Collections.singletonList(currentProgram)) @@ -1022,4 +1031,3 @@ private boolean displayOrgUnit() { } - diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt index d80600ff4f6..01bc7e7aa06 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.view.View +import android.view.ViewTreeObserver import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.animation.AnimatedVisibility @@ -154,6 +155,7 @@ class SearchTEActivity : } private var currentContent: Content? = null + private var initialLandscapeDrawReady = true @OptIn(ExperimentalAnimationApi::class) @SuppressLint("ClickableViewAccessibility") @@ -167,6 +169,7 @@ class SearchTEActivity : super.onCreate(savedInstanceState) binding = DataBindingUtil.setContentView(this, R.layout.activity_search) + postponeInitialLandscapeDraw() // prevents jitter depending on if search panel set to visible or not val currentScreen = savedInstanceState?.getString(CURRENT_SCREEN).orEmpty() if (currentScreen.isNotBlank()) { currentContent = Content.valueOf(currentScreen) @@ -188,7 +191,7 @@ class SearchTEActivity : if (isLandscape()) { viewModel.filtersOpened.observe(this) { isOpened: Boolean -> - if (java.lang.Boolean.TRUE == isOpened) { + if (java.lang.Boolean.TRUE == isOpened || viewModel.isSearchEnabled.value == false) { binding.mainComponent.clipWithRoundedCorners(16.dp) } else { binding.mainComponent.clipWithTopRightRoundedCorner(16.dp) @@ -541,10 +544,32 @@ class SearchTEActivity : } private fun observeScreenState() { - viewModel.screenState.observe(this, searchScreenConfigurator::configure) + viewModel.screenState.observe(this) { screenState -> + searchScreenConfigurator.configure(screenState) + initialLandscapeDrawReady = true + binding.root.invalidate() + } viewModel.screenState.observe(this, viewModel::updateBackdrop) } + private fun postponeInitialLandscapeDraw() { + if (!isLandscape()) return + initialLandscapeDrawReady = false + binding.root.viewTreeObserver.addOnPreDrawListener( + object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean = + if (initialLandscapeDrawReady) { + binding.root.viewTreeObserver + .takeIf { it.isAlive } + ?.removeOnPreDrawListener(this) + true + } else { + false + } + }, + ) + } + private fun observeDownload() { viewModel.downloadResult.observe(this) { result: TeiDownloadResult -> result.handleResult( diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt index 74bc1e7a49b..d8fbd38b547 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt @@ -148,6 +148,9 @@ class SearchTEIViewModel( private val _screenState = MutableLiveData() val screenState: LiveData = _screenState + private val _isSearchEnabled = MutableLiveData(true) + val isSearchEnabled: LiveData = _isSearchEnabled + val createButtonScrollVisibility = MutableLiveData(false) val isScrollingDown = MutableLiveData(false) val simprintsBiometricSearchNavigation: Flow = @@ -214,6 +217,11 @@ class SearchTEIViewModel( init { viewModelScope.launch(dispatchers.io()) { + val isSearchEnabled = searchRepository.isSearchEnabled() + withContext(dispatchers.ui()) { + _isSearchEnabled.value = isSearchEnabled + updateSearchEnabledInCurrentScreen(isSearchEnabled) + } createButtonScrollVisibility.postValue( searchRepository.canCreateInProgramWithoutSearch(), ) @@ -294,10 +302,12 @@ class SearchTEIViewModel( _screenState.value.takeIf { it?.screenState == SearchScreenState.MAP }?.let { searching = (it as SearchList).isSearching || isSimprintsBiometricNoMatchesSearch() } + val isSearchEnabled = isSearchEnabled() val displayFrontPageList = searchRepository.getProgram(initialProgramUid)?.displayFrontPageList() ?: true val shouldForceSearch = - !displayFrontPageList && + isSearchEnabled && + !displayFrontPageList && !searchRepository.canCreateInProgramWithoutSearch() && !searching && filtersActive.value == false @@ -326,6 +336,7 @@ class SearchTEIViewModel( .getProgram(initialProgramUid) ?.minAttributesRequiredToSearch() ?: 1, + isEnabled = isSearchEnabled, isForced = shouldForceSearch, isOpened = shouldForceSearch, ), @@ -363,6 +374,7 @@ class SearchTEIViewModel( .getProgram(initialProgramUid) ?.minAttributesRequiredToSearch() ?: 1, + isEnabled = isSearchEnabled(), isForced = false, isOpened = false, ), @@ -384,6 +396,8 @@ class SearchTEIViewModel( } fun setSearchScreen() { + if (!isSearchEnabled()) return + _screenState.postValue( SearchList( previousSate = _screenState.value?.screenState ?: SearchScreenState.NONE, @@ -416,6 +430,8 @@ class SearchTEIViewModel( } fun onSearchFormRequested() { + if (!isSearchEnabled()) return + if (shouldLaunchSimprintsBiometricIdentification()) { simprintsSearchViewModel.clearPendingSession() viewModelScope.launch { @@ -1172,8 +1188,10 @@ class SearchTEIViewModel( private fun handleInitWithoutData() { val result = - when (searchRepository.canCreateInProgramWithoutSearch()) { - true -> + when { + !isSearchEnabled() -> emptyList() + + searchRepository.canCreateInProgramWithoutSearch() -> listOf( SearchResult( SearchResult.SearchResultType.SEARCH_OR_CREATE, @@ -1181,7 +1199,7 @@ class SearchTEIViewModel( ), ) - false -> + else -> listOf( SearchResult( SearchResult.SearchResultType.SEARCH, @@ -1192,6 +1210,22 @@ class SearchTEIViewModel( _dataResult.postValue(result) } + private fun isSearchEnabled(): Boolean = _isSearchEnabled.value ?: true + + private fun updateSearchEnabledInCurrentScreen(isEnabled: Boolean) { + (_screenState.value as? SearchList)?.let { currentScreen -> + _screenState.value = + currentScreen.copy( + searchForm = + currentScreen.searchForm.copy( + isEnabled = isEnabled, + isForced = currentScreen.searchForm.isForced && isEnabled, + isOpened = currentScreen.searchForm.isOpened && isEnabled, + ), + ) + } + } + fun onBackPressed( isPortrait: Boolean, searchOrFilterIsOpen: Boolean, diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java index b7e7e4f7bc2..614f9ffafc5 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java @@ -4,6 +4,8 @@ import androidx.annotation.NonNull; +import com.google.gson.Gson; + import org.dhis2.R; import org.dhis2.commons.data.ProgramConfigurationRepository; import org.dhis2.commons.date.DateLabelProvider; @@ -22,6 +24,7 @@ import org.dhis2.commons.resources.DhisPeriodUtils; import org.dhis2.commons.resources.MetadataIconProvider; import org.dhis2.commons.resources.ResourceManager; +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository; import org.dhis2.commons.simprints.repository.SimprintsD2Repository; import org.dhis2.commons.simprints.repository.SimprintsSessionRepository; import org.dhis2.commons.simprints.usecases.SimprintsHasAutoOpenEligibleIdentificationUseCase; @@ -154,6 +157,12 @@ MapTeiEventsToFeatureCollection provideMapTeiEventsToFeatureCollection() { new GetBoundingBox()); } + @Provides + @PerActivity + RampDatastoreRepository provideRampDatastoreRepository(@NonNull D2 d2) { + return new RampDatastoreRepository(d2, new Gson()); + } + @Provides @PerActivity SearchRepository searchRepository(@NonNull D2 d2, @@ -168,7 +177,8 @@ SearchRepository searchRepository(@NonNull D2 d2, ThemeManager themeManager, MetadataIconProvider metadataIconProvider, DateUtils dateUtils, - CustomIntentRepository customIntentRepository) { + CustomIntentRepository customIntentRepository, + RampDatastoreRepository rampDatastoreRepository) { ProfilePictureProvider profilePictureProvider = new ProfilePictureProvider(d2); return new SearchRepositoryImpl(teiType, initialProgram, @@ -185,7 +195,8 @@ SearchRepository searchRepository(@NonNull D2 d2, metadataIconProvider, profilePictureProvider, dateUtils, - customIntentRepository); + customIntentRepository, + rampDatastoreRepository); } @Provides diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEScreenState.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEScreenState.kt index c44d643c9ab..9e350440df0 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEScreenState.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEScreenState.kt @@ -20,6 +20,7 @@ data class SearchList( data class SearchForm( val queryHasData: Boolean, val minAttributesToSearch: Int, + val isEnabled: Boolean = true, val isForced: Boolean = false, val isOpened: Boolean = false, ) diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt index e2b2021cfdf..b3a800e2598 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt @@ -223,6 +223,7 @@ class SearchTEList : FragmentGlobalAbstract() { if (!teTypeName.isNullOrBlank()) { val isFilterOpened by viewModel.filtersOpened.observeAsState(false) + val isSearchEnabled by viewModel.isSearchEnabled.observeAsState(true) val createButtonVisibility by viewModel .createButtonScrollVisibility .observeAsState(true) @@ -240,6 +241,7 @@ class SearchTEList : FragmentGlobalAbstract() { FullSearchButtonAndWorkingList( teTypeName = teTypeName!!, modifier = Modifier, + searchButtonVisible = isSearchEnabled, createButtonVisible = createButtonVisibility, closeFilterVisibility = isFilterOpened, isLandscape = isLandscape(), diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/mapView/SearchTEMap.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/mapView/SearchTEMap.kt index 541bcb66871..47e93807243 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/mapView/SearchTEMap.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/mapView/SearchTEMap.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -141,6 +142,7 @@ class SearchTEMap : FragmentGlobalAbstract() { val locationState = teiMapManager?.locationState?.collectAsState() val mapDataFinishedLoading = teiMapManager?.dataFinishedLoading?.collectAsState() + val isSearchEnabled by viewModel.isSearchEnabled.observeAsState(true) LaunchedEffect(key1 = clickedItem) { clickedItem?.let { @@ -184,17 +186,19 @@ class SearchTEMap : FragmentGlobalAbstract() { } }, actionButtons = { - IconButton( - style = IconButtonStyle.TONAL, - icon = { - Icon( - painter = painterResource(id = R.drawable.ic_search), - contentDescription = "", - tint = TextColor.OnPrimaryContainer, - ) - }, - ) { - viewModel.onSearchFormRequested() + if (isSearchEnabled) { + IconButton( + style = IconButtonStyle.TONAL, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_search), + contentDescription = "", + tint = TextColor.OnPrimaryContainer, + ) + }, + ) { + viewModel.onSearchFormRequested() + } } mapDataFinishedLoading?.let { if (it.value) { diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchScreenConfigurator.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchScreenConfigurator.kt index 82e7483cadc..c27348378fe 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchScreenConfigurator.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchScreenConfigurator.kt @@ -18,17 +18,23 @@ class SearchScreenConfigurator( val binding: ActivitySearchBinding, val filterIsOpenCallback: (isOpen: Boolean) -> Unit, ) { + private var hasConfiguredLandscape = false + fun configure(screenState: SearchTEScreenState) { when (screenState) { - is SearchAnalytics -> configureLandscapeAnalyticsScreen(true) + is SearchAnalytics -> configureLandscapeAnalyticsScreen(true, withAnimation = hasConfiguredLandscape) is SearchList -> if (isPortrait()) { configureListScreen(screenState) } else { - if (screenState.screenState != SearchScreenState.MAP) { - configureLandscapeAnalyticsScreen(false) + val withAnimation = hasConfiguredLandscape + if (screenState.screenState != SearchScreenState.MAP && + (screenState.searchForm.isEnabled || screenState.searchFilters.isOpened) + ) { + configureLandscapeAnalyticsScreen(false, withAnimation = withAnimation) } - configureLandscapeListScreen(screenState) + configureLandscapeListScreen(screenState, withAnimation) + hasConfiguredLandscape = true } } } @@ -45,22 +51,39 @@ class SearchScreenConfigurator( setFiltersVisibility(!searchConfiguration.searchForm.isOpened) } - private fun configureLandscapeListScreen(searchConfiguration: SearchList) { - if (searchConfiguration.searchFilters.isOpened) { - openFilters() - } else { - openSearch() + private fun configureLandscapeListScreen(searchConfiguration: SearchList, withAnimation: Boolean) { + when { + searchConfiguration.searchFilters.isOpened -> { + if (searchConfiguration.screenState == SearchScreenState.MAP) { + configureLandscapeAnalyticsScreen(false, withAnimation = withAnimation) + } + openFilters() + } + + searchConfiguration.searchForm.isEnabled -> { + if (searchConfiguration.screenState == SearchScreenState.MAP) { + configureLandscapeAnalyticsScreen(false, withAnimation = withAnimation) + } + openSearch() + } + + else -> { + configureLandscapeAnalyticsScreen(true, withAnimation = false) + closeBackdrop() + } } syncButtonVisibility(true) setFiltersVisibility(true) } - private fun configureLandscapeAnalyticsScreen(expanded: Boolean) { + private fun configureLandscapeAnalyticsScreen(expanded: Boolean, withAnimation: Boolean = true) { val constraintSet = ConstraintSet() constraintSet.clone(binding.backdropLayout) constraintSet.setGuidelinePercent(R.id.backdropGuideDiv, if (expanded) 0.0f else 0.26f) - TransitionManager.beginDelayedTransition(binding.backdropLayout) + if (withAnimation) { + TransitionManager.beginDelayedTransition(binding.backdropLayout) + } constraintSet.applyTo(binding.backdropLayout) } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt index 56eda9bcf90..1ab9540218b 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt @@ -244,6 +244,7 @@ fun WrappedSearchButton( fun FullSearchButtonAndWorkingList( teTypeName: String, modifier: Modifier, + searchButtonVisible: Boolean = true, createButtonVisible: Boolean = false, closeFilterVisibility: Boolean = false, isLandscape: Boolean = false, @@ -258,7 +259,12 @@ fun FullSearchButtonAndWorkingList( workingListViewModel: WorkingListViewModel? = null, ) { Column(modifier = modifier) { - if (!isLandscape || queryData.isNotEmpty()) { + val displaySearchControls = + searchButtonVisible || + createButtonVisible || + closeFilterVisibility + + if (displaySearchControls && (!isLandscape || queryData.isNotEmpty())) { Row( modifier = Modifier.padding( @@ -276,7 +282,7 @@ fun FullSearchButtonAndWorkingList( .weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.Spacing8), ) { - if (queryData.isNotEmpty()) { + if (queryData.isNotEmpty() && searchButtonVisible) { SearchButtonWithQuery( modifier = Modifier.fillMaxWidth(), queryData = queryData, @@ -285,10 +291,11 @@ fun FullSearchButtonAndWorkingList( onClick = onSearchClick, onClearSearchQuery = onClearSearchQuery, ) - } else { + } else if (searchButtonVisible || createButtonVisible) { SearchAndCreateTEIButton( onSearchClick = onSearchClick, teTypeName = teTypeName, + searchButtonVisible = searchButtonVisible, createButtonVisible = createButtonVisible, onEnrollClick = onEnrollClick, ) @@ -322,7 +329,11 @@ fun FullSearchButtonAndWorkingList( } } - if (isSimprintsBiometricSearch && !isSimprintsPossibleDuplicatesSearch && queryData.isNotEmpty()) { + if (searchButtonVisible && + isSimprintsBiometricSearch && + !isSimprintsPossibleDuplicatesSearch && + queryData.isNotEmpty() + ) { SimprintsBiometricSearchFallbackButton( modifier = Modifier.padding( @@ -347,15 +358,18 @@ fun FullSearchButtonAndWorkingList( private fun SearchAndCreateTEIButton( onSearchClick: () -> Unit, teTypeName: String, + searchButtonVisible: Boolean, createButtonVisible: Boolean, onEnrollClick: () -> Unit, ) { - SearchButton( - modifier = Modifier.fillMaxWidth(), - onClick = onSearchClick, - teTypeName = teTypeName, - createButtonVisible = createButtonVisible, - ) + if (searchButtonVisible) { + SearchButton( + modifier = Modifier.fillMaxWidth(), + onClick = onSearchClick, + teTypeName = teTypeName, + createButtonVisible = createButtonVisible, + ) + } if (createButtonVisible) { AddNewButton( diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt index eb0bdf3f4b5..807db06e254 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt @@ -32,7 +32,6 @@ import org.dhis2.utils.analytics.ACTIVE_FOLLOW_UP import org.dhis2.utils.analytics.AnalyticsHelper import org.dhis2.utils.analytics.FOLLOW_UP import org.dhis2.utils.customviews.navigationbar.NavigationPageConfigurator -import org.dhis2.utils.isPortrait import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.common.State.SYNCED import org.hisp.dhis.android.core.enrollment.EnrollmentStatus @@ -49,6 +48,8 @@ class DashboardViewModel( private val eventUid = MutableLiveData() private val selectedEventUid = MutableLiveData() + private var displayDetailsNavigationItem = pageConfigurator.displayDetails() + private var forceDisplayDetailsNavigationItemForSimprintsRampTable = false val showStatusErrorMessages = MutableLiveData(StatusChangeResultCode.CHANGED) @@ -123,9 +124,13 @@ class DashboardViewModel( } private fun loadNavigationBarItems() { + updateNavigationBarItems(_navigationBarUIState.value.selectedItem) + } + + private fun updateNavigationBarItems(selectedItem: TEIDashboardItems?) { val enrollmentItems = mutableListOf>() - if (isPortrait()) { + if (displayDetailsNavigationItem || forceDisplayDetailsNavigationItemForSimprintsRampTable) { enrollmentItems.add( NavigationBarItem( id = TEIDashboardItems.DETAILS, @@ -158,14 +163,16 @@ class DashboardViewModel( ) } - enrollmentItems.add( - NavigationBarItem( - id = TEIDashboardItems.NOTES, - icon = Icons.AutoMirrored.Outlined.StickyNote2, - selectedIcon = Icons.AutoMirrored.Filled.StickyNote2, - label = resourcesManager.getString(R.string.navigation_notes), - ), - ) + if (pageConfigurator.displayNotes()) { + enrollmentItems.add( + NavigationBarItem( + id = TEIDashboardItems.NOTES, + icon = Icons.AutoMirrored.Outlined.StickyNote2, + selectedIcon = Icons.AutoMirrored.Filled.StickyNote2, + label = resourcesManager.getString(R.string.navigation_notes), + ), + ) + } if (pageConfigurator.displayTableView()) { enrollmentItems.add( @@ -173,20 +180,21 @@ class DashboardViewModel( id = TEIDashboardItems.SIMPRINTS_RAMP_HISTORY_TABLE, icon = Icons.Outlined.TableChart, selectedIcon = Icons.Filled.TableChart, - label = resourcesManager.getString(R.string.navigation_simprints_ramp_history), + label = resourcesManager.getString(R.string.navigation_simprints_ramp_history_chart), ), ) } - _navigationBarUIState.value = _navigationBarUIState.value.copy(items = enrollmentItems) + val resolvedSelectedItem = + selectedItem + ?.takeIf { item -> enrollmentItems.any { it.id == item } } + ?: enrollmentItems.firstOrNull()?.id - if (navigationBarUIState.value.items.none { it.id == navigationBarUIState.value.selectedItem }) { - onNavigationItemSelected( - navigationBarUIState.value.items - .first() - .id, + _navigationBarUIState.value = + _navigationBarUIState.value.copy( + items = enrollmentItems, + selectedItem = resolvedSelectedItem, ) - } } private fun fetchGrouping() { @@ -299,7 +307,21 @@ class DashboardViewModel( } fun onNavigationItemSelected(itemId: TEIDashboardItems) { - _navigationBarUIState.value = _navigationBarUIState.value.copy(selectedItem = itemId) + updateNavigationBarItems(itemId) + } + + fun setDetailsNavigationItemVisible(visible: Boolean) { + if (displayDetailsNavigationItem != visible) { + displayDetailsNavigationItem = visible + updateNavigationBarItems(_navigationBarUIState.value.selectedItem) + } + } + + fun setForceDisplayDetailsNavigationItemForSimprintsRampTable(forceDisplay: Boolean) { + if (forceDisplayDetailsNavigationItemForSimprintsRampTable != forceDisplay) { + forceDisplayDetailsNavigationItemForSimprintsRampTable = forceDisplay + updateNavigationBarItems(_navigationBarUIState.value.selectedItem) + } } fun checkIfTeiCanBeTransferred(): Boolean = repository.teiCanBeTransferred() 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 a0d604f464a..b1d9a091990 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt @@ -15,7 +15,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.MoveDown import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -200,6 +199,7 @@ class TeiDashboardMobileActivity : super.onCreate(savedInstanceState) dashboardViewModel = ViewModelProvider(this, viewModelFactory)[DashboardViewModel::class.java] + dashboardViewModel.setDetailsNavigationItemVisible(isPortrait()) binding = DataBindingUtil.setContentView(this, R.layout.activity_dashboard_mobile) showLoadingProgress(true) binding.presenter = presenter @@ -340,20 +340,16 @@ class TeiDashboardMobileActivity : binding.navigationBar.setContent { DHIS2Theme { val uiState by dashboardViewModel.navigationBarUIState.collectAsStateWithLifecycle() - var selectedHomeItemIndex by remember(uiState) { - mutableIntStateOf( - uiState.items.indexOfFirst { - it.id == uiState.selectedItem - }, - ) - } + val selectedHomeItemIndex = + uiState.items.indexOfFirst { + it.id == uiState.selectedItem + } NavigationBar( items = uiState.items, selectedItemIndex = selectedHomeItemIndex, ) { itemId -> - selectedHomeItemIndex = uiState.items.indexOfFirst { it.id == itemId } - dashboardViewModel.onNavigationItemSelected(itemId) + onNavigationBarItemSelected(itemId) } uiState.selectedItem?.let { @@ -363,8 +359,39 @@ class TeiDashboardMobileActivity : } } + private fun onNavigationBarItemSelected(item: TEIDashboardItems) { + if (isLandscape()) { + when (item) { + TEIDashboardItems.DETAILS -> { + restoreSimprintsRampHistoryTableLandscapeLayout() + } + + TEIDashboardItems.SIMPRINTS_RAMP_HISTORY_TABLE -> { + setSimprintsRampHistoryTableLandscapeFullscreen(true) + dashboardViewModel.setForceDisplayDetailsNavigationItemForSimprintsRampTable(true) + dashboardViewModel.onNavigationItemSelected(item) + } + + else -> { + setSimprintsRampHistoryTableLandscapeFullscreen(false) + dashboardViewModel.setForceDisplayDetailsNavigationItemForSimprintsRampTable(false) + dashboardViewModel.onNavigationItemSelected(item) + } + } + } else { + dashboardViewModel.onNavigationItemSelected(item) + } + } + + private fun restoreSimprintsRampHistoryTableLandscapeLayout() { + setSimprintsRampHistoryTableLandscapeFullscreen(false) + dashboardViewModel.setForceDisplayDetailsNavigationItemForSimprintsRampTable(false) + } + private fun navigateToFragment(item: TEIDashboardItems) { - setSimprintsRampHistoryTableLandscapeFullscreen(item == TEIDashboardItems.SIMPRINTS_RAMP_HISTORY_TABLE) + if (item != TEIDashboardItems.SIMPRINTS_RAMP_HISTORY_TABLE) { + setSimprintsRampHistoryTableLandscapeFullscreen(false) + } val fragment = when (item) { @@ -480,6 +507,7 @@ class TeiDashboardMobileActivity : super.onResume() if (sessionManagerServiceImpl.isUserLoggedIn()) { currentOrientation = if (this.isLandscape()) 1 else 0 + dashboardViewModel.setDetailsNavigationItemVisible(isPortrait()) presenter.refreshTabCounters() dashboardViewModel.updateDashboard() } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfigurator.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfigurator.kt index 23f524b7edb..1ac18b7cfc8 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfigurator.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfigurator.kt @@ -8,11 +8,11 @@ class TeiDashboardPageConfigurator( ) : NavigationPageConfigurator { override fun displayDetails(): Boolean = isPortrait - override fun displayAnalytics(): Boolean = dashboardRepository.programHasAnalytics() + override fun displayAnalytics(): Boolean = dashboardRepository.programHasAnalytics() && !displayTableView() override fun displayRelationships(): Boolean = dashboardRepository.programHasRelationships() - override fun displayNotes(): Boolean = true + override fun displayNotes(): Boolean = !displayTableView() override fun displayTableView(): Boolean = dashboardRepository.programHasSimprintsRampProgramStageHistoryTable() } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataModule.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataModule.kt index 484f9a05b85..1feec813cb3 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataModule.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataModule.kt @@ -14,6 +14,7 @@ import org.dhis2.commons.resources.DhisPeriodUtils import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.schedulers.SchedulerProvider +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.dhislogic.DhisEnrollmentUtils import org.dhis2.data.forms.dataentry.SearchTEIRepository @@ -129,7 +130,15 @@ class TEIDataModule( @Provides fun provideGetNewEventCreationTypeOptions( programConfigurationRepository: ProgramConfigurationRepository, - ): GetNewEventCreationTypeOptions = GetNewEventCreationTypeOptions(programConfigurationRepository) + rampDatastoreRepository: RampDatastoreRepository, + ): GetNewEventCreationTypeOptions = + GetNewEventCreationTypeOptions( + programConfigurationRepository, + rampDatastoreRepository, + ) + + @Provides + fun provideRampDatastoreRepository(d2: D2): RampDatastoreRepository = RampDatastoreRepository(d2) @Provides fun provideEventCreationsOptionsMapper(resourceManager: ResourceManager): EventCreationOptionsMapper = diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/domain/GetNewEventCreationTypeOptions.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/domain/GetNewEventCreationTypeOptions.kt index 2bdafd67e49..f66b777e07e 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/domain/GetNewEventCreationTypeOptions.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/domain/GetNewEventCreationTypeOptions.kt @@ -5,10 +5,12 @@ import org.dhis2.commons.data.EventCreationType.ADDNEW import org.dhis2.commons.data.EventCreationType.REFERAL import org.dhis2.commons.data.EventCreationType.SCHEDULE import org.dhis2.commons.data.ProgramConfigurationRepository +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository import org.hisp.dhis.android.core.program.ProgramStage class GetNewEventCreationTypeOptions( private val programConfigurationRepository: ProgramConfigurationRepository, + private val rampDatastoreRepository: RampDatastoreRepository, ) { operator fun invoke( programStage: ProgramStage?, @@ -16,10 +18,16 @@ class GetNewEventCreationTypeOptions( ): List { val options: MutableList = mutableListOf() options.add(ADDNEW) - if (programStage == null || shouldShowScheduleEvents(programStage)) { + if ( + (programStage == null || shouldShowScheduleEvents(programStage)) && + rampDatastoreRepository.isScheduleOptionEnabled(programStage?.uid()) + ) { options.add(SCHEDULE) } - if (shouldShowReferralEvents(programUid)) { + if ( + shouldShowReferralEvents(programUid) && + rampDatastoreRepository.isReferOptionEnabled(programStage?.uid()) + ) { options.add(REFERAL) } return options diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml index a63fff7254a..edbcd1bca7d 100644 --- a/app/src/main/res/layout/fragment_about.xml +++ b/app/src/main/res/layout/fragment_about.xml @@ -28,13 +28,21 @@ android:layout_height="wrap_content" android:padding="10dp"> + + + app:layout_constraintTop_toBottomOf="@id/rampCaptureVersion" /> Do you want to add more data from QR? - App Version: %s + RAMPcapture Version: %s + DHIS2 App Version: %s SDK Version: %s Connected to: %s Current user: %s @@ -759,7 +760,7 @@ Charts Relations Notes - History + Chart Data entry Form Details diff --git a/app/src/test/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepositoryTest.kt b/app/src/test/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepositoryTest.kt index 35cc534220a..37e443480ae 100644 --- a/app/src/test/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepositoryTest.kt @@ -4,6 +4,7 @@ import org.dhis2.commons.simprints.ramp.model.ProgramStageHistoryTableConfig import org.dhis2.commons.simprints.ramp.model.RampDatastoreConfig import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.arch.repositories.filters.internal.BooleanFilterConnector import org.hisp.dhis.android.core.arch.repositories.filters.internal.StringFilterConnector import org.hisp.dhis.android.core.arch.repositories.`object`.ReadOnlyOneObjectRepositoryFinalImpl import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope @@ -40,6 +41,8 @@ class EventHistoryTableRepositoryTest { private val enrollmentEvents: EventCollectionRepository = mock() private val programStageFilter: StringFilterConnector = mock() private val followUpEvents: EventCollectionRepository = mock() + private val deletedFilter: BooleanFilterConnector = mock() + private val activeFollowUpEvents: EventCollectionRepository = mock() private val programStageDataElements: ProgramStageDataElementCollectionRepository = mock() private val programStageDataElementsWithRenderType: ProgramStageDataElementCollectionRepository = mock() private val programStageDataElementProgramStageFilter: @@ -205,6 +208,118 @@ class EventHistoryTableRepositoryTest { ) } + @Test + fun `table should exclude locally deleted follow-up events`() { + stubRampConfig() + stubRowsAndSections() + stubFollowUpEvents( + events = + listOf( + event( + uid = "active", + eventDate = Date(1_000), + values = + listOf( + value(VISIT_NUMBER_UID, "0"), + value(WEIGHT_UID, "8.0"), + ), + ), + ), + unfilteredEvents = + listOf( + event( + uid = "deleted", + eventDate = Date(1_000), + values = + listOf( + value(VISIT_NUMBER_UID, "0"), + value(WEIGHT_UID, "99.0"), + ), + ).toBuilder().deleted(true).build(), + ), + ) + stubOptions() + + val table = + EventHistoryTableRepository( + d2 = d2, + simprintsRampDatastoreRepository = simprintsRampDatastoreRepository, + programUid = PROGRAM_UID, + enrollmentUid = ENROLLMENT_UID, + ).getTable() + + assertEquals("active", table?.columns?.get(0)?.eventUid) + assertEquals( + "8.0", + table + ?.sections + ?.single() + ?.rows + ?.first() + ?.values + ?.first() + ?.value, + ) + } + + @Test + fun `table should follow section form order for sections and rows`() { + stubRampConfig() + stubRowsAndSections( + programStageDataElementUids = + listOf( + VISIT_NUMBER_UID, + WEIGHT_UID, + STATUS_UID, + BOOLEAN_UID, + EXCLUDED_UID, + ), + sections = + listOf( + TestSectionDefinition( + uid = "second-section", + displayName = "Second section", + sortOrder = 2, + dataElementUids = listOf(BOOLEAN_UID), + ), + TestSectionDefinition( + uid = "first-section", + displayName = "First section", + sortOrder = 1, + dataElementUids = listOf(STATUS_UID, WEIGHT_UID), + ), + ), + ) + stubFollowUpEvents(emptyList()) + stubOptions() + + val table = + EventHistoryTableRepository( + d2 = d2, + simprintsRampDatastoreRepository = simprintsRampDatastoreRepository, + programUid = PROGRAM_UID, + enrollmentUid = ENROLLMENT_UID, + ).getTable() + + assertEquals(listOf("First section", "Second section"), table?.sections?.map { it.title }) + assertEquals( + listOf("Status", "Weight"), + table + ?.sections + ?.first() + ?.rows + ?.map { it.label }, + ) + assertEquals( + listOf("Confirmed"), + table + ?.sections + ?.get(1) + ?.rows + ?.map { it.label }, + ) + } + private fun stubRampConfig() { whenever(simprintsRampDatastoreRepository.getConfig()) doReturn RampDatastoreConfig( @@ -230,31 +345,65 @@ class EventHistoryTableRepositoryTest { whenever(currentEventRepository.blockingGet()) doReturn event } - private fun stubFollowUpEvents(events: List) { + private fun stubFollowUpEvents( + events: List, + unfilteredEvents: List = events, + ) { whenever(d2.eventModule().events()) doReturn this.events whenever(this.events.withTrackedEntityDataValues()) doReturn eventsWithDataValues whenever(eventsWithDataValues.byEnrollmentUid()) doReturn enrollmentFilter whenever(enrollmentFilter.eq(ENROLLMENT_UID)) doReturn enrollmentEvents whenever(enrollmentEvents.byProgramStageUid()) doReturn programStageFilter whenever(programStageFilter.eq(PROGRAM_STAGE_UID)) doReturn followUpEvents - whenever(followUpEvents.blockingGet()) doReturn events + whenever(followUpEvents.blockingGet()) doReturn unfilteredEvents + whenever(followUpEvents.byDeleted()) doReturn deletedFilter + whenever(deletedFilter.isFalse) doReturn activeFollowUpEvents + whenever(activeFollowUpEvents.blockingGet()) doReturn events } - private fun stubRowsAndSections() { + private fun stubRowsAndSections( + programStageDataElementUids: List = + listOf( + VISIT_NUMBER_UID, + WEIGHT_UID, + STATUS_UID, + BOOLEAN_UID, + EXCLUDED_UID, + ), + sections: List = + listOf( + TestSectionDefinition( + uid = "section", + displayName = "Follow up", + sortOrder = 1, + dataElementUids = listOf(WEIGHT_UID, STATUS_UID, BOOLEAN_UID, EXCLUDED_UID), + ), + TestSectionDefinition( + uid = "empty-section", + displayName = "Hidden", + sortOrder = 2, + dataElementUids = listOf(EXCLUDED_UID), + ), + ), + ) { val programStage = ObjectWithUid.create(PROGRAM_STAGE_UID) val weightDataElement = dataElement(WEIGHT_UID) val statusDataElement = dataElement(STATUS_UID) val booleanDataElement = dataElement(BOOLEAN_UID) val excludedDataElement = dataElement(EXCLUDED_UID) val visitNumberDataElement = dataElement(VISIT_NUMBER_UID) - val programStageDataElementsForStage = + val dataElementsByUid = listOf( - programStageDataElement(programStage, visitNumberDataElement), - programStageDataElement(programStage, weightDataElement), - programStageDataElement(programStage, statusDataElement), - programStageDataElement(programStage, booleanDataElement), - programStageDataElement(programStage, excludedDataElement), - ) + weightDataElement, + statusDataElement, + booleanDataElement, + excludedDataElement, + visitNumberDataElement, + ).associateBy { dataElement -> dataElement.uid().orEmpty() } + val programStageDataElementsForStage = + programStageDataElementUids.map { dataElementUid -> + programStageDataElement(programStage, dataElementsByUid.getValue(dataElementUid)) + } whenever(d2.programModule().programStageDataElements()) doReturn programStageDataElements whenever(programStageDataElements.withRenderType()) doReturn programStageDataElementsWithRenderType @@ -269,24 +418,16 @@ class EventHistoryTableRepositoryTest { whenever(programStageSectionFilter.eq(PROGRAM_STAGE_UID)) doReturn programStageSectionsByStage whenever(programStageSectionsByStage.withDataElements()) doReturn programStageSectionsWithDataElements whenever(programStageSectionsWithDataElements.blockingGet()) doReturn - listOf( + sections.map { section -> ProgramStageSection .builder() - .uid("section") - .displayName("Follow up") - .sortOrder(1) + .uid(section.uid) + .displayName(section.displayName) + .sortOrder(section.sortOrder) .programStage(programStage) - .dataElements(listOf(weightDataElement, statusDataElement, booleanDataElement, excludedDataElement)) - .build(), - ProgramStageSection - .builder() - .uid("empty-section") - .displayName("Hidden") - .sortOrder(2) - .programStage(programStage) - .dataElements(listOf(excludedDataElement)) - .build(), - ) + .dataElements(section.dataElementUids.map(dataElementsByUid::getValue)) + .build() + } whenever( d2 .dataElementModule() @@ -402,6 +543,13 @@ class EventHistoryTableRepositoryTest { private fun dataElement(uid: String): DataElement = DataElement.builder().uid(uid).build() + private data class TestSectionDefinition( + val uid: String, + val displayName: String, + val sortOrder: Int?, + val dataElementUids: List, + ) + private companion object { const val CURRENT_EVENT_UID = "current" const val ENROLLMENT_UID = "enrollment" diff --git a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePresenterTest.kt b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePresenterTest.kt index 1637e661cbc..45cd124ea52 100644 --- a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePresenterTest.kt @@ -4,14 +4,22 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import io.reactivex.Flowable import io.reactivex.Observable import io.reactivex.Single +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.commons.resources.ResourceManager import org.dhis2.data.schedulers.TrampolineSchedulerProvider +import org.dhis2.utils.customviews.navigationbar.NavigationPage import org.dhis2.utils.customviews.navigationbar.NavigationPageConfigurator import org.hisp.dhis.android.core.common.ValidationStrategy import org.hisp.dhis.android.core.event.EventStatus import org.hisp.dhis.android.core.organisationunit.OrganisationUnit import org.hisp.dhis.android.core.program.ProgramStage +import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -26,10 +34,12 @@ import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import java.util.Date +@OptIn(ExperimentalCoroutinesApi::class) class EventCapturePresenterTest { @get:Rule val rule = InstantTaskExecutorRule() + private val testingDispatcher = UnconfinedTestDispatcher() private lateinit var presenter: EventCapturePresenterImpl private val view: EventCaptureContract.View = mock() private val eventUid = "eventUid" @@ -41,6 +51,8 @@ class EventCapturePresenterTest { @Before fun setUp() { + Dispatchers.setMain(testingDispatcher) + whenever(resourceManager.getString(any())) doReturn "" presenter = EventCapturePresenterImpl( view, @@ -53,6 +65,11 @@ class EventCapturePresenterTest { ) } + @After + fun tearDown() { + Dispatchers.resetMain() + } + @Test fun `Should initialize the event capture form`() { initializeMocks() @@ -293,6 +310,52 @@ class EventCapturePresenterTest { verify(view).updateNoteBadge(1) } + @Test + fun `Should display one chart navigation item when only Simprints RAMP history table tab is configured`() { + initializeMocks() + whenever(eventRepository.eventIntegrityCheck()) doReturn Flowable.just(true) + whenever(eventRepository.eventStatus()) doReturn Flowable.just(EventStatus.ACTIVE) + whenever(eventRepository.isEventEditable("eventUid")) doReturn true + whenever(pageConfigurator.displayDataEntry()) doReturn false + whenever(pageConfigurator.displayAnalytics()) doReturn false + whenever(pageConfigurator.displayRelationships()) doReturn false + whenever(pageConfigurator.displayNotes()) doReturn false + whenever(pageConfigurator.displayTableView()) doReturn true + + presenter.init() + + assertEquals( + listOf(NavigationPage.TABLE_VIEW), + presenter.observeNavigationBarUIState().value.items.map { it.id }, + ) + assertEquals( + NavigationPage.TABLE_VIEW, + presenter.observeNavigationBarUIState().value.selectedItem, + ) + + presenter.setForceDisplayDataEntryNavigationItemForSimprintsRampTable(true) + + assertEquals( + listOf(NavigationPage.DATA_ENTRY, NavigationPage.TABLE_VIEW), + presenter.observeNavigationBarUIState().value.items.map { it.id }, + ) + assertEquals( + NavigationPage.TABLE_VIEW, + presenter.observeNavigationBarUIState().value.selectedItem, + ) + + presenter.setForceDisplayDataEntryNavigationItemForSimprintsRampTable(false) + + assertEquals( + listOf(NavigationPage.TABLE_VIEW), + presenter.observeNavigationBarUIState().value.items.map { it.id }, + ) + assertEquals( + NavigationPage.TABLE_VIEW, + presenter.observeNavigationBarUIState().value.selectedItem, + ) + } + private fun initializeMocks() { val stage = ProgramStage diff --git a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImplTest.kt b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImplTest.kt index 6bdd8436996..045a7a84e96 100644 --- a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImplTest.kt @@ -2,11 +2,13 @@ package org.dhis2.usescases.eventsWithoutRegistration.eventCapture import io.reactivex.Completable import io.reactivex.Single +import org.dhis2.commons.simprints.ramp.model.ProgramStageHistoryTableConfig +import org.dhis2.commons.simprints.ramp.model.RampDatastoreConfig +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository import org.dhis2.data.dhislogic.AUTH_ALL import org.dhis2.data.dhislogic.AUTH_UNCOMPLETE_EVENT import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.dataelement.DataElement -import org.hisp.dhis.android.core.datastore.DataStoreEntry.builder import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentStatus import org.hisp.dhis.android.core.event.Event @@ -34,6 +36,7 @@ import java.util.GregorianCalendar class EventCaptureRepositoryImplTest { private val eventUid = "eventUid" private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) + private val rampDatastoreRepository: RampDatastoreRepository = mock() private val trackerEventEnrollmentUid = "enrollmentUid" private val testEventStageUid = "stageUid" @@ -67,6 +70,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) assertTrue(repository.isEnrollmentOpen) @@ -85,6 +89,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) repository.isEnrollmentOpen @@ -110,6 +115,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) assertTrue(!repository.isEnrollmentCancelled) @@ -137,6 +143,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) assertTrue(repository.isEnrollmentCancelled) @@ -151,6 +158,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) repository.isEventEditable(eventUid) @@ -184,6 +192,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) val testObserver = repository.programStageName().test() @@ -201,6 +210,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) whenever( @@ -231,6 +241,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) whenever( @@ -257,6 +268,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) val testStatus = EventStatus.SKIPPED whenever( @@ -279,6 +291,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) val testNewDate = GregorianCalendar(3021, 11, 1).time whenever( @@ -304,6 +317,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) repository @@ -318,7 +332,11 @@ class EventCaptureRepositoryImplTest { mockEvent() mockSections() - val repository = EventCaptureRepositoryImpl(eventUid, d2) + val repository = EventCaptureRepositoryImpl( + eventUid, + d2, + rampDatastoreRepository, + ) whenever( d2 @@ -337,7 +355,11 @@ class EventCaptureRepositoryImplTest { mockEvent() mockSections() - val repository = EventCaptureRepositoryImpl(eventUid, d2) + val repository = EventCaptureRepositoryImpl( + eventUid, + d2, + rampDatastoreRepository, + ) whenever( d2 @@ -360,6 +382,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) repository @@ -378,6 +401,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) whenever( @@ -430,6 +454,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) whenever( @@ -455,6 +480,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) whenever( @@ -480,6 +506,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) repository @@ -498,6 +525,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) repository @@ -516,6 +544,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) val numberOfNotes = 12 whenever( @@ -552,6 +581,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) whenever( @@ -570,6 +600,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) whenever( @@ -599,6 +630,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) whenever( @@ -622,25 +654,16 @@ class EventCaptureRepositoryImplTest { @Test fun `hasSimprintsRampProgramStageHistoryTable should return true if RAMP program stage history table is configured for event`() { mockEvent() - stubRampConfigRawValue( - """ - { - "programStageHistoryTable": [ - { - "programId": " $testEventProgramUid ", - "followUpVisitProgramStageId": " $testEventStageUid ", - "followUpVisitMaxNumber": 3, - "headerVisitNumberDataElementId": "visitNumberUid" - } - ] - } - """.trimIndent(), + mockRampConfig( + programUid = " $testEventProgramUid ", + programStageUid = " $testEventStageUid ", ) val repository = EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) assertTrue(repository.hasSimprintsRampProgramStageHistoryTable()) @@ -668,25 +691,13 @@ class EventCaptureRepositoryImplTest { .program(testEventProgramUid) .build(), ) - stubRampConfigRawValue( - """ - { - "programStageHistoryTable": [ - { - "programId": "$testEventProgramUid", - "followUpVisitProgramStageId": "$testEventStageUid", - "followUpVisitMaxNumber": 3, - "headerVisitNumberDataElementId": "visitNumberUid" - } - ] - } - """.trimIndent(), - ) + mockRampConfig() val repository = EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) assertFalse(repository.hasSimprintsRampProgramStageHistoryTable()) @@ -702,6 +713,7 @@ class EventCaptureRepositoryImplTest { EventCaptureRepositoryImpl( eventUid, d2, + rampDatastoreRepository, ) whenever( d2 @@ -768,18 +780,22 @@ class EventCaptureRepositoryImplTest { .build() } - private fun stubRampConfigRawValue(value: String) { - whenever( - d2 - .dataStoreModule() - .dataStore() - .value("simprints", "ramp") - .blockingGet(), - ) doReturn builder() - .namespace("simprints") - .key("ramp") - .value(value) - .build() + private fun mockRampConfig( + programUid: String? = testEventProgramUid, + programStageUid: String? = testEventStageUid, + ) { + whenever(rampDatastoreRepository.getConfig()) doReturn + RampDatastoreConfig( + programStageHistoryTables = + listOf( + ProgramStageHistoryTableConfig( + programId = programUid, + followUpVisitProgramStageId = programStageUid, + followUpVisitMaxNumber = 3, + headerVisitNumberDataElementId = "visitNumberUid", + ), + ), + ) } private fun mockEmptySections() { diff --git a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfiguratorTest.kt b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfiguratorTest.kt index fe1d5213fcc..87f05381898 100644 --- a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfiguratorTest.kt +++ b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfiguratorTest.kt @@ -24,4 +24,32 @@ class EventPageConfiguratorTest { whenever(eventCaptureRepository.hasSimprintsRampProgramStageHistoryTable()) doReturn false assertFalse(pageConfigurator.displayTableView()) } + + @Test + fun `displayAnalytics should be true when analytics are configured and Simprints RAMP history table is not configured`() { + whenever(eventCaptureRepository.hasAnalytics()) doReturn true + whenever(eventCaptureRepository.hasSimprintsRampProgramStageHistoryTable()) doReturn false + + assertTrue(pageConfigurator.displayAnalytics()) + } + + @Test + fun `displayAnalytics should be false when Simprints RAMP history table is configured`() { + whenever(eventCaptureRepository.hasAnalytics()) doReturn true + whenever(eventCaptureRepository.hasSimprintsRampProgramStageHistoryTable()) doReturn true + + assertFalse(pageConfigurator.displayAnalytics()) + } + + @Test + fun `displayNotes should be true when Simprints RAMP history table is not configured`() { + whenever(eventCaptureRepository.hasSimprintsRampProgramStageHistoryTable()) doReturn false + assertTrue(pageConfigurator.displayNotes()) + } + + @Test + fun `displayNotes should be false when Simprints RAMP history table is configured`() { + whenever(eventCaptureRepository.hasSimprintsRampProgramStageHistoryTable()) doReturn true + assertFalse(pageConfigurator.displayNotes()) + } } diff --git a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryTest.kt index b2437a4242b..4014e2b29b7 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryTest.kt @@ -10,6 +10,7 @@ import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.resources.DhisPeriodUtils import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.forms.dataentry.SearchTEIRepository import org.dhis2.data.sorting.SearchSortingValueSetter @@ -98,6 +99,7 @@ class SearchRepositoryTest { private val searchTEIRepository: SearchTEIRepository = mock() private val themeManager: ThemeManager = mock() private val profilePictureProvider: ProfilePictureProvider = mock() + private val rampDatastoreRepository: RampDatastoreRepository = mock() private val dateUtils: DateUtils = DateUtils() @Before @@ -147,6 +149,7 @@ class SearchRepositoryTest { profilePictureProvider, dateUtils, customIntentRepository, + rampDatastoreRepository, ) } diff --git a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt index cfcce366651..22448d54908 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt @@ -106,6 +106,7 @@ class SearchTEIViewModelTest { whenever(pageConfigurator.initVariables()) doReturn pageConfigurator setCurrentProgram(testingProgram()) whenever(repository.canCreateInProgramWithoutSearch()) doReturn true + whenever(repository.isSearchEnabled()) doReturn true whenever(repository.getTrackedEntityType()) doReturn testingTrackedEntityType() whenever(repository.filtersApplyOnGlobalSearch()) doReturn true whenever(filterManager.stateFilters) doReturn emptyList() @@ -209,6 +210,47 @@ class SearchTEIViewModelTest { assertTrue(screenState is SearchList) } + @Test + fun `Should not open search screen when search is disabled`() { + whenever(repository.isSearchEnabled()) doReturn false + viewModel = + SearchTEIViewModel( + initialProgram, + initialQuery, + repository, + repositoryKt, + pageConfigurator, + mapDataRepository, + networkUtils, + object : DispatcherProvider { + override fun io(): CoroutineDispatcher = testingDispatcher + + override fun computation(): CoroutineDispatcher = testingDispatcher + + override fun ui(): CoroutineDispatcher = testingDispatcher + }, + mapStyleConfiguration, + resourceManager = resourceManager, + displayNameProvider = displayNameProvider, + filterManager = filterManager, + simprintsSearchViewModel = simprintsSearchViewModel, + loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, + mapSimprintsBiometricSearchResult = mapSimprintsBiometricSearchResult, + ) + testingDispatcher.scheduler.advanceUntilIdle() + + viewModel.setListScreen() + viewModel.setSearchScreen() + viewModel.onSearchFormRequested() + + val screenState = viewModel.screenState.value + assertFalse(viewModel.isSearchEnabled.value!!) + assertTrue(screenState is SearchList) + val searchForm = (screenState as SearchList).searchForm + assertFalse(searchForm.isEnabled) + assertFalse(searchForm.isOpened) + } + @Test fun `Should request Simprints biometric identification launch instead of opening search form`() = runTest { diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardViewModelTest.kt index 48dd0e14fd5..64b033011ec 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardViewModelTest.kt @@ -13,9 +13,11 @@ import org.dhis2.utils.analytics.ACTIVE_FOLLOW_UP import org.dhis2.utils.analytics.AnalyticsHelper import org.dhis2.utils.analytics.FOLLOW_UP import org.dhis2.utils.customviews.navigationbar.NavigationPageConfigurator +import org.dhis2.tracker.TEIDashboardItems import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentStatus +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -40,6 +42,7 @@ class DashboardViewModelTest { @Before fun setUp() { Dispatchers.setMain(testingDispatcher) + whenever(resoourcesManager.getString(any())) doReturn "" } @Test @@ -101,6 +104,72 @@ class DashboardViewModelTest { } } + @Test + fun shouldDisplayOnlyHistoryUntilLandscapeHistoryFullscreenIsForced() { + mockEnrollmentModel() + mockGrouping(false) + whenever(pageConfigurator.displayDetails()) doReturn false + whenever(pageConfigurator.displayAnalytics()) doReturn false + whenever(pageConfigurator.displayTableView()) doReturn true + + val dashboardViewModel = getViewModel() + + assertEquals( + listOf(TEIDashboardItems.SIMPRINTS_RAMP_HISTORY_TABLE), + dashboardViewModel.navigationItemIds(), + ) + assertEquals( + TEIDashboardItems.SIMPRINTS_RAMP_HISTORY_TABLE, + dashboardViewModel.navigationBarUIState.value.selectedItem, + ) + + dashboardViewModel.onNavigationItemSelected(TEIDashboardItems.SIMPRINTS_RAMP_HISTORY_TABLE) + + assertEquals( + listOf(TEIDashboardItems.SIMPRINTS_RAMP_HISTORY_TABLE), + dashboardViewModel.navigationItemIds(), + ) + + dashboardViewModel.setForceDisplayDetailsNavigationItemForSimprintsRampTable(true) + + assertEquals( + listOf( + TEIDashboardItems.DETAILS, + TEIDashboardItems.SIMPRINTS_RAMP_HISTORY_TABLE, + ), + dashboardViewModel.navigationItemIds(), + ) + assertEquals( + TEIDashboardItems.SIMPRINTS_RAMP_HISTORY_TABLE, + dashboardViewModel.navigationBarUIState.value.selectedItem, + ) + + dashboardViewModel.setForceDisplayDetailsNavigationItemForSimprintsRampTable(false) + + assertEquals( + listOf(TEIDashboardItems.SIMPRINTS_RAMP_HISTORY_TABLE), + dashboardViewModel.navigationItemIds(), + ) + assertEquals( + TEIDashboardItems.SIMPRINTS_RAMP_HISTORY_TABLE, + dashboardViewModel.navigationBarUIState.value.selectedItem, + ) + } + + @Test + fun shouldKeepDetailsVisibleInPortrait() { + mockEnrollmentModel() + mockGrouping(false) + whenever(pageConfigurator.displayDetails()) doReturn true + whenever(pageConfigurator.displayTableView()) doReturn true + + val dashboardViewModel = getViewModel() + + assertTrue(dashboardViewModel.navigationItemIds().contains(TEIDashboardItems.DETAILS)) + dashboardViewModel.onNavigationItemSelected(TEIDashboardItems.SIMPRINTS_RAMP_HISTORY_TABLE) + assertTrue(dashboardViewModel.navigationItemIds().contains(TEIDashboardItems.DETAILS)) + } + @Test fun shouldSetFollowUpOnEnrollment() { mockEnrollmentModel() @@ -168,6 +237,9 @@ class DashboardViewModelTest { testingDispatcher.scheduler.advanceUntilIdle() } + private fun DashboardViewModel.navigationItemIds(): List = + navigationBarUIState.value.items.map { it.id } + private fun mockEnrollmentModel() { whenever(repository.getDashboardModel()) doReturn mockedEnrollmentModel whenever(mockedEnrollmentModel.currentEnrollment) doReturn mockedEnrollment diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfiguratorTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfiguratorTest.kt index d435dd38336..3182d09d7fe 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfiguratorTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfiguratorTest.kt @@ -1,6 +1,7 @@ package org.dhis2.usescases.teiDashboard import org.dhis2.utils.customviews.navigationbar.NavigationPageConfigurator +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import org.mockito.kotlin.doReturn @@ -19,8 +20,9 @@ class TeiDashboardPageConfiguratorTest { } @Test - fun `Should display analytics screen if the program is configured`() { + fun `Should display analytics screen if the program is configured and Simprints RAMP history screen is not configured`() { whenever(dashboardRepository.programHasAnalytics()) doReturn true + whenever(dashboardRepository.programHasSimprintsRampProgramStageHistoryTable()) doReturn false assertTrue(pageConfigurator.displayAnalytics()) } @@ -30,6 +32,13 @@ class TeiDashboardPageConfiguratorTest { assertTrue(!pageConfigurator.displayAnalytics()) } + @Test + fun `Should not display analytics screen if Simprints RAMP history screen is configured`() { + whenever(dashboardRepository.programHasAnalytics()) doReturn true + whenever(dashboardRepository.programHasSimprintsRampProgramStageHistoryTable()) doReturn true + assertFalse(pageConfigurator.displayAnalytics()) + } + @Test fun `Should display relationships screen if the program is configured`() { whenever(dashboardRepository.programHasRelationships()) doReturn true @@ -43,10 +52,17 @@ class TeiDashboardPageConfiguratorTest { } @Test - fun `Should display the notes screen`() { + fun `Should display notes screen when history screen is not configured`() { + whenever(dashboardRepository.programHasSimprintsRampProgramStageHistoryTable()) doReturn false assertTrue(pageConfigurator.displayNotes()) } + @Test + fun `Should not display notes screen when history screen is configured`() { + whenever(dashboardRepository.programHasSimprintsRampProgramStageHistoryTable()) doReturn true + assertFalse(pageConfigurator.displayNotes()) + } + @Test fun `Should display history screen if the program is configured`() { whenever(dashboardRepository.programHasSimprintsRampProgramStageHistoryTable()) doReturn true diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/domain/GetNewEventCreationTypeOptionsTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/domain/GetNewEventCreationTypeOptionsTest.kt index 74935d5622c..00a2d6a9162 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/domain/GetNewEventCreationTypeOptionsTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/domain/GetNewEventCreationTypeOptionsTest.kt @@ -1,14 +1,18 @@ package org.dhis2.usescases.teiDashboard.domain +import org.dhis2.commons.data.EventCreationType.ADDNEW import org.dhis2.commons.data.EventCreationType.REFERAL import org.dhis2.commons.data.EventCreationType.SCHEDULE import org.dhis2.commons.data.ProgramConfigurationRepository +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository import org.hisp.dhis.android.core.program.ProgramStage import org.hisp.dhis.android.core.settings.ProgramConfigurationSetting +import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -22,6 +26,7 @@ class GetNewEventCreationTypeOptionsTest { mock { on { getConfigurationByProgram(programUid) } doReturn programConfigurationSetting } + private val rampDatastoreRepository: RampDatastoreRepository = mock() val programStage: ProgramStage = mock() @@ -29,8 +34,10 @@ class GetNewEventCreationTypeOptionsTest { @Before fun setUp() { + whenever(rampDatastoreRepository.isScheduleOptionEnabled(anyOrNull())) doReturn true + whenever(rampDatastoreRepository.isReferOptionEnabled(anyOrNull())) doReturn true getNewEventCreationTypeOptions = - GetNewEventCreationTypeOptions(programConfigurationRepository) + GetNewEventCreationTypeOptions(programConfigurationRepository, rampDatastoreRepository) } @Test @@ -86,4 +93,15 @@ class GetNewEventCreationTypeOptionsTest { assertNull(result.find { it == REFERAL }) } + + @Test + fun shouldOnlyReturnAddNewTypeWhenStageOptionsAreDisabled() { + whenever(programStage.uid()) doReturn "programStageUid" + whenever(rampDatastoreRepository.isScheduleOptionEnabled("programStageUid")) doReturn false + whenever(rampDatastoreRepository.isReferOptionEnabled("programStageUid")) doReturn false + + val result = getNewEventCreationTypeOptions.invoke(programStage, programUid) + + assertEquals(listOf(ADDNEW), result) + } } diff --git a/commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt b/commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt index a1aa4af258a..c9e808c88df 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt @@ -5,10 +5,14 @@ import com.google.gson.annotations.SerializedName data class RampDatastoreConfig( val dataElementHistoryCharts: List = emptyList(), val programStageHistoryTables: List = emptyList(), + val programSpecificSettings: List = emptyList(), + val programStageSpecificSettings: List = emptyList(), ) { fun isNotEmpty(): Boolean = dataElementHistoryCharts.isNotEmpty() || - programStageHistoryTables.isNotEmpty() + programStageHistoryTables.isNotEmpty() || + programSpecificSettings.isNotEmpty() || + programStageSpecificSettings.isNotEmpty() } data class DataElementHistoryChartConfig( @@ -22,6 +26,8 @@ data class DataElementHistoryChartConfig( val xAxisVisitNumberDataElementId: String? = null, @SerializedName("followUpVisitMaxNumber") val followUpVisitMaxNumber: Int? = null, + @SerializedName("displayMaxDecimalPlaces") + val displayMaxDecimalPlaces: Int? = null, ) { fun isValid(): Boolean = !programId.isNullOrBlank() && @@ -49,3 +55,23 @@ data class ProgramStageHistoryTableConfig( !headerVisitNumberDataElementId.isNullOrBlank() && (followUpVisitMaxNumber ?: -1) >= 0 } + +data class ProgramSpecificSetting( + @SerializedName("programId") + val programId: String? = null, + @SerializedName("isSearchEnabled") + val isSearchEnabled: Boolean? = null, +) { + fun isValid(): Boolean = !programId.isNullOrBlank() +} + +data class ProgramStageSpecificSetting( + @SerializedName("programStageId") + val programStageId: String? = null, + @SerializedName("isScheduleOptionEnabled") + val isScheduleOptionEnabled: Boolean? = null, + @SerializedName("isReferOptionEnabled") + val isReferOptionEnabled: Boolean? = null, +) { + fun isValid(): Boolean = !programStageId.isNullOrBlank() +} diff --git a/commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt b/commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt index 83b35415c21..2d3cf5dba5b 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt @@ -5,7 +5,9 @@ import com.google.gson.JsonElement import com.google.gson.JsonParseException import com.google.gson.JsonParser import org.dhis2.commons.simprints.ramp.model.DataElementHistoryChartConfig +import org.dhis2.commons.simprints.ramp.model.ProgramSpecificSetting import org.dhis2.commons.simprints.ramp.model.ProgramStageHistoryTableConfig +import org.dhis2.commons.simprints.ramp.model.ProgramStageSpecificSetting import org.dhis2.commons.simprints.ramp.model.RampDatastoreConfig import org.hisp.dhis.android.core.D2 import timber.log.Timber @@ -37,6 +39,41 @@ class RampDatastoreRepository( .blockingDownload() } + fun isSearchEnabled(programId: String?): Boolean { + val normalizedProgramId = programId.trimToValue() ?: return true + + return try { + getConfig() + .programSpecificSettings + .firstOrNull { it.programId == normalizedProgramId } + ?.isSearchEnabled != false + } catch (exception: RuntimeException) { + Timber.e(exception, RAMP_DATASTORE_PARSE_ERROR) + true + } + } + + fun isScheduleOptionEnabled(programStageId: String?): Boolean = isProgramStageOptionEnabled(programStageId) { isScheduleOptionEnabled } + + fun isReferOptionEnabled(programStageId: String?): Boolean = isProgramStageOptionEnabled(programStageId) { isReferOptionEnabled } + + private fun isProgramStageOptionEnabled( + programStageId: String?, + option: ProgramStageSpecificSetting.() -> Boolean?, + ): Boolean { + val normalizedProgramStageId = programStageId.trimToValue() ?: return true + + return try { + getConfig() + .programStageSpecificSettings + .firstOrNull { it.programStageId == normalizedProgramStageId } + ?.option() != false + } catch (exception: RuntimeException) { + Timber.e(exception, RAMP_DATASTORE_PARSE_ERROR) + true + } + } + private fun getLocalRampDatastoreValue(): String? = d2 .dataStoreModule() @@ -55,12 +92,28 @@ class RampDatastoreRepository( .get(DATA_ELEMENT_HISTORY_CHARTS_KEY) ?.parseList() .orEmpty() + .map { it.normalized() } .filter { it.isValid() }, programStageHistoryTables = root .get(PROGRAM_STAGE_HISTORY_TABLE_KEY) ?.parseList() .orEmpty() + .map { it.normalized() } + .filter { it.isValid() }, + programSpecificSettings = + root + .get(PROGRAM_SPECIFIC_SETTINGS_KEY) + ?.parseList() + .orEmpty() + .map { it.normalized() } + .filter { it.isValid() }, + programStageSpecificSettings = + root + .get(PROGRAM_STAGE_SPECIFIC_SETTINGS_KEY) + ?.parseList() + .orEmpty() + .map { it.normalized() } .filter { it.isValid() }, ) } catch (exception: JsonParseException) { @@ -123,6 +176,32 @@ class RampDatastoreRepository( null } + private fun DataElementHistoryChartConfig.normalized(): DataElementHistoryChartConfig = + copy( + programId = programId.trimToValue(), + followUpVisitProgramStageId = followUpVisitProgramStageId.trimToValue(), + dataElementId = dataElementId.trimToValue(), + xAxisVisitNumberDataElementId = xAxisVisitNumberDataElementId.trimToValue(), + ) + + private fun ProgramStageHistoryTableConfig.normalized(): ProgramStageHistoryTableConfig = + copy( + programId = programId.trimToValue(), + followUpVisitProgramStageId = followUpVisitProgramStageId.trimToValue(), + headerVisitNumberDataElementId = headerVisitNumberDataElementId.trimToValue(), + excludedFollowUpVisitDataElementIds = + excludedFollowUpVisitDataElementIds + ?.mapNotNull { it.trimToValue() }, + ) + + private fun ProgramSpecificSetting.normalized(): ProgramSpecificSetting = + copy(programId = programId.trimToValue()) + + private fun ProgramStageSpecificSetting.normalized(): ProgramStageSpecificSetting = + copy(programStageId = programStageId.trimToValue()) + + private fun String?.trimToValue(): String? = this?.trim()?.takeIf { it.isNotEmpty() } + private data class CachedConfig( val rawValue: String?, val config: RampDatastoreConfig, @@ -133,6 +212,8 @@ class RampDatastoreRepository( private const val RAMP_DATASTORE_KEY = "ramp" private const val DATA_ELEMENT_HISTORY_CHARTS_KEY = "dataElementHistoryCharts" private const val PROGRAM_STAGE_HISTORY_TABLE_KEY = "programStageHistoryTable" + private const val PROGRAM_SPECIFIC_SETTINGS_KEY = "programSpecificSettings" + private const val PROGRAM_STAGE_SPECIFIC_SETTINGS_KEY = "programStageSpecificSettings" private const val DATASTORE_JSON_WRAPPER_NAME = "JsonWrapper" private const val DATASTORE_JSON_WRAPPER_JSON_FIELD = "json" private const val RAMP_DATASTORE_PARSE_ERROR = "Failed to parse Simprints RAMP datastore config" diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepositoryTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepositoryTest.kt index 62196b1f40b..f1341686835 100644 --- a/commons/src/test/kotlin/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepositoryTest.kt +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepositoryTest.kt @@ -30,7 +30,8 @@ class RampDatastoreRepositoryTest { "followUpVisitProgramStageId": "follow-stage", "dataElementId": "weight", "xAxisVisitNumberDataElementId": "visit-number", - "followUpVisitMaxNumber": 12 + "followUpVisitMaxNumber": 12, + "displayMaxDecimalPlaces": 1 }, { "programId": "program", @@ -69,6 +70,7 @@ class RampDatastoreRepositoryTest { assertEquals("weight", chart.dataElementId) assertEquals("visit-number", chart.xAxisVisitNumberDataElementId) assertEquals(12, chart.followUpVisitMaxNumber) + assertEquals(1, chart.displayMaxDecimalPlaces) } assertEquals(1, config.programStageHistoryTables.size) config.programStageHistoryTables.first().let { table -> @@ -80,6 +82,139 @@ class RampDatastoreRepositoryTest { } } + @Test + fun `getConfig should trim configured identifiers across all RAMP config sections`() { + stubRampConfigRawValue( + """ + { + "dataElementHistoryCharts": [ + { + "programId": " program ", + "followUpVisitProgramStageId": " follow-stage ", + "dataElementId": " weight ", + "xAxisVisitNumberDataElementId": " visit-number ", + "followUpVisitMaxNumber": 12 + } + ], + "programStageHistoryTable": [ + { + "programId": " program ", + "followUpVisitProgramStageId": " follow-stage ", + "followUpVisitMaxNumber": 12, + "headerVisitNumberDataElementId": " visit-number ", + "excludedFollowUpVisitDataElementIds": [" excluded ", " "] + } + ], + "programSpecificSettings": [ + { + "programId": " disabledProgram ", + "isSearchEnabled": false + } + ], + "programStageSpecificSettings": [ + { + "programStageId": " disabledStage ", + "isScheduleOptionEnabled": false, + "isReferOptionEnabled": false + } + ] + } + """.trimIndent(), + ) + + val config = repository.getConfig() + + config.dataElementHistoryCharts.single().let { chart -> + assertEquals("program", chart.programId) + assertEquals("follow-stage", chart.followUpVisitProgramStageId) + assertEquals("weight", chart.dataElementId) + assertEquals("visit-number", chart.xAxisVisitNumberDataElementId) + } + config.programStageHistoryTables.single().let { table -> + assertEquals("program", table.programId) + assertEquals("follow-stage", table.followUpVisitProgramStageId) + assertEquals("visit-number", table.headerVisitNumberDataElementId) + assertEquals(listOf("excluded"), table.excludedFollowUpVisitDataElementIds) + } + assertEquals("disabledProgram", config.programSpecificSettings.single().programId) + assertEquals("disabledStage", config.programStageSpecificSettings.single().programStageId) + } + + @Test + fun `isSearchEnabled should return false only when program disables search`() { + stubRampConfigRawValue( + """ + { + "programSpecificSettings": [ + { + "programId": " disabledProgram ", + "isSearchEnabled": false + }, + { + "programId": "defaultProgram" + }, + { + "programId": "enabledProgram", + "isSearchEnabled": true + } + ] + } + """.trimIndent(), + ) + + assertEquals(false, repository.isSearchEnabled("disabledProgram")) + assertEquals(false, repository.isSearchEnabled(" disabledProgram ")) + assertEquals(true, repository.isSearchEnabled("enabledProgram")) + assertEquals(true, repository.isSearchEnabled("defaultProgram")) + assertEquals(true, repository.isSearchEnabled("missingProgram")) + assertEquals(true, repository.isSearchEnabled(null)) + assertEquals(true, repository.isSearchEnabled(" ")) + } + + @Test + fun `program stage options should return false only when stage disables them`() { + stubRampConfigRawValue( + """ + { + "programStageSpecificSettings": [ + { + "programStageId": " disabledStage ", + "isScheduleOptionEnabled": false, + "isReferOptionEnabled": false + }, + { + "programStageId": "defaultStage" + }, + { + "programStageId": "enabledStage", + "isScheduleOptionEnabled": true, + "isReferOptionEnabled": true + }, + { + "isScheduleOptionEnabled": false, + "isReferOptionEnabled": false + } + ] + } + """.trimIndent(), + ) + + assertEquals(false, repository.isScheduleOptionEnabled("disabledStage")) + assertEquals(false, repository.isReferOptionEnabled("disabledStage")) + assertEquals(false, repository.isScheduleOptionEnabled(" disabledStage ")) + assertEquals(false, repository.isReferOptionEnabled(" disabledStage ")) + assertEquals(true, repository.isScheduleOptionEnabled("enabledStage")) + assertEquals(true, repository.isReferOptionEnabled("enabledStage")) + assertEquals(true, repository.isScheduleOptionEnabled("defaultStage")) + assertEquals(true, repository.isReferOptionEnabled("defaultStage")) + assertEquals(true, repository.isScheduleOptionEnabled("missingStage")) + assertEquals(true, repository.isReferOptionEnabled("missingStage")) + assertEquals(true, repository.isScheduleOptionEnabled(null)) + assertEquals(true, repository.isReferOptionEnabled(null)) + assertEquals(true, repository.isScheduleOptionEnabled(" ")) + assertEquals(true, repository.isReferOptionEnabled(" ")) + } + @Test fun `getConfig should parse wrapped string datastore value`() { val rawJson = diff --git a/form/src/main/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepository.kt b/form/src/main/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepository.kt index a24df722736..962ec49ea60 100644 --- a/form/src/main/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepository.kt +++ b/form/src/main/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepository.kt @@ -11,14 +11,17 @@ class FormHistoryChartRepository( private val eventUid: String, private val d2: D2, ) { - private val currentEvent by lazy { loadCurrentEvent() } private val followUpVisitEventsByProgramStage = mutableMapOf>() fun getChart( fieldUiModel: FieldUiModel, configs: List, ): FormHistoryChart? { - val currentEvent = currentEvent ?: return null + configs.firstOrNull { config -> + config.dataElementId?.trim() == fieldUiModel.uid + } ?: return null + + val currentEvent = loadCurrentEvent() ?: return null val chartConfig = configs.firstOrNull { config -> config.programId?.trim() == currentEvent.program() && @@ -69,6 +72,7 @@ class FormHistoryChartRepository( labels = labels, values = values, currentValueIndex = currentValueIndex, + displayMaxDecimalPlaces = chartConfig.displayMaxDecimalPlaces, ) } @@ -76,6 +80,7 @@ class FormHistoryChartRepository( d2 .eventModule() .events() + .withTrackedEntityDataValues() .uid(eventUid) .blockingGet() @@ -83,20 +88,32 @@ class FormHistoryChartRepository( currentEvent: Event, followUpVisitProgramStageUid: String, ): List { - followUpVisitEventsByProgramStage[followUpVisitProgramStageUid]?.let { return it } - - val enrollmentUid = currentEvent.enrollment() ?: return emptyList() + val enrollmentUid = currentEvent.enrollment() val currentEventDate = currentEvent.displayDate() - - return d2 - .eventModule() - .events() - .withTrackedEntityDataValues() - .byEnrollmentUid() - .eq(enrollmentUid) - .byProgramStageUid() - .eq(followUpVisitProgramStageUid) - .blockingGet() + val queriedEvents = + if (enrollmentUid.isNullOrBlank()) { + emptyList() + } else { + followUpVisitEventsByProgramStage[followUpVisitProgramStageUid] + ?: d2 + .eventModule() + .events() + .withTrackedEntityDataValues() + .byEnrollmentUid() + .eq(enrollmentUid) + .byProgramStageUid() + .eq(followUpVisitProgramStageUid) + .byDeleted() + .isFalse + .blockingGet() + .also { followUpVisitEventsByProgramStage[followUpVisitProgramStageUid] = it } + } + val events = + (queriedEvents + currentEvent) + .associateBy { event -> event.uid() } + .values + + return events .filter { followUpEvent -> val followUpEventDate = followUpEvent.displayDate() followUpEvent.uid() == eventUid || @@ -108,7 +125,7 @@ class FormHistoryChartRepository( { followUpEvent -> followUpEvent.displayDate() ?: Date(0) }, { followUpEvent -> followUpEvent.uid() }, ), - ).also { followUpVisitEventsByProgramStage[followUpVisitProgramStageUid] = it } + ) } private fun Event.dataValuesByDataElement(): Map = diff --git a/form/src/main/java/org/dhis2/form/simprints/ramp/model/FormHistoryChart.kt b/form/src/main/java/org/dhis2/form/simprints/ramp/model/FormHistoryChart.kt index 55e96d34499..e8759541ac7 100644 --- a/form/src/main/java/org/dhis2/form/simprints/ramp/model/FormHistoryChart.kt +++ b/form/src/main/java/org/dhis2/form/simprints/ramp/model/FormHistoryChart.kt @@ -5,6 +5,7 @@ data class FormHistoryChart( val labels: List, val values: List, val currentValueIndex: Int? = null, + val displayMaxDecimalPlaces: Int? = null, ) { fun withCurrentValue(value: String?): FormHistoryChart { val index = currentValueIndex?.takeIf { it in values.indices } ?: return this diff --git a/form/src/main/java/org/dhis2/form/simprints/ramp/ui/FormHistoryChartView.kt b/form/src/main/java/org/dhis2/form/simprints/ramp/ui/FormHistoryChartView.kt index b7657764e66..823bf757525 100644 --- a/form/src/main/java/org/dhis2/form/simprints/ramp/ui/FormHistoryChartView.kt +++ b/form/src/main/java/org/dhis2/form/simprints/ramp/ui/FormHistoryChartView.kt @@ -37,12 +37,13 @@ import kotlin.math.roundToInt private const val HISTORY_CHART_HEIGHT_DP = 180 private const val HISTORY_CHART_PERIOD_STEP = 1L private const val HISTORY_CHART_X_AXIS_LABEL_ROTATION = 30f -private const val HISTORY_CHART_TEXT_SIZE_FACTOR = 1.25f +private const val HISTORY_CHART_TEXT_SIZE_FACTOR = 1.5f private const val HISTORY_CHART_TEXT_SIZE = DEFAULT_VALUE_TEXT_SIZE * HISTORY_CHART_TEXT_SIZE_FACTOR private const val HISTORY_CHART_Y_AXIS_LABEL_COUNT = 5 private const val HISTORY_CHART_FLAT_RANGE_EPSILON = 0.0001f private const val HISTORY_CHART_INTEGER_GRANULARITY_MIN_SPAN = 1f private const val HISTORY_CHART_INTEGER_GRANULARITY = 1f +private const val HISTORY_CHART_DEFAULT_MAX_DECIMAL_PLACES = 2 @Composable fun FormHistoryChartView(historyChart: FormHistoryChart) { @@ -87,11 +88,12 @@ private fun LineChart.configureHistoryChart( historyChart: FormHistoryChart, graph: Graph, ) { - val valueFormatter = IntegerAwareValueFormatter() + val axisValueFormatter = IntegerAwareValueFormatter() + val pointValueFormatter = IntegerAwareValueFormatter(historyChart.displayMaxDecimalPlaces) setHighlightPerTapEnabled(false) setHighlightPerDragEnabled(false) - data = graph.toHistoryLineData(valueFormatter) + data = graph.toHistoryLineData(pointValueFormatter) xAxis.apply { axisMinimum = -1f axisMaximum = historyChart.labels.size.toFloat() @@ -102,7 +104,7 @@ private fun LineChart.configureHistoryChart( textSize = HISTORY_CHART_TEXT_SIZE } axisLeft.apply { - this.valueFormatter = valueFormatter + this.valueFormatter = axisValueFormatter setLabelCount(HISTORY_CHART_Y_AXIS_LABEL_COUNT, false) textSize = HISTORY_CHART_TEXT_SIZE } @@ -180,9 +182,14 @@ private fun Graph.toHistoryLineData(valueFormatter: ValueFormatter) = setValueTextSize(HISTORY_CHART_TEXT_SIZE) } -private class IntegerAwareValueFormatter : ValueFormatter() { +internal class IntegerAwareValueFormatter( + maxDecimalPlaces: Int? = null, +) : ValueFormatter() { private val decimalFormat = - DecimalFormat("0.##", DecimalFormatSymbols(Locale.US)) + DecimalFormat("0", DecimalFormatSymbols(Locale.US)).apply { + minimumFractionDigits = 0 + maximumFractionDigits = maxDecimalPlaces.validMaxDecimalPlaces() + } override fun getFormattedValue(value: Float): String = if (abs(value - value.roundToInt()) < INTEGER_VALUE_EPSILON) { @@ -201,6 +208,9 @@ private class IntegerAwareValueFormatter : ValueFormatter() { } } +private fun Int?.validMaxDecimalPlaces(): Int = + this?.takeIf { it >= 0 } ?: HISTORY_CHART_DEFAULT_MAX_DECIMAL_PLACES + private fun FormHistoryChart.toAnalyticsGraph(): Graph = Graph( title = title, diff --git a/form/src/test/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepositoryTest.kt b/form/src/test/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepositoryTest.kt index db465f5509e..fe562a3f57b 100644 --- a/form/src/test/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepositoryTest.kt +++ b/form/src/test/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepositoryTest.kt @@ -3,6 +3,7 @@ package org.dhis2.form.simprints.ramp.data import org.dhis2.commons.simprints.ramp.model.DataElementHistoryChartConfig import org.dhis2.form.model.FieldUiModelImpl import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.arch.repositories.filters.internal.BooleanFilterConnector import org.hisp.dhis.android.core.arch.repositories.filters.internal.StringFilterConnector import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.event.Event @@ -29,6 +30,8 @@ class FormHistoryChartRepositoryTest { private val enrollmentEvents: EventCollectionRepository = mock() private val programStageFilter: StringFilterConnector = mock() private val followUpEvents: EventCollectionRepository = mock() + private val deletedFilter: BooleanFilterConnector = mock() + private val activeFollowUpEvents: EventCollectionRepository = mock() private val repository = FormHistoryChartRepository(CURRENT_EVENT_UID, d2) @Test @@ -86,6 +89,52 @@ class FormHistoryChartRepositoryTest { assertEquals(listOf("0", "1", "2", "3"), chart?.labels) assertEquals(listOf(8f, 9.5f, null, null), chart?.values) assertEquals(1, chart?.currentValueIndex) + assertEquals(1, chart?.displayMaxDecimalPlaces) + } + + @Test + fun `getChart should plot current input value when current event not saved yet`() { + val currentEvent = + getEvent( + uid = CURRENT_EVENT_UID, + eventDate = Date(2_000), + dataValues = + listOf( + dataValue(VISIT_NUMBER_UID, "1"), + dataValue(DATA_ELEMENT_UID, "9.0"), + ), + ) + stubCurrentEvent(currentEvent) + stubFollowUpEvents( + listOf( + getEvent( + uid = "previous", + eventDate = Date(1_000), + dataValues = + listOf( + dataValue(VISIT_NUMBER_UID, "0"), + dataValue(DATA_ELEMENT_UID, "8.0"), + ), + ), + ), + ) + + val chart = + repository.getChart( + fieldUiModel = + FieldUiModelImpl( + uid = DATA_ELEMENT_UID, + value = "9.5", + label = "Weight", + valueType = ValueType.NUMBER, + optionSetConfiguration = null, + autocompleteList = null, + ), + configs = listOf(getConfig()), + ) + + assertEquals(listOf(8f, 9.5f, null, null), chart?.values) + assertEquals(1, chart?.currentValueIndex) } @Test @@ -106,6 +155,7 @@ class FormHistoryChartRepositoryTest { ) assertNull(chart) + verify(currentEventRepository, times(0)).blockingGet() } @Test @@ -140,6 +190,63 @@ class FormHistoryChartRepositoryTest { assertEquals(1, chart?.currentValueIndex) } + @Test + fun `getChart should exclude locally deleted follow-up events`() { + val currentEvent = + getEvent( + uid = CURRENT_EVENT_UID, + eventDate = Date(2_000), + dataValues = + listOf( + dataValue(VISIT_NUMBER_UID, "1"), + dataValue(DATA_ELEMENT_UID, "9.0"), + ), + ) + stubCurrentEvent(currentEvent) + stubFollowUpEvents( + eventsToReturn = + listOf( + getEvent( + uid = "active", + eventDate = Date(1_000), + dataValues = + listOf( + dataValue(VISIT_NUMBER_UID, "0"), + dataValue(DATA_ELEMENT_UID, "8.0"), + ), + ), + ), + unfilteredEvents = + listOf( + getEvent( + uid = "deleted", + eventDate = Date(1_000), + dataValues = + listOf( + dataValue(VISIT_NUMBER_UID, "0"), + dataValue(DATA_ELEMENT_UID, "99.0"), + ), + ).toBuilder().deleted(true).build(), + ), + ) + + val chart = + repository.getChart( + fieldUiModel = + FieldUiModelImpl( + uid = DATA_ELEMENT_UID, + value = "9.5", + label = "Weight", + valueType = ValueType.NUMBER, + optionSetConfiguration = null, + autocompleteList = null, + ), + configs = listOf(getConfig()), + ) + + assertEquals(listOf(8f, 9.5f, null, null), chart?.values) + } + @Test fun `getChart should reuse loaded current event and follow up events`() { val currentEvent = @@ -166,8 +273,8 @@ class FormHistoryChartRepositoryTest { repository.getChart(fieldUiModel, listOf(getConfig())) repository.getChart(fieldUiModel, listOf(getConfig())) - verify(currentEventRepository, times(1)).blockingGet() - verify(followUpEvents, times(1)).blockingGet() + verify(currentEventRepository, times(2)).blockingGet() + verify(activeFollowUpEvents, times(1)).blockingGet() } private fun getConfig() = @@ -177,21 +284,29 @@ class FormHistoryChartRepositoryTest { dataElementId = DATA_ELEMENT_UID, xAxisVisitNumberDataElementId = VISIT_NUMBER_UID, followUpVisitMaxNumber = 3, + displayMaxDecimalPlaces = 1, ) private fun stubCurrentEvent(event: Event) { whenever(d2.eventModule().events()) doReturn events - whenever(events.uid(CURRENT_EVENT_UID)) doReturn currentEventRepository + whenever(events.withTrackedEntityDataValues()) doReturn eventsWithDataValues + whenever(eventsWithDataValues.uid(CURRENT_EVENT_UID)) doReturn currentEventRepository whenever(currentEventRepository.blockingGet()) doReturn event } - private fun stubFollowUpEvents(eventsToReturn: List) { + private fun stubFollowUpEvents( + eventsToReturn: List, + unfilteredEvents: List = eventsToReturn, + ) { whenever(events.withTrackedEntityDataValues()) doReturn eventsWithDataValues whenever(eventsWithDataValues.byEnrollmentUid()) doReturn enrollmentFilter whenever(enrollmentFilter.eq(ENROLLMENT_UID)) doReturn enrollmentEvents whenever(enrollmentEvents.byProgramStageUid()) doReturn programStageFilter whenever(programStageFilter.eq(PROGRAM_STAGE_UID)) doReturn followUpEvents - whenever(followUpEvents.blockingGet()) doReturn eventsToReturn + whenever(followUpEvents.blockingGet()) doReturn unfilteredEvents + whenever(followUpEvents.byDeleted()) doReturn deletedFilter + whenever(deletedFilter.isFalse) doReturn activeFollowUpEvents + whenever(activeFollowUpEvents.blockingGet()) doReturn eventsToReturn } private fun getEvent( diff --git a/form/src/test/java/org/dhis2/form/simprints/ramp/ui/IntegerAwareValueFormatterTest.kt b/form/src/test/java/org/dhis2/form/simprints/ramp/ui/IntegerAwareValueFormatterTest.kt new file mode 100644 index 00000000000..55ef40cb33d --- /dev/null +++ b/form/src/test/java/org/dhis2/form/simprints/ramp/ui/IntegerAwareValueFormatterTest.kt @@ -0,0 +1,27 @@ +package org.dhis2.form.simprints.ramp.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class IntegerAwareValueFormatterTest { + @Test + fun `getFormattedValue should use configured maximum decimal places`() { + val formatter = IntegerAwareValueFormatter(maxDecimalPlaces = 1) + + assertEquals("9.6", formatter.getFormattedValue(9.56f)) + } + + @Test + fun `getFormattedValue should use two decimal places by default`() { + val formatter = IntegerAwareValueFormatter() + + assertEquals("9.57", formatter.getFormattedValue(9.567f)) + } + + @Test + fun `getFormattedValue should not add decimals to integers`() { + val formatter = IntegerAwareValueFormatter(maxDecimalPlaces = 1) + + assertEquals("9", formatter.getFormattedValue(9f)) + } +}