diff --git a/README.md b/README.md index 2d65610b577..282d3919832 100644 --- a/README.md +++ b/README.md @@ -16,30 +16,30 @@ 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#L121](app/build.gradle.kts#L121) | 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-L151](README.md#L1-L151) | Code change | This section in README | +| Docs: fork-specific README | [README.md#L1-L162](README.md#L1-L162) | Code change | This section in README | | Enrolment+ Possible Duplicates | [CustomIntentActivityResultContract.kt#L95](form/src/main/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContract.kt#L95) | Code addition | Treats non-identify Simprints callouts that return identification matches as `PossibleDuplicates` results | -| Enrolment+ Possible Duplicates | [FormView.kt#L627](form/src/main/java/org/dhis2/form/ui/FormView.kt#L627) | Code addition | Stores the returned SID session and hands the enrolment form off to the possible duplicates search flow | +| Enrolment+ Possible Duplicates | [FormView.kt#L628](form/src/main/java/org/dhis2/form/ui/FormView.kt#L628) | Code addition | Stores the returned SID session and hands the enrolment form off to the possible duplicates search flow | | Enrolment+ Possible Duplicates | [EnrollmentActivity.kt#L296](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L296) | Code addition | Launches possible duplicates search from the enrolment form, carrying the biometric field and returned GUID matches | | Enrolment+ Possible Duplicates | [SimprintsLoadPossibleDuplicatesSearchResultsUseCase.kt](app/src/main/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCase.kt) | New file | Resolves returned GUIDs one by one into TEI search results for the possible duplicates review list | -| Enrolment+ Possible Duplicates | [SearchTEIViewModel.kt#L727](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L727) | Code addition | Switches search loading into possible-duplicates mode and auto-falls back to Enrol Last when no matching TEIs exist in DHIS2 | +| Enrolment+ Possible Duplicates | [SearchTEIViewModel.kt#L740](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L740) | Code addition | Switches search loading into possible-duplicates mode and auto-falls back to Enrol Last when no matching TEIs exist in DHIS2 | | Enrolment+ Possible Duplicates | [SearchTEList.kt#L317](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L317) | Code addition | Replaces the default "+ New" action button with `None of the above` while reviewing possible duplicates | -| Enrolment+ Enrol Last | [SearchTEIViewModel.kt#L659](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L659) | Code addition | Marks `None of the above` as a pending enrolment action and closes the possible duplicates search | -| Enrolment+ Enrol Last | [SearchTEActivity.kt#L675](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L675) | Code addition | Returns possible duplicates search with an auto-enrol-last signal when the flow should continue to `REGISTER_LAST_BIOMETRICS` | +| Enrolment+ Enrol Last | [SearchTEIViewModel.kt#L671](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L671) | Code addition | Marks `None of the above` as a pending enrolment action and closes the possible duplicates search | +| Enrolment+ Enrol Last | [SearchTEActivity.kt#L687](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L687) | Code addition | Returns possible duplicates search with an auto-enrol-last signal when the flow should continue to `REGISTER_LAST_BIOMETRICS` | | Enrolment+ Enrol Last | [EnrollmentActivity.kt#L150](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L150) | Code addition | Receives the possible duplicates return flow and launches save-time Enrol Last before enrolment finishes | | Enrolment+ Enrol Last | [SimprintsEnrollmentViewModel.kt#L41](app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt#L41) | Code addition | Builds the auto-enrol-last intent from the stored session and restores pending actions across lifecycle interruptions | | Enrolment+ Enrol Last | [SimprintsSessionRepository.kt#L30](commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsSessionRepository.kt#L30) | Code addition | Keeps separate pending Enrol Last source state for possible-duplicates returns | | Enrolment+ Enrol Last | [SimprintsRememberCustomIntentFormPresenter.kt#L32](form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt#L32) | Code change | Shows `(Using last biometrics)` when Enrol Last is resumed after returning from possible duplicates | | Identification+ Enrol Last | [EnrollmentActivity.kt#L326](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L326) | Code addition | Launches Enrol Last before completing a valid enrollment save and resumes finish after SID returns | | Identification+ Enrol Last | [EnrollmentPresenterImpl.kt#L163](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImpl.kt#L163) | Code addition | Delegates save-time Enrol Last resolution and result handling | -| Identification+ Enrol Last | [EnrollmentModule.kt#L181](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt#L181) | Code addition | DI for separate Simprints-specific components for Identification+ Enrol Last | +| Identification+ Enrol Last | [EnrollmentModule.kt#L193](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt#L193) | Code addition | DI for separate Simprints-specific components for Identification+ Enrol Last | | Identification+ Enrol Last | [SimprintsEnrollmentViewModelFactory.kt](app/src/main/java/org/dhis2/simprints/di/SimprintsEnrollmentViewModelFactory.kt) | New file | Creates activity-scoped Simprints Enrol Last ViewModel so pending save-time state survives configuration changes | -| Identification+ Enrol Last | [SearchTEActivity.kt#L597](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L597) | Code change | Carries biometric search context into create/enroll actions via prepared enrollment query data | +| Identification+ Enrol Last | [SearchTEActivity.kt#L609](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L609) | Code change | Carries biometric search context into create/enroll actions via prepared enrollment query data | | Identification+ Enrol Last | [SimprintsSearchViewModel.kt#L103](app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt#L103) | Code addition | Prepares Simprints enrollment query data and tracks whether the create action should use last biometrics | -| Identification+ Enrol Last | [SearchTEIViewModel.kt#L1592](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1592) | Code change | Preserves existing search parameter values when fields reload so pending Enrol Last state survives configuration changes | +| Identification+ Enrol Last | [SearchTEIViewModel.kt#L1619](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1619) | Code change | Preserves existing search parameter values when fields reload so pending Enrol Last state survives configuration changes | | Identification+ Enrol Last | [SearchTEList.kt#L299](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L299) | Code addition | Observes the Simprints create-action label state for if the new TEI enrollment button in the result list should use Enrol Last | -| Identification+ Enrol Last | [SearchTEUi.kt#L707](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L707) | Code change | Switches the new TEI enrollment button text to the last-biometrics label when a pending biometric session exists | +| Identification+ Enrol Last | [SearchTEUi.kt#L713](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L713) | Code change | Switches the new TEI enrollment button text to the last-biometrics label when a pending biometric session exists | | Identification+ Enrol Last | [FormRepositoryImpl.kt#L58](form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt#L58) | Code change | Counts pending Enrol Last values as present for form completion, sections, and mandatory validation | -| Identification+ Enrol Last | [Injector.kt#L281](form/src/main/java/org/dhis2/form/di/Injector.kt#L281) | Code addition | DI for separate Simprints-specific components for Identification+ Enrol Last | +| Identification+ Enrol Last | [Injector.kt#L296](form/src/main/java/org/dhis2/form/di/Injector.kt#L296) | Code addition | DI for separate Simprints-specific components for Identification+ Enrol Last | | Identification+ Enrol Last | [CustomIntentProvider.kt#L74](form/src/main/java/org/dhis2/form/ui/provider/inputfield/CustomIntentProvider.kt#L74) | Code addition | Shows pending biometric placeholder state, clears it on user clear, and routes Simprints enrollment results | | Identification+ Enrol Last | [SimprintsCustomIntentResultMapper.kt](app/src/main/java/org/dhis2/simprints/SimprintsCustomIntentResultMapper.kt) | New file | Maps SID result intents through the existing custom-intent response contract | | Identification+ Enrol Last | [SimprintsEnrollmentViewModel.kt](app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt) | New file | Coordinates pending Enrol Last launch, save-time result persistence, and session clearing | @@ -51,26 +51,35 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | Identification+ Enrol Last | [SimprintsCustomIntentFormPresenter.kt](form/src/main/java/org/dhis2/form/simprints/SimprintsCustomIntentFormPresenter.kt) | New file | Handles Simprints form callout launch, session capture, placeholder display, and pending-state clearing | | 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#L1016](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1016) | Code addition | Intercepts TEI openings from biometric search to launch Confirm Identity when required | -| Identification Confirm Identity | [SearchTEModule.java#L371](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L371) | Code addition | DI for separate Simprints-specific components for Identification Confirm Identity | +| Identification Confirm Identity | [SearchTEIViewModel.kt#L1038](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1038) | Code addition | Intercepts TEI openings from biometric search to launch Confirm Identity when required | +| Identification Confirm Identity | [SearchTEModule.java#L378](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L378) | Code addition | DI for separate Simprints-specific components for Identification Confirm Identity | | Identification Confirm Identity | [SearchTeiViewModelFactory.kt#L55](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L55) | 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 | -| ModuleID in Identification | [CustomIntentRepositoryImpl.kt#L103](commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt#L103) | Code change | Uses the current user's root organisation unit code, rather than its name, as SID's `moduleId` | -| Identification result ordering by score | [SearchTEIViewModel.kt#L752](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L752) | 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#L379](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L379) | Code change | DI for separate Simprints-specific components for Identification result ordering by score | +| ModuleID in Identification | [CustomIntentRepositoryImpl.kt#L116](commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt#L116) | Code addition | Overrides `moduleId` for Simprints identification intents with the current user's Org Unit UID | +| ModuleID in Identification | [CustomIntentRepositoryImpl.kt#L140](commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt#L140) | Code change | Uses the current user's root organisation unit UID, rather than its name, as SID's `moduleId` | +| Parent Org Unit ModuleID for biometrics | [RampDatastoreConfig.kt](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt) | Code addition | Adds the opt-in `isOneLevelUpOrgUnitForBiometricsModuleId` per-program setting | +| Parent Org Unit ModuleID for biometrics | [CustomIntentRepositoryImpl.kt](commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt) | Code change | Applies the immediate-parent UID to all SID callout variants, falling back to the current UID when no parent is available | +| Program-specific SID ModuleID prefix | [CustomIntentRepositoryImpl.kt](commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt) | Code addition | Optional `programSpecificSettings.moduleIdPrefix` setting for prefixes for module IDs for SID callouts | +| Identification result ordering by score | [SearchTEIViewModel.kt#L765](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L765) | 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#L386](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L386) | Code change | DI for separate Simprints-specific components for Identification result ordering by score | | Identification result ordering by score | [SearchTeiViewModelFactory.kt#L56](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L56) | 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 | [SearchTEIViewModel.kt#L514](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L514) | Code addition | Starts Simprints identification callout immediately on biometric search field click instead of expanding the field | +| Identification with less buttons | [SearchTEIViewModel.kt#L518](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L518) | Code addition | Starts Simprints identification callout immediately on biometric search field click instead of expanding the field | | Identification with less buttons | [SearchTEUi.kt#L214](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L214) | Code change | Keeps the Biometric search label read-only | | Identification with less buttons | [SearchParametersScreenProvider.kt#L70](app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreenProvider.kt#L70) | Code addition | Treats a successful Simprints identification return as an immediate search command instead of waiting for a manual extra step | -| Identification with less buttons | [SearchTEIViewModel.kt#L1551](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1551) | Code addition | Rehydrates carried query values and performs the search immediately when biometric query data is present | -| Identification with less buttons | [SearchTEActivity.kt#L637](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L637) | Code addition | Skips the transient search editor and jumps straight to the biometric result list without backdrop animation | +| Identification with less buttons | [SearchTEIViewModel.kt#L1563](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1563) | Code addition | Rehydrates carried query values and performs the search immediately when biometric query data is present | +| Identification with less buttons | [SearchTEActivity.kt#L649](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L649) | Code addition | Skips the transient search editor and jumps straight to the biometric result list without backdrop animation | +| Identification with less buttons | [SearchTEIViewModel.kt#L1485](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1485) | Code change | Returns from landscape filters to direct identification without reopening the legacy form, while preserving fallback search | +| Identification with less buttons | [SearchTEActivity.kt#L545](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L545) | Code change | Reconfigures the landscape search screen when direct-identification eligibility becomes available | +| Identification with less buttons | [SearchScreenConfigurator.kt#L23](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchScreenConfigurator.kt#L23) | Code change | Replaces the standard landscape parameter pane with the top-aligned Search button while retaining filters and fallback search | +| Identification with less buttons | [SearchTEUi.kt#L233](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L233) | Code change | Lets the shared Search button fill the landscape column without changing its default portrait sizing | | MFID Auto-Open Record | [SimprintsMapBiometricSearchResultUseCase.kt#L46](app/src/main/java/org/dhis2/simprints/SimprintsMapBiometricSearchResultUseCase.kt#L46) | Code addition | Detects credential-linked identification results that qualify for the MFID direct-open shortcut | | MFID Auto-Open Record | [SimprintsSearchViewModel.kt#L185](app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt#L185) | 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 | +| MFID Save external credential | [SimprintsExternalCredentialUtils.kt](commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsExternalCredentialUtils.kt) | New file | Reads supported external credentials from successful Enroll, Enroll Last and Confirm Identity responses | +| MFID Save external credential | [SimprintsD2Repository.kt](commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsD2Repository.kt) | Code addition | Uses optional per-program `externalCredentialAttributeId`; saves only new or changed external credential values | | 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#L129](app/build.gradle.kts#L129) | Code addition | Reads `RAMP_CAPTURE_VERSION`, falls back to `local build`, and exposes it as `BuildConfig.RAMP_CAPTURE_VERSION` | @@ -105,35 +114,49 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | RAMP ACF-39,40,41: history tables | [DashboardViewModel.kt#L218](app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt#L218) | Code addition | Adds the RAMP Chart navigation item and returns portrait TEI enrollment navigation to Details after landscape Chart viewing | | 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#L378](app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt#L378) | Code addition | Expands the enrollment history table only after Chart is pressed in landscape, with Details as restore tab | +| RAMP Chart table custom column label | [RampDatastoreConfig.kt#L47](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L47) | Code addition | Adds optional `followUpVisitMinNumber` and `followUpVisitLabel` to program-stage history table config | +| RAMP Chart table custom column label | [EventHistoryTableRepository.kt#L31](app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt#L31) | Code change | Starts visit columns at the configured minimum visit number and passes the configured header label to the table | | 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#L496](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java#L496) | Code addition | Resolves the current program's search-enabled flag through the RAMP datastore repository | -| RAMP Search restriction in a program | [SearchTEIViewModel.kt#L254](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L254) | 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#L249](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L249) | Code change | Lets shared search/create controls hide search actions while preserving create actions where allowed | +| RAMP Search restriction in a program | [SearchTEIViewModel.kt#L255](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L255) | 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#L255](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L255) | Code change | Lets shared search/create controls hide search actions while preserving create actions where allowed | +| RAMP unfiltered list visibility | [RampDatastoreConfig.kt#L68](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L68) | Code addition | Adds the default-enabled per-program `isShowingUnfilteredList` datastore setting | +| RAMP unfiltered list visibility | [SearchTEIViewModel.kt#L237](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L237) | Code change | Hides unfiltered patient cards and their footer while keeping search and narrowing-filter results | +| RAMP unfiltered list visibility | [SearchTEList.kt#L513](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L513) | Code change | Clears the result footer and any global-search cards when returning to the hidden unfiltered state | +| RAMP capture-only biometrics | [RampDatastoreConfig.kt#L66](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L66) | Code addition | Adds the per-program `isBiometricsCaptureOnlyButtonEnabledForAttributeId` attribute setting | +| RAMP capture-only biometrics | [FormRepositoryImpl.kt#L72](form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt#L72) | Code addition | Hides the capture-only biometric field during capture and after a value is captured | | RAMP Search restriction in a program | [SearchTEList.kt#L234](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L234) | Code change | Applies the restriction to the list screen's search controls | | RAMP Search restriction in a program | [SearchTEMap.kt#L189](app/src/main/java/org/dhis2/usescases/searchTrackEntity/mapView/SearchTEMap.kt#L189) | 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 Search restriction in a program | [SearchJavaToCompose.kt#L24](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchJavaToCompose.kt#L24) | Code change | Hides the landscape open-search button when the selected program disables search | +| RAMP Search restriction in a program | [SearchScreenConfigurator.kt#L74](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchScreenConfigurator.kt#L74) | 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#L88](commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt#L88) | Code addition | Resolves per-stage schedule and refer option flags from the RAMP datastore | +| RAMP Menu option off in a program stage | [RampDatastoreRepository.kt#L111](commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt#L111) | 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 | +| RAMP anchored visit scheduling | [RampDatastoreConfig.kt#L99](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L99) | Code addition | Adds the default-off `isScheduleAnchoredToInitialVisit` per-stage setting | +| RAMP anchored visit scheduling | [ConfigureEventReportDate.kt#L110](app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventReportDate.kt#L110) | Code change | Suggests the next due date from visit 0, visit number, and the stage interval without moving the anchor after late visits | | RAMP Org unit filter shortcut | [FilterRepository.kt#L68](commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt#L68) | Code addition | Resolves a program's single data-capture organisation unit for the one-tap filter shortcut | | RAMP Org unit filter shortcut | [FilterItem.kt#L34](commons/src/main/java/org/dhis2/commons/filters/FilterItem.kt#L34) | Code addition | Applies the user's organisation unit directly unless a working-list filter controls the selection | | RAMP Org unit filter shortcut | [item_header_filter.xml#L112](commons/src/main/res/layout/item_header_filter.xml#L112) | Code addition | Adds the one-tap user organisation unit action to the organisation-unit filter header | | RAMP Filter dialog upstream crash fix | [MainActivity.kt#L317](app/src/main/java/org/dhis2/usescases/main/MainActivity.kt#L317) | Code addition | Avoids opening period or organisation-unit filter dialogs after FragmentManager state is saved | | RAMP Transferred patients | [TransferredPatientFilter.kt](commons/src/main/java/org/dhis2/commons/filters/data/TransferredPatientFilter.kt) | New file | Finds transferred TEIs when the program owner organisation unit differs from the active or latest enrolment unit | | RAMP Transferred patients | [FilterRepository.kt#L154](commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt#L154) | Code addition | Adds and applies the transferred-patient filter, including an empty-result-safe SDK query | -| RAMP Transferred patients | [TEICardMapper.kt#L73](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt#L73) | Code change | Marks result-card ownership as an emphasized `Transferred to` row with the transfer icon | -| RAMP Transferred patients | [ListCardProvider.kt#L48](commons/src/main/java/org/dhis2/commons/ui/ListCardProvider.kt#L48) | Code addition | Renders emphasized result-card information in bold while preserving its icon and expand/collapse behavior | +| RAMP Transferred patients | [TEICardMapper.kt#L60](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt#L60) | Code change | Marks result-card ownership as an emphasized `Transferred to` row with the transfer icon | +| RAMP Transferred patients | [ListCardProvider.kt#L45](commons/src/main/java/org/dhis2/commons/ui/ListCardProvider.kt#L45) | Code addition | Renders emphasized result-card information in bold while preserving its icon and expand/collapse behavior | | RAMP Transferred patients | [TeiDashboardCardMapper.kt#L59](app/src/main/java/org/dhis2/usescases/teiDashboard/ui/mapper/TeiDashboardCardMapper.kt#L59) | Code change | Maps transferred ownership to an emphasized `Transferred to` row with the transfer icon | | RAMP Transferred patients | [TeiDetailDashboard.kt#L61](app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TeiDetailDashboard.kt#L61) | Code addition | Renders the emphasized `Transferred to` row on the TEI details card | -| RAMP ACF-54: visit numbers before dates | [RampDatastoreConfig.kt#L82](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L82) | Code addition | Adds per-stage opt-in for visit-number date prefixes and selects the visit-number data element | -| RAMP ACF-54: visit numbers before dates | [TeiDataRepositoryImpl.kt#L536](app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt#L536) | Code addition | Reads a visit number from the configured data element for follow-up events | +| RAMP Quick status filters | [FilterItem.kt#L355](commons/src/main/java/org/dhis2/commons/filters/FilterItem.kt#L355) | Code addition | Adds Overdue and With sync errors switches as aliases of the existing event-status and sync-state selections | +| RAMP Quick status filters | [FilterRepository.kt#L558](commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt#L558) | Code addition | Adds each shortcut only when its Appearance Settings-controlled source filter is present | +| RAMP ACF-54: visit numbers before dates | [RampDatastoreConfig.kt#L101](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L101) | Code addition | Adds per-stage opt-in for visit-number date prefixes and selects the visit-number data element | +| RAMP ACF-54: visit numbers before dates | [TeiDataRepositoryImpl.kt#L561](app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt#L561) | Code addition | Reads a visit number from the configured data element for follow-up events | | RAMP ACF-54: visit numbers before dates | [TEIEventCardMapper.kt#L85](app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/teievents/ui/mapper/TEIEventCardMapper.kt#L85) | Code change | Prefixes configured follow-up event dates with `Visit :` in event cards | -| RAMP ACF-57: enrolment listings | [RampDatastoreConfig.kt#L59](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L59) | Code addition | Adds per-program detailed-listing settings for admission, discharge, and outcome metadata | -| RAMP ACF-57: enrolment listings | [RampDatastoreRepository.kt#L57](commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt#L57) | Code addition | Resolves normalized detailed-enrolment settings and disables the listing when it is not enabled | -| RAMP ACF-57: enrolment listings | [DetailedEnrollmentRepository.kt](app/src/main/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepository.kt) | New file | Builds other-enrolment summaries with program, site, admission, discharge, and latest outcome | -| RAMP ACF-57: enrolment listings | [SearchRepositoryImplKt.kt#L228](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt#L228) | Code addition | Attaches configured summaries for other enrolments to TEI search results | -| RAMP ACF-57: enrolment listings | [TEICardMapper.kt#L334](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt#L334) | Code change | Replaces the Programs value with a multiline listing whose program names are bold | +| RAMP Follow-up visit lists | [RampDatastoreConfig.kt#L105](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L105) | Code addition | Adds opt-in per-stage `hasAllVisitsExpanded` and `hasVisitNumbersSortedAscending` flags | +| RAMP Follow-up visit lists | [TeiDataRepositoryImpl.kt#L226](app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt#L226) | Code change | Shows all grouped visit cards without a toggle and sorts numerically by the configured visit-number field when enabled | +| RAMP ACF-57: enrolment listings | [RampDatastoreConfig.kt#L70](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L70) | Code addition | Adds per-program detailed-listing settings for admission, discharge, and outcome metadata | +| RAMP ACF-57: enrolment listings | [RampDatastoreRepository.kt#L80](commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt#L80) | Code addition | Resolves normalized detailed-enrolment settings and disables the listing when it is not enabled | +| RAMP ACF-57: enrolment listings | [DetailedEnrollmentRepository.kt](app/src/main/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepository.kt) | New file | Resolves configured site, admission, discharge, and latest outcome details for enrolments | +| RAMP ACF-57: enrolment listings | [TeiProgramListRepositoryImpl.java#L77](app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListRepositoryImpl.java#L77) | Code change | Adds the configured details to active and inactive enrolment rows | +| RAMP ACF-57: enrolment listings | [item_tei_programs_enrollment.xml](app/src/main/res/layout/item_tei_programs_enrollment.xml) | Code change | Replaces the default site and date line with inline `Admitted`, `Discharged`, `Outcome`, and `Site` details when configured | +| RAMP ACF-57: enrolment listings | [item_tei_programs_enrollment_inactive.xml](app/src/main/res/layout/item_tei_programs_enrollment_inactive.xml) | Code change | Applies the same configured inline details to inactive enrolment rows | | RAMP ACF-63: mandatory forms force open | [FormRepositoryImpl.kt#L534](form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt#L534) | Code change | Keeps sections with blank mandatory fields open while allowing completed sections to collapse independently | | RAMP ACF-63: mandatory forms force open | [Form.kt#L51](form/src/main/java/org/dhis2/form/ui/Form.kt#L51) | Code change | Uses keyed, fully rendered sections and bring-into-view scrolling to keep multi-open forms responsive | | RAMP ACF-63: mandatory forms force open | [FormViewModel.kt#L136](form/src/main/java/org/dhis2/form/ui/FormViewModel.kt#L136) | Code change | Preserves consecutive section-toggle intents so rapid expand/collapse actions are not coalesced | diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt index 378b9204dce..ce61dc3c8da 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt @@ -3,9 +3,12 @@ package org.dhis2.simprints import android.app.Activity.RESULT_OK import android.content.Intent import androidx.lifecycle.ViewModel +import kotlinx.coroutines.CancellationException import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.commons.simprints.usecases.SimprintsResolvePendingEnrollmentActionUseCase +import org.dhis2.commons.simprints.utils.SimprintsExternalCredentialUtils +import timber.log.Timber import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi @@ -87,6 +90,25 @@ class SimprintsEnrollmentViewModel( attributeUid = resolvedAction.fieldUid, value = value, ) + val externalCredentialValue = + SimprintsExternalCredentialUtils.enrollmentExternalCredentialValue( + resolvedAction.callout.launchIntent.action, + value, + data?.extras, + ) + if (externalCredentialValue != null && enrollmentUid != null) { + try { + simprintsD2Repository.saveEnrollmentExternalCredential( + enrollmentUid = enrollmentUid, + biometricAttributeUid = resolvedAction.fieldUid, + externalCredentialValue = externalCredentialValue, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Unable to save Simprints external credential after enrollment") + } + } sessionRepository.clear() RegisterLastResult.CONTINUE_FINISH } diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt index deb8cb82ac2..f0ef259afb4 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt @@ -2,18 +2,23 @@ package org.dhis2.simprints import android.app.Activity.RESULT_OK import android.content.Intent +import android.os.Bundle import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.receiveAsFlow +import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.commons.simprints.usecases.SimprintsResolveConfirmIdentityCalloutUseCase +import org.dhis2.commons.simprints.utils.SimprintsExternalCredentialUtils import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.commons.simprints.utils.SimprintsSearchUtils import org.dhis2.form.model.FieldUiModel import org.dhis2.form.model.FieldUiModelImpl +import timber.log.Timber import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi @@ -22,6 +27,7 @@ class SimprintsSearchViewModel( private val resolveConfirmIdentityCallout: SimprintsResolveConfirmIdentityCalloutUseCase, private val sessionRepository: SimprintsSessionRepository, private val resolveSingleBiometricSearchNavigation: SimprintsResolveSingleBiometricSearchNavigationUseCase, + private val simprintsD2Repository: SimprintsD2Repository, ) : ViewModel() { private data class PendingSimprintsMfidBiometricIdentification( val uid: String, @@ -33,6 +39,7 @@ class SimprintsSearchViewModel( val programUid: String?, val enrollmentUid: String?, val isOnline: Boolean = false, + val biometricAttributeUid: String? = null, ) sealed class DashboardAction { @@ -46,6 +53,7 @@ class SimprintsSearchViewModel( } private val pendingDashboardNavigation = AtomicReference(null) + private var confirmIdentityStateRestored = false private val pendingSimprintsMfidBiometricIdentification = AtomicReference(null) private var useLastBiometricsForSequentialSearch = false @@ -71,14 +79,14 @@ class SimprintsSearchViewModel( sessionRepository .get() ?.takeIf { searchState.hasBiometricIdentificationQuery || useSequentialSearchLastBiometrics } - val confirmIdentityIntent = + val confirmIdentityCallout = sessionId?.let { resolveConfirmIdentityCallout( teiUid = teiUid, searchFields = searchFields, sessionId = it, allowBlankSearchValue = useSequentialSearchLastBiometrics, - )?.launchIntent + ) } val navigation = @@ -86,8 +94,9 @@ class SimprintsSearchViewModel( teiUid = teiUid, programUid = programUid, enrollmentUid = enrollmentUid, + biometricAttributeUid = confirmIdentityCallout?.biometricAttributeUid, ) - if (confirmIdentityIntent == null) { + if (confirmIdentityCallout == null) { return DashboardAction.OpenDashboard(navigation) } @@ -97,7 +106,7 @@ class SimprintsSearchViewModel( useLastBiometricsForSequentialSearch = false sessionRepository.clear() } - return DashboardAction.LaunchConfirmIdentity(confirmIdentityIntent) + return DashboardAction.LaunchConfirmIdentity(confirmIdentityCallout.launchIntent) } fun prepareEnrollmentQueryData( @@ -124,10 +133,52 @@ class SimprintsSearchViewModel( sessionRepository.markPendingEnrollmentFromPossibleDuplicates() } - fun onConfirmIdentityResult(resultCode: Int): PendingDashboardNavigation? = - pendingDashboardNavigation - .exchange(null) - ?.takeIf { resultCode == RESULT_OK } + fun savePendingConfirmIdentity(outState: Bundle) { + val navigation = pendingDashboardNavigation.load() + outState.putString("simprints.confirmIdentity.teiUid", navigation?.teiUid) + outState.putString("simprints.confirmIdentity.programUid", navigation?.programUid) + outState.putString("simprints.confirmIdentity.enrollmentUid", navigation?.enrollmentUid) + outState.putString("simprints.confirmIdentity.biometricAttributeUid", navigation?.biometricAttributeUid) + } + + fun restorePendingConfirmIdentity(savedState: Bundle?) { + if (confirmIdentityStateRestored) return + confirmIdentityStateRestored = true + val teiUid = savedState?.getString("simprints.confirmIdentity.teiUid")?.takeIf(String::isNotBlank) ?: return + pendingDashboardNavigation.compareAndSet( + null, + PendingDashboardNavigation( + teiUid = teiUid, + programUid = savedState.getString("simprints.confirmIdentity.programUid"), + enrollmentUid = savedState.getString("simprints.confirmIdentity.enrollmentUid"), + biometricAttributeUid = savedState.getString("simprints.confirmIdentity.biometricAttributeUid"), + ), + ) + } + + suspend fun onConfirmIdentityResult( + resultCode: Int, + data: Intent? = null, + ): PendingDashboardNavigation? { + val navigation = pendingDashboardNavigation.exchange(null) ?: return null + if (resultCode != RESULT_OK) return null + + SimprintsExternalCredentialUtils.externalCredentialValue(data?.extras)?.let { externalCredentialValue -> + try { + simprintsD2Repository.saveExternalCredential( + teiUid = navigation.teiUid, + programUid = navigation.programUid, + biometricAttributeUid = navigation.biometricAttributeUid, + externalCredentialValue = externalCredentialValue, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Unable to save Simprints external credential after identity confirmation") + } + } + return navigation + } fun onConfirmIdentityLaunchFailed() { pendingDashboardNavigation.store(null) diff --git a/app/src/main/java/org/dhis2/simprints/di/SimprintsSearchViewModelFactory.kt b/app/src/main/java/org/dhis2/simprints/di/SimprintsSearchViewModelFactory.kt index e9391116356..c9e38befd69 100644 --- a/app/src/main/java/org/dhis2/simprints/di/SimprintsSearchViewModelFactory.kt +++ b/app/src/main/java/org/dhis2/simprints/di/SimprintsSearchViewModelFactory.kt @@ -2,6 +2,7 @@ package org.dhis2.simprints.di import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.commons.simprints.usecases.SimprintsResolveConfirmIdentityCalloutUseCase import org.dhis2.simprints.SimprintsResolveSingleBiometricSearchNavigationUseCase @@ -11,11 +12,13 @@ class SimprintsSearchViewModelFactory( private val resolveConfirmIdentityCallout: SimprintsResolveConfirmIdentityCalloutUseCase, private val sessionRepository: SimprintsSessionRepository, private val resolveSingleBiometricSearchNavigation: SimprintsResolveSingleBiometricSearchNavigationUseCase, + private val simprintsD2Repository: SimprintsD2Repository, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T = SimprintsSearchViewModel( resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) as T } diff --git a/app/src/main/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepository.kt b/app/src/main/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepository.kt index cabf5cab7ce..5b54a4aeadd 100644 --- a/app/src/main/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepository.kt +++ b/app/src/main/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepository.kt @@ -3,9 +3,8 @@ package org.dhis2.simprints.ramp.data import org.dhis2.bindings.userFriendlyValue import org.dhis2.commons.simprints.ramp.model.DetailedEnrollmentListingSettings import org.dhis2.simprints.ramp.model.DetailedEnrollment -import org.dhis2.tracker.search.model.DomainEnrollment -import org.dhis2.tracker.search.model.DomainProgram import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.event.Event import java.util.Date @@ -13,36 +12,30 @@ internal class DetailedEnrollmentRepository( private val d2: D2, ) { fun get( - enrollments: List?, - programs: List?, + enrollments: List, settings: DetailedEnrollmentListingSettings, - excludedProgramUid: String? = null, - ): List { - val includedEnrollments = enrollments?.filter { it.program != excludedProgramUid }.orEmpty() - if (includedEnrollments.isEmpty() || programs.isNullOrEmpty()) return emptyList() + ): Map { + val includedEnrollments = enrollments.filter { !it.uid().isNullOrBlank() } + if (includedEnrollments.isEmpty()) return emptyMap() - val programNames = programs.associate { it.uid to it.displayName } val siteNames = getSiteNames(includedEnrollments) val eventDetails = getEventDetails(includedEnrollments, settings) - return includedEnrollments.mapNotNull { enrollment -> - programNames[enrollment.program] - ?.takeIf { it.isNotBlank() } - ?.let { programName -> - val details = eventDetails[enrollment.uid] - DetailedEnrollment( - programName = programName, - admitted = details?.admitted, - discharge = details?.discharge, - outcome = details?.outcome, - site = siteNames[enrollment.orgUnit], - ) - } + return includedEnrollments.associate { enrollment -> + val enrollmentUid = enrollment.uid() + val details = eventDetails[enrollmentUid] + enrollmentUid to + DetailedEnrollment( + admitted = details?.admitted, + discharge = details?.discharge, + outcome = details?.outcome, + site = siteNames[enrollment.organisationUnit()], + ) } } - private fun getSiteNames(enrollments: List): Map { - val orgUnitIds = enrollments.mapNotNull { it.orgUnit }.distinct() + private fun getSiteNames(enrollments: List): Map { + val orgUnitIds = enrollments.mapNotNull { it.organisationUnit() }.distinct() if (orgUnitIds.isEmpty()) return emptyMap() return d2 @@ -59,7 +52,7 @@ internal class DetailedEnrollmentRepository( } private fun getEventDetails( - enrollments: List, + enrollments: List, settings: DetailedEnrollmentListingSettings, ): Map { if ( @@ -69,7 +62,7 @@ internal class DetailedEnrollmentRepository( return emptyMap() } - val enrollmentIds = enrollments.map { it.uid }.distinct() + val enrollmentIds = enrollments.map { it.uid() }.distinct() if (enrollmentIds.isEmpty()) return emptyMap() return d2 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 eea9847bb74..ca3ea21b464 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 @@ -28,7 +28,9 @@ class EventHistoryTableRepository( val tableContext = getTableContext() ?: return null val config = tableContext.config val followUpVisitProgramStageUid = config.followUpVisitProgramStageId.trimToValue() ?: return null - val followUpVisitMaxNumber = config.followUpVisitMaxNumber?.takeIf { it >= 0 } ?: return null + val followUpVisitNumbers = + ((config.followUpVisitMinNumber ?: 0)..(config.followUpVisitMaxNumber ?: -1)) + .takeIf { it.first in 0..it.last } ?: return null val headerVisitNumberDataElementUid = config.headerVisitNumberDataElementId.trimToValue() ?: return null val excludedDataElementIds = ( @@ -49,9 +51,9 @@ class EventHistoryTableRepository( events = events, eventDataValuesByUid = eventDataValuesByUid, headerVisitNumberDataElementUid = headerVisitNumberDataElementUid, - followUpVisitMaxNumber = followUpVisitMaxNumber, + followUpVisitNumbers = followUpVisitNumbers, ) - val columnIndexes = (0..followUpVisitMaxNumber).toList() + val columnIndexes = followUpVisitNumbers.toList() val optionDisplayNamesBySet = getOptionDisplayNamesBySet(rowDefinitions) fun eventForColumn(columnIndex: Int): Event? = eventsByColumnIndex[columnIndex] @@ -107,6 +109,7 @@ class EventHistoryTableRepository( columns = tableColumns, sections = it, dateRowValues = dateRowValues, + visitLabel = config.followUpVisitLabel.trimToValue(), ) } } @@ -325,14 +328,14 @@ class EventHistoryTableRepository( events: List, eventDataValuesByUid: Map>, headerVisitNumberDataElementUid: String, - followUpVisitMaxNumber: Int, + followUpVisitNumbers: IntRange, ): Map = events .mapNotNull { event -> eventDataValuesByUid[event.uid()] ?.get(headerVisitNumberDataElementUid) ?.toVisitNumber() - ?.takeIf { it in 0..followUpVisitMaxNumber } + ?.takeIf { it in followUpVisitNumbers } ?.let { visitNumber -> visitNumber to event } }.toMap() diff --git a/app/src/main/java/org/dhis2/simprints/ramp/model/DetailedEnrollment.kt b/app/src/main/java/org/dhis2/simprints/ramp/model/DetailedEnrollment.kt index 76c333b056c..54d231b36d4 100644 --- a/app/src/main/java/org/dhis2/simprints/ramp/model/DetailedEnrollment.kt +++ b/app/src/main/java/org/dhis2/simprints/ramp/model/DetailedEnrollment.kt @@ -3,7 +3,6 @@ package org.dhis2.simprints.ramp.model import java.util.Date data class DetailedEnrollment( - val programName: String, val admitted: Date?, val discharge: Date?, val outcome: String?, diff --git a/app/src/main/java/org/dhis2/simprints/ramp/model/EventHistoryTableModels.kt b/app/src/main/java/org/dhis2/simprints/ramp/model/EventHistoryTableModels.kt index 657d1a7b6d1..c92a75828f6 100644 --- a/app/src/main/java/org/dhis2/simprints/ramp/model/EventHistoryTableModels.kt +++ b/app/src/main/java/org/dhis2/simprints/ramp/model/EventHistoryTableModels.kt @@ -20,6 +20,7 @@ data class EventHistoryTable( val columns: List, val sections: List, val dateRowValues: List = emptyList(), + val visitLabel: String? = null, ) data class EventHistoryTableColumn( diff --git a/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableScreen.kt b/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableScreen.kt index 26581912df5..08013fd4ece 100644 --- a/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableScreen.kt +++ b/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableScreen.kt @@ -87,6 +87,7 @@ private fun HistoryTable( .background(colorResource(id = R.color.white)), ) { VisitHeaderRow( + label = table.visitLabel ?: stringResource(R.string.simprints_ramp_visit), columns = table.columns, horizontalScrollState = horizontalScrollState, ) @@ -125,12 +126,13 @@ private fun HistoryTable( @Composable private fun VisitHeaderRow( + label: String, columns: List, horizontalScrollState: ScrollState, ) { Row(modifier = Modifier.heightIntrinsicRow()) { HeaderCell( - text = stringResource(R.string.simprints_ramp_visit), + text = label, width = RowHeaderWidth, textAlign = TextAlign.Start, ) diff --git a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt index 89b87399ec2..2ebefc1900d 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt @@ -14,6 +14,7 @@ import org.dhis2.commons.resources.DhisPeriodUtils import org.dhis2.commons.resources.EventResourcesProvider 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.SimprintsResolvePendingEnrollmentActionUseCase @@ -151,7 +152,22 @@ class EnrollmentModule( @Provides @PerActivity - fun provideCustomIntentProvider(d2: D2): CustomIntentRepository = CustomIntentRepositoryImpl(d2) + fun provideRampDatastoreRepository(d2: D2): RampDatastoreRepository = + RampDatastoreRepository(d2) + + @Provides + @PerActivity + fun provideCustomIntentProvider( + d2: D2, + rampDatastoreRepository: RampDatastoreRepository, + ): CustomIntentRepository = + CustomIntentRepositoryImpl( + d2 = d2, + isOneLevelUpOrgUnitForBiometricsModuleIdEnabled = { + rampDatastoreRepository.isOneLevelUpOrgUnitForBiometricsModuleId(programUid) + }, + moduleIdPrefix = { rampDatastoreRepository.moduleIdPrefix(programUid) }, + ) @Provides @PerActivity diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/data/EventDetailsRepository.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/data/EventDetailsRepository.kt index 37d033281d0..c33ab9cc8e6 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/data/EventDetailsRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/data/EventDetailsRepository.kt @@ -9,6 +9,7 @@ import org.dhis2.data.dhislogic.AUTH_ALL import org.dhis2.data.dhislogic.AUTH_UNCOMPLETE_EVENT import org.dhis2.form.model.FieldUiModel import org.dhis2.form.ui.FieldViewModelFactory +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.AnchoredScheduleContext import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope import org.hisp.dhis.android.core.category.CategoryCombo @@ -135,6 +136,51 @@ class EventDetailsRepository( } } + fun getAnchoredScheduleContext( + enrollmentUid: String?, + visitNumberDataElementUid: String, + ): AnchoredScheduleContext? { + val events = + d2 + .eventModule() + .events() + .withTrackedEntityDataValues() + .byEnrollmentUid() + .eq(enrollmentUid) + .byProgramStageUid() + .eq(programStageUid) + .byDeleted() + .isFalse + .blockingGet() + + val factualVisits = + events.mapNotNull { event -> + event + .takeIf { + it.eventDate() != null && + it.status() !in NON_FACTUAL_EVENT_STATUSES + } + ?.visitNumber(visitNumberDataElementUid) + ?.let { visitNumber -> NumberedEvent(event, visitNumber) } + } + val initialVisitDate = + factualVisits + .filter { it.visitNumber == 0 } + .mapNotNull { it.event.eventDate() } + .minOrNull() ?: return null + val latestFactualVisit = factualVisits.maxWithOrNull(numberedEventComparator) ?: return null + val skippedVisits = + events.count { event -> + event.status() == EventStatus.SKIPPED && + event.isAfter(latestFactualVisit.event) + } + + return AnchoredScheduleContext( + initialVisitDate = initialVisitDate, + currentVisitNumber = latestFactualVisit.visitNumber + skippedVisits, + ) + } + fun hasAccessDataWrite(): Boolean { return if (eventUid != null) { d2 @@ -498,4 +544,42 @@ class EventDetailsRepository( emit(uid) } + + private fun Event.visitNumber(dataElementUid: String): Int? = + trackedEntityDataValues() + .orEmpty() + .firstOrNull { it.dataElement() == dataElementUid } + ?.value() + ?.trim() + ?.toIntOrNull() + ?.takeIf { it >= 0 } + + private fun Event.isAfter(other: Event): Boolean = eventOrder.compare(this, other) > 0 + + private data class NumberedEvent( + val event: Event, + val visitNumber: Int, + ) + + private companion object { + val NON_FACTUAL_EVENT_STATUSES = + setOf( + EventStatus.SCHEDULE, + EventStatus.SKIPPED, + EventStatus.OVERDUE, + ) + + val eventOrder = + compareBy( + { it.createdAtClient() ?: it.created() ?: Date(0) }, + { it.uid() }, + ) + + val numberedEventComparator = + compareBy( + { it.visitNumber }, + { it.event.createdAtClient() ?: it.event.created() ?: Date(0) }, + { it.event.uid() }, + ) + } } diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventReportDate.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventReportDate.kt index 68401a5ba8d..e39886a0b9a 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventReportDate.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventReportDate.kt @@ -25,6 +25,7 @@ class ConfigureEventReportDate( private val periodUtils: DhisPeriodUtils, private val enrollmentId: String? = null, private val scheduleInterval: Int = 0, + private val anchoredScheduleVisitNumberDataElementId: String? = null, ) { operator fun invoke(selectedDate: Date? = null): Flow = flowOf( @@ -108,6 +109,8 @@ class ConfigureEventReportDate( } fun getNextScheduleDate(): Date { + getAnchoredScheduleDate()?.let { return it } + val scheduleDate = repository.getStageLastDate(enrollmentId)?.let { val lastStageDate = DateUtils.getInstance().getCalendarByDate(it) @@ -134,6 +137,20 @@ class ConfigureEventReportDate( return DateUtils.getInstance().getNextPeriod(periodType, scheduleDate.time, if (periodType != null) 1 else 0) } + private fun getAnchoredScheduleDate(): Date? { + if (creationType != SCHEDULE) return null + val visitNumberDataElementId = anchoredScheduleVisitNumberDataElementId ?: return null + if (scheduleInterval <= 0) return null + val context = + repository.getAnchoredScheduleContext( + enrollmentUid = enrollmentId, + visitNumberDataElementUid = visitNumberDataElementId, + ) ?: return null + val scheduleDate = DateUtils.getInstance().getCalendarByDate(context.initialVisitDate) + scheduleDate.add(DAY_OF_YEAR, scheduleInterval * (context.currentVisitNumber + 1)) + return scheduleDate.time + } + private fun getCurrentDay() = DateUtils.getInstance().getStartOfDay(Date()) private fun getMinDate(initialDate: Date?): Date? { diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/injection/EventDetailsModule.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/injection/EventDetailsModule.kt index 263c3d05224..55fa6d489a6 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/injection/EventDetailsModule.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/injection/EventDetailsModule.kt @@ -12,6 +12,7 @@ import org.dhis2.commons.resources.DhisPeriodUtils import org.dhis2.commons.resources.EventResourcesProvider import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository import org.dhis2.form.data.GeometryController import org.dhis2.form.data.GeometryParserImpl import org.dhis2.form.data.metadata.FileResourceConfiguration @@ -68,6 +69,10 @@ class EventDetailsModule( @PerFragment fun provideGeometryController(): GeometryController = GeometryController(GeometryParserImpl()) + @Provides + @PerFragment + fun provideRampDatastoreRepository(d2: D2): RampDatastoreRepository = RampDatastoreRepository(d2) + @Provides @PerFragment fun provideEventDetailsRepository( @@ -123,6 +128,7 @@ class EventDetailsModule( eventDetailResourcesProvider: EventDetailResourcesProvider, metadataIconProvider: MetadataIconProvider, configurePeriodSelector: ConfigurePeriodSelector, + rampDatastoreRepository: RampDatastoreRepository, ): EventDetailsViewModelFactory = EventDetailsViewModelFactory( ConfigureEventDetails( @@ -140,6 +146,8 @@ class EventDetailsModule( periodUtils = periodUtils, enrollmentId = enrollmentId, scheduleInterval = scheduleInterval, + anchoredScheduleVisitNumberDataElementId = + rampDatastoreRepository.anchoredScheduleVisitNumberDataElementId(programStageUid), ), ConfigureOrgUnit( creationType = eventCreationType, diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/models/AnchoredScheduleContext.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/models/AnchoredScheduleContext.kt new file mode 100644 index 00000000000..0293db63a07 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/models/AnchoredScheduleContext.kt @@ -0,0 +1,8 @@ +package org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models + +import java.util.Date + +data class AnchoredScheduleContext( + val initialVisitDate: Date, + val currentVisitNumber: Int, +) 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 df6669b71ac..af588ea9f66 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchJavaToCompose.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchJavaToCompose.kt @@ -3,8 +3,10 @@ package org.dhis2.usescases.searchTrackEntity import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalConfiguration import org.dhis2.usescases.searchTrackEntity.ui.WrappedSearchButton @@ -20,15 +22,30 @@ fun ComposeView?.setLandscapeOpenSearchButton( val screenState by searchTEIViewModel.screenState.observeAsState() val teTypeName by searchTEIViewModel.teTypeName.observeAsState() val isSearchEnabled by searchTEIViewModel.isSearchEnabled.observeAsState(true) + val shouldLaunchSimprintsBiometricIdentification by + searchTEIViewModel.shouldLaunchSimprintsBiometricIdentification.observeAsState(false) - val visible = + val isDirectSimprintsSearch = screenState?.let { - (it is SearchList) && it.searchFilters.isOpened + it is SearchList && + !it.searchFilters.isOpened && + !it.searchForm.isOpened && + shouldLaunchSimprintsBiometricIdentification } ?: false + val visible = + screenState?.let { it is SearchList && (it.searchFilters.isOpened || isDirectSimprintsSearch) } + ?: false val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE - AnimatedVisibility(visible = isLandscape && visible && isSearchEnabled && !teTypeName.isNullOrBlank()) { - WrappedSearchButton(onClick = onClick, teTypeName = teTypeName!!) + AnimatedVisibility( + visible = isLandscape && visible && isSearchEnabled && !teTypeName.isNullOrBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + WrappedSearchButton( + onClick = onClick, + teTypeName = teTypeName!!, + fillWidth = isDirectSimprintsSearch, + ) } } } 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 da87850bbcd..5dccc57c045 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepository.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepository.java @@ -74,6 +74,8 @@ public interface SearchRepository { boolean isSearchEnabled(); + boolean isShowingUnfilteredList(); + 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 3747fd0b1b1..820227f1f98 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java @@ -497,6 +497,11 @@ public boolean isSearchEnabled() { return rampDatastoreRepository.isSearchEnabled(currentProgram); } + @Override + public boolean isShowingUnfilteredList() { + return rampDatastoreRepository.isShowingUnfilteredList(currentProgram); + } + private static final String OPTION_SET_REGEX = "_os_"; } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt index e842dc36d87..3b720c76569 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt @@ -7,14 +7,11 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.withContext import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.filters.sorting.SortingItem -import org.dhis2.commons.simprints.ramp.model.DetailedEnrollmentListingSettings -import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.search.SearchParametersModel import org.dhis2.maps.model.MapItemModel import org.dhis2.mobile.commons.customintents.CustomIntentRepository import org.dhis2.mobile.commons.model.CustomIntentActionTypeModel -import org.dhis2.simprints.ramp.data.DetailedEnrollmentRepository import org.dhis2.tracker.input.model.TrackerInputType import org.dhis2.tracker.input.ui.action.FieldUid import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult @@ -41,9 +38,6 @@ class SearchRepositoryImplKt( private val eventInfoProvider: EventInfoProvider, private val customIntentRepository: CustomIntentRepository, ) : SearchRepositoryKt { - private val rampDatastoreRepository = RampDatastoreRepository(d2) - private val detailedEnrollmentRepository = DetailedEnrollmentRepository(d2) - private var detailedEnrollmentListingSettings: DetailedEnrollmentListingSettings? = null private lateinit var savedSearchParameters: SearchParametersModel private lateinit var savedFilters: FilterManager @@ -56,8 +50,6 @@ class SearchRepositoryImplKt( queryData: MutableMap?>?, programUid: String?, ): Boolean { - detailedEnrollmentListingSettings = - rampDatastoreRepository.detailedEnrollmentListingSettings(programUid) if (!this::savedSearchParameters.isInitialized) { savedSearchParameters = SearchParametersModel( @@ -231,15 +223,6 @@ class SearchRepositoryImplKt( ): SearchTeiModel { val searchTeiModel = SearchTeiModel() searchTeiModel.tei = searchItemResult - detailedEnrollmentListingSettings?.let { listingSettings -> - searchTeiModel.detailedEnrollments = - detailedEnrollmentRepository.get( - enrollments = searchItemResult.enrollments, - programs = searchItemResult.enrolledPrograms, - settings = listingSettings, - excludedProgramUid = searchItemResult.selectedEnrollment?.program, - ) - } searchItemResult.enrolledPrograms?.forEach { searchTeiModel.addProgramInfo( it.uid, 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 b45a90d84ab..bc55c6d7caa 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt @@ -126,7 +126,7 @@ class SearchTEActivity : private val simprintsConfirmIdentityLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - viewModel.onConfirmIdentityResult(result.resultCode) + viewModel.onConfirmIdentityResult(result.resultCode, result.data) } private var simprintsKeepSessionOnFinish = false @@ -165,6 +165,7 @@ class SearchTEActivity : themeManager.setProgramTheme(initialProgram!!) } super.onCreate(savedInstanceState) + viewModel.restorePendingSimprintsConfirmIdentity(savedInstanceState) binding = DataBindingUtil.setContentView(this, R.layout.activity_search) postponeInitialLandscapeDraw() // prevents jitter depending on if search panel set to visible or not @@ -324,6 +325,7 @@ class SearchTEActivity : } override fun onSaveInstanceState(outState: Bundle) { + viewModel.savePendingSimprintsConfirmIdentity(outState) super.onSaveInstanceState(outState) outState.putString(Constants.QUERY_DATA, JSONObject(viewModel.queryDataAsMap()).toString()) outState.putString(CURRENT_SCREEN, currentContent?.name) @@ -396,6 +398,7 @@ class SearchTEActivity : DHIS2Theme { val uiState by viewModel.navigationBarUIState val isBackdropActive by viewModel.backdropActive.observeAsState(false) + val screenState by viewModel.screenState.observeAsState() var selectedItemIndex by remember(uiState) { mutableIntStateOf( uiState.items.indexOfFirst { @@ -414,7 +417,10 @@ class SearchTEActivity : } AnimatedVisibility( - visible = (isBackdropActive.not() && uiState.items.isNotEmpty()) || isLandscape(), + visible = + screenState != null && + (uiState.selectedItem != NavigationPage.LIST_VIEW || viewModel.shouldShowListContent()) && + ((isBackdropActive.not() && uiState.items.isNotEmpty()) || isLandscape()), enter = slideInVertically(animationSpec = tween(200)) { it }, exit = slideOutVertically(animationSpec = tween(200)) { it }, ) { @@ -539,14 +545,22 @@ class SearchTEActivity : } private fun observeScreenState() { - viewModel.screenState.observe(this) { screenState -> - searchScreenConfigurator.configure(screenState) - initialLandscapeDrawReady = true - binding.root.invalidate() + viewModel.screenState.observe(this, ::configureSearchScreen) + viewModel.shouldLaunchSimprintsBiometricIdentification.observe(this) { + viewModel.screenState.value?.let(::configureSearchScreen) } viewModel.screenState.observe(this, viewModel::updateBackdrop) } + private fun configureSearchScreen(screenState: SearchTEScreenState) { + searchScreenConfigurator.configure( + screenState, + viewModel.shouldLaunchSimprintsBiometricIdentification.value == true, + ) + initialLandscapeDrawReady = true + binding.root.invalidate() + } + private fun postponeInitialLandscapeDraw() { if (!isLandscape()) return initialLandscapeDrawReady = false 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 e9afc520ef9..dc40bb3b1f8 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt @@ -1,6 +1,7 @@ package org.dhis2.usescases.searchTrackEntity import android.content.Intent +import android.os.Bundle import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.outlined.List @@ -31,10 +32,10 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow @@ -60,6 +61,7 @@ import org.dhis2.maps.layer.basemaps.BaseMapStyle import org.dhis2.maps.managers.MapManager import org.dhis2.maps.usecases.MapStyleConfiguration import org.dhis2.mobile.commons.coroutine.CoroutineTracker +import org.dhis2.mobile.commons.extensions.launchUseCase import org.dhis2.mobile.commons.model.CustomIntentModel import org.dhis2.simprints.SimprintsLoadBiometricSearchResultsUseCase import org.dhis2.simprints.SimprintsLoadPossibleDuplicatesSearchResultsUseCase @@ -180,9 +182,13 @@ class SearchTEIViewModel( simprintsSearchViewModel.isSimprintsUseLastBiometricsLabel private val _isSimprintsPossibleDuplicatesSearch = MutableLiveData(false) val isSimprintsPossibleDuplicatesSearch: LiveData = _isSimprintsPossibleDuplicatesSearch + private val _shouldLaunchSimprintsBiometricIdentification = MutableLiveData(false) + val shouldLaunchSimprintsBiometricIdentification: LiveData = + _shouldLaunchSimprintsBiometricIdentification private var searching: Boolean = false private val filtersActive = MutableLiveData(false) + private var isShowingUnfilteredList = false private val _downloadResult = MutableLiveData() val downloadResult: LiveData = _downloadResult @@ -236,8 +242,8 @@ class SearchTEIViewModel( emitAll( when { searching -> loadSearchResults() - shouldDisplayFrontPageList() -> loadDisplayInListResults() - else -> emptyFlow() + shouldLoadListResults() -> loadDisplayInListResults() + else -> flowOf(PagingData.empty()) }, ) CoroutineTracker.decrement() @@ -249,6 +255,8 @@ class SearchTEIViewModel( init { viewModelScope.launch(dispatchers.io()) { val isSearchEnabled = searchRepository.isSearchEnabled() + isShowingUnfilteredList = searchRepository.isShowingUnfilteredList() + onNewSearch.emit(Unit) withContext(dispatchers.ui()) { _isSearchEnabled.value = isSearchEnabled updateSearchEnabledInCurrentScreen(isSearchEnabled) @@ -271,6 +279,7 @@ class SearchTEIViewModel( fun setSimprintsPossibleDuplicatesSearch(isSimprintsPossibleDuplicatesSearch: Boolean) { _isSimprintsPossibleDuplicatesSearch.value = isSimprintsPossibleDuplicatesSearch + updateShouldLaunchSimprintsBiometricIdentification() } /** @@ -511,7 +520,7 @@ class SearchTEIViewModel( fun onSearchFormRequested() { if (!isSearchEnabled()) return - if (shouldLaunchSimprintsBiometricIdentification()) { + if (shouldLaunchSimprintsBiometricIdentification.value == true) { simprintsSearchViewModel.clearPendingSession() launchSimprintsBiometricIdentification() } else { @@ -519,12 +528,17 @@ class SearchTEIViewModel( } } - private fun shouldLaunchSimprintsBiometricIdentification(): Boolean = - queryDataList.isEmpty() && + private fun updateShouldLaunchSimprintsBiometricIdentification() { + val shouldLaunch = + queryDataList.isEmpty() && _isSimprintsPossibleDuplicatesSearch.value != true && searchParametersUiState.items.any { field -> SimprintsIntentUtils.isIdentifyCallout(customIntentByFieldUid[field.uid]) } + if (_shouldLaunchSimprintsBiometricIdentification.value != shouldLaunch) { + _shouldLaunchSimprintsBiometricIdentification.value = shouldLaunch + } + } private fun launchSimprintsBiometricIdentification() { val identifyCallout = @@ -687,6 +701,7 @@ class SearchTEIViewModel( } private fun updateSearch() { + updateShouldLaunchSimprintsBiometricIdentification() if (_screenState.value is SearchList) { val currentSearchList = _screenState.value as SearchList _screenState.postValue( @@ -971,6 +986,15 @@ class SearchTEIViewModel( private fun shouldDisplayFrontPageList(): Boolean = displayFrontPageListSettingIsConfigured() + private fun shouldLoadListResults(): Boolean = + shouldDisplayFrontPageList() && + (hasNarrowingFilters() || isShowingUnfilteredList) + + fun shouldShowListContent(): Boolean = searching || shouldLoadListResults() + + private fun hasNarrowingFilters(): Boolean = + filterManager.totalFilters > if (filterManager.sortingItem != null) 1 else 0 + private fun canPerformSearch(): Boolean = (_isSimprintsPossibleDuplicatesSearch.value == true && queryDataList.isNotEmpty()) || isSimprintsBiometricNoMatchesSearch() || @@ -1058,9 +1082,21 @@ class SearchTEIViewModel( } } - fun onConfirmIdentityResult(resultCode: Int) { - simprintsSearchViewModel.onConfirmIdentityResult(resultCode)?.let { navigation -> - viewModelScope.launch { + fun savePendingSimprintsConfirmIdentity(outState: Bundle) { + simprintsSearchViewModel.savePendingConfirmIdentity(outState) + } + + fun restorePendingSimprintsConfirmIdentity(savedState: Bundle?) { + simprintsSearchViewModel.restorePendingConfirmIdentity(savedState) + } + + fun onConfirmIdentityResult( + resultCode: Int, + data: Intent?, + ) { + launchUseCase { + try { + val navigation = simprintsSearchViewModel.onConfirmIdentityResult(resultCode, data) ?: return@launchUseCase _simprintsNavigation.send( SimprintsNavigationAction.OpenDashboard( teiUid = navigation.teiUid, @@ -1068,9 +1104,19 @@ class SearchTEIViewModel( enrollmentUid = navigation.enrollmentUid, ), ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e) + _simprintsNavigation.send( + SimprintsNavigationAction.ShowMessage( + resourceManager.getString(R.string.custom_intent_error), + ), + ) + } finally { + refreshSimprintsUiState() } } - refreshSimprintsUiState() } fun onConfirmIdentityLaunchFailed() { @@ -1132,7 +1178,6 @@ class SearchTEIViewModel( } fun onSimprintsBiometricNoMatches(uid: String) { - keepSearchScreenOpenForSimprintsBiometricFallback = true updateQuery(uid, listOf(SIMPRINTS_BIOMETRIC_NO_MATCH_QUERY_VALUE)) updateSimprintsBiometricNoMatchesParameter(uid) searchParametersUiState = @@ -1159,7 +1204,7 @@ class SearchTEIViewModel( searchParametersUiState.items.map { if (it.uid == uid) { it.copy( - value = SIMPRINTS_BIOMETRIC_NO_MATCH_QUERY_VALUE, + value = resourceManager.getString(R.string.simprints_biometric_no_match), displayName = resourceManager.getString(R.string.simprints_biometric_search), ) } else { @@ -1269,8 +1314,10 @@ class SearchTEIViewModel( hasProgramResults, hasGlobalResults, ) - } else if (shouldDisplayFrontPageList()) { + } else if (shouldLoadListResults()) { handleDisplayInListResult(hasProgramResults) + } else if (shouldDisplayFrontPageList()) { + _dataResult.postValue(emptyList()) } else { handleInitWithoutData() } @@ -1471,7 +1518,9 @@ class SearchTEIViewModel( if (filterFieldsVisible) { false } else { - isLandscape + isLandscape && + (shouldLaunchSimprintsBiometricIdentification.value != true || + keepSearchScreenOpenForSimprintsBiometricFallback) }, ), searchFilters = @@ -1540,6 +1589,7 @@ class SearchTEIViewModel( hydrateInitialQueryInFields(newItems), ), ) + updateShouldLaunchSimprintsBiometricIdentification() queryDataList.forEachIndexed { index, entry -> val searchOperator = 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 ad52a2648ed..583ca5ee864 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java @@ -293,8 +293,16 @@ MapCoordinateFieldToFeatureCollection provideMapDataElementToFeatureCollection(M @Provides @PerActivity - CustomIntentRepository provideCustomIntentRepository(D2 d2) { - return new CustomIntentRepositoryImpl(d2); + CustomIntentRepository provideCustomIntentRepository( + D2 d2, + RampDatastoreRepository rampDatastoreRepository + ) { + return new CustomIntentRepositoryImpl( + d2, + () -> rampDatastoreRepository + .isOneLevelUpOrgUnitForBiometricsModuleId(initialProgram), + () -> rampDatastoreRepository.moduleIdPrefix(initialProgram) + ); } @Provides @@ -387,12 +395,14 @@ SimprintsOrderSearchResultsByIdentifyResponseUseCase provideSimprintsOrderSearch SimprintsSearchViewModelFactory provideSimprintsSearchViewModelFactory( SimprintsResolveConfirmIdentityCalloutUseCase resolveConfirmIdentityCalloutUseCase, SimprintsSessionRepository simprintsSessionRepository, - SimprintsResolveSingleBiometricSearchNavigationUseCase resolveSingleBiometricSearchNavigationUseCase + SimprintsResolveSingleBiometricSearchNavigationUseCase resolveSingleBiometricSearchNavigationUseCase, + SimprintsD2Repository simprintsD2Repository ) { return new SimprintsSearchViewModelFactory( resolveConfirmIdentityCalloutUseCase, simprintsSessionRepository, - resolveSingleBiometricSearchNavigationUseCase + resolveSingleBiometricSearchNavigationUseCase, + simprintsD2Repository ); } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiModel.java b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiModel.java index d4adcb42b55..04573bbf6b7 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiModel.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiModel.java @@ -1,7 +1,6 @@ package org.dhis2.usescases.searchTrackEntity; import org.dhis2.mobile.commons.model.MetadataIconData; -import org.dhis2.simprints.ramp.model.DetailedEnrollment; import org.dhis2.tracker.search.model.DomainEnrollment; import org.dhis2.tracker.search.model.TrackedEntitySearchItemAttributeDomain; import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult; @@ -10,7 +9,6 @@ import java.util.HashMap; import java.util.LinkedHashMap; -import java.util.List; public class SearchTeiModel{ @@ -24,7 +22,6 @@ public class SearchTeiModel{ private boolean openedAttributeList = false; private String sortingKey; private String sortingValue; - private List detailedEnrollments; @Nullable public String onlineErrorMessage; @@ -37,7 +34,6 @@ public SearchTeiModel() { this.textAttributeValues = new LinkedHashMap<>(); this.sortingKey = null; this.sortingValue = null; - this.detailedEnrollments = null; this.onlineErrorMessage = null; this.metadataIconDataMap = new HashMap<>(); } @@ -144,14 +140,5 @@ public String getSortingValue() { return sortingValue; } - @Nullable - public List getDetailedEnrollments() { - return detailedEnrollments; - } - - public void setDetailedEnrollments(@Nullable List detailedEnrollments) { - this.detailedEnrollments = detailedEnrollments; - } - } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/di/SearchTEKoinModule.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/di/SearchTEKoinModule.kt index 658a02d765d..13e1792d5b5 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/di/SearchTEKoinModule.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/di/SearchTEKoinModule.kt @@ -58,6 +58,7 @@ val searchTEKoinModule = resolveConfirmIdentityCallout = get(), sessionRepository = get(), resolveSingleBiometricSearchNavigation = get { parametersOf(teType) }, + simprintsD2Repository = get(), ) } 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 664fbea8d9e..eb92e32c739 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 @@ -23,6 +23,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.viewModelScope import androidx.paging.LoadState +import androidx.paging.PagingData import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -425,7 +426,9 @@ class SearchTEList : FragmentGlobalAbstract() { private var lastSearchPagingData: Any? = null private fun initData() { - displayLoadingData() + if (viewModel.shouldShowListContent()) { + displayLoadingData() + } val listener: () -> Unit = { onInitDataLoaded() @@ -510,7 +513,13 @@ class SearchTEList : FragmentGlobalAbstract() { private fun hideStaleProgramResults() { listAdapter.removeAdapter(liveAdapter) - initLoading(listOf(SearchResult(SearchResult.SearchResultType.LOADING))) + if (viewModel.shouldShowListContent()) { + initLoading(listOf(SearchResult(SearchResult.SearchResultType.LOADING))) + } else { + globalAdapter.submitData(lifecycle, PagingData.empty()) + initLoading(emptyList()) + displayResult(emptyList()) + } } private fun restoreProgramAdapterAfterRefresh() { 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 c27348378fe..4bc337c15d9 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 @@ -20,20 +20,40 @@ class SearchScreenConfigurator( ) { private var hasConfiguredLandscape = false - fun configure(screenState: SearchTEScreenState) { + fun configure( + screenState: SearchTEScreenState, + shouldLaunchSimprintsBiometricIdentification: Boolean = false, + ) { + val isDirectSimprintsSearch = + screenState is SearchList && + screenState.searchForm.isEnabled && + !screenState.searchForm.isOpened && + !screenState.searchFilters.isOpened && + shouldLaunchSimprintsBiometricIdentification + binding.directSearchBackground?.visibility = + if (isDirectSimprintsSearch) { + View.VISIBLE + } else { + View.GONE + } when (screenState) { is SearchAnalytics -> configureLandscapeAnalyticsScreen(true, withAnimation = hasConfiguredLandscape) is SearchList -> if (isPortrait()) { configureListScreen(screenState) } else { + positionLandscapeSearchButton(isDirectSimprintsSearch) val withAnimation = hasConfiguredLandscape if (screenState.screenState != SearchScreenState.MAP && (screenState.searchForm.isEnabled || screenState.searchFilters.isOpened) ) { configureLandscapeAnalyticsScreen(false, withAnimation = withAnimation) } - configureLandscapeListScreen(screenState, withAnimation) + configureLandscapeListScreen( + screenState, + withAnimation, + shouldLaunchSimprintsBiometricIdentification, + ) hasConfiguredLandscape = true } } @@ -51,7 +71,11 @@ class SearchScreenConfigurator( setFiltersVisibility(!searchConfiguration.searchForm.isOpened) } - private fun configureLandscapeListScreen(searchConfiguration: SearchList, withAnimation: Boolean) { + private fun configureLandscapeListScreen( + searchConfiguration: SearchList, + withAnimation: Boolean, + shouldLaunchSimprintsBiometricIdentification: Boolean, + ) { when { searchConfiguration.searchFilters.isOpened -> { if (searchConfiguration.screenState == SearchScreenState.MAP) { @@ -60,13 +84,21 @@ class SearchScreenConfigurator( openFilters() } - searchConfiguration.searchForm.isEnabled -> { + searchConfiguration.searchForm.isEnabled && + (!shouldLaunchSimprintsBiometricIdentification || searchConfiguration.searchForm.isOpened) -> { if (searchConfiguration.screenState == SearchScreenState.MAP) { configureLandscapeAnalyticsScreen(false, withAnimation = withAnimation) } openSearch() } + searchConfiguration.searchForm.isEnabled -> { + if (searchConfiguration.screenState == SearchScreenState.MAP) { + configureLandscapeAnalyticsScreen(false, withAnimation = withAnimation) + } + closeBackdrop() + } + else -> { configureLandscapeAnalyticsScreen(true, withAnimation = false) closeBackdrop() @@ -77,6 +109,21 @@ class SearchScreenConfigurator( setFiltersVisibility(true) } + private fun positionLandscapeSearchButton(atTop: Boolean) { + val constraintSet = ConstraintSet() + constraintSet.clone(binding.backdropLayout) + constraintSet.clear(R.id.landOpenSearchButton, ConstraintSet.TOP) + constraintSet.clear(R.id.landOpenSearchButton, ConstraintSet.BOTTOM) + val side = if (atTop) ConstraintSet.TOP else ConstraintSet.BOTTOM + constraintSet.connect( + R.id.landOpenSearchButton, + side, + ConstraintSet.PARENT_ID, + side, + ) + constraintSet.applyTo(binding.backdropLayout) + } + private fun configureLandscapeAnalyticsScreen(expanded: Boolean, withAnimation: Boolean = true) { val constraintSet = ConstraintSet() constraintSet.clone(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 ebbd7edfafe..343cb14b1fc 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 @@ -232,10 +232,16 @@ fun SearchButtonWithQuery( @Composable fun WrappedSearchButton( teTypeName: String, + fillWidth: Boolean = false, onClick: () -> Unit, ) { SearchButton( - modifier = Modifier.wrapContentWidth(align = Alignment.CenterHorizontally), + modifier = + if (fillWidth) { + Modifier.fillMaxWidth() + } else { + Modifier.wrapContentWidth(align = Alignment.CenterHorizontally) + }, onClick = onClick, teTypeName = teTypeName, ) diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt index e8bae4a5e4b..25fe407e394 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt @@ -17,20 +17,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle import org.dhis2.R import org.dhis2.commons.bindings.isFilePathValid -import org.dhis2.commons.date.DateUtils import org.dhis2.commons.date.toDateSpan import org.dhis2.commons.date.toOverdueOrScheduledUiText import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.ui.model.ListCardUiModel import org.dhis2.mobile.commons.extensions.toJavaDate -import org.dhis2.simprints.ramp.model.DetailedEnrollment import org.dhis2.tracker.search.model.DomainEnrollment import org.dhis2.tracker.search.model.DomainProgram import org.dhis2.tracker.search.model.EnrollmentStatus @@ -58,18 +51,12 @@ class TEICardMapper( onSyncIconClick: () -> Unit, onCardClick: () -> Unit, onImageClick: (String) -> Unit, - ): ListCardUiModel { - val programsKey = resourceManager.getString(R.string.programs) - val detailedEnrollmentListing = - searchTEIModel.detailedEnrollments?.toDetailedEnrollmentListing() - - return ListCardUiModel( + ): ListCardUiModel = + ListCardUiModel( avatar = { ProvideAvatar(searchTEIModel, onImageClick) }, title = getTitle(searchTEIModel), lastUpdated = searchTEIModel.tei.lastUpdated?.toJavaDate().toDateSpan(context), - additionalInfo = getAdditionalInfoList(searchTEIModel, detailedEnrollmentListing), - styledAdditionalInfoValues = - detailedEnrollmentListing?.let { mapOf(programsKey to it) }.orEmpty(), + additionalInfo = getAdditionalInfoList(searchTEIModel), emphasizedAdditionalInfoKey = resourceManager.getString(R.string.transferredTo).takeIf { searchTEIModel.tei.ownerOrgUnit != null && @@ -80,7 +67,6 @@ class TEICardMapper( shrinkLabelText = resourceManager.getString(R.string.show_less), onCardCLick = onCardClick, ) - } @Composable private fun ProvideAvatar( @@ -144,10 +130,7 @@ class TEICardMapper( "$key: $value" } - private fun getAdditionalInfoList( - searchTEIModel: SearchTeiModel, - detailedEnrollmentListing: AnnotatedString?, - ): List { + private fun getAdditionalInfoList(searchTEIModel: SearchTeiModel): List { val attributeList = searchTEIModel.tei.attributeValues .map { @@ -177,19 +160,11 @@ class TEICardMapper( enrolledOrgUnit = searchTEIModel.tei.enrollmentOrgUnit, ) } - if (searchTEIModel.detailedEnrollments != null) { - detailedEnrollmentListing?.let { - addDetailedEnrollmentListing( - list = list, - detailedEnrollmentListing = it.text, - ) - } - } else { - checkEnrolledPrograms( - list = list, - enrolledPrograms = searchTEIModel.tei.enrolledPrograms, - ) - } + + checkEnrolledPrograms( + list = list, + enrolledPrograms = searchTEIModel.tei.enrolledPrograms, + ) val programUid: String? = if (searchTEIModel.selectedEnrollment != null) { searchTEIModel.selectedEnrollment.program @@ -317,58 +292,6 @@ class TEICardMapper( } } - private fun addDetailedEnrollmentListing( - list: MutableList, - detailedEnrollmentListing: String, - ) { - list.add( - AdditionalInfoItem( - key = resourceManager.getString(R.string.programs), - value = detailedEnrollmentListing, - isConstantItem = true, - truncate = false, - ), - ) - } - - private fun List.toDetailedEnrollmentListing(): AnnotatedString? { - val namedEnrollments = filter { it.programName.isNotBlank() } - if (namedEnrollments.isEmpty()) return null - val admittedLabel = resourceManager.getString(R.string.simprints_ramp_admitted) - val dischargedLabel = resourceManager.getString(R.string.simprints_ramp_discharged) - val outcomeLabel = resourceManager.getString(R.string.simprints_ramp_outcome) - val siteLabel = resourceManager.getString(R.string.simprints_ramp_site) - val dateFormat = DateUtils.oldUiDateFormat() - - // attributes are optional, so we are not using a string resource with params here - return buildAnnotatedString { - namedEnrollments.forEachIndexed { index, enrollment -> - if (index > 0) append(". ") - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(enrollment.programName) - } - - val details = - buildList { - enrollment.admitted - ?.let { add("$admittedLabel: ${dateFormat.format(it)}") } - enrollment.discharge - ?.let { add("$dischargedLabel: ${dateFormat.format(it)}") } - enrollment.outcome - ?.takeIf { it.isNotBlank() } - ?.let { add("$outcomeLabel: $it") } - enrollment.site - ?.takeIf { it.isNotBlank() } - ?.let { add("$siteLabel: $it") } - } - if (details.isNotEmpty()) { - append(": ${details.joinToString(", ")}") - } - } - append(".") - } - } - private fun checkEnrolledIn( list: MutableList, enrolledOrgUnit: String?, 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 3b165892f35..c33e0163552 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt @@ -700,6 +700,7 @@ class TeiDashboardMobileActivity : override fun goToEnrollmentList() { val intent = Intent(this, TeiProgramListActivity::class.java) intent.putExtra(TEI_UID, teiUid) + intent.putExtra(Constants.PROGRAM_UID, programUid) teiProgramListLauncher.launch(intent) } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt index 114bf3dd77d..a89d6e15c49 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt @@ -223,6 +223,26 @@ class TeiDataRepositoryImpl( eventRepo .orderByTimeline(RepositoryScope.OrderByDirection.DESC) .blockingGet() + val stageSettings = + rampDatastoreConfig.programStageSpecificSettings.firstOrNull { + it.programStageId == programStage.uid() + } + val visitNumbers = + stageSettings + ?.takeIf { it.hasVisitNumbersSortedAscending == true } + ?.visitNumberDataElementId + ?.let { dataElementId -> getVisitNumbers(eventList, dataElementId) } + val orderedEvents = + if (visitNumbers != null) { + eventList.sortedWith( + compareBy(nullsLast()) { visitNumbers[it.uid()] } + .thenBy { it.eventDate() ?: it.dueDate() } + .thenBy { it.uid() }, + ) + } else { + eventList + } + val hasAllVisitsExpanded = stageSettings?.hasAllVisitsExpanded == true val canAddEventToEnrollment = enrollmentUid?.let { @@ -234,8 +254,8 @@ class TeiDataRepositoryImpl( } ?: false val showAllEvents = - selectedStage.showAllEvents && - selectedStage.stageUid == programStage.uid() + hasAllVisitsExpanded || + (selectedStage.showAllEvents && selectedStage.stageUid == programStage.uid()) eventModels.add( EventModel( @@ -259,7 +279,7 @@ class TeiDataRepositoryImpl( ), ), ) - eventList + orderedEvents .take( if (showAllEvents) eventList.size else maxEventToShow, ).forEachIndexed { index, event -> @@ -298,12 +318,17 @@ class TeiDataRepositoryImpl( programStage.style(), program?.style()?.color()?.toColor() ?: SurfaceColor.Primary, ), - followUpVisitNumber = getFollowUpVisitNumber(event, rampDatastoreConfig), + followUpVisitNumber = + if (visitNumbers != null && stageSettings.hasVisitNumberPrefixForDateInList == true) { + visitNumbers[event.uid()] + } else { + getFollowUpVisitNumber(event, rampDatastoreConfig) + }, ), ) } - if (eventList.size > maxEventToShow) { + if (!hasAllVisitsExpanded && eventList.size > maxEventToShow) { eventModels.add( EventModel( EventViewModelType.TOGGLE_BUTTON, @@ -543,6 +568,14 @@ class TeiDataRepositoryImpl( setting.programStageId == event.programStage() && setting.hasVisitNumberPrefixForDateInList == true }?.visitNumberDataElementId ?: return null + + return getVisitNumber(event, dataElementId) + } + + private fun getVisitNumber( + event: Event, + dataElementId: String, + ): Int? { val valueRepository = d2 .trackedEntityModule() @@ -556,6 +589,20 @@ class TeiDataRepositoryImpl( } } + private fun getVisitNumbers( + events: List, + dataElementId: String, + ): Map = + d2 + .trackedEntityModule() + .trackedEntityDataValues() + .byEvent() + .`in`(events.map { it.uid() }) + .byDataElement() + .eq(dataElementId) + .blockingGet() + .associate { it.event() to it.value()?.toVisitNumber() } + private fun String.toVisitNumber(): Int? = trim().toIntOrNull()?.takeIf { it >= 0 } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingModule.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingModule.kt index 3ca18b52098..7c694f5a11c 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingModule.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingModule.kt @@ -3,9 +3,14 @@ package org.dhis2.usescases.teiDashboard.dialogs.scheduling import dagger.Module import dagger.Provides import org.dhis2.commons.date.DateUtils +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository +import org.hisp.dhis.android.core.D2 @Module class SchedulingModule { @Provides fun providesDateUtils(): DateUtils = DateUtils.getInstance() + + @Provides + fun provideRampDatastoreRepository(d2: D2): RampDatastoreRepository = RampDatastoreRepository(d2) } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModel.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModel.kt index b0d6ff7acd9..228a24326d0 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModel.kt @@ -21,6 +21,7 @@ import org.dhis2.commons.periods.model.Period import org.dhis2.commons.resources.DhisPeriodUtils import org.dhis2.commons.resources.EventResourcesProvider import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.data.EventDetailsRepository import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.domain.ConfigureEventCatCombo @@ -50,6 +51,7 @@ class SchedulingViewModel( private val launchMode: LaunchMode, private val dateUtils: DateUtils, private val getEventPeriods: GetEventPeriods, + private val rampDatastoreRepository: RampDatastoreRepository, ) : ViewModel() { lateinit var repository: EventDetailsRepository lateinit var configureEventReportDate: ConfigureEventReportDate @@ -163,6 +165,10 @@ class SchedulingViewModel( periodUtils = periodUtils, enrollmentId = enrollmentId, scheduleInterval = programStage.value?.standardInterval() ?: 0, + anchoredScheduleVisitNumberDataElementId = + rampDatastoreRepository.anchoredScheduleVisitNumberDataElementId( + programStage.value?.uid(), + ), ) configureEventCatCombo = ConfigureEventCatCombo(repository = repository) diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModelFactory.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModelFactory.kt index 9ab1deac1d7..4e2c15d653d 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModelFactory.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModelFactory.kt @@ -10,6 +10,7 @@ import org.dhis2.commons.periods.domain.GetEventPeriods import org.dhis2.commons.resources.DhisPeriodUtils import org.dhis2.commons.resources.EventResourcesProvider import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository import org.dhis2.commons.viewmodel.DispatcherProvider import org.hisp.dhis.android.core.D2 @@ -23,6 +24,7 @@ class SchedulingViewModelFactory private val dateUtils: DateUtils, private val dispatcherProvider: DispatcherProvider, private val getEventPeriods: GetEventPeriods, + private val rampDatastoreRepository: RampDatastoreRepository, @Assisted private val launchMode: SchedulingDialog.LaunchMode, ) : ViewModelProvider.Factory { @AssistedFactory @@ -40,5 +42,6 @@ class SchedulingViewModelFactory dispatchersProvider = dispatcherProvider, launchMode = launchMode, getEventPeriods = getEventPeriods, + rampDatastoreRepository = rampDatastoreRepository, ) as T } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/EnrollmentViewModel.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/EnrollmentViewModel.kt index d0b04b8c29e..582f0859b28 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/EnrollmentViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/EnrollmentViewModel.kt @@ -10,4 +10,5 @@ data class EnrollmentViewModel( val orgUnitName: String, val followUp: Boolean, val programUid: String, + val enrollmentDetails: String? = null, ) diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListActivity.java b/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListActivity.java index 70df38985c7..7bd96c3b5e8 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListActivity.java +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListActivity.java @@ -9,6 +9,7 @@ import org.dhis2.App; import org.dhis2.R; +import org.dhis2.commons.Constants; import org.dhis2.databinding.ActivityTeiProgramListBinding; import org.dhis2.ui.ThemeManager; import org.dhis2.usescases.general.ActivityGlobalAbstract; @@ -34,7 +35,9 @@ public class TeiProgramListActivity extends ActivityGlobalAbstract implements Te @Override public void onCreate(@Nullable Bundle savedInstanceState) { String trackedEntityId = getIntent().getStringExtra("TEI_UID"); - ((App) getApplicationContext()).userComponent().plus(new TeiProgramListModule(this, trackedEntityId, getSyncStatusController())).inject(this); + String currentProgramId = getIntent().getStringExtra(Constants.PROGRAM_UID); + ((App) getApplicationContext()).userComponent().plus(new TeiProgramListModule(this, trackedEntityId, + currentProgramId, getSyncStatusController())).inject(this); super.onCreate(savedInstanceState); binding = DataBindingUtil.setContentView(this, R.layout.activity_tei_program_list); binding.setPresenter(presenter); diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListModule.java b/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListModule.java index bd6a5e6c556..6aafb40f915 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListModule.java +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListModule.java @@ -2,10 +2,15 @@ import androidx.annotation.NonNull; +import com.google.gson.Gson; + import org.dhis2.commons.di.dagger.PerActivity; import org.dhis2.commons.prefs.PreferenceProvider; import org.dhis2.commons.resources.MetadataIconProvider; +import org.dhis2.commons.resources.ResourceManager; +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository; import org.dhis2.mobile.sync.domain.SyncStatusController; +import org.dhis2.simprints.ramp.data.DetailedEnrollmentRepository; import org.dhis2.usescases.main.program.ProgramViewModelMapper; import org.dhis2.utils.analytics.AnalyticsHelper; import org.hisp.dhis.android.core.D2; @@ -19,12 +24,15 @@ public class TeiProgramListModule { private final TeiProgramListContract.View view; private final String teiUid; + private final String currentProgramUid; private final SyncStatusController syncStatusController; - TeiProgramListModule(TeiProgramListContract.View view, String teiUid, SyncStatusController syncStatusController) { + TeiProgramListModule(TeiProgramListContract.View view, String teiUid, String currentProgramUid, + SyncStatusController syncStatusController) { this.view = view; this.teiUid = teiUid; + this.currentProgramUid = currentProgramUid; this.syncStatusController = syncStatusController; } @@ -56,15 +64,34 @@ TeiProgramListAdapter provideProgramEventDetailAdapter(TeiProgramListContract.Pr return new TeiProgramListAdapter(presenter); } + @Provides + @PerActivity + RampDatastoreRepository provideRampDatastoreRepository(D2 d2) { + return new RampDatastoreRepository(d2, new Gson()); + } + + @Provides + @PerActivity + DetailedEnrollmentRepository provideDetailedEnrollmentRepository(D2 d2) { + return new DetailedEnrollmentRepository(d2); + } + @Provides @PerActivity TeiProgramListRepository eventDetailRepository( D2 d2, - MetadataIconProvider metadataIconProvider) { + MetadataIconProvider metadataIconProvider, + ResourceManager resourceManager, + RampDatastoreRepository rampDatastoreRepository, + DetailedEnrollmentRepository detailedEnrollmentRepository) { return new TeiProgramListRepositoryImpl( d2, new ProgramViewModelMapper(), - metadataIconProvider + metadataIconProvider, + detailedEnrollmentRepository, + () -> rampDatastoreRepository + .detailedEnrollmentListingSettings(currentProgramUid), + resourceManager ); } } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListRepositoryImpl.java b/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListRepositoryImpl.java index e525150eba8..ba4e7a6748d 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListRepositoryImpl.java +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListRepositoryImpl.java @@ -1,9 +1,15 @@ package org.dhis2.usescases.teiDashboard.teiProgramList; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import org.dhis2.R; import org.dhis2.commons.date.DateUtils; +import org.dhis2.commons.resources.ResourceManager; import org.dhis2.commons.resources.MetadataIconProvider; +import org.dhis2.commons.simprints.ramp.model.DetailedEnrollmentListingSettings; +import org.dhis2.simprints.ramp.data.DetailedEnrollmentRepository; +import org.dhis2.simprints.ramp.model.DetailedEnrollment; import org.dhis2.usescases.main.program.ProgramDownloadState; import org.dhis2.usescases.main.program.ProgramUiModel; import org.dhis2.usescases.main.program.ProgramViewModelMapper; @@ -19,6 +25,8 @@ import java.util.Date; import java.util.Iterator; import java.util.List; +import java.util.Map; +import java.util.function.Supplier; import io.reactivex.Flowable; import io.reactivex.Observable; @@ -28,11 +36,24 @@ public class TeiProgramListRepositoryImpl implements TeiProgramListRepository { private final D2 d2; private final ProgramViewModelMapper programViewModelMapper; private final MetadataIconProvider metadataIconProvider; - - TeiProgramListRepositoryImpl(D2 d2, ProgramViewModelMapper programViewModelMapper, MetadataIconProvider metadataIconProvider) { + private final DetailedEnrollmentRepository detailedEnrollmentRepository; + private final Supplier detailedEnrollmentListingSettings; + private final ResourceManager resourceManager; + + TeiProgramListRepositoryImpl( + D2 d2, + ProgramViewModelMapper programViewModelMapper, + MetadataIconProvider metadataIconProvider, + DetailedEnrollmentRepository detailedEnrollmentRepository, + Supplier detailedEnrollmentListingSettings, + ResourceManager resourceManager + ) { this.d2 = d2; this.programViewModelMapper = programViewModelMapper; this.metadataIconProvider = metadataIconProvider; + this.detailedEnrollmentRepository = detailedEnrollmentRepository; + this.detailedEnrollmentListingSettings = detailedEnrollmentListingSettings; + this.resourceManager = resourceManager; } @NonNull @@ -43,46 +64,90 @@ public Observable> activeEnrollments(String trackedEnt .byTrackedEntityInstance().eq(trackedEntityId) .byStatus().eq(EnrollmentStatus.ACTIVE) .byDeleted().eq(false).blockingGet()) - .flatMapIterable(enrollments -> enrollments) - .map(enrollment -> { - Program program = d2.programModule().programs().byUid().eq(enrollment.program()).one().blockingGet(); - OrganisationUnit orgUnit = d2.organisationUnitModule().organisationUnits().byUid().eq(enrollment.organisationUnit()).one().blockingGet(); - return new EnrollmentViewModel( - enrollment.uid(), - DateUtils.getInstance().formatDate(enrollment.enrollmentDate()), - metadataIconProvider.invoke(program.style()), - program.displayName(), - orgUnit.displayName(), - enrollment.followUp() != null ? enrollment.followUp() : false, - program.uid() - ); - }) - .toList() - .toObservable(); + .flatMap(this::mapEnrollments); } @NonNull @Override public Observable> otherEnrollments(String trackedEntityId) { return Observable.fromCallable(() -> d2.enrollmentModule().enrollments().byTrackedEntityInstance().eq(trackedEntityId).byStatus().neq(EnrollmentStatus.ACTIVE).blockingGet()) - .flatMapIterable(enrollments -> enrollments) + .flatMap(this::mapEnrollments); + } + + private Observable> mapEnrollments( + List enrollments + ) { + DetailedEnrollmentListingSettings listingSettings = detailedEnrollmentListingSettings.get(); + Map detailedEnrollments = + listingSettings == null ? + Collections.emptyMap() : + detailedEnrollmentRepository.get( + enrollments, + listingSettings + ); + + return Observable.fromIterable(enrollments) .map(enrollment -> { Program program = d2.programModule().programs().byUid().eq(enrollment.program()).one().blockingGet(); - OrganisationUnit orgUnit = d2.organisationUnitModule().organisationUnits().byUid().eq(enrollment.organisationUnit()).one().blockingGet(); + DetailedEnrollment detailedEnrollment = detailedEnrollments.get(enrollment.uid()); + String orgUnitName; + if (detailedEnrollment != null) { + orgUnitName = detailedEnrollment.getSite() == null ? "" : detailedEnrollment.getSite(); + } else { + OrganisationUnit orgUnit = d2.organisationUnitModule().organisationUnits().byUid().eq(enrollment.organisationUnit()).one().blockingGet(); + orgUnitName = orgUnit.displayName(); + } + return new EnrollmentViewModel( enrollment.uid(), DateUtils.getInstance().formatDate(enrollment.enrollmentDate()), metadataIconProvider.invoke(program.style()), program.displayName(), - orgUnit.displayName(), + orgUnitName, enrollment.followUp() != null ? enrollment.followUp() : false, - program.uid() + program.uid(), + formatDetailedEnrollment(detailedEnrollment) ); }) .toList() .toObservable(); } + @Nullable + private String formatDetailedEnrollment(@Nullable DetailedEnrollment enrollment) { + if (enrollment == null) { + return null; + } + + List details = new ArrayList<>(); + if (enrollment.getAdmitted() != null) { + details.add( + resourceManager.getString(R.string.simprints_ramp_admitted) + ": " + + DateUtils.getInstance().formatDate(enrollment.getAdmitted()) + ); + } + if (enrollment.getDischarge() != null) { + details.add( + resourceManager.getString(R.string.simprints_ramp_discharged) + ": " + + DateUtils.getInstance().formatDate(enrollment.getDischarge()) + ); + } + if (enrollment.getOutcome() != null && !enrollment.getOutcome().trim().isEmpty()) { + details.add( + resourceManager.getString(R.string.simprints_ramp_outcome) + ": " + + enrollment.getOutcome() + ); + } + if (enrollment.getSite() != null && !enrollment.getSite().trim().isEmpty()) { + details.add( + resourceManager.getString(R.string.simprints_ramp_site) + ": " + + enrollment.getSite() + ); + } + + return String.join(", ", details); + } + @NonNull @Override public Flowable> allPrograms(String trackedEntityId) { diff --git a/app/src/main/res/drawable/bg_landscape_direct_search_simprints_ramp.xml b/app/src/main/res/drawable/bg_landscape_direct_search_simprints_ramp.xml new file mode 100644 index 00000000000..2dd68577fe9 --- /dev/null +++ b/app/src/main/res/drawable/bg_landscape_direct_search_simprints_ramp.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout-land/activity_search.xml b/app/src/main/res/layout-land/activity_search.xml index 1c1398cf4d6..fffbf320e36 100644 --- a/app/src/main/res/layout-land/activity_search.xml +++ b/app/src/main/res/layout-land/activity_search.xml @@ -182,6 +182,17 @@ android:orientation="vertical" app:layout_constraintGuide_percent="0.26" /> + + + + @@ -72,7 +74,8 @@ + android:orientation="horizontal" + android:visibility='@{enrollment.enrollmentDetails == null ? View.VISIBLE : View.GONE}'> + + + + @@ -74,7 +76,8 @@ + android:orientation="horizontal" + android:visibility='@{enrollment.enrollmentDetails == null ? View.VISIBLE : View.GONE}'> + + Search / Add new %s Search for %s Biometric search + (no match) Possible duplicates No biometric match?\nSearch by name instead No biometric search matches. Try searching by name, etc. @@ -98,10 +99,10 @@ Enrollment list Active programs - admitted - discharged - outcome - site + Admitted + Discharged + Outcome + Site Enroll diff --git a/app/src/test/java/org/dhis2/data/filter/FilterRepositoryTest.kt b/app/src/test/java/org/dhis2/data/filter/FilterRepositoryTest.kt index d22832c3677..18d5f136956 100644 --- a/app/src/test/java/org/dhis2/data/filter/FilterRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/data/filter/FilterRepositoryTest.kt @@ -8,8 +8,11 @@ import org.dhis2.commons.filters.EventStatusFilter import org.dhis2.commons.filters.FilterItem import org.dhis2.commons.filters.FilterResources import org.dhis2.commons.filters.Filters +import org.dhis2.commons.filters.OverdueFilter import org.dhis2.commons.filters.PeriodFilter import org.dhis2.commons.filters.ProgramType +import org.dhis2.commons.filters.SyncErrorFilter +import org.dhis2.commons.filters.SyncStateFilter import org.dhis2.commons.filters.data.FilterRepository import org.dhis2.commons.filters.data.GetFiltersApplyingWebAppConfig import org.dhis2.commons.filters.sorting.SortingItem @@ -46,6 +49,8 @@ class FilterRepositoryTest { const val PROGRAM_FILTER = "ProgramFilter" const val FOLLOW_UP = "follow_up" const val TRANSFERRED = "transferred" + const val OVERDUE = "overdue" + const val WITH_SYNC_ERRORS = "with_sync_errors" } private val observableSortingInject = ObservableField() @@ -86,6 +91,8 @@ class FilterRepositoryTest { whenever(filterResources.filterEventDateLabel("random")) doReturn EVENT_DATE whenever(filterResources.filterFollowUpLabel("Name")) doReturn FOLLOW_UP whenever(filterResources.filterTransferredPatientLabel()) doReturn TRANSFERRED + whenever(filterResources.filterOverdueLabel()) doReturn OVERDUE + whenever(filterResources.filterWithSyncErrorsLabel()) doReturn WITH_SYNC_ERRORS } @Test @@ -744,6 +751,7 @@ class FilterRepositoryTest { ) doReturn emptyList() val result = filterRepository.programFilters(program.uid()) + .filterNot { it is OverdueFilter || it is SyncErrorFilter } assert(result[0].type == Filters.PERIOD) assert(result[1].type == Filters.ENROLLMENT_STATUS) @@ -754,6 +762,101 @@ class FilterRepositoryTest { assert(result.size == 6) } + @Test + fun `Should add quick tracker filters for available source filters`() { + val program = + Program + .builder() + .uid("random") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .programType(org.hisp.dhis.android.core.program.ProgramType.WITH_REGISTRATION) + .build() + whenever(d2.programModule().programs().uid(any()).get()) doReturn Single.just(program) + whenever(d2.settingModule().appearanceSettings().blockingExists()) doReturn true + whenever(filterResources.filterFollowUpLabel("")) doReturn FOLLOW_UP + whenever( + d2.programModule().programStages().byProgramUid().eq(program.uid()), + ) doReturn mock() + whenever( + d2 + .programModule() + .programStages() + .byProgramUid() + .eq(program.uid()) + .byEnableUserAssignment(), + ) doReturn mock() + whenever( + d2 + .programModule() + .programStages() + .byProgramUid() + .eq(program.uid()) + .byEnableUserAssignment() + .eq(true), + ) doReturn mock() + whenever( + d2 + .programModule() + .programStages() + .byProgramUid() + .eq(program.uid()) + .byEnableUserAssignment() + .eq(true) + .blockingIsEmpty(), + ) doReturn false + + val sourceFilters = + createDefaultTrackerFilterResult().toMutableList().apply { + add( + 1, + SyncStateFilter( + ProgramType.TRACKER, + observableSortingInject, + observableOpenFilter, + SYNC_STATUS, + ), + ) + } + whenever( + getFiltersApplyingWebAppConfig.execute( + any>(), + any>(), + ), + ) doReturn sourceFilters + + val result = filterRepository.programFilters(program.uid()) + val eventStatusFilter = sourceFilters.filterIsInstance().single() + val syncStateFilter = sourceFilters.filterIsInstance().single() + + assert((result.single { it.type == Filters.OVERDUE } as OverdueFilter).eventStatusFilter === eventStatusFilter) + assert((result.single { it.type == Filters.SYNC_ERROR } as SyncErrorFilter).syncStateFilter === syncStateFilter) + + whenever( + getFiltersApplyingWebAppConfig.execute( + any>(), + any>(), + ), + ) doReturn sourceFilters.filterNot { it is SyncStateFilter } + + val filtersWithoutSyncState = filterRepository.programFilters(program.uid()) + + assert(filtersWithoutSyncState.none { it.type == Filters.SYNC_ERROR }) + assert(filtersWithoutSyncState.any { it.type == Filters.OVERDUE }) + + whenever( + getFiltersApplyingWebAppConfig.execute( + any>(), + any>(), + ), + ) doReturn sourceFilters.filterNot { it is EventStatusFilter } + + val filtersWithoutEventStatus = filterRepository.programFilters(program.uid()) + + assert(filtersWithoutEventStatus.none { it.type == Filters.OVERDUE }) + assert(filtersWithoutEventStatus.any { it.type == Filters.SYNC_ERROR }) + } + private fun createTrackerEntityInstanceFilters(): List = listOf( TrackedEntityInstanceFilter diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt index 7167c919bf3..dc89513e973 100644 --- a/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt +++ b/app/src/test/java/org/dhis2/simprints/SimprintsEnrollmentViewModelTest.kt @@ -2,6 +2,7 @@ package org.dhis2.simprints import android.app.Activity.RESULT_OK import android.content.Intent +import android.os.Bundle import kotlinx.coroutines.test.runTest import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.commons.simprints.repository.SimprintsSessionRepository @@ -15,6 +16,7 @@ import org.junit.Assert.assertSame import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doReturn +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -454,4 +456,186 @@ class SimprintsEnrollmentViewModelTest { verify(sessionRepository).clearPendingEnrollment() } + + @Test + fun `pending finish should save GUID then SID external credential then clear session`() = + runTest { + val resultIntent = externalCredentialResult() + val viewModel = prepareExternalCredentialResult(resultIntent) + + viewModel.onFinishRequested("enrollment-uid") + + assertExternalCredentialSavedInOrder(viewModel, resultIntent) + } + + @Test + fun `automatic Enrol Last should save GUID then SID external credential then clear session`() = + runTest { + val resultIntent = externalCredentialResult() + val viewModel = prepareExternalCredentialResult(resultIntent) + + viewModel.onAutoEnrollLastRequested("enrollment-uid") + + assertExternalCredentialSavedInOrder(viewModel, resultIntent) + verify(sessionRepository).markPendingEnrollmentFromPossibleDuplicates() + } + + @Test + fun `restored Enrol Last should save GUID then SID external credential then clear session`() = + runTest { + val resultIntent = externalCredentialResult() + val viewModel = prepareExternalCredentialResult(resultIntent) + + assertExternalCredentialSavedInOrder(viewModel, resultIntent) + } + + @Test + fun `Enrol Last without external credential should save GUID without writing external credential`() = + runTest { + val resultIntent = externalCredentialResult(credentialJson = null) + val viewModel = prepareExternalCredentialResult(resultIntent) + + val result = viewModel.onRegisterLastResult(RESULT_OK, resultIntent, "tei-uid", "enrollment-uid") + + assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.CONTINUE_FINISH, result) + verify(simprintsD2Repository).saveTrackedEntityAttributeValue("tei-uid", "attribute-uid", "subject-guid") + verify(simprintsD2Repository, never()).saveEnrollmentExternalCredential(any(), any(), any()) + verify(sessionRepository).clear() + } + + @Test + fun `unsuccessful Enrol Last must not save returned external credential`() = + runTest { + val resultIntent = externalCredentialResult() + val viewModel = prepareExternalCredentialResult(resultIntent) + + val result = viewModel.onRegisterLastResult(0, resultIntent, "tei-uid", "enrollment-uid") + + assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.ERROR, result) + verify(simprintsD2Repository, never()).saveTrackedEntityAttributeValue(any(), any(), any()) + verify(simprintsD2Repository, never()).saveEnrollmentExternalCredential(any(), any(), any()) + verify(sessionRepository).clearPendingEnrollment() + verify(sessionRepository, never()).clear() + } + + @Test + fun `Enrol Last missing GUID must not save returned external credential`() = + runTest { + val resultIntent = externalCredentialResult() + val viewModel = prepareExternalCredentialResult(resultIntent, guid = null) + + val result = viewModel.onRegisterLastResult(RESULT_OK, resultIntent, "tei-uid", "enrollment-uid") + + assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.ERROR, result) + verify(simprintsD2Repository, never()).saveTrackedEntityAttributeValue(any(), any(), any()) + verify(simprintsD2Repository, never()).saveEnrollmentExternalCredential(any(), any(), any()) + verify(sessionRepository, never()).clear() + } + + @Test + fun `Enrol Last blank GUID must not save returned external credential`() = + runTest { + val resultIntent = externalCredentialResult() + val viewModel = prepareExternalCredentialResult(resultIntent, guid = " ") + + viewModel.onRegisterLastResult(RESULT_OK, resultIntent, "tei-uid", "enrollment-uid") + + verify(simprintsD2Repository, never()).saveEnrollmentExternalCredential(any(), any(), any()) + } + + @Test + fun `failed GUID save must not save external credential or clear successful session`() = + runTest { + val resultIntent = externalCredentialResult() + val viewModel = prepareExternalCredentialResult(resultIntent) + val error = IllegalStateException("GUID save failed") + whenever( + simprintsD2Repository.saveTrackedEntityAttributeValue("tei-uid", "attribute-uid", "subject-guid"), + ).thenThrow(error) + + val result = + runCatching { + viewModel.onRegisterLastResult(RESULT_OK, resultIntent, "tei-uid", "enrollment-uid") + } + + assertSame(error, result.exceptionOrNull()) + verify(simprintsD2Repository, never()).saveEnrollmentExternalCredential(any(), any(), any()) + verify(sessionRepository).clearPendingEnrollment() + verify(sessionRepository, never()).clear() + } + + @Test + fun `failed external credential save must still clear session and continue finish`() = + runTest { + val resultIntent = externalCredentialResult() + val viewModel = prepareExternalCredentialResult(resultIntent) + whenever( + simprintsD2Repository.saveEnrollmentExternalCredential("enrollment-uid", "attribute-uid", "external-credential-1"), + ).thenThrow(IllegalStateException("Credential save failed")) + + val result = viewModel.onRegisterLastResult(RESULT_OK, resultIntent, "tei-uid", "enrollment-uid") + + assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.CONTINUE_FINISH, result) + verify(simprintsD2Repository).saveTrackedEntityAttributeValue("tei-uid", "attribute-uid", "subject-guid") + verify(sessionRepository).clear() + verify(sessionRepository, never()).clearPendingEnrollment() + } + + private suspend fun prepareExternalCredentialResult( + resultIntent: Intent, + guid: String? = "subject-guid", + ): SimprintsEnrollmentViewModel { + val launchIntent = + mock { + on { action } doReturn "com.simprints.id.REGISTER_LAST_BIOMETRICS" + } + val pendingAction = + SimprintsResolvePendingEnrollmentActionUseCase.PendingEnrollmentAction( + fieldUid = "attribute-uid", + callout = + SimprintsIntentUtils.PreparedCallout( + launchIntent = launchIntent, + responseData = emptyList(), + ), + ) + whenever(sessionRepository.pendingEnrollmentSessionId()) doReturn "session-id" + whenever(sessionRepository.get()) doReturn "session-id" + whenever(resolvePendingEnrollmentAction("enrollment-uid", "session-id")) doReturn pendingAction + whenever(resultMapper.map(emptyList(), resultIntent)) doReturn guid + return SimprintsEnrollmentViewModel( + simprintsD2Repository = simprintsD2Repository, + resolvePendingEnrollmentAction = resolvePendingEnrollmentAction, + sessionRepository = sessionRepository, + resultMapper = resultMapper, + ) + } + + private fun externalCredentialResult( + credentialJson: String? = """{"type":"QRCode","value":"external-credential-1"}""", + identificationKey: String? = null, + ): Intent { + val resultExtras = + mock { + on { getString("scannedCredential") } doReturn credentialJson + on { containsKey("identification") } doReturn (identificationKey == "identification") + on { containsKey("identifications") } doReturn (identificationKey == "identifications") + } + return mock { + on { extras } doReturn resultExtras + } + } + + private suspend fun assertExternalCredentialSavedInOrder( + viewModel: SimprintsEnrollmentViewModel, + resultIntent: Intent, + ) { + val result = viewModel.onRegisterLastResult(RESULT_OK, resultIntent, "tei-uid", "enrollment-uid") + + assertEquals(SimprintsEnrollmentViewModel.RegisterLastResult.CONTINUE_FINISH, result) + inOrder(simprintsD2Repository, sessionRepository).apply { + verify(simprintsD2Repository).saveTrackedEntityAttributeValue("tei-uid", "attribute-uid", "subject-guid") + verify(simprintsD2Repository).saveEnrollmentExternalCredential("enrollment-uid", "attribute-uid", "external-credential-1") + verify(sessionRepository).clear() + } + } } diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt index d0f06674cec..1e3bdfb0721 100644 --- a/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt +++ b/app/src/test/java/org/dhis2/simprints/SimprintsSearchViewModelTest.kt @@ -2,9 +2,13 @@ package org.dhis2.simprints import android.app.Activity.RESULT_OK import android.content.Intent +import android.os.Bundle import androidx.arch.core.executor.testing.InstantTaskExecutorRule import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.test.runTest +import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.commons.simprints.usecases.SimprintsResolveConfirmIdentityCalloutUseCase import org.dhis2.commons.simprints.utils.SimprintsIntentUtils @@ -21,11 +25,15 @@ import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever class SimprintsSearchViewModelTest { @@ -35,6 +43,7 @@ class SimprintsSearchViewModelTest { private val resolveConfirmIdentityCallout: SimprintsResolveConfirmIdentityCalloutUseCase = mock() private val sessionRepository: SimprintsSessionRepository = mock() + private val simprintsD2Repository: SimprintsD2Repository = mock() private val resolveSingleBiometricSearchNavigation: SimprintsResolveSingleBiometricSearchNavigationUseCase = mock() @@ -53,6 +62,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) val action = @@ -75,6 +85,7 @@ class SimprintsSearchViewModelTest { assertEquals("program-uid", navigation?.programUid) assertEquals("enrollment-uid", navigation?.enrollmentUid) assertNull(viewModel.onConfirmIdentityResult(RESULT_OK)) + verifyNoInteractions(simprintsD2Repository) } @Test @@ -92,6 +103,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) val action = @@ -121,6 +133,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) val firstAction = @@ -153,6 +166,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) val action = @@ -183,6 +197,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) val queryData = mutableMapOf?>( @@ -225,6 +240,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) val filteredQueryData = @@ -253,6 +269,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) val filteredQueryData = @@ -273,6 +290,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) val queryData = mutableMapOf?>( @@ -308,6 +326,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) val queryData = mutableMapOf?>( @@ -338,6 +357,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) viewModel.clearPendingSessionIfNeeded( @@ -355,6 +375,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) val queryData = mutableMapOf?>( @@ -384,6 +405,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) val shouldUseLastBiometricsLabel = @@ -403,6 +425,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) val shouldUseLastBiometricsLabel = @@ -427,6 +450,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) viewModel.onDashboardRequested( @@ -436,8 +460,165 @@ class SimprintsSearchViewModelTest { enrollmentUid = "enrollment-uid", ) - assertNull(viewModel.onConfirmIdentityResult(resultCode = 0)) + assertNull(viewModel.onConfirmIdentityResult(resultCode = 0, data = externalCredentialResult())) assertNull(viewModel.onConfirmIdentityResult(RESULT_OK)) + verifyNoInteractions(simprintsD2Repository) + } + + @Test + fun `successful confirm identity should save external credential for the selected patient`() = + runTest { + val viewModel = pendingConfirmIdentityViewModel() + val data = externalCredentialResult() + + val navigation = viewModel.onConfirmIdentityResult(RESULT_OK, data) + + assertEquals("tei-uid", navigation?.teiUid) + verify(simprintsD2Repository).saveExternalCredential( + teiUid = "tei-uid", + programUid = "program-uid", + biometricAttributeUid = "selected-biometric", + externalCredentialValue = "new-external-credential", + ) + assertNull(viewModel.onConfirmIdentityResult(RESULT_OK, data)) + } + + @Test + fun `recreated viewmodel should restore confirmation context and save returned external credential`() = + runTest { + val savedState = savedStateBundle() + pendingConfirmIdentityViewModel().savePendingConfirmIdentity(savedState) + val restored = restoredConfirmIdentityViewModel(savedState) + + val navigation = restored.onConfirmIdentityResult(RESULT_OK, externalCredentialResult()) + + assertEquals("tei-uid", navigation?.teiUid) + assertEquals("program-uid", navigation?.programUid) + assertEquals("enrollment-uid", navigation?.enrollmentUid) + verify(simprintsD2Repository).saveExternalCredential( + "tei-uid", "program-uid", "selected-biometric", "new-external-credential", + ) + restored.savePendingConfirmIdentity(savedState) + assertNull(restoredConfirmIdentityViewModel(savedState).onConfirmIdentityResult(RESULT_OK, externalCredentialResult())) + } + + @Test + fun `cancellation and launch failure should clear restored confirmation state`() = + runTest { + val savedState = savedStateBundle() + pendingConfirmIdentityViewModel().savePendingConfirmIdentity(savedState) + listOf(true, false).forEach { cancelled -> + val restored = restoredConfirmIdentityViewModel(savedState) + if (cancelled) { + assertNull(restored.onConfirmIdentityResult(0, externalCredentialResult())) + } else { + restored.onConfirmIdentityLaunchFailed() + } + val clearedState = savedStateBundle() + restored.savePendingConfirmIdentity(clearedState) + assertNull(restoredConfirmIdentityViewModel(clearedState).onConfirmIdentityResult(RESULT_OK, externalCredentialResult())) + } + verifyNoInteractions(simprintsD2Repository) + } + + @Test + fun `absent or blank saved patient should not accept confirmation external credential`() = + runTest { + val blankPatient = savedStateBundle().apply { putString("simprints.confirmIdentity.teiUid", " ") } + listOf(null, savedStateBundle(), blankPatient).forEach { savedState -> + assertNull(restoredConfirmIdentityViewModel(savedState).onConfirmIdentityResult(RESULT_OK, externalCredentialResult())) + } + verifyNoInteractions(simprintsD2Repository) + } + + @Test + fun `confirm identity without pending patient should not read or save external credential`() = + runTest { + val viewModel = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, + ) + val data: Intent = mock() + + assertNull(viewModel.onConfirmIdentityResult(RESULT_OK, data)) + + verifyNoInteractions(data, simprintsD2Repository) + } + + @Test + fun `successful confirm identity without external credential should keep navigation and not save`() = + runTest { + val viewModel = pendingConfirmIdentityViewModel() + val data: Intent = mock() + + val navigation = viewModel.onConfirmIdentityResult(RESULT_OK, data) + + assertEquals("tei-uid", navigation?.teiUid) + verifyNoInteractions(simprintsD2Repository) + } + + @Test + fun `successful confirm identity with non QRCode external credential should not save`() = + runTest { + val viewModel = pendingConfirmIdentityViewModel() + + val navigation = + viewModel.onConfirmIdentityResult( + RESULT_OK, + externalCredentialResult("""{"type":"Other","value":"other-credential"}"""), + ) + + assertEquals("tei-uid", navigation?.teiUid) + verifyNoInteractions(simprintsD2Repository) + } + + @Test + fun `confirm identity should wait for external credential persistence before returning navigation`() = + runTest { + val viewModel = pendingConfirmIdentityViewModel() + val saveStarted = CompletableDeferred() + val finishSaving = CompletableDeferred() + whenever( + simprintsD2Repository.saveExternalCredential( + "tei-uid", + "program-uid", + "selected-biometric", + "new-external-credential", + ), + ) doSuspendableAnswer { + saveStarted.complete(Unit) + finishSaving.await() + "external-credential-attribute" + } + + val navigation = async { viewModel.onConfirmIdentityResult(RESULT_OK, externalCredentialResult()) } + saveStarted.await() + assertFalse(navigation.isCompleted) + + finishSaving.complete(Unit) + assertEquals("tei-uid", navigation.await()?.teiUid) + } + + @Test + fun `confirm identity save failure should still return navigation without retaining pending patient`() = + runTest { + val viewModel = pendingConfirmIdentityViewModel() + whenever( + simprintsD2Repository.saveExternalCredential( + "tei-uid", + "program-uid", + "selected-biometric", + "new-external-credential", + ), + ).thenThrow(IllegalStateException("Unable to save")) + + val navigation = viewModel.onConfirmIdentityResult(RESULT_OK, externalCredentialResult()) + + assertEquals("tei-uid", navigation?.teiUid) + assertNull(viewModel.onConfirmIdentityResult(RESULT_OK, externalCredentialResult())) } @Test @@ -454,6 +635,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) viewModel.onDashboardRequested( @@ -475,6 +657,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) viewModel.refreshSimprintsUiState( @@ -493,6 +676,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) viewModel.simprintsBiometricSearchNavigation.test { @@ -532,6 +716,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) viewModel.simprintsBiometricSearchNavigation.test { @@ -571,6 +756,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) viewModel.onSimprintsBiometricIdentificationResult( uid = "biometric", @@ -621,6 +807,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) viewModel.onSimprintsBiometricIdentificationResult( uid = "biometric", @@ -664,6 +851,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) viewModel.onSimprintsBiometricIdentificationResult( uid = "biometric", @@ -695,6 +883,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) val queryData = mutableMapOf?>( @@ -730,6 +919,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) val queryData = mutableMapOf?>("name" to listOf("Name")) @@ -752,6 +942,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) val queryData = mutableMapOf?>( @@ -788,6 +979,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) viewModel.onSimprintsBiometricIdentificationResult( uid = "biometric", @@ -819,6 +1011,7 @@ class SimprintsSearchViewModelTest { resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, sessionRepository = sessionRepository, resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, ) viewModel.onSimprintsBiometricIdentificationResult( uid = "biometric", @@ -842,6 +1035,59 @@ class SimprintsSearchViewModelTest { } } + private suspend fun pendingConfirmIdentityViewModel(): SimprintsSearchViewModel { + whenever(sessionRepository.get()) doReturn "session-id" + whenever(resolveConfirmIdentityCallout.invoke(any(), any(), any(), any())) doReturn + SimprintsIntentUtils.PreparedCallout( + launchIntent = mock(), + responseData = emptyList(), + biometricAttributeUid = "selected-biometric", + ) + return SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, + ).also { viewModel -> + viewModel.restorePendingConfirmIdentity(null) + viewModel.onDashboardRequested( + searchItems = listOf(identifyField(value = "guid-1")), + teiUid = "tei-uid", + programUid = "program-uid", + enrollmentUid = "enrollment-uid", + ) + } + } + + private fun restoredConfirmIdentityViewModel(savedState: Bundle?) = + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = resolveConfirmIdentityCallout, + sessionRepository = sessionRepository, + resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation, + simprintsD2Repository = simprintsD2Repository, + ).also { it.restorePendingConfirmIdentity(savedState) } + + private fun savedStateBundle(): Bundle { + val values = mutableMapOf() + val bundle: Bundle = mock() + whenever(bundle.getString(any())).thenAnswer { values[it.getArgument(0)] } + doAnswer { + values[it.getArgument(0)] = it.getArgument(1) + null + }.whenever(bundle).putString(any(), anyOrNull()) + return bundle + } + + private fun externalCredentialResult(credentialJson: String = """{"type":"QRCode","value":"new-external-credential"}"""): Intent { + val extras: Bundle = + mock { + on { getString("scannedCredential") } doReturn credentialJson + } + return mock { + on { getExtras() } doReturn extras + } + } + private fun identifyField(value: String?) = FieldUiModelImpl( uid = "biometric", diff --git a/app/src/test/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepositoryTest.kt b/app/src/test/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepositoryTest.kt index c88504fb2d3..101848075a7 100644 --- a/app/src/test/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepositoryTest.kt @@ -2,16 +2,13 @@ package org.dhis2.simprints.ramp.data import org.dhis2.commons.simprints.ramp.model.DetailedEnrollmentListingSettings import org.dhis2.simprints.ramp.model.DetailedEnrollment -import org.dhis2.tracker.search.model.DomainEnrollment -import org.dhis2.tracker.search.model.DomainObjectStyle -import org.dhis2.tracker.search.model.DomainProgram -import org.dhis2.tracker.search.model.EnrollmentStatus 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.ObjectWithUid import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.dataelement.DataElement +import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.event.Event import org.hisp.dhis.android.core.event.EventCollectionRepository import org.hisp.dhis.android.core.organisationunit.OrganisationUnit @@ -24,7 +21,6 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import java.util.Date -import kotlin.time.Instant class DetailedEnrollmentRepositoryTest { private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) @@ -50,24 +46,7 @@ class DetailedEnrollmentRepositoryTest { fun `should return assembled enrollment data from d2`() { val admitted = Date(1_000) val discharge = Date(4_000) - val enrollment = - DomainEnrollment( - uid = ENROLLMENT_UID, - orgUnit = ORG_UNIT_UID, - program = PROGRAM_UID, - enrollmentDate = Instant.parse("2020-01-01T00:00:00Z"), - incidentDate = null, - completedDate = Instant.parse("2030-01-01T00:00:00Z"), - followUp = false, - status = EnrollmentStatus.COMPLETED, - trackedEntityInstance = "tei", - ) - val program = - DomainProgram( - uid = PROGRAM_UID, - displayName = "OTP", - style = DomainObjectStyle(icon = null, color = null), - ) + val enrollment = enrollment(ORG_UNIT_UID) stubOrganisationUnits() stubEvents() whenever( @@ -84,22 +63,17 @@ class DetailedEnrollmentRepositoryTest { .valueType(ValueType.TEXT) .build() - val result = - DetailedEnrollmentRepository(d2).get( - enrollments = listOf(enrollment), - programs = listOf(program), - settings = settings, - ) + val result = DetailedEnrollmentRepository(d2).get(listOf(enrollment), settings) assertEquals( - listOf( - DetailedEnrollment( - programName = "OTP", - admitted = admitted, - discharge = discharge, - outcome = "Cured", - site = "Site1", - ), + mapOf( + ENROLLMENT_UID to + DetailedEnrollment( + admitted = admitted, + discharge = discharge, + outcome = "Cured", + site = "Site1", + ), ), result, ) @@ -108,45 +82,25 @@ class DetailedEnrollmentRepositoryTest { @Test fun `should return no details without enrollments`() { assertEquals( - emptyList(), - DetailedEnrollmentRepository(d2).get(null, null, settings), + emptyMap(), + DetailedEnrollmentRepository(d2).get(emptyList(), settings), ) } @Test fun `should omit event details without configured ids`() { - val enrollment = - DomainEnrollment( - uid = ENROLLMENT_UID, - orgUnit = null, - program = PROGRAM_UID, - enrollmentDate = null, - incidentDate = null, - completedDate = null, - followUp = false, - status = EnrollmentStatus.ACTIVE, - trackedEntityInstance = "tei", - ) - val program = - DomainProgram( - uid = PROGRAM_UID, - displayName = "OTP", - style = DomainObjectStyle(icon = null, color = null), - ) - assertEquals( - listOf( - DetailedEnrollment( - programName = "OTP", - admitted = null, - discharge = null, - outcome = null, - site = null, - ), + mapOf( + ENROLLMENT_UID to + DetailedEnrollment( + admitted = null, + discharge = null, + outcome = null, + site = null, + ), ), DetailedEnrollmentRepository(d2).get( - enrollments = listOf(enrollment), - programs = listOf(program), + enrollments = listOf(enrollment(null)), settings = DetailedEnrollmentListingSettings( dischargeOutcomeDataElementIds = emptySet(), @@ -157,37 +111,11 @@ class DetailedEnrollmentRepositoryTest { ) } - @Test - fun `should exclude enrollments in viewed program`() { - val enrollment = - DomainEnrollment( - uid = ENROLLMENT_UID, - orgUnit = ORG_UNIT_UID, - program = PROGRAM_UID, - enrollmentDate = null, - incidentDate = null, - completedDate = null, - followUp = false, - status = EnrollmentStatus.ACTIVE, - trackedEntityInstance = "tei", - ) - val program = - DomainProgram( - uid = PROGRAM_UID, - displayName = "General Registration", - style = DomainObjectStyle(icon = null, color = null), - ) - - assertEquals( - emptyList(), - DetailedEnrollmentRepository(d2).get( - enrollments = listOf(enrollment), - programs = listOf(program), - settings = settings, - excludedProgramUid = PROGRAM_UID, - ), - ) - } + private fun enrollment(orgUnitUid: String?): Enrollment = + mock { + on { uid() } doReturn ENROLLMENT_UID + on { organisationUnit() } doReturn orgUnitUid + } private fun stubOrganisationUnits() { whenever(d2.organisationUnitModule().organisationUnits()) doReturn organisationUnits @@ -269,7 +197,6 @@ class DetailedEnrollmentRepositoryTest { private companion object { const val ENROLLMENT_UID = "enrollment" - const val PROGRAM_UID = "program" const val ORG_UNIT_UID = "org-unit" const val OUTCOME_DATA_ELEMENT_UID = "outcome1" const val OTHER_OUTCOME_DATA_ELEMENT_UID = "outcome2" 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 1a0eeacb641..040063b4884 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 @@ -208,6 +208,57 @@ class EventHistoryTableRepositoryTest { ) } + @Test + fun `table should start visit columns at configured minimum with configured label`() { + stubRampConfig(followUpVisitMinNumber = 1, followUpVisitLabel = "Custom") + stubRowsAndSections() + stubFollowUpEvents( + listOf( + event( + uid = "visit-zero", + eventDate = Date(1_000), + values = + listOf( + value(VISIT_NUMBER_UID, "0"), + value(WEIGHT_UID, "8.0"), + ), + ), + event( + uid = "visit-one", + eventDate = Date(2_000), + values = + listOf( + value(VISIT_NUMBER_UID, "1"), + value(WEIGHT_UID, "9.0"), + ), + ), + ), + ) + stubOptions() + + val table = + EventHistoryTableRepository( + d2 = d2, + simprintsRampDatastoreRepository = simprintsRampDatastoreRepository, + programUid = PROGRAM_UID, + enrollmentUid = ENROLLMENT_UID, + ).getTable() + + assertEquals("Custom", table?.visitLabel) + assertEquals(listOf("1", "2", "3"), table?.columns?.map { it.label }) + assertEquals(listOf("visit-one", null, null), table?.columns?.map { it.eventUid }) + assertEquals( + listOf("9.0", "", ""), + table + ?.sections + ?.single() + ?.rows + ?.first() + ?.values + ?.map { it.value }, + ) + } + @Test fun `table should exclude locally deleted follow-up events`() { stubRampConfig() @@ -320,7 +371,10 @@ class EventHistoryTableRepositoryTest { ) } - private fun stubRampConfig() { + private fun stubRampConfig( + followUpVisitMinNumber: Int? = null, + followUpVisitLabel: String? = null, + ) { whenever(simprintsRampDatastoreRepository.getConfig()) doReturn RampDatastoreConfig( programStageHistoryTables = @@ -328,7 +382,9 @@ class EventHistoryTableRepositoryTest { ProgramStageHistoryTableConfig( programId = PROGRAM_UID, followUpVisitProgramStageId = PROGRAM_STAGE_UID, + followUpVisitMinNumber = followUpVisitMinNumber, followUpVisitMaxNumber = 3, + followUpVisitLabel = followUpVisitLabel, headerVisitNumberDataElementId = VISIT_NUMBER_UID, excludedFollowUpVisitDataElementIds = listOf(EXCLUDED_UID), ), diff --git a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/data/EventDetailsRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/data/EventDetailsRepositoryTest.kt index a7e7671be48..ee11e4b9ba2 100644 --- a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/data/EventDetailsRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/data/EventDetailsRepositoryTest.kt @@ -3,10 +3,15 @@ package org.dhis2.usescases.eventsWithoutRegistration.eventDetails.data import org.dhis2.commons.data.EventCreationType import org.dhis2.form.ui.FieldViewModelFactory 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.scope.RepositoryScope.OrderByDirection.DESC import org.hisp.dhis.android.core.event.Event +import org.hisp.dhis.android.core.event.EventCollectionRepository +import org.hisp.dhis.android.core.event.EventStatus import org.hisp.dhis.android.core.organisationunit.OrganisationUnit import org.hisp.dhis.android.core.program.ProgramStage +import org.hisp.dhis.android.core.trackedentity.TrackedEntityDataValue import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -214,6 +219,51 @@ class EventDetailsRepositoryTest { assertEquals(repository.getStageLastDate(ENROLLMENT_UID), date) } + @Test + fun `Simprints RAMP anchored schedule context should advance for skipped visits after latest factual visit`() { + val initialVisitDate = Date(1_000) + val visit0 = stageEvent("visit0", Date(10), EventStatus.COMPLETED, initialVisitDate, 0) + val oldSkippedVisit = stageEvent("oldSkip", Date(15), EventStatus.SKIPPED) + val visit1 = stageEvent("visit1", Date(20), EventStatus.COMPLETED, Date(2_000), 1) + val skippedVisit2 = stageEvent("skip2", Date(30), EventStatus.SKIPPED) + val skippedVisit3 = stageEvent("skip3", Date(40), EventStatus.SKIPPED) + stubAnchoredScheduleEvents( + listOf( + skippedVisit3, + visit0, + oldSkippedVisit, + skippedVisit2, + visit1, + ), + ) + + val context = + repository.getAnchoredScheduleContext( + ENROLLMENT_UID, + VISIT_NUMBER_DATA_ELEMENT_UID, + ) + + assertEquals(initialVisitDate, context?.initialVisitDate) + assertEquals(3, context?.currentVisitNumber) + } + + @Test + fun `Simprints RAMP anchored schedule context should require factual visit zero`() { + stubAnchoredScheduleEvents( + listOf( + stageEvent("visit1", Date(20), EventStatus.COMPLETED, Date(2_000), 1), + ), + ) + + val context = + repository.getAnchoredScheduleContext( + ENROLLMENT_UID, + VISIT_NUMBER_DATA_ELEMENT_UID, + ) + + assertEquals(null, context) + } + @Test fun `should use search scope in referral`() { whenever( @@ -279,10 +329,58 @@ class EventDetailsRepositoryTest { ).byOrganisationUnitScope(OrganisationUnit.Scope.SCOPE_DATA_CAPTURE) } + private fun stubAnchoredScheduleEvents(events: List) { + val eventRepository: EventCollectionRepository = mock() + val eventsWithDataValues: EventCollectionRepository = mock() + val enrollmentFilter: StringFilterConnector = mock() + val enrollmentEvents: EventCollectionRepository = mock() + val programStageFilter: StringFilterConnector = mock() + val programStageEvents: EventCollectionRepository = mock() + val deletedFilter: BooleanFilterConnector = mock() + val activeEvents: EventCollectionRepository = mock() + + whenever(d2.eventModule().events()) doReturn eventRepository + whenever(eventRepository.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 programStageEvents + whenever(programStageEvents.byDeleted()) doReturn deletedFilter + whenever(deletedFilter.isFalse) doReturn activeEvents + whenever(activeEvents.blockingGet()) doReturn events + } + + private fun stageEvent( + uid: String, + createdAtClient: Date, + status: EventStatus, + eventDate: Date? = null, + visitNumber: Int? = null, + ): Event { + val event: Event = mock() + val dataValues = + visitNumber?.let { visitNumber -> + val visitNumberValue = visitNumber.toString() + listOf( + mock { + on { dataElement() } doReturn VISIT_NUMBER_DATA_ELEMENT_UID + on { value() } doReturn visitNumberValue + }, + ) + }.orEmpty() + whenever(event.uid()) doReturn uid + whenever(event.createdAtClient()) doReturn createdAtClient + whenever(event.status()) doReturn status + whenever(event.eventDate()) doReturn eventDate + whenever(event.trackedEntityDataValues()) doReturn dataValues + return event + } + companion object { const val PROGRAM_UID = "programUid" const val EVENT_UID = "eventUid" const val PROGRAM_STAGE_UID = "programStageUid" const val ENROLLMENT_UID = "enrollmentUid" + const val VISIT_NUMBER_DATA_ELEMENT_UID = "visitNumberDataElementUid" } } diff --git a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventReportDateTest.kt b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventReportDateTest.kt index e716aeb9c13..6037cd09a7c 100644 --- a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventReportDateTest.kt +++ b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/domain/ConfigureEventReportDateTest.kt @@ -6,6 +6,7 @@ import org.dhis2.commons.data.EventCreationType import org.dhis2.commons.date.DateUtils import org.dhis2.commons.resources.DhisPeriodUtils import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.data.EventDetailsRepository +import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.models.AnchoredScheduleContext import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.providers.EventDetailResourcesProvider import org.hisp.dhis.android.core.event.Event import org.hisp.dhis.android.core.period.PeriodType @@ -131,6 +132,55 @@ class ConfigureEventReportDateTest { assert(eventDate.dateValue == nextEventDate) } + @Test + fun `Get Simprints RAMP anchored schedule date from initial visit and current visit number`() { + val initialVisitDate = DateUtils.uiDateFormat().parse("01/01/2026")!! + configureEventReportDate = + ConfigureEventReportDate( + creationType = EventCreationType.SCHEDULE, + resourceProvider = resourcesProvider, + repository = repository, + periodUtils = periodUtils, + enrollmentId = ENROLLMENT_ID, + scheduleInterval = 28, + anchoredScheduleVisitNumberDataElementId = VISIT_NUMBER_DATA_ELEMENT_ID, + ) + whenever( + repository.getAnchoredScheduleContext( + ENROLLMENT_ID, + VISIT_NUMBER_DATA_ELEMENT_ID, + ), + ) doReturn + AnchoredScheduleContext( + initialVisitDate = initialVisitDate, + currentVisitNumber = 1, + ) + + val nextScheduleDate = configureEventReportDate.getNextScheduleDate() + + assertEquals("26/02/2026", DateUtils.uiDateFormat().format(nextScheduleDate)) // for visit 2 + } + + @Test + fun `Selected schedule date in Simprints RAMP should override anchored suggestion`() = + runBlocking { + val selectedDate = DateUtils.uiDateFormat().parse("10/02/2026")!! + configureEventReportDate = + ConfigureEventReportDate( + creationType = EventCreationType.SCHEDULE, + resourceProvider = resourcesProvider, + repository = repository, + periodUtils = periodUtils, + enrollmentId = ENROLLMENT_ID, + scheduleInterval = 28, + anchoredScheduleVisitNumberDataElementId = VISIT_NUMBER_DATA_ELEMENT_ID, + ) + + val eventDate = configureEventReportDate(selectedDate).first() + + assertEquals(selectedDate, eventDate.currentDate) + } + @Test fun `Get next period when creating first scheduled event generated by enrollment date`() = runBlocking { @@ -261,5 +311,6 @@ class ConfigureEventReportDateTest { const val DUE_DATE = "Due date" const val EVENT_DATE = "Event date" const val NEXT_EVENT = "Next event" + const val VISIT_NUMBER_DATA_ELEMENT_ID = "visitNumberDataElementId" } } 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 81c18f76301..6192bd4d812 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryTest.kt @@ -164,6 +164,15 @@ class SearchRepositoryTest { assertTrue(!searchRepositoryJava.isSearchEnabled) } + @Test + fun `isShowingUnfilteredList delegates to Simprints RAMP datastore repository`() { + val programUid = "programUid" + searchRepositoryJava.setCurrentProgram(programUid) + whenever(rampDatastoreRepository.isShowingUnfilteredList(programUid)) doReturn false + + assertTrue(!searchRepositoryJava.isShowingUnfilteredList) + } + @Test fun shouldTransformToSearchTeiModelWithOverdueEvents() { val overdueDate = dateUtils.getCalendarByDate(Date()) 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 732971e4884..92acb8f45a8 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt @@ -1,5 +1,7 @@ package org.dhis2.usescases.searchTrackEntity +import android.app.Activity.RESULT_OK +import android.content.Intent import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List @@ -9,6 +11,7 @@ import androidx.compose.material.icons.outlined.Map import androidx.paging.PagingData import androidx.paging.testing.asSnapshot import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -20,6 +23,9 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.dhis2.R import org.dhis2.commons.filters.FilterManager +import org.dhis2.commons.filters.Filters +import org.dhis2.commons.filters.sorting.SortingItem +import org.dhis2.commons.filters.sorting.SortingStatus import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.viewmodel.DispatcherProvider @@ -37,6 +43,7 @@ import org.dhis2.tracker.input.ui.state.TrackerInputUiState import org.dhis2.tracker.search.domain.FetchOptionSetOptions import org.dhis2.tracker.search.domain.FetchSearchParameters import org.dhis2.tracker.search.domain.SearchTrackedEntities +import org.dhis2.tracker.search.model.SearchParameterModel import org.dhis2.tracker.search.model.SearchTrackedEntitiesInput import org.dhis2.usescases.searchTrackEntity.listView.SearchResult.SearchResultType import org.dhis2.utils.customviews.navigationbar.NavigationPage @@ -46,6 +53,7 @@ import org.hisp.dhis.android.core.trackedentity.TrackedEntityType import org.hisp.dhis.mobile.ui.designsystem.component.Orientation import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBarItem import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -54,6 +62,7 @@ import org.maplibre.geojson.BoundingBox import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -99,11 +108,16 @@ class SearchTEIViewModelTest { setCurrentProgram(testingProgram()) whenever(repository.canCreateInProgramWithoutSearch()) doReturn true whenever(repository.isSearchEnabled()) doReturn true + whenever(repository.isShowingUnfilteredList()) doReturn true whenever(repository.getTrackedEntityType()) doReturn testingTrackedEntityType() whenever(repository.filtersApplyOnGlobalSearch()) doReturn true whenever(repositoryKt.getExcludeValues()) doReturn HashSet() whenever(repositoryKt.saveSearchValuesAndGetAllowCache(any(), any())) doReturn true - viewModel = + viewModel = createViewModel() + testingDispatcher.scheduler.advanceUntilIdle() + } + + private fun createViewModel() = SearchTEIViewModel( initialProgram, initialQuery, @@ -130,8 +144,6 @@ class SearchTEIViewModelTest { loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, mapSimprintsBiometricSearchResult = mapSimprintsBiometricSearchResult, ) - testingDispatcher.scheduler.advanceUntilIdle() - } @ExperimentalCoroutinesApi @After @@ -178,6 +190,20 @@ class SearchTEIViewModelTest { assertTrue((screenState as SearchList).searchForm.isOpened) } + @Test + fun `Should display localized no match and close search form after Simprints biometric search`() { + whenever(resourceManager.getString(R.string.simprints_biometric_no_match)) doReturn "(no match)" + whenever(resourceManager.getString(R.string.simprints_biometric_search)) doReturn "Biometric search" + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy(items = customIntentFieldUIModels()) + + viewModel.onSimprintsBiometricNoMatches("fieldUid") + + assertTrue(viewModel.searchParametersUiState.items.single().value == "(no match)") + assertTrue(viewModel.searchParametersUiState.items.single().displayName == "Biometric search") + assertTrue((viewModel.screenState.value as SearchList).searchForm.isOpened.not()) + } + @Test fun `Should set Map screen`() { viewModel.setMapScreen() @@ -211,6 +237,74 @@ class SearchTEIViewModelTest { assertTrue(screenState is SearchList) } + @Test + fun `Should keep search form closed and launch Simprints identification when direct search is eligible`() = + runTest { + val identifyIntent = + CustomIntentModel( + uid = "identify-intent", + name = "Identify", + packageName = "com.simprints.id.IDENTIFY", + customIntentRequest = emptyList(), + customIntentResponse = emptyList(), + ) + whenever(fetchSearchParameters.invoke(any())) doReturn + Result.success( + listOf( + SearchParameterModel( + uid = "biometric", + label = "Biometrics", + inputType = TrackerInputType.CUSTOM_INTENT, + optionSet = null, + customIntentUid = identifyIntent.uid, + minCharactersToSearch = null, + searchOperator = null, + isUnique = false, + ), + ), + ) + whenever(repositoryKt.getCustomIntent("biometric")) doReturn identifyIntent + + viewModel.fetchSearchParameters(initialProgram, "teiTypeUid") + testingDispatcher.scheduler.advanceUntilIdle() + + assertTrue(viewModel.shouldLaunchSimprintsBiometricIdentification.value == true) + viewModel.setSimprintsPossibleDuplicatesSearch(true) + assertTrue(viewModel.shouldLaunchSimprintsBiometricIdentification.value == false) + viewModel.setSimprintsPossibleDuplicatesSearch(false) + assertTrue(viewModel.shouldLaunchSimprintsBiometricIdentification.value == true) + + viewModel.setListScreen() + viewModel.onFiltersClick(isLandscape = true) + assertTrue((viewModel.screenState.value as SearchList).searchFilters.isOpened) + + viewModel.updateActiveFilters(true) + viewModel.onFiltersClick(isLandscape = true) + (viewModel.screenState.value as SearchList).let { screenState -> + assertTrue(screenState.searchFilters.isOpened.not()) + assertTrue(screenState.searchForm.isOpened.not()) + } + + viewModel.searchActions.test { + viewModel.onSearchFormRequested() + testingDispatcher.scheduler.advanceUntilIdle() + + assertTrue( + awaitItem() == + TrackerInputAction.LaunchCustomIntent( + fieldUid = "biometric", + customIntentModel = identifyIntent, + ), + ) + assertTrue((viewModel.screenState.value as SearchList).searchForm.isOpened.not()) + cancelAndIgnoreRemainingEvents() + } + + viewModel.onValueChange("name", "User") + + assertTrue(viewModel.shouldLaunchSimprintsBiometricIdentification.value == false) + } + @Test fun `Should set previous screen`() { viewModel.setListScreen() @@ -321,6 +415,80 @@ class SearchTEIViewModelTest { } } + @Test + fun `Should hide unfiltered list when disabled by Simprints RAMP config`() = + runTest { + whenever(repository.isShowingUnfilteredList()) doReturn false + viewModel = createViewModel() + testingDispatcher.scheduler.advanceUntilIdle() + + val result = viewModel.searchPagingData.take(1).asSnapshot() + + assertTrue(result.isEmpty()) + assertTrue(!viewModel.shouldShowListContent()) + verify(searchTrackedEntities, never()).invoke(any()) + } + + @Test + fun `Should show list when a non-sorting filter is active`() = + runTest { + val testingProgram = testingProgram() + setCurrentProgram(testingProgram) + whenever(repository.isShowingUnfilteredList()) doReturn false + whenever(filterManager.totalFilters) doReturn 1 + viewModel = createViewModel() + testingDispatcher.scheduler.advanceUntilIdle() + viewModel.updateActiveFilters(true) + + viewModel.searchPagingData.take(1).asSnapshot() + + assertTrue(viewModel.shouldShowListContent()) + verify(searchTrackedEntities).invoke( + eq( + SearchTrackedEntitiesInput( + selectedProgram = testingProgram.uid(), + queryDataList = mutableListOf(), + allowCache = true, + excludeValues = emptySet(), + hasStateFilters = false, + isOnline = false, + ), + ), + ) + } + + @Test + fun `Should keep list hidden when sorting is the only active filter`() = + runTest { + whenever(repository.isShowingUnfilteredList()) doReturn false + whenever(filterManager.totalFilters) doReturn 1 + whenever(filterManager.sortingItem) doReturn + SortingItem(Filters.ORG_UNIT, SortingStatus.ASC) + viewModel = createViewModel() + testingDispatcher.scheduler.advanceUntilIdle() + viewModel.updateActiveFilters(true) + + val result = viewModel.searchPagingData.take(1).asSnapshot() + + assertTrue(result.isEmpty()) + assertTrue(!viewModel.shouldShowListContent()) + verify(searchTrackedEntities, never()).invoke(any()) + } + + @Test + fun `Should show search results when unfiltered list is hidden`() = + runTest { + whenever(repository.isShowingUnfilteredList()) doReturn false + viewModel = createViewModel() + testingDispatcher.scheduler.advanceUntilIdle() + performSearch() + + viewModel.searchPagingData.take(1).asSnapshot() + + assertTrue(viewModel.shouldShowListContent()) + verify(searchTrackedEntities).invoke(any()) + } + @Test fun `Should return empty global results if not searching`() = runTest { @@ -590,6 +758,33 @@ class SearchTEIViewModelTest { } } + @Test + fun `Should hide result footer when unfiltered list is hidden`() { + whenever(repository.isShowingUnfilteredList()) doReturn false + viewModel = createViewModel() + testingDispatcher.scheduler.advanceUntilIdle() + + viewModel.onDataLoaded(0, null) + + assertTrue(viewModel.dataResult.value?.isEmpty() == true) + } + + @Test + fun `Should show result footer for an active filter with no matches`() { + whenever(repository.isShowingUnfilteredList()) doReturn false + whenever(filterManager.totalFilters) doReturn 1 + viewModel = createViewModel() + testingDispatcher.scheduler.advanceUntilIdle() + viewModel.updateActiveFilters(true) + + viewModel.onDataLoaded(0, null) + + viewModel.dataResult.value?.apply { + assertTrue(size == 1) + assertTrue(first().type == SearchResultType.SEARCH_OR_CREATE) + } + } + @Test fun `Should return no more results for global search when filter do not apply for it`() { setCurrentProgram(testingProgram(maxTeiCountToReturn = 1)) @@ -903,6 +1098,48 @@ class SearchTEIViewModelTest { } } + @Test + fun `confirm identity should forward response and wait before opening dashboard`() = + runTest { + val data: Intent = mock() + val saveStarted = CompletableDeferred() + val finishSaving = CompletableDeferred() + whenever(simprintsSearchViewModel.onConfirmIdentityResult(RESULT_OK, data)) doSuspendableAnswer { + saveStarted.complete(Unit) + finishSaving.await() + SimprintsSearchViewModel.PendingDashboardNavigation("tei-uid", "program-uid", "enrollment-uid") + } + + viewModel.simprintsNavigation.test { + viewModel.onConfirmIdentityResult(RESULT_OK, data) + saveStarted.await() + expectNoEvents() + + finishSaving.complete(Unit) + assertEquals( + SimprintsNavigationAction.OpenDashboard("tei-uid", "program-uid", "enrollment-uid"), + awaitItem(), + ) + verify(simprintsSearchViewModel).onConfirmIdentityResult(RESULT_OK, data) + } + } + + @Test + fun `confirm identity unexpected failure should show error instead of opening dashboard`() = + runTest { + val data: Intent = mock() + whenever(simprintsSearchViewModel.onConfirmIdentityResult(RESULT_OK, data)) + .thenThrow(IllegalStateException("Unable to save")) + whenever(resourceManager.getString(R.string.custom_intent_error)) doReturn "Custom intent error message" + + viewModel.simprintsNavigation.test { + viewModel.onConfirmIdentityResult(RESULT_OK, data) + + assertEquals(SimprintsNavigationAction.ShowMessage("Custom intent error message"), awaitItem()) + expectNoEvents() + } + } + @Test fun `should set error if custom intent result is error`() { whenever(resourceManager.getString(R.string.custom_intent_error)) doReturn "Custom intent error message" diff --git a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapperTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapperTest.kt index 6207d994794..33389fd06dd 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapperTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapperTest.kt @@ -1,8 +1,6 @@ package org.dhis2.usescases.searchTrackEntity.ui.mapper import android.content.Context -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontWeight import org.dhis2.R import org.dhis2.commons.date.DateUtils import org.dhis2.commons.date.toDateSpan @@ -10,7 +8,6 @@ import org.dhis2.commons.date.toOverdueOrScheduledUiText import org.dhis2.commons.resources.ResourceManager import org.dhis2.mobile.commons.extensions.toJavaDate import org.dhis2.mobile.commons.extensions.toKtxInstant -import org.dhis2.simprints.ramp.model.DetailedEnrollment import org.dhis2.tracker.input.model.TrackerInputType import org.dhis2.tracker.search.model.DomainEnrollment import org.dhis2.tracker.search.model.DomainObjectStyle @@ -24,7 +21,6 @@ import org.dhis2.tracker.search.model.TrackedEntityTypeAttributeDomain import org.dhis2.tracker.search.model.TrackedEntityTypeDomain import org.dhis2.usescases.searchTrackEntity.SearchTeiModel import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Before @@ -69,11 +65,6 @@ class TEICardMapperTest { whenever(resourceManager.getString(R.string.show_less)) doReturn "Show less" whenever(resourceManager.getString(R.string.completed)) doReturn "Completed" whenever(resourceManager.getString(R.string.enrolledIn)) doReturn "Enrolled in" - whenever(resourceManager.getString(R.string.programs)) doReturn "Programs" - whenever(resourceManager.getString(R.string.simprints_ramp_admitted)) doReturn "admitted" - whenever(resourceManager.getString(R.string.simprints_ramp_discharged)) doReturn "discharged" - whenever(resourceManager.getString(R.string.simprints_ramp_outcome)) doReturn "outcome" - whenever(resourceManager.getString(R.string.simprints_ramp_site)) doReturn "site" whenever(resourceManager.getString(R.string.transferredTo)) doReturn "Transferred to" whenever( resourceManager.formatWithEnrollmentLabel(any(), any(), any(), any()), @@ -143,112 +134,6 @@ class TEICardMapperTest { assertEquals(result.additionalInfo[1].value, model.tei.enrollmentOrgUnit) } - @Test - fun shouldShowDetailedEnrollmentListingInsteadOfDefaultProgramsRow() { - val model = createFakeModel() - model.detailedEnrollments = - listOf( - DetailedEnrollment( - programName = "OTP", - admitted = Instant.parse("2025-12-12T00:00:00Z").toJavaDate(), - discharge = Instant.parse("2026-12-12T00:00:00Z").toJavaDate(), - outcome = "Cured", - site = "Site1", - ), - DetailedEnrollment( - programName = "RAMP", - admitted = Instant.parse("2025-12-14T00:00:00Z").toJavaDate(), - discharge = Instant.parse("2026-12-13T00:00:00Z").toJavaDate(), - outcome = "Cured", - site = "Site1", - ), - ) - - val result = - mapper.map( - searchTEIModel = model, - onSyncIconClick = {}, - onCardClick = {}, - onImageClick = {}, - ) - - assertEquals("Enrolled in", result.additionalInfo[2].key) - assertEquals(model.tei.enrollmentOrgUnit, result.additionalInfo[2].value) - assertEquals("Programs", result.additionalInfo[3].key) - assertEquals( - "OTP: admitted: 2025-12-12, discharged: 2026-12-12, " + - "outcome: Cured, site: Site1. RAMP: admitted: 2025-12-14, " + - "discharged: 2026-12-13, outcome: Cured, site: Site1.", - result.additionalInfo[3].value, - ) - val styledValue = result.styledAdditionalInfoValues.getValue("Programs") - assertEquals(result.additionalInfo[3].value, styledValue.text) - assertEquals( - listOf("OTP", "RAMP"), - styledValue.spanStyles - .filter { it.item.fontWeight == FontWeight.Bold } - .map { styledValue.text.substring(it.start, it.end) }, - ) - assertFalse(result.additionalInfo[3].truncate) - assertFalse( - result.additionalInfo.any { - it.key == "Programs" && it.value == "Program 1, Program 2" - }, - ) - } - - @Test - fun shouldOmitUnavailableDetailedEnrollmentFields() { - val model = createFakeModel() - model.detailedEnrollments = - listOf( - DetailedEnrollment( - programName = "OTP", - admitted = Instant.parse("2025-12-12T00:00:00Z").toJavaDate(), - discharge = null, - outcome = "", - site = "Site1", - ), - DetailedEnrollment( - programName = "RAMP", - admitted = null, - discharge = null, - outcome = null, - site = null, - ), - ) - - val result = - mapper.map( - searchTEIModel = model, - onSyncIconClick = {}, - onCardClick = {}, - onImageClick = {}, - ) - - assertEquals( - "OTP: admitted: 2025-12-12, site: Site1. RAMP.", - result.additionalInfo[3].value, - ) - } - - @Test - fun shouldNotFallBackToDefaultProgramsWhenNoOtherEnrollmentExists() { - val model = createFakeModel() - model.detailedEnrollments = emptyList() - - val result = - mapper.map( - searchTEIModel = model, - onSyncIconClick = {}, - onCardClick = {}, - onImageClick = {}, - ) - - assertFalse(result.additionalInfo.any { it.key == "Programs" }) - assertEquals(emptyMap(), result.styledAdditionalInfoValues) - } - @Test fun shouldShowOverDueLabel() { val overdueDate = DateUtils.getInstance().calendar diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModelTest.kt index 9909ea134db..36843c09168 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModelTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.dhis2.commons.data.EventCreationType +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository import org.dhis2.commons.viewmodel.DispatcherProvider import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.helpers.DateUtils @@ -78,6 +79,7 @@ class SchedulingViewModelTest { ownerOrgUnitUid = OWNER_ORG_UNIT_UID, ), getEventPeriods = mock(), + rampDatastoreRepository = mock(), ) } diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListRepositoryImplTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListRepositoryImplTest.kt index ec2de96bd51..1755c5d611d 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListRepositoryImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListRepositoryImplTest.kt @@ -1,14 +1,30 @@ package org.dhis2.usescases.teiDashboard.teiProgramList import io.reactivex.Single +import org.dhis2.R import org.dhis2.commons.date.DateUtils import org.dhis2.commons.resources.MetadataIconProvider +import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.simprints.ramp.model.DetailedEnrollmentListingSettings +import org.dhis2.simprints.ramp.data.DetailedEnrollmentRepository +import org.dhis2.simprints.ramp.model.DetailedEnrollment import org.dhis2.usescases.main.program.ProgramViewModelMapper 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.EnumFilterConnector +import org.hisp.dhis.android.core.arch.repositories.filters.internal.StringFilterConnector import org.hisp.dhis.android.core.common.ObjectWithUid +import org.hisp.dhis.android.core.common.ObjectStyle import org.hisp.dhis.android.core.enrollment.Enrollment +import org.hisp.dhis.android.core.enrollment.EnrollmentCollectionRepository import org.hisp.dhis.android.core.enrollment.EnrollmentCreateProjection +import org.hisp.dhis.android.core.enrollment.EnrollmentStatus +import org.hisp.dhis.android.core.organisationunit.OrganisationUnit +import org.hisp.dhis.android.core.organisationunit.OrganisationUnitCollectionRepository import org.hisp.dhis.android.core.program.Program +import org.hisp.dhis.android.core.program.ProgramCollectionRepository +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.mockito.Mockito @@ -24,11 +40,83 @@ class TeiProgramListRepositoryImplTest { private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) private val programViewModelMapper: ProgramViewModelMapper = mock() private val metadataIconProvider: MetadataIconProvider = mock() + private val detailedEnrollmentRepository: DetailedEnrollmentRepository = mock() + private val resourceManager: ResourceManager = mock() + private val listingSettings = DetailedEnrollmentListingSettings(emptySet(), emptySet(), emptySet()) @Before fun setUp() { - teiProgramRepository = - TeiProgramListRepositoryImpl(d2, programViewModelMapper, metadataIconProvider) + teiProgramRepository = createRepository() + whenever(resourceManager.getString(R.string.simprints_ramp_admitted)) doReturn "Admitted" + whenever(resourceManager.getString(R.string.simprints_ramp_discharged)) doReturn "Discharged" + whenever(resourceManager.getString(R.string.simprints_ramp_outcome)) doReturn "Outcome" + whenever(resourceManager.getString(R.string.simprints_ramp_site)) doReturn "Site" + } + + @Test + fun `should format available simprints ramp configured details and omit unavailable details`() { + val fullEnrollment = enrollment(FULL_ENROLLMENT_UID) + val partialEnrollment = enrollment(PARTIAL_ENROLLMENT_UID) + val enrollments = listOf(fullEnrollment, partialEnrollment) + stubActiveEnrollments(enrollments) + whenever(detailedEnrollmentRepository.get(enrollments, listingSettings)) doReturn + mapOf( + FULL_ENROLLMENT_UID to + DetailedEnrollment( + admitted = date(ADMITTED_DATE), + discharge = date(DISCHARGE_DATE), + outcome = OUTCOME_VALUE, + site = ORGANISATION_UNIT_NAME, + ), + PARTIAL_ENROLLMENT_UID to + DetailedEnrollment( + admitted = null, + discharge = date(DISCHARGE_DATE), + outcome = " ", + site = ORGANISATION_UNIT_NAME, + ), + ) + teiProgramRepository = createRepository(listingSettings) + + val result = teiProgramRepository.activeEnrollments(TEI_UID).blockingFirst() + + assertEquals( + listOf( + "Admitted: $ADMITTED_DATE, Discharged: $DISCHARGE_DATE, " + + "Outcome: $OUTCOME_VALUE, Site: $ORGANISATION_UNIT_NAME", + "Discharged: $DISCHARGE_DATE, Site: $ORGANISATION_UNIT_NAME", + ), + result.map { it.enrollmentDetails }, + ) + } + + @Test + fun `should retain standard row outside simprints ramp configured program`() { + val enrollment = enrollment(FULL_ENROLLMENT_UID) + stubActiveEnrollments(listOf(enrollment)) + val organisationUnits = + Mockito.mock( + OrganisationUnitCollectionRepository::class.java, + Mockito.RETURNS_DEEP_STUBS, + ) + val organisationUnitUidFilter: + StringFilterConnector = mock() + val organisationUnit = + OrganisationUnit + .builder() + .uid(ORGANISATION_UNIT_UID) + .displayName(ORGANISATION_UNIT_NAME) + .build() + whenever(d2.organisationUnitModule().organisationUnits()) doReturn organisationUnits + whenever(organisationUnits.byUid()) doReturn organisationUnitUidFilter + whenever(organisationUnitUidFilter.eq(ORGANISATION_UNIT_UID)) doReturn organisationUnits + whenever(organisationUnits.one().blockingGet()) doReturn organisationUnit + + val result = teiProgramRepository.activeEnrollments(TEI_UID).blockingFirst().single() + + assertNull(result.enrollmentDetails) + assertEquals(ORGANISATION_UNIT_NAME, result.orgUnitName) + assertEquals(ENROLLMENT_DATE, result.enrollmentDate) } @Test @@ -164,4 +252,67 @@ class TeiProgramListRepositoryImplTest { DateUtils.getInstance().today, ) } + + private fun enrollment(uid: String): Enrollment = + mock { + on { uid() } doReturn uid + on { program() } doReturn PROGRAM_UID + on { organisationUnit() } doReturn ORGANISATION_UNIT_UID + on { enrollmentDate() } doReturn date(ENROLLMENT_DATE) + on { followUp() } doReturn false + } + + private fun stubActiveEnrollments(enrollments: List) { + val enrollmentRepository: EnrollmentCollectionRepository = mock() + val trackedEntityFilter: StringFilterConnector = mock() + val statusFilter: EnumFilterConnector = mock() + val deletedFilter: BooleanFilterConnector = mock() + whenever(d2.enrollmentModule().enrollments()) doReturn enrollmentRepository + whenever(enrollmentRepository.byTrackedEntityInstance()) doReturn trackedEntityFilter + whenever(trackedEntityFilter.eq(TEI_UID)) doReturn enrollmentRepository + whenever(enrollmentRepository.byStatus()) doReturn statusFilter + whenever(statusFilter.eq(EnrollmentStatus.ACTIVE)) doReturn enrollmentRepository + whenever(enrollmentRepository.byDeleted()) doReturn deletedFilter + whenever(deletedFilter.eq(false)) doReturn enrollmentRepository + whenever(enrollmentRepository.blockingGet()) doReturn enrollments + + val programs = + Mockito.mock(ProgramCollectionRepository::class.java, Mockito.RETURNS_DEEP_STUBS) + val programUidFilter: StringFilterConnector = mock() + val program: Program = mock { + on { uid() } doReturn PROGRAM_UID + on { displayName() } doReturn PROGRAM_NAME + on { style() } doReturn ObjectStyle.builder().build() + } + whenever(d2.programModule().programs()) doReturn programs + whenever(programs.byUid()) doReturn programUidFilter + whenever(programUidFilter.eq(PROGRAM_UID)) doReturn programs + whenever(programs.one().blockingGet()) doReturn program + } + + private fun date(value: String): Date = DateUtils.uiDateFormat().parse(value)!! + + private fun createRepository(settings: DetailedEnrollmentListingSettings? = null) = + TeiProgramListRepositoryImpl( + d2, + programViewModelMapper, + metadataIconProvider, + detailedEnrollmentRepository, + { settings }, + resourceManager, + ) + + private companion object { + const val TEI_UID = "trackedEntity" + const val FULL_ENROLLMENT_UID = "fullEnrollment" + const val PARTIAL_ENROLLMENT_UID = "partialEnrollment" + const val PROGRAM_UID = "program" + const val ORGANISATION_UNIT_UID = "organisationUnit" + const val PROGRAM_NAME = "Program" + const val ORGANISATION_UNIT_NAME = "Organisation unit" + const val OUTCOME_VALUE = "Outcome value" + const val ADMITTED_DATE = "01/01/2020" + const val DISCHARGE_DATE = "02/01/2020" + const val ENROLLMENT_DATE = ADMITTED_DATE + } } diff --git a/commons/src/main/java/org/dhis2/commons/filters/FilterItem.kt b/commons/src/main/java/org/dhis2/commons/filters/FilterItem.kt index 84b2926666a..35135a72e9d 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/FilterItem.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/FilterItem.kt @@ -79,7 +79,14 @@ sealed class FilterItem( } fun displayExpandArrow(): Boolean { - val filters = listOf(Filters.FOLLOW_UP, Filters.TRANSFERRED, Filters.ASSIGNED_TO_ME) + val filters = + listOf( + Filters.FOLLOW_UP, + Filters.TRANSFERRED, + Filters.OVERDUE, + Filters.SYNC_ERROR, + Filters.ASSIGNED_TO_ME, + ) return !filters.any { it == type } } } @@ -344,3 +351,43 @@ data class TransferredFilter( override fun icon() = R.drawable.ic_transfer } + +data class OverdueFilter( + val eventStatusFilter: EventStatusFilter, + override val filterLabel: String, +) : FilterItem( + Filters.OVERDUE, + eventStatusFilter.programType, + eventStatusFilter.sortingItem, + eventStatusFilter.openFilter, + filterLabel, + ) { + fun activate(setActive: Boolean) { + if (!FilterManager.getInstance().isFilterActiveForWorkingList(Filters.EVENT_STATUS)) { + eventStatusFilter.setEventStatus(setActive, EventStatus.OVERDUE) + } + } + + fun observeEventStatus(): ObservableField> = eventStatusFilter.observeEventStatus() + + override fun icon() = R.drawable.ic_overdue_filter +} + +data class SyncErrorFilter( + val syncStateFilter: SyncStateFilter, + override val filterLabel: String, +) : FilterItem( + Filters.SYNC_ERROR, + syncStateFilter.programType, + syncStateFilter.sortingItem, + syncStateFilter.openFilter, + filterLabel, + ) { + fun activate(setActive: Boolean) { + syncStateFilter.setSyncStatus(setActive, State.ERROR, State.WARNING) + } + + fun observeSyncError(): ObservableBoolean = syncStateFilter.observeSyncState(State.ERROR) + + override fun icon() = R.drawable.ic_sync_problem_red +} diff --git a/commons/src/main/java/org/dhis2/commons/filters/FilterManager.java b/commons/src/main/java/org/dhis2/commons/filters/FilterManager.java index 06af3724e54..c4471faf610 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/FilterManager.java +++ b/commons/src/main/java/org/dhis2/commons/filters/FilterManager.java @@ -100,6 +100,8 @@ public enum PeriodRequest { private ObservableField assignedToMeApplied; private ObservableField followUpFilterApplied; private ObservableField transferredFilterApplied; + private ObservableField overdueFilterApplied; + private ObservableField syncErrorFilterApplied; private List stateValues = new ArrayList<>(); @@ -172,6 +174,8 @@ public void reset() { assignedToMeApplied = new ObservableField<>(0); followUpFilterApplied = new ObservableField<>(0); transferredFilterApplied = new ObservableField<>(0); + overdueFilterApplied = new ObservableField<>(0); + syncErrorFilterApplied = new ObservableField<>(0); filterProcessor = PublishProcessor.create(); filterFlow = FilterManagerExtensionsKt.initFlow(this); @@ -233,6 +237,7 @@ public void addState(boolean remove, State... states) { } } observableStates.postValue(stateFilters); + updateSyncErrorFilterCounter(); boolean hasNotSyncedState = stateFilters.contains(State.TO_POST) && stateFilters.contains(State.TO_UPDATE) && @@ -270,7 +275,8 @@ else if (!eventStatusFilters.contains(eventStatus)) } } if (changed) { - observableEventStatus.set(eventStatusFilters); + observableEventStatus.set(new ArrayList<>(eventStatusFilters)); // passing a new instance so data binding observes the change + updateOverdueFilterCounter(); if (eventStatusFilters.contains(EventStatus.ACTIVE)) { eventStatusFiltersApplied.set(eventStatusFilters.size() - 1); } else { @@ -373,6 +379,10 @@ public ObservableField observeField(Filters filter) { return followUpFilterApplied; case TRANSFERRED: return transferredFilterApplied; + case OVERDUE: + return overdueFilterApplied; + case SYNC_ERROR: + return syncErrorFilterApplied; default: return new ObservableField<>(0); } @@ -521,7 +531,8 @@ public void clearCatOptCombo() { public void clearEventStatus() { eventStatusFilters.clear(); eventStatusFiltersApplied.set(eventStatusFilters.size()); - observableEventStatus.set(eventStatusFilters); + observableEventStatus.set(new ArrayList<>(eventStatusFilters)); // passing a new instance so data binding observes the change + updateOverdueFilterCounter(); } public void clearEnrollmentStatus() { @@ -591,6 +602,7 @@ public void clearSyncFilter() { stateFilters.clear(); observableStates.postValue(stateFilters); stateFiltersApplied.set(stateFilters.size()); + updateSyncErrorFilterCounter(); } public void clearOuFilter() { @@ -634,6 +646,8 @@ public void clearAllFilters() { setWorkingListScope(new EmptyWorkingList()); followUpFilterApplied.set(0); transferredFilterApplied.set(0); + updateOverdueFilterCounter(); + updateSyncErrorFilterCounter(); if (!workingListActive()) publishData(); @@ -760,10 +774,22 @@ private void setFilterCountersForWorkingList(WorkingListScope scope) { enrollmentStatusFiltersApplied.set(scope.enrollmentStatusCount()); eventStatusFiltersApplied.set(scope.eventStatusCount()); assignedToMeApplied.set(scope.assignCount()); + updateOverdueFilterCounter(); + updateSyncErrorFilterCounter(); publishData(); } + private void updateOverdueFilterCounter() { + overdueFilterApplied.set(eventStatusFilters.contains(EventStatus.OVERDUE) ? 1 : 0); + } + + private void updateSyncErrorFilterCounter() { + syncErrorFilterApplied.set( + stateFilters.contains(State.ERROR) || stateFilters.contains(State.WARNING) ? 1 : 0 + ); + } + private int getTotalFilterCounterForWorkingList(WorkingListScope scope) { int eventDateCount = scope.eventDateCount() != 0 ? 1 : 0; int enrollmentDateCount = scope.enrollmentDateCount() != 0 ? 1 : 0; diff --git a/commons/src/main/java/org/dhis2/commons/filters/FilterResources.kt b/commons/src/main/java/org/dhis2/commons/filters/FilterResources.kt index 59846dc0b44..60910bcceb6 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/FilterResources.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/FilterResources.kt @@ -120,6 +120,10 @@ class FilterResources( fun filterTransferredPatientLabel(): String = resourceManager.getString(R.string.filter_transferred_patient_label) + fun filterOverdueLabel(): String = resourceManager.getString(R.string.filter_event_status_overdue) + + fun filterWithSyncErrorsLabel(): String = resourceManager.getString(R.string.filter_with_sync_errors) + fun filterEventStatusLabel(programUid: String): String = eventResourcesProvider.formatWithProgramEventLabel( R.string.filters_title_event_label_status, diff --git a/commons/src/main/java/org/dhis2/commons/filters/Filters.kt b/commons/src/main/java/org/dhis2/commons/filters/Filters.kt index 8513f03e7be..f702606967e 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/Filters.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/Filters.kt @@ -17,5 +17,7 @@ enum class Filters( WORKING_LIST(R.layout.item_filter_working_list), FOLLOW_UP(R.layout.item_filter_followup), TRANSFERRED(R.layout.item_filter_transferred), + OVERDUE(R.layout.item_filter_overdue), + SYNC_ERROR(R.layout.item_filter_sync_error), NON(-1), } diff --git a/commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt b/commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt index 4e04b288cc1..8a3a368a8e0 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt @@ -12,8 +12,10 @@ import org.dhis2.commons.filters.FilterResources import org.dhis2.commons.filters.Filters import org.dhis2.commons.filters.FollowUpFilter import org.dhis2.commons.filters.OrgUnitFilter +import org.dhis2.commons.filters.OverdueFilter import org.dhis2.commons.filters.PeriodFilter import org.dhis2.commons.filters.ProgramType +import org.dhis2.commons.filters.SyncErrorFilter import org.dhis2.commons.filters.SyncStateFilter import org.dhis2.commons.filters.TransferredFilter import org.dhis2.commons.filters.WorkingListFilter @@ -587,6 +589,21 @@ constructor( filtersToShow.add(followUpFilter) filtersToShow.add(transferredFilter) } + + val quickFilters = + listOfNotNull( + filtersToShow.filterIsInstance().singleOrNull()?.let { + OverdueFilter(it, resources.filterOverdueLabel()) + }, + filtersToShow.filterIsInstance().singleOrNull()?.let { + SyncErrorFilter(it, resources.filterWithSyncErrorsLabel()) + }, + ) + val insertionIndex = + filtersToShow + .indexOfFirst { it.type == Filters.ASSIGNED_TO_ME } + .takeIf { it >= 0 } ?: filtersToShow.size + filtersToShow.addAll(insertionIndex, quickFilters) return filtersToShow.toList() } 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 195cf2c2a59..9fb65f2821f 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 @@ -28,6 +28,8 @@ data class DataElementHistoryChartConfig( val followUpVisitMaxNumber: Int? = null, @SerializedName("displayMaxDecimalPlaces") val displayMaxDecimalPlaces: Int? = null, + @SerializedName("isYAxisInverted") + val isYAxisInverted: Boolean = false, ) { fun isValid(): Boolean = !programId.isNullOrBlank() && @@ -42,8 +44,12 @@ data class ProgramStageHistoryTableConfig( val programId: String? = null, @SerializedName("followUpVisitProgramStageId") val followUpVisitProgramStageId: String? = null, + @SerializedName("followUpVisitMinNumber") + val followUpVisitMinNumber: Int? = null, @SerializedName("followUpVisitMaxNumber") val followUpVisitMaxNumber: Int? = null, + @SerializedName("followUpVisitLabel") + val followUpVisitLabel: String? = null, @SerializedName("headerVisitNumberDataElementId") val headerVisitNumberDataElementId: String? = null, @SerializedName("excludedFollowUpVisitDataElementIds") @@ -53,14 +59,22 @@ data class ProgramStageHistoryTableConfig( !programId.isNullOrBlank() && !followUpVisitProgramStageId.isNullOrBlank() && !headerVisitNumberDataElementId.isNullOrBlank() && - (followUpVisitMaxNumber ?: -1) >= 0 + (followUpVisitMinNumber ?: 0) in 0..(followUpVisitMaxNumber ?: -1) } data class ProgramSpecificSetting( @SerializedName("programId") val programId: String? = null, + @SerializedName("isOneLevelUpOrgUnitForBiometricsModuleId") + val isOneLevelUpOrgUnitForBiometricsModuleId: Boolean? = null, + @SerializedName("isBiometricsCaptureOnlyButtonEnabledForAttributeId") + val isBiometricsCaptureOnlyButtonEnabledForAttributeId: String? = null, + @SerializedName("externalCredentialAttributeId") + val externalCredentialAttributeId: String? = null, @SerializedName("isSearchEnabled") val isSearchEnabled: Boolean? = null, + @SerializedName("isShowingUnfilteredList") + val isShowingUnfilteredList: Boolean? = null, @SerializedName("hasDetailedEnrollmentListing") val hasDetailedEnrollmentListing: Boolean? = null, @SerializedName("detailedEnrollmentListingDischargeOutcomeDataElementIds") @@ -69,6 +83,8 @@ data class ProgramSpecificSetting( val detailedEnrollmentListingAdmissionProgramStageIds: List? = null, @SerializedName("detailedEnrollmentListingDischargeProgramStageIds") val detailedEnrollmentListingDischargeProgramStageIds: List? = null, + @SerializedName("moduleIdPrefix") + val moduleIdPrefix: String? = null, ) { fun isValid(): Boolean = !programId.isNullOrBlank() } @@ -86,10 +102,16 @@ data class ProgramStageSpecificSetting( val hasVisitNumberPrefixForDateInList: Boolean? = null, @SerializedName("visitNumberDataElementId") val visitNumberDataElementId: String? = null, + @SerializedName("hasAllVisitsExpanded") + val hasAllVisitsExpanded: Boolean? = null, + @SerializedName("hasVisitNumbersSortedAscending") + val hasVisitNumbersSortedAscending: Boolean? = null, @SerializedName("isScheduleOptionEnabled") val isScheduleOptionEnabled: Boolean? = null, @SerializedName("isReferOptionEnabled") val isReferOptionEnabled: Boolean? = null, + @SerializedName("isScheduleAnchoredToInitialVisit") + val isScheduleAnchoredToInitialVisit: Boolean = false, ) { 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 986cbba0842..21131b08c32 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 @@ -40,14 +40,79 @@ class RampDatastoreRepository( .blockingDownload() } - fun isSearchEnabled(programId: String?): Boolean { + fun isSearchEnabled(programId: String?): Boolean = + isProgramOptionEnabled(programId) { isSearchEnabled } + + fun isShowingUnfilteredList(programId: String?): Boolean = + isProgramOptionEnabled(programId) { isShowingUnfilteredList } + + fun isOneLevelUpOrgUnitForBiometricsModuleId(programId: String?): Boolean { + val normalizedProgramId = programId.trimToValue() ?: return false + + return try { + getConfig() + .programSpecificSettings + .firstOrNull { it.programId == normalizedProgramId } + ?.isOneLevelUpOrgUnitForBiometricsModuleId == true + } catch (exception: RuntimeException) { + Timber.e(exception, RAMP_DATASTORE_PARSE_ERROR) + false + } + } + + fun moduleIdPrefix(programId: String?): String? { + val normalizedProgramId = programId.trimToValue() ?: return null + + return try { + getConfig() + .programSpecificSettings + .firstOrNull { it.programId == normalizedProgramId } + ?.moduleIdPrefix + } catch (exception: RuntimeException) { + Timber.e(exception, RAMP_DATASTORE_PARSE_ERROR) + null + } + } + + fun biometricsCaptureOnlyAttributeId(programId: String?): String? { + val normalizedProgramId = programId.trimToValue() ?: return null + + return try { + getConfig() + .programSpecificSettings + .firstOrNull { it.programId == normalizedProgramId } + ?.isBiometricsCaptureOnlyButtonEnabledForAttributeId + } catch (exception: RuntimeException) { + Timber.e(exception, RAMP_DATASTORE_PARSE_ERROR) + null + } + } + + fun externalCredentialAttributeId(programId: String?): String? { + val normalizedProgramId = programId.trimToValue() ?: return null + + return try { + getConfig() + .programSpecificSettings + .firstOrNull { it.programId == normalizedProgramId } + ?.externalCredentialAttributeId + } catch (exception: RuntimeException) { + Timber.e(exception, RAMP_DATASTORE_PARSE_ERROR) + null + } + } + + private fun isProgramOptionEnabled( + programId: String?, + option: ProgramSpecificSetting.() -> Boolean?, + ): Boolean { val normalizedProgramId = programId.trimToValue() ?: return true return try { getConfig() .programSpecificSettings .firstOrNull { it.programId == normalizedProgramId } - ?.isSearchEnabled != false + ?.option() != false } catch (exception: RuntimeException) { Timber.e(exception, RAMP_DATASTORE_PARSE_ERROR) true @@ -89,6 +154,22 @@ class RampDatastoreRepository( fun isReferOptionEnabled(programStageId: String?): Boolean = isProgramStageOptionEnabled(programStageId) { isReferOptionEnabled } + fun anchoredScheduleVisitNumberDataElementId(programStageId: String?): String? { + val normalizedProgramStageId = programStageId.trimToValue() ?: return null + + return try { + getConfig() + .programStageSpecificSettings + .firstOrNull { + it.programStageId == normalizedProgramStageId && + it.isScheduleAnchoredToInitialVisit + }?.visitNumberDataElementId + } catch (exception: RuntimeException) { + Timber.e(exception, RAMP_DATASTORE_PARSE_ERROR) + null + } + } + private fun isProgramStageOptionEnabled( programStageId: String?, option: ProgramStageSpecificSetting.() -> Boolean?, @@ -220,6 +301,7 @@ class RampDatastoreRepository( copy( programId = programId.trimToValue(), followUpVisitProgramStageId = followUpVisitProgramStageId.trimToValue(), + followUpVisitLabel = followUpVisitLabel.trimToValue(), headerVisitNumberDataElementId = headerVisitNumberDataElementId.trimToValue(), excludedFollowUpVisitDataElementIds = excludedFollowUpVisitDataElementIds @@ -229,6 +311,10 @@ class RampDatastoreRepository( private fun ProgramSpecificSetting.normalized(): ProgramSpecificSetting = copy( programId = programId.trimToValue(), + moduleIdPrefix = moduleIdPrefix.trimToValue(), + isBiometricsCaptureOnlyButtonEnabledForAttributeId = + isBiometricsCaptureOnlyButtonEnabledForAttributeId.trimToValue(), + externalCredentialAttributeId = externalCredentialAttributeId.trimToValue(), detailedEnrollmentListingDischargeOutcomeDataElementIds = detailedEnrollmentListingDischargeOutcomeDataElementIds ?.mapNotNull { it.trimToValue() }, diff --git a/commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsD2Repository.kt b/commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsD2Repository.kt index a9bd8d7f05b..f100b9086df 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsD2Repository.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsD2Repository.kt @@ -3,13 +3,16 @@ package org.dhis2.commons.simprints.repository import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.common.ValueType class SimprintsD2Repository @JvmOverloads constructor( private val d2: D2, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO.limitedParallelism( IO_PARALLEL_THREADS ), + private val rampDatastoreRepository: RampDatastoreRepository = RampDatastoreRepository(d2), ) { data class EnrollmentContext( val teiUid: String, @@ -84,6 +87,90 @@ class SimprintsD2Repository @JvmOverloads constructor( .blockingSet(value) } + suspend fun saveExternalCredential( + teiUid: String, + programUid: String?, + biometricAttributeUid: String?, + externalCredentialValue: String, + ): String? = + onIo { + blockingSaveExternalCredential(teiUid, programUid, biometricAttributeUid, externalCredentialValue) + } + + suspend fun saveEnrollmentExternalCredential( + enrollmentUid: String, + biometricAttributeUid: String, + externalCredentialValue: String, + ): String? = + onIo { + blockingSaveEnrollmentExternalCredential(enrollmentUid, biometricAttributeUid, externalCredentialValue) + } + + fun blockingSaveEnrollmentExternalCredential( + enrollmentUid: String, + biometricAttributeUid: String, + externalCredentialValue: String, + ): String? { + val enrollment = + d2 + .enrollmentModule() + .enrollments() + .uid(enrollmentUid) + .blockingGet() ?: return null + return blockingSaveExternalCredential( + enrollment.trackedEntityInstance() ?: return null, + enrollment.program(), + biometricAttributeUid, + externalCredentialValue, + ) + } + + private fun blockingSaveExternalCredential( + teiUid: String, + programUid: String?, + biometricAttributeUid: String?, + externalCredentialValue: String, + ): String? { + if (externalCredentialValue.isBlank()) return null + val normalizedProgramUid = programUid?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val attributeUid = rampDatastoreRepository.externalCredentialAttributeId(normalizedProgramUid) ?: return null + if (attributeUid == biometricAttributeUid || + d2 + .programModule() + .programTrackedEntityAttributes() + .byProgram() + .eq(normalizedProgramUid) + .blockingGet() + .none { it.trackedEntityAttribute()?.uid() == attributeUid } + ) { + return null + } + + val attribute = + d2 + .trackedEntityModule() + .trackedEntityAttributes() + .uid(attributeUid) + .blockingGet() + ?: return null + if (attribute.valueType() != ValueType.TEXT || + attribute.generated() == true || + attribute.optionSet() != null + ) { + return null + } + + val attributeValue = + d2 + .trackedEntityModule() + .trackedEntityAttributeValues() + .value(attributeUid, teiUid) + if (attributeValue.blockingGet()?.value() != externalCredentialValue) { + attributeValue.blockingSet(externalCredentialValue) + } + return attributeUid + } + private suspend fun onIo(block: () -> T): T = withContext(ioDispatcher) { block() } private companion object { diff --git a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCase.kt b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCase.kt index 9a088e3ecdb..4109ebabce6 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCase.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCase.kt @@ -26,7 +26,7 @@ class SimprintsResolveConfirmIdentityCalloutUseCase( customIntent = customIntent, sessionId = sessionId, selectedGuid = selectedGuid, - ) + ).copy(biometricAttributeUid = field.uid) } } } diff --git a/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsExternalCredentialUtils.kt b/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsExternalCredentialUtils.kt new file mode 100644 index 00000000000..203839e4e94 --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsExternalCredentialUtils.kt @@ -0,0 +1,41 @@ +package org.dhis2.commons.simprints.utils + +import android.os.Bundle +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.Strictness + +object SimprintsExternalCredentialUtils { + private const val REGISTER_ACTION = "com.simprints.id.REGISTER" + private const val REGISTER_LAST_ACTION = "com.simprints.id.REGISTER_LAST_BIOMETRICS" + private const val SCANNED_CREDENTIAL_EXTRA = "scannedCredential" + private val gson = GsonBuilder().setStrictness(Strictness.STRICT).create() + + fun externalCredentialValue(extras: Bundle?): String? { + val credentialJson = extras?.getString(SCANNED_CREDENTIAL_EXTRA) ?: return null + val credential = + try { + gson.fromJson(credentialJson, JsonObject::class.java) + } catch (_: JsonParseException) { + null + } ?: return null + + if (credential.stringValue("type") != "QRCode") return null + return credential.stringValue("value")?.takeIf(String::isNotBlank) + } + + fun enrollmentExternalCredentialValue( + action: String?, + guid: String?, + extras: Bundle?, + ): String? { + if (action != REGISTER_ACTION && action != REGISTER_LAST_ACTION) return null + if (guid.isNullOrBlank()) return null + if (extras?.containsKey("identification") == true || extras?.containsKey("identifications") == true) return null + return externalCredentialValue(extras) + } + + private fun JsonObject.stringValue(key: String): String? = + get(key)?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }?.asString +} diff --git a/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt b/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt index 8957ba722f2..b101aa28780 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt @@ -19,6 +19,7 @@ object SimprintsIntentUtils { data class PreparedCallout( val launchIntent: Intent, val responseData: List?, + val biometricAttributeUid: String? = null, ) fun isCallout(customIntent: CustomIntentModel?): Boolean = customIntent?.packageName?.startsWith(SIMPRINTS_PACKAGE_NAME) == true diff --git a/commons/src/main/java/org/dhis2/commons/ui/ListCardProvider.kt b/commons/src/main/java/org/dhis2/commons/ui/ListCardProvider.kt index c46d4359a4a..785e95da3ab 100644 --- a/commons/src/main/java/org/dhis2/commons/ui/ListCardProvider.kt +++ b/commons/src/main/java/org/dhis2/commons/ui/ListCardProvider.kt @@ -14,13 +14,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -31,7 +29,6 @@ import org.hisp.dhis.mobile.ui.designsystem.component.ListCardDescriptionModel import org.hisp.dhis.mobile.ui.designsystem.component.ListCardTitleModel import org.hisp.dhis.mobile.ui.designsystem.component.ProvideKeyValueItem import org.hisp.dhis.mobile.ui.designsystem.component.ToggleInfoTextButton -import org.hisp.dhis.mobile.ui.designsystem.component.getKeyTrimmedText import org.hisp.dhis.mobile.ui.designsystem.component.getKeyValueAnnotatedString import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberAdditionalInfoColumnState import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberListCardState @@ -46,8 +43,6 @@ fun ListCardProvider( @StringRes syncingResourceId: Int, ) { val emphasizedKey = card.emphasizedAdditionalInfoKey - val hasCustomAdditionalInfo = - emphasizedKey != null || card.styledAdditionalInfoValues.isNotEmpty() ListCard( modifier = modifier, @@ -58,8 +53,7 @@ fun ListCardProvider( lastUpdated = card.lastUpdated, additionalInfoColumnState = rememberAdditionalInfoColumnState( - additionalInfoList = - card.additionalInfo.takeUnless { hasCustomAdditionalInfo } ?: emptyList(), + additionalInfoList = card.additionalInfo.takeIf { emphasizedKey == null } ?: emptyList(), syncProgressItem = AdditionalInfoItem( key = stringResource(id = syncingResourceId), @@ -74,14 +68,13 @@ fun ListCardProvider( listAvatar = card.avatar, onCardClick = card.onCardCLick, actionButton = - if (!hasCustomAdditionalInfo) { + if (emphasizedKey == null) { card.actionButton } else { { EmphasizedAdditionalInfoColumn( additionalInfo = card.additionalInfo, emphasizedKey = emphasizedKey, - styledValues = card.styledAdditionalInfoValues, expandLabelText = card.expandLabelText, shrinkLabelText = card.shrinkLabelText, ) @@ -94,8 +87,7 @@ fun ListCardProvider( @Composable fun EmphasizedAdditionalInfoColumn( additionalInfo: List, - emphasizedKey: String?, - styledValues: Map = emptyMap(), + emphasizedKey: String, expandLabelText: String, shrinkLabelText: String, isDetailCard: Boolean = false, @@ -112,22 +104,12 @@ fun EmphasizedAdditionalInfoColumn( Column(Modifier.testTag("LIST_CARD_ADDITIONAL_INFO_COLUMN")) { visibleItems.forEach { - AdditionalInfoRow( - item = it, - emphasized = false, - styledValue = it.key?.let(styledValues::get), - isDetailCard = isDetailCard, - ) + AdditionalInfoRow(it, false, isDetailCard) } } Column(Modifier.testTag("LIST_CARD_ADDITIONAL_INFO_CONSTANT_COLUMN")) { columnState.constantItemList().forEach { - AdditionalInfoRow( - item = it, - emphasized = it.key == emphasizedKey, - styledValue = it.key?.let(styledValues::get), - isDetailCard = isDetailCard, - ) + AdditionalInfoRow(it, it.key == emphasizedKey, isDetailCard) } } if (columnState.showExpandableContent()) { @@ -144,35 +126,14 @@ fun EmphasizedAdditionalInfoColumn( private fun AdditionalInfoRow( item: AdditionalInfoItem, emphasized: Boolean, - styledValue: AnnotatedString?, isDetailCard: Boolean, ) { BoxWithConstraints(Modifier.fillMaxWidth()) { - if (emphasized || styledValue != null) { - val keyText = - getKeyTrimmedText( - item.key.orEmpty(), - maxWidth / 2 - Spacing.Spacing16, - rememberTextMeasurer(), - ) - val keyValueText = getKeyValueAnnotatedString(keyText, item, isDetailCard) + if (emphasized) { val text = buildAnnotatedString { - append(keyValueText) - if (emphasized) { - addStyle(SpanStyle(fontWeight = FontWeight.Bold), 0, length) - } else if (styledValue?.text == item.value) { - val valueStart = keyValueText.text.lastIndexOf(item.value) - if (valueStart >= 0) { - styledValue.spanStyles.forEach { range -> - addStyle( - style = range.item, - start = valueStart + range.start, - end = valueStart + range.end, - ) - } - } - } + append(getKeyValueAnnotatedString(item.key ?: "", item, false)) + addStyle(SpanStyle(fontWeight = FontWeight.Bold), 0, length) } Text( text = text, @@ -194,12 +155,7 @@ private fun AdditionalInfoRow( color = item.color ?: TextColor.OnSurface, style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = - if (emphasized) { - FontWeight.Bold - } else { - MaterialTheme.typography.bodyMedium.fontWeight - }, + fontWeight = FontWeight.Bold, lineHeight = 20.sp, ), overflow = if (item.truncate) TextOverflow.Ellipsis else TextOverflow.Clip, diff --git a/commons/src/main/java/org/dhis2/commons/ui/model/ListCardUiModel.kt b/commons/src/main/java/org/dhis2/commons/ui/model/ListCardUiModel.kt index f54d7cf728a..b0f36bf8e6a 100644 --- a/commons/src/main/java/org/dhis2/commons/ui/model/ListCardUiModel.kt +++ b/commons/src/main/java/org/dhis2/commons/ui/model/ListCardUiModel.kt @@ -1,7 +1,6 @@ package org.dhis2.commons.ui.model import androidx.compose.runtime.Composable -import androidx.compose.ui.text.AnnotatedString import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItem data class ListCardUiModel( @@ -10,7 +9,6 @@ data class ListCardUiModel( val description: String? = null, val lastUpdated: String? = null, val additionalInfo: List, - val styledAdditionalInfoValues: Map = emptyMap(), val emphasizedAdditionalInfoKey: String? = null, val actionButton: @Composable (() -> Unit), val expandLabelText: String, diff --git a/commons/src/main/res/layout/item_filter_overdue.xml b/commons/src/main/res/layout/item_filter_overdue.xml new file mode 100644 index 00000000000..3e9df6208c1 --- /dev/null +++ b/commons/src/main/res/layout/item_filter_overdue.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/commons/src/main/res/layout/item_filter_sync_error.xml b/commons/src/main/res/layout/item_filter_sync_error.xml new file mode 100644 index 00000000000..ad163a10a0b --- /dev/null +++ b/commons/src/main/res/layout/item_filter_sync_error.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + diff --git a/commons/src/main/res/values/strings.xml b/commons/src/main/res/values/strings.xml index 017c2f9db15..676e7b7e284 100644 --- a/commons/src/main/res/values/strings.xml +++ b/commons/src/main/res/values/strings.xml @@ -144,6 +144,7 @@ Synced Not Synced Sync Error + With sync errors SMS Synced 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 5a34961cd7d..42be43d3634 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 @@ -31,6 +31,7 @@ class RampDatastoreRepositoryTest { "dataElementId": "weight", "xAxisVisitNumberDataElementId": "visit-number", "followUpVisitMaxNumber": 12, + "isYAxisInverted": true, "displayMaxDecimalPlaces": 1 }, { @@ -47,7 +48,9 @@ class RampDatastoreRepositoryTest { { "programId": "program", "followUpVisitProgramStageId": "follow-stage", + "followUpVisitMinNumber": 1, "followUpVisitMaxNumber": 12, + "followUpVisitLabel": "Custom", "headerVisitNumberDataElementId": "visit-number", "excludedFollowUpVisitDataElementIds": ["excluded"] }, @@ -55,6 +58,13 @@ class RampDatastoreRepositoryTest { "programId": "program", "followUpVisitProgramStageId": "follow-stage", "followUpVisitMaxNumber": 12 + }, + { + "programId": "program", + "followUpVisitProgramStageId": "follow-stage", + "followUpVisitMinNumber": 13, + "followUpVisitMaxNumber": 12, + "headerVisitNumberDataElementId": "visit-number" } ] } @@ -70,13 +80,16 @@ class RampDatastoreRepositoryTest { assertEquals("weight", chart.dataElementId) assertEquals("visit-number", chart.xAxisVisitNumberDataElementId) assertEquals(12, chart.followUpVisitMaxNumber) + assertEquals(true, chart.isYAxisInverted) assertEquals(1, chart.displayMaxDecimalPlaces) } assertEquals(1, config.programStageHistoryTables.size) config.programStageHistoryTables.first().let { table -> assertEquals("program", table.programId) assertEquals("follow-stage", table.followUpVisitProgramStageId) + assertEquals(1, table.followUpVisitMinNumber) assertEquals(12, table.followUpVisitMaxNumber) + assertEquals("Custom", table.followUpVisitLabel) assertEquals("visit-number", table.headerVisitNumberDataElementId) assertEquals(listOf("excluded"), table.excludedFollowUpVisitDataElementIds) } @@ -101,6 +114,7 @@ class RampDatastoreRepositoryTest { "programId": " program ", "followUpVisitProgramStageId": " follow-stage ", "followUpVisitMaxNumber": 12, + "followUpVisitLabel": " Custom ", "headerVisitNumberDataElementId": " visit-number ", "excludedFollowUpVisitDataElementIds": [" excluded ", " "] } @@ -108,7 +122,12 @@ class RampDatastoreRepositoryTest { "programSpecificSettings": [ { "programId": " disabledProgram ", + "isOneLevelUpOrgUnitForBiometricsModuleId": true, + "moduleIdPrefix": " prefix_ ", + "isBiometricsCaptureOnlyButtonEnabledForAttributeId": " biometrics ", + "externalCredentialAttributeId": " external-credential-attribute ", "isSearchEnabled": false, + "isShowingUnfilteredList": false, "hasDetailedEnrollmentListing": true, "detailedEnrollmentListingDischargeOutcomeDataElementIds": [" outcome ", " "], "detailedEnrollmentListingAdmissionProgramStageIds": [" admission ", " "], @@ -120,8 +139,11 @@ class RampDatastoreRepositoryTest { "programStageId": " disabledStage ", "hasVisitNumberPrefixForDateInList": true, "visitNumberDataElementId": " visit-number ", + "hasAllVisitsExpanded": true, + "hasVisitNumbersSortedAscending": true, "isScheduleOptionEnabled": false, - "isReferOptionEnabled": false + "isReferOptionEnabled": false, + "isScheduleAnchoredToInitialVisit": true } ] } @@ -135,14 +157,32 @@ class RampDatastoreRepositoryTest { assertEquals("follow-stage", chart.followUpVisitProgramStageId) assertEquals("weight", chart.dataElementId) assertEquals("visit-number", chart.xAxisVisitNumberDataElementId) + assertEquals(false, chart.isYAxisInverted) } config.programStageHistoryTables.single().let { table -> assertEquals("program", table.programId) assertEquals("follow-stage", table.followUpVisitProgramStageId) + assertEquals("Custom", table.followUpVisitLabel) assertEquals("visit-number", table.headerVisitNumberDataElementId) assertEquals(listOf("excluded"), table.excludedFollowUpVisitDataElementIds) } assertEquals("disabledProgram", config.programSpecificSettings.single().programId) + assertEquals("prefix_", config.programSpecificSettings.single().moduleIdPrefix) + assertEquals( + true, + config.programSpecificSettings.single() + .isOneLevelUpOrgUnitForBiometricsModuleId, + ) + assertEquals( + "biometrics", + config.programSpecificSettings.single() + .isBiometricsCaptureOnlyButtonEnabledForAttributeId, + ) + assertEquals( + "external-credential-attribute", + config.programSpecificSettings.single().externalCredentialAttributeId, + ) + assertEquals(false, config.programSpecificSettings.single().isShowingUnfilteredList) assertEquals(true, config.programSpecificSettings.single().hasDetailedEnrollmentListing) assertEquals( listOf("outcome"), @@ -162,6 +202,9 @@ class RampDatastoreRepositoryTest { assertEquals("disabledStage", config.programStageSpecificSettings.single().programStageId) assertEquals(true, config.programStageSpecificSettings.single().hasVisitNumberPrefixForDateInList) assertEquals("visit-number", config.programStageSpecificSettings.single().visitNumberDataElementId) + assertEquals(true, config.programStageSpecificSettings.single().hasAllVisitsExpanded) + assertEquals(true, config.programStageSpecificSettings.single().hasVisitNumbersSortedAscending) + assertEquals(true, config.programStageSpecificSettings.single().isScheduleAnchoredToInitialVisit) } @Test @@ -195,6 +238,251 @@ class RampDatastoreRepositoryTest { assertEquals(true, repository.isSearchEnabled(" ")) } + @Test + fun `isShowingUnfilteredList should return false only when program hides it`() { + stubRampConfigRawValue( + """ + { + "programSpecificSettings": [ + { + "programId": " hiddenProgram ", + "isShowingUnfilteredList": false + }, + { + "programId": "defaultProgram" + }, + { + "programId": "visibleProgram", + "isShowingUnfilteredList": true + } + ] + } + """.trimIndent(), + ) + + assertEquals(false, repository.isShowingUnfilteredList("hiddenProgram")) + assertEquals(false, repository.isShowingUnfilteredList(" hiddenProgram ")) + assertEquals(true, repository.isShowingUnfilteredList("visibleProgram")) + assertEquals(true, repository.isShowingUnfilteredList("defaultProgram")) + assertEquals(true, repository.isShowingUnfilteredList("missingProgram")) + assertEquals(true, repository.isShowingUnfilteredList(null)) + assertEquals(true, repository.isShowingUnfilteredList(" ")) + } + + @Test + fun `one level up biometrics module id should be enabled only when program opts in`() { + stubRampConfigRawValue( + """ + { + "programSpecificSettings": [ + { + "programId": " enabledProgram ", + "isOneLevelUpOrgUnitForBiometricsModuleId": true + }, + { + "programId": "disabledProgram", + "isOneLevelUpOrgUnitForBiometricsModuleId": false + }, + { + "programId": "defaultProgram" + }, + { + "programId": "nullProgram", + "isOneLevelUpOrgUnitForBiometricsModuleId": null + } + ] + } + """.trimIndent(), + ) + + assertEquals(true, repository.isOneLevelUpOrgUnitForBiometricsModuleId("enabledProgram")) + assertEquals(true, repository.isOneLevelUpOrgUnitForBiometricsModuleId(" enabledProgram ")) + assertEquals(false, repository.isOneLevelUpOrgUnitForBiometricsModuleId("disabledProgram")) + assertEquals(false, repository.isOneLevelUpOrgUnitForBiometricsModuleId("defaultProgram")) + assertEquals(false, repository.isOneLevelUpOrgUnitForBiometricsModuleId("nullProgram")) + assertEquals(false, repository.isOneLevelUpOrgUnitForBiometricsModuleId("missingProgram")) + assertEquals(false, repository.isOneLevelUpOrgUnitForBiometricsModuleId(null)) + assertEquals(false, repository.isOneLevelUpOrgUnitForBiometricsModuleId(" ")) + } + + @Test + fun `module id prefix should be returned only for configured program`() { + stubRampConfigRawValue( + """ + { + "programSpecificSettings": [ + { + "programId": " prefixedProgram ", + "moduleIdPrefix": " prefix_ " + }, + { + "programId": "otherPrefixedProgram", + "moduleIdPrefix": "other_" + }, + { + "programId": "blankPrefixProgram", + "moduleIdPrefix": " " + }, + { + "programId": "emptyPrefixProgram", + "moduleIdPrefix": "" + }, + { + "programId": "nullPrefixProgram", + "moduleIdPrefix": null + }, + { + "programId": "defaultProgram" + }, + { + "programId": " ", + "moduleIdPrefix": "invalid_" + } + ] + } + """.trimIndent(), + ) + + assertEquals("prefix_", repository.moduleIdPrefix("prefixedProgram")) + assertEquals("prefix_", repository.moduleIdPrefix(" prefixedProgram ")) + assertEquals("other_", repository.moduleIdPrefix("otherPrefixedProgram")) + assertEquals(null, repository.moduleIdPrefix("blankPrefixProgram")) + assertEquals(null, repository.moduleIdPrefix("emptyPrefixProgram")) + assertEquals(null, repository.moduleIdPrefix("nullPrefixProgram")) + assertEquals(null, repository.moduleIdPrefix("defaultProgram")) + assertEquals(null, repository.moduleIdPrefix("missingProgram")) + assertEquals(null, repository.moduleIdPrefix(null)) + assertEquals(null, repository.moduleIdPrefix(" ")) + } + + @Test + fun `module id prefix should be absent when datastore is missing`() { + whenever( + d2 + .dataStoreModule() + .dataStore() + .value("simprints", "ramp") + .blockingGet(), + ).thenReturn(null) + + assertEquals(null, repository.moduleIdPrefix("program")) + } + + @Test + fun `module id prefix should be absent when datastore read fails`() { + whenever( + d2 + .dataStoreModule() + .dataStore() + .value("simprints", "ramp") + .blockingGet(), + ).thenThrow(RuntimeException("Datastore unavailable")) + + assertEquals(null, repository.moduleIdPrefix("program")) + } + + @Test + fun `biometrics capture only attribute should be returned only for configured program`() { + stubRampConfigRawValue( + """ + { + "programSpecificSettings": [ + { + "programId": " enabledProgram ", + "isBiometricsCaptureOnlyButtonEnabledForAttributeId": " biometrics " + }, + { + "programId": "blankAttributeProgram", + "isBiometricsCaptureOnlyButtonEnabledForAttributeId": " " + }, + { + "programId": "defaultProgram" + } + ] + } + """.trimIndent(), + ) + + assertEquals("biometrics", repository.biometricsCaptureOnlyAttributeId("enabledProgram")) + assertEquals("biometrics", repository.biometricsCaptureOnlyAttributeId(" enabledProgram ")) + assertEquals(null, repository.biometricsCaptureOnlyAttributeId("blankAttributeProgram")) + assertEquals(null, repository.biometricsCaptureOnlyAttributeId("defaultProgram")) + assertEquals(null, repository.biometricsCaptureOnlyAttributeId("missingProgram")) + assertEquals(null, repository.biometricsCaptureOnlyAttributeId(null)) + assertEquals(null, repository.biometricsCaptureOnlyAttributeId(" ")) + } + + @Test + fun `external credential attribute should be returned only for configured program`() { + stubRampConfigRawValue( + """ + { + "programSpecificSettings": [ + { + "programId": " enabledProgram ", + "externalCredentialAttributeId": " external-credential-attribute " + }, + { + "programId": "otherProgram", + "externalCredentialAttributeId": "other-attribute" + }, + { + "programId": "blankAttributeProgram", + "externalCredentialAttributeId": " " + }, + { + "programId": "nullAttributeProgram", + "externalCredentialAttributeId": null + }, + { + "programId": "defaultProgram" + }, + { + "programId": " ", + "externalCredentialAttributeId": "invalid-program-attribute" + } + ] + } + """.trimIndent(), + ) + + assertEquals("external-credential-attribute", repository.externalCredentialAttributeId("enabledProgram")) + assertEquals("external-credential-attribute", repository.externalCredentialAttributeId(" enabledProgram ")) + assertEquals("other-attribute", repository.externalCredentialAttributeId("otherProgram")) + assertEquals(null, repository.externalCredentialAttributeId("blankAttributeProgram")) + assertEquals(null, repository.externalCredentialAttributeId("nullAttributeProgram")) + assertEquals(null, repository.externalCredentialAttributeId("defaultProgram")) + assertEquals(null, repository.externalCredentialAttributeId("missingProgram")) + assertEquals(null, repository.externalCredentialAttributeId(null)) + assertEquals(null, repository.externalCredentialAttributeId(" ")) + } + + @Test + fun `external credential attribute should be absent when datastore is missing`() { + whenever( + d2 + .dataStoreModule() + .dataStore() + .value("simprints", "ramp") + .blockingGet(), + ).thenReturn(null) + + assertEquals(null, repository.externalCredentialAttributeId("program")) + } + + @Test + fun `external credential attribute should be absent when datastore read fails`() { + whenever( + d2 + .dataStoreModule() + .dataStore() + .value("simprints", "ramp") + .blockingGet(), + ).thenThrow(RuntimeException("Datastore unavailable")) + + assertEquals(null, repository.externalCredentialAttributeId("program")) + } + @Test fun `detailed enrollment listing should be enabled only for configured program`() { stubRampConfigRawValue( @@ -290,6 +578,82 @@ class RampDatastoreRepositoryTest { assertEquals(true, repository.isReferOptionEnabled(" ")) } + @Test + fun `getConfig should preserve optional visit list flags independently`() { + stubRampConfigRawValue( + """ + { + "programStageSpecificSettings": [ + { + "programStageId": "expandedStage", + "hasAllVisitsExpanded": true, + "hasVisitNumbersSortedAscending": false + }, + { + "programStageId": "sortedStage", + "hasAllVisitsExpanded": false, + "hasVisitNumbersSortedAscending": true + }, + { + "programStageId": "defaultStage" + }, + { + "programStageId": "nullStage", + "hasAllVisitsExpanded": null, + "hasVisitNumbersSortedAscending": null + } + ] + } + """.trimIndent(), + ) + + val settings = repository.getConfig().programStageSpecificSettings + + assertEquals(listOf(true, false, null, null), settings.map { it.hasAllVisitsExpanded }) + assertEquals(listOf(false, true, null, null), settings.map { it.hasVisitNumbersSortedAscending }) + } + + @Test + fun `anchored schedule should return visit number data element only for enabled stage`() { + stubRampConfigRawValue( + """ + { + "programStageSpecificSettings": [ + { + "programStageId": "anchoredStage", + "visitNumberDataElementId": "visit-number", + "isScheduleAnchoredToInitialVisit": true + }, + { + "programStageId": "disabledStage", + "visitNumberDataElementId": "visit-number", + "isScheduleAnchoredToInitialVisit": false + }, + { + "programStageId": "defaultStage", + "visitNumberDataElementId": "visit-number" + }, + { + "programStageId": "missingVisitNumber", + "isScheduleAnchoredToInitialVisit": true + } + ] + } + """.trimIndent(), + ) + + assertEquals( + "visit-number", + repository.anchoredScheduleVisitNumberDataElementId(" anchoredStage "), + ) + assertEquals(null, repository.anchoredScheduleVisitNumberDataElementId("disabledStage")) + assertEquals(null, repository.anchoredScheduleVisitNumberDataElementId("defaultStage")) + assertEquals(null, repository.anchoredScheduleVisitNumberDataElementId("missingVisitNumber")) + assertEquals(null, repository.anchoredScheduleVisitNumberDataElementId("missingStage")) + assertEquals(null, repository.anchoredScheduleVisitNumberDataElementId(null)) + assertEquals(null, repository.anchoredScheduleVisitNumberDataElementId(" ")) + } + @Test fun `getConfig should parse wrapped string datastore value`() { val rawJson = @@ -328,6 +692,10 @@ class RampDatastoreRepositoryTest { assertEquals(0, config.dataElementHistoryCharts.size) assertEquals(0, config.programStageHistoryTables.size) + assertEquals(false, repository.isOneLevelUpOrgUnitForBiometricsModuleId("program")) + assertEquals(null, repository.moduleIdPrefix("program")) + assertEquals(null, repository.biometricsCaptureOnlyAttributeId("program")) + assertEquals(null, repository.externalCredentialAttributeId("program")) } @Test diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsD2RepositoryTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsD2RepositoryTest.kt index 3ffbccd74d9..e742020f1cd 100644 --- a/commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsD2RepositoryTest.kt +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/repository/SimprintsD2RepositoryTest.kt @@ -2,12 +2,15 @@ package org.dhis2.commons.simprints.repository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking +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.StringFilterConnector import org.hisp.dhis.android.core.common.ObjectWithUid +import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.program.ProgramTrackedEntityAttribute import org.hisp.dhis.android.core.program.ProgramTrackedEntityAttributeCollectionRepository +import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttribute import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValue import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValueObjectRepository import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance @@ -15,17 +18,23 @@ import org.hisp.dhis.android.core.trackedentity.TrackedEntityTypeAttribute import org.hisp.dhis.android.core.trackedentity.TrackedEntityTypeAttributeCollectionRepository import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test import org.mockito.Mockito +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever class SimprintsD2RepositoryTest { private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) - private val repository = SimprintsD2Repository(d2, Dispatchers.Unconfined) + private val rampDatastoreRepository: RampDatastoreRepository = mock() + private val repository = SimprintsD2Repository(d2, Dispatchers.Unconfined, rampDatastoreRepository) + private val externalCredentialValueRepository: TrackedEntityAttributeValueObjectRepository = mock() @Test fun `getEnrollmentContext should map enrollment fields`() = runBlocking { @@ -234,4 +243,218 @@ class SimprintsD2RepositoryTest { verify(attributeValueRepository).blockingSet("subject-guid") } + + @Test + fun `saveExternalCredential should do nothing when not configured`() = + runBlocking { + val result = + repository.saveExternalCredential("tei-uid", "program-uid", "biometrics-uid", "credential-value") + + assertNull(result) + verifyNoInteractions(d2) + } + + @Test + fun `saveExternalCredential should do nothing for blank credentials or absent program`() = + runBlocking { + assertNull(repository.saveExternalCredential("tei-uid", "program-uid", "biometrics-uid", "")) + assertNull(repository.saveExternalCredential("tei-uid", "program-uid", "biometrics-uid", " \n ")) + assertNull(repository.saveExternalCredential("tei-uid", null, "biometrics-uid", "credential-value")) + assertNull(repository.saveExternalCredential("tei-uid", " ", "biometrics-uid", "credential-value")) + + verifyNoInteractions(d2, rampDatastoreRepository) + } + + @Test + fun `saveExternalCredential should not overwrite the biometrics attribute`() = + runBlocking { + whenever(rampDatastoreRepository.externalCredentialAttributeId("program-uid")) doReturn "biometrics-uid" + + assertNull( + repository.saveExternalCredential("tei-uid", "program-uid", "biometrics-uid", "credential-value"), + ) + + verifyNoInteractions(d2) + } + + @Test + fun `saveExternalCredential should reject an attribute outside the program`() = + runBlocking { + stubExternalCredentialTarget(programAttributes = listOf("different-attribute")) + + assertNull( + repository.saveExternalCredential("tei-uid", "program-uid", "biometrics-uid", "credential-value"), + ) + + verify(externalCredentialValueRepository, never()).blockingSet(any()) + } + + @Test + fun `saveExternalCredential should reject missing or incompatible attributes`() = + runBlocking { + val textAttribute = externalCredentialAttribute() + val invalidAttributes = + listOf( + null, + textAttribute.toBuilder().valueType(ValueType.NUMBER).build(), + textAttribute.toBuilder().generated(true).build(), + textAttribute.toBuilder().optionSet(ObjectWithUid.create("option-set")).build(), + ) + + invalidAttributes.forEach { attribute -> + stubExternalCredentialTarget(attribute = attribute) + + assertNull( + repository.saveExternalCredential("tei-uid", "program-uid", "biometrics-uid", "credential-value"), + ) + } + + verify(externalCredentialValueRepository, never()).blockingSet(any()) + } + + @Test + fun `saveExternalCredential should save a new credential`() = + runBlocking { + stubExternalCredentialTarget() + + val result = + repository.saveExternalCredential("tei-uid", " program-uid ", "biometrics-uid", " credential-value ") + + assertEquals("external-credential-attribute", result) + verify(externalCredentialValueRepository).blockingSet(" credential-value ") + } + + @Test + fun `saveExternalCredential should update a changed credential`() = + runBlocking { + stubExternalCredentialTarget(storedValue = "old-credential-value") + + val result = + repository.saveExternalCredential("tei-uid", "program-uid", "biometrics-uid", "new-credential-value") + + assertEquals("external-credential-attribute", result) + verify(externalCredentialValueRepository).blockingSet("new-credential-value") + } + + @Test + fun `saveExternalCredential should not rewrite an unchanged credential`() = + runBlocking { + stubExternalCredentialTarget(storedValue = "credential-value") + + val result = + repository.saveExternalCredential("tei-uid", "program-uid", "biometrics-uid", "credential-value") + + assertEquals("external-credential-attribute", result) + verify(externalCredentialValueRepository, never()).blockingSet(any()) + } + + @Test + fun `saveExternalCredential should propagate save failures`() = + runBlocking { + stubExternalCredentialTarget() + val error = IllegalStateException("Credential save failed") + whenever(externalCredentialValueRepository.blockingSet("credential-value")).thenThrow(error) + + val result = + runCatching { + repository.saveExternalCredential("tei-uid", "program-uid", "biometrics-uid", "credential-value") + } + + assertSame(error, result.exceptionOrNull()) + } + + @Test + fun `saveEnrollmentExternalCredential should save using the enrollment context`() = + runBlocking { + stubExternalCredentialTarget() + whenever( + d2 + .enrollmentModule() + .enrollments() + .uid("enrollment-uid") + .blockingGet(), + ) doReturn + Enrollment + .builder() + .uid("enrollment-uid") + .trackedEntityInstance("tei-uid") + .program("program-uid") + .attributeOptionCombo("attribute-option-combo-uid") + .build() + + val result = + repository.saveEnrollmentExternalCredential("enrollment-uid", "biometrics-uid", "credential-value") + + assertEquals("external-credential-attribute", result) + verify(externalCredentialValueRepository).blockingSet("credential-value") + } + + @Test + fun `saveEnrollmentExternalCredential should do nothing when enrollment is missing`() = + runBlocking { + whenever( + d2 + .enrollmentModule() + .enrollments() + .uid("enrollment-uid") + .blockingGet(), + ) doReturn null + + assertNull( + repository.saveEnrollmentExternalCredential("enrollment-uid", "biometrics-uid", "credential-value"), + ) + + verifyNoInteractions(rampDatastoreRepository, externalCredentialValueRepository) + } + + private fun stubExternalCredentialTarget( + attribute: TrackedEntityAttribute? = externalCredentialAttribute(), + programAttributes: List = listOf("external-credential-attribute"), + storedValue: String? = null, + ) { + whenever(rampDatastoreRepository.externalCredentialAttributeId("program-uid")) doReturn + "external-credential-attribute" + val programFilter: StringFilterConnector = mock() + val programAttributeRepository: ProgramTrackedEntityAttributeCollectionRepository = mock() + whenever(d2.programModule().programTrackedEntityAttributes().byProgram()) doReturn programFilter + whenever(programFilter.eq("program-uid")) doReturn programAttributeRepository + whenever(programAttributeRepository.blockingGet()) doReturn + programAttributes.map { attributeUid -> + ProgramTrackedEntityAttribute + .builder() + .uid("program-$attributeUid") + .trackedEntityAttribute(ObjectWithUid.create(attributeUid)) + .build() + } + whenever( + d2 + .trackedEntityModule() + .trackedEntityAttributes() + .uid("external-credential-attribute") + .blockingGet(), + ) doReturn attribute + whenever( + d2 + .trackedEntityModule() + .trackedEntityAttributeValues() + .value("external-credential-attribute", "tei-uid"), + ) doReturn externalCredentialValueRepository + whenever(externalCredentialValueRepository.blockingGet()) doReturn + storedValue?.let { + TrackedEntityAttributeValue + .builder() + .trackedEntityInstance("tei-uid") + .trackedEntityAttribute("external-credential-attribute") + .value(it) + .build() + } + } + + private fun externalCredentialAttribute(): TrackedEntityAttribute = + TrackedEntityAttribute + .builder() + .uid("external-credential-attribute") + .valueType(ValueType.TEXT) + .generated(false) + .build() } diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCaseTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCaseTest.kt index e49d2675def..6ab825a1e44 100644 --- a/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCaseTest.kt +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCaseTest.kt @@ -61,6 +61,7 @@ class SimprintsResolveConfirmIdentityCalloutUseCaseTest { verify(launchIntent).putExtra("sessionId", "session-id") verify(launchIntent).putExtra("selectedGuid", "selected-guid") assertEquals(customIntent.customIntentResponse, result.responseData) + assertEquals("biometric", result.biometricAttributeUid) } } @@ -130,6 +131,7 @@ class SimprintsResolveConfirmIdentityCalloutUseCaseTest { verify(launchIntent).putExtra("sessionId", "session-id") verify(launchIntent).putExtra("selectedGuid", "selected-guid") assertEquals(customIntent.customIntentResponse, result.responseData) + assertEquals("biometric", result.biometricAttributeUid) } } diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsExternalCredentialUtilsTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsExternalCredentialUtilsTest.kt new file mode 100644 index 00000000000..b97f9e70c46 --- /dev/null +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsExternalCredentialUtilsTest.kt @@ -0,0 +1,125 @@ +package org.dhis2.commons.simprints.utils + +import android.os.Bundle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class SimprintsExternalCredentialUtilsTest { + @Test + fun `externalCredentialValue reads SID credential without altering its value`() { + val extras = extras("""{"type":"QRCode","value":" 001234 ","documentFields":{}}""") + + assertEquals(" 001234 ", SimprintsExternalCredentialUtils.externalCredentialValue(extras)) + } + + @Test + fun `externalCredentialValue ignores missing credentials`() { + assertNull(SimprintsExternalCredentialUtils.externalCredentialValue(null)) + assertNull(SimprintsExternalCredentialUtils.externalCredentialValue(extras(null))) + } + + @Test + fun `externalCredentialValue does not coerce non-string values`() { + listOf("1234", "true", "{}", "[]").forEach { value -> + assertNull(SimprintsExternalCredentialUtils.externalCredentialValue(extras("""{"type":"QRCode","value":$value}"""))) + } + } + + @Test + fun `externalCredentialValue requires exact QRCode type`() { + listOf("\"qrCode\"", "\"GhanaNhisCard\"", "null", "1234", "true", "{}", "[]").forEach { type -> + assertNull(SimprintsExternalCredentialUtils.externalCredentialValue(extras("""{"type":$type,"value":"credential-1"}"""))) + } + assertNull(SimprintsExternalCredentialUtils.externalCredentialValue(extras("""{"value":"credential-1"}"""))) + } + + @Test + fun `externalCredentialValue ignores malformed and non-object JSON`() { + listOf( + "", + "{", + "{invalid", + "null", + "1234", + "[]", + "true", + "\"credential-1\"", + """{type:"QRCode",value:"credential-1"}""", + """{"type":"QRCode","value":"credential-1"} trailing""", + ).forEach { json -> + assertNull(SimprintsExternalCredentialUtils.externalCredentialValue(extras(json))) + } + } + + @Test + fun `enrollmentExternalCredentialValue accepts Enrol and Enrol Last results`() { + listOf("com.simprints.id.REGISTER", "com.simprints.id.REGISTER_LAST_BIOMETRICS").forEach { action -> + assertEquals( + "credential-1", + SimprintsExternalCredentialUtils.enrollmentExternalCredentialValue( + action = action, + guid = "guid-1", + extras = extras(), + ), + ) + } + } + + @Test + fun `enrollmentExternalCredentialValue rejects unrelated and non-enrollment actions`() { + listOf( + null, + "", + "other.app.REGISTER", + "com.simprints.id.REGISTER_OTHER", + "com.simprints.id.IDENTIFY", + "com.simprints.id.VERIFY", + "com.simprints.id.CONFIRM_IDENTITY", + ).forEach { action -> + assertNull( + SimprintsExternalCredentialUtils.enrollmentExternalCredentialValue( + action = action, + guid = "guid-1", + extras = extras(), + ), + ) + } + } + + @Test + fun `enrollmentExternalCredentialValue requires nonblank mapped GUID`() { + listOf(null, "", " \t\n").forEach { guid -> + assertNull( + SimprintsExternalCredentialUtils.enrollmentExternalCredentialValue( + action = "com.simprints.id.REGISTER", + guid = guid, + extras = extras(), + ), + ) + } + } + + @Test + fun `enrollmentExternalCredentialValue allows enrollment without a credential`() { + assertNull( + SimprintsExternalCredentialUtils.enrollmentExternalCredentialValue( + action = "com.simprints.id.REGISTER", + guid = "guid-1", + extras = extras(null), + ), + ) + } + + private fun extras( + credentialJson: String? = """{"type":"QRCode","value":"credential-1"}""", + identificationKey: String? = null, + ): Bundle = + mock { + on { getString("scannedCredential") } doReturn credentialJson + on { containsKey("identification") } doReturn (identificationKey == "identification") + on { containsKey("identifications") } doReturn (identificationKey == "identifications") + } +} diff --git a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt index a20295b055e..9ff22f56fb5 100644 --- a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt +++ b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt @@ -13,7 +13,11 @@ import org.hisp.dhis.android.core.settings.CustomIntentResponseExtraType as Extr class CustomIntentRepositoryImpl( private val d2: D2, + private val isOneLevelUpOrgUnitForBiometricsModuleIdEnabled: () -> Boolean, + private val moduleIdPrefix: () -> String?, ) : CustomIntentRepository { + constructor(d2: D2) : this(d2, { false }, { null }) + private val customIntents: List = d2.settingModule().customIntents().blockingGet() override fun getCustomIntent( @@ -100,27 +104,66 @@ class CustomIntentRepositoryImpl( .settingModule() .customIntentService() .blockingEvaluateRequestParams(customIntent, context) - .overrideSimprintsIdentifyModuleIdWithUserOrgUnitCode(customIntent) + .overrideSimprintsModuleId(customIntent, context) + .prefixSimprintsModuleId(customIntent) + + private fun Map.prefixSimprintsModuleId(customIntent: CustomIntent): Map { + if (customIntent.packageName()?.startsWith(SIMPRINTS_ACTION_PREFIX) != true) return this + val prefix = moduleIdPrefix()?.takeUnless(String::isEmpty) ?: return this + val moduleId = this[SIMPRINTS_MODULE_ID_KEY] as? String ?: return this + return this + (SIMPRINTS_MODULE_ID_KEY to prefix + moduleId) + } - private fun Map.overrideSimprintsIdentifyModuleIdWithUserOrgUnitCode(customIntent: CustomIntent): Map { - val simprintsIdentifyAction = "com.simprints.id.IDENTIFY" - val simprintsModuleIdKey = "moduleId" - if (customIntent.packageName() != simprintsIdentifyAction) { - return this + private fun Map.overrideSimprintsModuleId( + customIntent: CustomIntent, + context: CustomIntentContext, + ): Map { + val action = customIntent.packageName() ?: return this + if (!action.startsWith(SIMPRINTS_ACTION_PREFIX)) return this + + val isOneLevelUp = isOneLevelUpOrgUnitForBiometricsModuleIdEnabled() + if (!isOneLevelUp) { + if (action != SIMPRINTS_IDENTIFY_ACTION) return this + val orgUnitUid = currentUserOrgUnitUid() ?: return this + return this + (SIMPRINTS_MODULE_ID_KEY to orgUnitUid) } - val orgUnitCode = - currentUserOrgUnitCode() - ?: return this - return this + (simprintsModuleIdKey to orgUnitCode) + + val evaluatedModuleId = this[SIMPRINTS_MODULE_ID_KEY] as? String + val orgUnitUid = + if (action == SIMPRINTS_IDENTIFY_ACTION) { + currentUserOrgUnitUid() + } else { + evaluatedModuleId.asOrgUnitUidOrNull() + ?: context.orgunitUid.asOrgUnitUidOrNull() + ?: currentUserOrgUnitUid() + } ?: return this + + return this + (SIMPRINTS_MODULE_ID_KEY to oneLevelUpOrgUnitUidOrCurrent(orgUnitUid)) } - private fun currentUserOrgUnitCode(): String? = + private fun String?.asOrgUnitUidOrNull(): String? = + this + ?.trim() + ?.takeUnless { it.isEmpty() || it.equals(SIMPRINTS_NOT_APPLICABLE, ignoreCase = true) } + + private fun currentUserOrgUnitUid(): String? = d2 .organisationUnitModule() .organisationUnits() .byRootOrganisationUnit(true) .blockingGet() - .firstNotNullOfOrNull { it.code()?.takeUnless(String::isBlank) } + .firstNotNullOfOrNull { it.uid().takeUnless(String::isBlank) } + + private fun oneLevelUpOrgUnitUidOrCurrent(orgUnitUid: String): String = + d2 + .organisationUnitModule() + .organisationUnits() + .uid(orgUnitUid) + .blockingGet() + ?.parent() + ?.uid() + ?.takeUnless(String::isBlank) + ?: orgUnitUid override fun reEvaluateCustomIntentRequestParams( orgUnitUid: String, @@ -132,4 +175,11 @@ class CustomIntentRepositoryImpl( evaluateCustomIntentRequestParams(customIntent, context) } ?: emptyMap() } + + private companion object { + private const val SIMPRINTS_ACTION_PREFIX = "com.simprints.id." + private const val SIMPRINTS_IDENTIFY_ACTION = "${SIMPRINTS_ACTION_PREFIX}IDENTIFY" + private const val SIMPRINTS_MODULE_ID_KEY = "moduleId" + private const val SIMPRINTS_NOT_APPLICABLE = "N/A" + } } diff --git a/form/src/main/java/org/dhis2/form/data/EnrollmentRepository.kt b/form/src/main/java/org/dhis2/form/data/EnrollmentRepository.kt index 67c4625061f..e8d6aaf4cfa 100644 --- a/form/src/main/java/org/dhis2/form/data/EnrollmentRepository.kt +++ b/form/src/main/java/org/dhis2/form/data/EnrollmentRepository.kt @@ -44,6 +44,7 @@ class EnrollmentRepository( private val enrollmentFormLabelsProvider: EnrollmentFormLabelsProvider, private val customIntentRepository: CustomIntentRepository, metadataIconProvider: MetadataIconProvider, + private val biometricsCaptureOnlyAttributeIdProvider: () -> String? = { null }, ) : DataEntryBaseRepository(conf, fieldFactory, metadataIconProvider) { override val programUid by lazy { conf.program()?.uid() @@ -146,19 +147,25 @@ class EnrollmentRepository( private fun getFieldsForSingleSection(): Single> = Single.fromCallable { - conf.programAttributes().map { programTrackedEntityAttribute -> - transform(programTrackedEntityAttribute) - } + val captureOnlyFieldUid = biometricsCaptureOnlyField()?.uid + conf + .programAttributes() + .filterNot { it.trackedEntityAttribute()?.uid() == captureOnlyFieldUid } + .map { programTrackedEntityAttribute -> + transform(programTrackedEntityAttribute) + } } private fun getFieldsForMultipleSections(): Single> { return Single.fromCallable { val fields = mutableListOf() + val captureOnlyFieldUid = biometricsCaptureOnlyField()?.uid programSections.forEach { section -> fields.add( transformSection(section.uid(), section.displayName(), section.description()), ) section.attributes()?.forEachIndexed { _, attribute -> + if (attribute.uid() == captureOnlyFieldUid) return@forEachIndexed conf.programAttribute(attribute.uid())?.let { programTrackedEntityAttribute -> fields.add(transform(programTrackedEntityAttribute, section.uid())) } @@ -168,6 +175,12 @@ class EnrollmentRepository( } } + private fun biometricsCaptureOnlyField(): FieldUiModel? = + biometricsCaptureOnlyAttributeIdProvider() + ?.let(conf::programAttribute) + ?.let { transform(it, ENROLLMENT_DATA_SECTION_UID) } + ?.takeIf { it.customIntent != null } + private fun transform( programTrackedEntityAttribute: ProgramTrackedEntityAttribute, sectionUid: String? = SINGLE_SECTION_UID, @@ -348,6 +361,9 @@ class EnrollmentRepository( conf.program()?.description(), ), ) + biometricsCaptureOnlyField() + ?.takeIf { it.value.isNullOrEmpty() } + ?.let(enrollmentDataList::add) enrollmentDataList.add( getEnrollmentDateField( diff --git a/form/src/main/java/org/dhis2/form/data/FormRepository.kt b/form/src/main/java/org/dhis2/form/data/FormRepository.kt index 5cd1c2b5251..0fe0e88d52d 100644 --- a/form/src/main/java/org/dhis2/form/data/FormRepository.kt +++ b/form/src/main/java/org/dhis2/form/data/FormRepository.kt @@ -31,6 +31,11 @@ interface FormRepository { extraData: String?, ): StoreResult? + fun saveSimprintsExternalCredential( + biometricAttributeUid: String, + value: String, + ): StoreResult? + fun updateValueOnList( uid: String, value: String?, diff --git a/form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt b/form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt index a01efbc46b4..d26726c62d1 100644 --- a/form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt +++ b/form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt @@ -7,6 +7,7 @@ import org.dhis2.commons.dialogs.bottomsheet.IssueType import org.dhis2.commons.periods.model.Period import org.dhis2.commons.prefs.Preference import org.dhis2.commons.prefs.PreferenceProvider +import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.form.data.EnrollmentRepository.Companion.ENROLLMENT_DATE_UID @@ -16,6 +17,7 @@ import org.dhis2.form.model.OptionSetConfiguration import org.dhis2.form.model.RowAction import org.dhis2.form.model.SectionUiModelImpl import org.dhis2.form.model.StoreResult +import org.dhis2.form.model.ValueStoreResult import org.dhis2.form.ui.provider.DisplayNameProvider import org.dhis2.form.ui.provider.LegendValueProvider import org.dhis2.mobile.commons.model.CustomIntentRequestArgumentModel @@ -26,6 +28,7 @@ import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.common.ValueType.LONG_TEXT import org.hisp.dhis.android.core.event.EventStatus import org.hisp.dhis.rules.models.RuleEffect +import timber.log.Timber class FormRepositoryImpl( private val formValueStore: FormValueStore, @@ -37,6 +40,9 @@ class FormRepositoryImpl( private val legendValueProvider: LegendValueProvider, private val useCompose: Boolean, private val preferenceProvider: PreferenceProvider, + private val biometricsCaptureOnlyAttributeIdProvider: () -> String? = { null }, + private val externalCredentialAttributeIdProvider: () -> String? = { null }, + private val simprintsD2Repository: SimprintsD2Repository? = null, ) : FormRepository { private var completionPercentage: Float = 0f private val itemsWithError: MutableList = mutableListOf() @@ -45,6 +51,7 @@ class FormRepositoryImpl( private var itemList: List = emptyList() private var focusedItemId: String? = null private var ruleEffects: List = emptyList() + private var ruleEffectsNeedRefresh = false private var ruleEffectsResult: RuleUtilsProviderResult? = null private var runDataIntegrity: Boolean = false private var backupList: List = emptyList() @@ -69,14 +76,36 @@ class FormRepositoryImpl( } override suspend fun composeList(skipProgramRules: Boolean): List { - return itemList - .applyRuleEffects(skipProgramRules) + return synchronized(this) { itemList.applyRuleEffects(skipProgramRules) } + .applyBiometricsCaptureOnlyVisibility() .mergeListWithErrorFields(itemsWithError) .also { calculateCompletionPercentage(it) }.setSectionStates() .setFocusedItem() .setLastItem() + .applyExternalCredentialReadOnly() + } + + private fun List.applyExternalCredentialReadOnly(): List { + val attributeUid = externalCredentialAttributeIdProvider() ?: return this + return map { field -> + if (field.uid == attributeUid && field.valueType == ValueType.TEXT) { + field.setEditable(false) + } else { + field + } + } + } + + private fun List.applyBiometricsCaptureOnlyVisibility(): List { + val attributeId = biometricsCaptureOnlyAttributeIdProvider() ?: return this + val sourceField = itemList.firstOrNull { it.uid == attributeId } ?: return this + if (sourceField.customIntent == null) return this + if (sourceField.isLoadingData || !sourceField.value.isNullOrEmpty()) { + return filterNot { it.uid == attributeId } + } + return this } override fun completeEvent() { @@ -443,6 +472,8 @@ class FormRepositoryImpl( ) override fun completedFieldsPercentage(value: List): Float = completionPercentage + + @Synchronized override fun backupOfChangedItems() = backupList.minus(itemList.applyRuleEffects()) private suspend fun getFieldsWithError() = @@ -476,9 +507,10 @@ class FormRepositoryImpl( @Synchronized private fun List.applyRuleEffects(skipProgramRules: Boolean = false): List { ruleEffects = - if (skipProgramRules) { + if (skipProgramRules && !ruleEffectsNeedRefresh) { ruleEffects } else { + ruleEffectsNeedRefresh = false ruleEffects() } val fieldMap = this.associateBy { it.uid }.toMutableMap() @@ -813,6 +845,28 @@ class FormRepositoryImpl( return result } + @Synchronized + override fun saveSimprintsExternalCredential( + biometricAttributeUid: String, + value: String, + ): StoreResult? { + if (dataEntryRepository.isEvent()) return null + return try { + val attributeUid = + simprintsD2Repository?.blockingSaveEnrollmentExternalCredential( + enrollmentUid = formValueStore.enrollmentUid() ?: return null, + biometricAttributeUid = biometricAttributeUid, + externalCredentialValue = value, + ) ?: return null + updateValueOnList(attributeUid, value, ValueType.TEXT) + ruleEffectsNeedRefresh = true + StoreResult(attributeUid, ValueStoreResult.VALUE_CHANGED) + } catch (e: Exception) { + Timber.e(e, "Unable to save Simprints external credential after enrollment") + null + } + } + override fun storeFile( id: String, filePath: String?, diff --git a/form/src/main/java/org/dhis2/form/data/FormValueStore.kt b/form/src/main/java/org/dhis2/form/data/FormValueStore.kt index 1f01503a08a..f528b2e523c 100644 --- a/form/src/main/java/org/dhis2/form/data/FormValueStore.kt +++ b/form/src/main/java/org/dhis2/form/data/FormValueStore.kt @@ -124,6 +124,8 @@ class FormValueStore( fun recordUid(): String = recordUid + fun enrollmentUid(): String? = enrollmentRepository?.blockingGet()?.uid() + fun completeEvent() { try { d2 diff --git a/form/src/main/java/org/dhis2/form/di/Injector.kt b/form/src/main/java/org/dhis2/form/di/Injector.kt index 8ee62b4dc02..997c8c16669 100644 --- a/form/src/main/java/org/dhis2/form/di/Injector.kt +++ b/form/src/main/java/org/dhis2/form/di/Injector.kt @@ -11,6 +11,7 @@ import org.dhis2.commons.resources.EventResourcesProvider import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository as SimprintsRampDatastoreRepository +import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.form.data.DataEntryRepository @@ -60,6 +61,7 @@ object Injector { fun provideFormViewModelFactory( context: Context, repositoryRecords: FormRepositoryRecords, + programUid: String?, openErrorLocation: Boolean, useCompose: Boolean, ): FormViewModelFactory = @@ -67,6 +69,7 @@ object Injector { provideFormRepository( context, repositoryRecords, + programUid, useCompose, ), provideDispatchers(), @@ -93,14 +96,41 @@ object Injector { fun provideDispatchers(): DispatcherProvider = FormDispatcher() - fun provideCustomIntentProvider(): CustomIntentRepository = CustomIntentRepositoryImpl(provideD2()) + fun provideCustomIntentProvider(programUid: String?): CustomIntentRepository { + val d2 = provideD2() + val rampDatastoreRepository = SimprintsRampDatastoreRepository(d2) + return CustomIntentRepositoryImpl( + d2 = d2, + isOneLevelUpOrgUnitForBiometricsModuleIdEnabled = { + rampDatastoreRepository.isOneLevelUpOrgUnitForBiometricsModuleId(programUid) + }, + moduleIdPrefix = { rampDatastoreRepository.moduleIdPrefix(programUid) }, + ) + } private fun provideFormRepository( context: Context, repositoryRecords: FormRepositoryRecords, + programUid: String?, useCompose: Boolean, - ): FormRepository = - FormRepositoryImpl( + ): FormRepository { + val biometricsCaptureOnlyAttributeId by lazy { + if (repositoryRecords.entryMode == EntryMode.ATTR) { + SimprintsRampDatastoreRepository(provideD2()) + .biometricsCaptureOnlyAttributeId(programUid) + } else { + null + } + } + val biometricsCaptureOnlyAttributeIdProvider = { biometricsCaptureOnlyAttributeId } + val externalCredentialAttributeId by lazy { + if (repositoryRecords.entryMode == EntryMode.ATTR) { + SimprintsRampDatastoreRepository(provideD2()).externalCredentialAttributeId(programUid) + } else { + null + } + } + return FormRepositoryImpl( formValueStore = provideFormValueStore( context = context, @@ -115,6 +145,8 @@ object Injector { context = context, repositoryRecords = repositoryRecords, metadataIconProvider = provideMetadataIconProvider(), + customIntentRepository = provideCustomIntentProvider(programUid), + biometricsCaptureOnlyAttributeIdProvider = biometricsCaptureOnlyAttributeIdProvider, ), ruleEngineRepository = provideRuleEngineRepository( @@ -125,13 +157,20 @@ object Injector { legendValueProvider = provideLegendValueProvider(context), useCompose = useCompose, preferenceProvider = providePreferenceProvider(context), + biometricsCaptureOnlyAttributeIdProvider = biometricsCaptureOnlyAttributeIdProvider, + externalCredentialAttributeIdProvider = { externalCredentialAttributeId }, + simprintsD2Repository = + if (repositoryRecords.entryMode == EntryMode.ATTR) SimprintsD2Repository(provideD2()) else null, ) + } private fun provideDataEntryRepository( entryMode: EntryMode?, context: Context, repositoryRecords: FormRepositoryRecords, metadataIconProvider: MetadataIconProvider, + customIntentRepository: CustomIntentRepository, + biometricsCaptureOnlyAttributeIdProvider: () -> String?, ): DataEntryRepository = when (entryMode) { EntryMode.ATTR -> @@ -139,12 +178,15 @@ object Injector { context, repositoryRecords as EnrollmentRecords, metadataIconProvider, + customIntentRepository, + biometricsCaptureOnlyAttributeIdProvider, ) else -> provideEventRepository( context, repositoryRecords as EventRecords, + customIntentRepository, ) } @@ -152,6 +194,8 @@ object Injector { context: Context, enrollmentRecords: EnrollmentRecords, metadataIconProvider: MetadataIconProvider, + customIntentRepository: CustomIntentRepository, + biometricsCaptureOnlyAttributeIdProvider: () -> String?, ): DataEntryRepository = EnrollmentRepository( fieldFactory = provideFieldFactory(context), @@ -164,12 +208,14 @@ object Injector { enrollmentMode = enrollmentRecords.enrollmentMode, enrollmentFormLabelsProvider = provideEnrollmentFormLabelsProvider(context), metadataIconProvider = metadataIconProvider, - customIntentRepository = provideCustomIntentProvider(), + customIntentRepository = customIntentRepository, + biometricsCaptureOnlyAttributeIdProvider = biometricsCaptureOnlyAttributeIdProvider, ) private fun provideEventRepository( context: Context, eventRecords: EventRecords, + customIntentRepository: CustomIntentRepository, ): DataEntryRepository = EventRepository( fieldFactory = provideFieldFactory(context), @@ -184,7 +230,7 @@ object Injector { ), eventMode = eventRecords.eventMode, dispatcherProvider = provideDispatchers(), - customIntentRepository = provideCustomIntentProvider(), + customIntentRepository = customIntentRepository, getSimprintsRampFormHistoryChart = GetSimprintsRampFormHistoryChartUseCase( SimprintsRampDatastoreRepository(provideD2()), 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 962ec49ea60..5ebc90c711a 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 @@ -3,7 +3,10 @@ package org.dhis2.form.simprints.ramp.data import org.dhis2.commons.simprints.ramp.model.DataElementHistoryChartConfig import org.dhis2.form.model.FieldUiModel import org.dhis2.form.simprints.ramp.model.FormHistoryChart +import org.dhis2.form.simprints.ramp.model.FormHistoryChartCategory +import org.dhis2.form.simprints.ramp.model.toHistoryChartValue import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope import org.hisp.dhis.android.core.event.Event import java.util.Date @@ -47,6 +50,7 @@ class FormHistoryChartRepository( val dataElementUid = chartConfig.dataElementId.trimToValue() ?: return null val labels = (0..followUpVisitMaxNumber).map(Int::toString) val values = MutableList(labels.size) { null } + val categories = getCategories(fieldUiModel.optionSet) var currentValueIndex: Int? = null getFollowUpVisitEvents(currentEvent, followUpVisitProgramStageUid).forEach { event -> @@ -59,9 +63,9 @@ class FormHistoryChartRepository( val value = if (event.uid() == eventUid) { currentValueIndex = visitNumber - fieldUiModel.value?.toFloatOrNull() + fieldUiModel.value.toHistoryChartValue(categories) } else { - dataValuesByDataElement[dataElementUid]?.toFloatOrNull() + dataValuesByDataElement[dataElementUid].toHistoryChartValue(categories) } values[visitNumber] = value @@ -71,11 +75,32 @@ class FormHistoryChartRepository( title = fieldUiModel.label, labels = labels, values = values, + categories = categories, currentValueIndex = currentValueIndex, + isYAxisInverted = chartConfig.isYAxisInverted, displayMaxDecimalPlaces = chartConfig.displayMaxDecimalPlaces, ) } + private fun getCategories(optionSetUid: String?): List? = + optionSetUid.trimToValue()?.let { uid -> + d2 + .optionModule() + .options() + .byOptionSetUid() + .eq(uid) + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) + .blockingGet() + .map { option -> + val storedValue = option.code().trimToValue() ?: option.uid() + FormHistoryChartCategory( + storedValue = storedValue, + name = option.name(), + displayName = option.displayName().trimToValue() ?: option.name().trimToValue() ?: storedValue, + ) + } + } + private fun loadCurrentEvent(): Event? = d2 .eventModule() 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 e8759541ac7..e4854842a38 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 @@ -4,16 +4,38 @@ data class FormHistoryChart( val title: String, val labels: List, val values: List, + val categories: List? = null, val currentValueIndex: Int? = null, val displayMaxDecimalPlaces: Int? = null, + val isYAxisInverted: Boolean = false, ) { fun withCurrentValue(value: String?): FormHistoryChart { val index = currentValueIndex?.takeIf { it in values.indices } ?: return this return copy( values = values.toMutableList().also { updatedValues -> - updatedValues[index] = value?.toFloatOrNull() + updatedValues[index] = value.toHistoryChartValue(categories) }, ) } } + +data class FormHistoryChartCategory( + val storedValue: String, + val name: String?, + val displayName: String, +) + +internal fun String?.toHistoryChartValue(categories: List?): Float? = + if (categories == null) { + this?.toFloatOrNull() + } else { + this + ?.let { value -> + categories.indexOfFirst { + category -> value == category.storedValue || value == category.name || value == category.displayName + } + } + ?.takeIf { index -> index >= 0 } + ?.toFloat() + } 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 83342056275..04da23e4562 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 @@ -88,8 +88,11 @@ private fun LineChart.configureHistoryChart( historyChart: FormHistoryChart, graph: Graph, ) { - val axisValueFormatter = IntegerAwareValueFormatter() - val pointValueFormatter = IntegerAwareValueFormatter(historyChart.displayMaxDecimalPlaces) + val categoryLabels = historyChart.categories?.map { category -> category.displayName } + val axisValueFormatter = categoryLabels?.let(::HistoryChartCategoryFormatter) ?: IntegerAwareValueFormatter() + val pointValueFormatter = + categoryLabels?.let(::HistoryChartCategoryFormatter) + ?: IntegerAwareValueFormatter(historyChart.displayMaxDecimalPlaces) setHighlightPerTapEnabled(false) setHighlightPerDragEnabled(false) @@ -105,7 +108,6 @@ private fun LineChart.configureHistoryChart( } axisLeft.apply { this.valueFormatter = axisValueFormatter - setLabelCount(HISTORY_CHART_Y_AXIS_LABEL_COUNT, false) textSize = HISTORY_CHART_TEXT_SIZE } configureHistoryYAxis(historyChart) @@ -115,6 +117,13 @@ private fun LineChart.configureHistoryChart( } private fun LineChart.configureHistoryYAxis(historyChart: FormHistoryChart) { + axisLeft.isInverted = historyChart.isYAxisInverted + historyChart.categories?.let { categories -> + configureCategoricalHistoryYAxis(categories.size) + return + } + + axisLeft.setLabelCount(HISTORY_CHART_Y_AXIS_LABEL_COUNT, false) val plottedValues = historyChart.values.filterNotNull() if (plottedValues.isEmpty()) { axisLeft.apply { @@ -138,6 +147,26 @@ private fun LineChart.configureHistoryYAxis(historyChart: FormHistoryChart) { applyHistoryYAxisRange(minValue, maxValue) } +private fun LineChart.configureCategoricalHistoryYAxis(categoryCount: Int) { + axisLeft.apply { + isGranularityEnabled = true + granularity = HISTORY_CHART_INTEGER_GRANULARITY + when (categoryCount) { + 0 -> { + setLabelCount(HISTORY_CHART_Y_AXIS_LABEL_COUNT, false) + resetAxisMinimum() + resetAxisMaximum() + } + + else -> { + setLabelCount(categoryCount + 2, true) + axisMinimum = -1f + axisMaximum = categoryCount.toFloat() + } + } + } +} + private fun LineChart.applyHistoryYAxisRange( minValue: Float, maxValue: Float, @@ -208,6 +237,28 @@ internal class IntegerAwareValueFormatter( } } +internal class HistoryChartCategoryFormatter( + private val labels: List, +) : ValueFormatter() { + override fun getFormattedValue(value: Float): String { + val index = value.roundToInt() + return if (abs(value - index) < CATEGORY_INDEX_EPSILON) { + labels.getOrNull(index).orEmpty() + } else { + "" + } + } + + override fun getAxisLabel( + value: Float, + axis: AxisBase?, + ): String = getFormattedValue(value) + + private companion object { + const val CATEGORY_INDEX_EPSILON = 0.0001f + } +} + private fun Int?.validMaxDecimalPlaces(): Int = this?.takeIf { it >= 0 } ?: HISTORY_CHART_DEFAULT_MAX_DECIMAL_PLACES diff --git a/form/src/main/java/org/dhis2/form/ui/FormView.kt b/form/src/main/java/org/dhis2/form/ui/FormView.kt index e3dc8c2ef91..deaa1a4aeda 100644 --- a/form/src/main/java/org/dhis2/form/ui/FormView.kt +++ b/form/src/main/java/org/dhis2/form/ui/FormView.kt @@ -42,6 +42,7 @@ import org.dhis2.commons.locationprovider.LocationProvider import org.dhis2.commons.orgunitselector.OUTreeFragment import org.dhis2.commons.periods.ui.PeriodSelectorContent import org.dhis2.commons.simprints.usecases.SimprintsResolvePossibleDuplicatesSearchUseCase.SimprintsPossibleDuplicatesSearch +import org.dhis2.commons.simprints.utils.SimprintsExternalCredentialUtils import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.form.R import org.dhis2.form.data.RulesUtilsProviderConfigurationError @@ -150,6 +151,7 @@ class FormView : Fragment() { repositoryRecords = arguments?.serializable(RECORDS) ?: throw RepositoryRecordsException(), + programUid = arguments?.getString(PROGRAM_UID), openErrorLocation = openErrorLocation, useCompose = useCompose, ) @@ -614,15 +616,22 @@ class FormView : Fragment() { intentHandler(intent) } is CustomIntentResult.Success -> { - val loadingIntent = FormIntent.OnFieldFinishedLoadingData(result.fieldUid) - intentHandler(loadingIntent) val intent = FormIntent.OnSaveCustomIntent( result.fieldUid, result.value, false, + simprintsExternalCredential = + SimprintsExternalCredentialUtils.enrollmentExternalCredentialValue( + result.action, + result.value, + result.extras, + ), ) intentHandler(intent) + // deferred clearing of loading state to prevent old UI state from flickering to visibility + val loadingIntent = FormIntent.OnFieldFinishedLoadingData(result.fieldUid) + intentHandler(loadingIntent) } is CustomIntentResult.PossibleDuplicates -> { val loadingIntent = FormIntent.OnFieldFinishedLoadingData(result.fieldUid) @@ -741,6 +750,7 @@ class FormView : Fragment() { val bundle = Bundle().apply { putSerializable(RECORDS, records) + putString(PROGRAM_UID, programUid) } fragment.arguments = bundle return fragment @@ -749,6 +759,7 @@ class FormView : Fragment() { companion object { const val RECORDS = "RECORDS" + private const val PROGRAM_UID = "PROGRAM_UID" const val TEMP_FILE = "tempFile.png" } } diff --git a/form/src/main/java/org/dhis2/form/ui/FormViewModel.kt b/form/src/main/java/org/dhis2/form/ui/FormViewModel.kt index de35103e313..cc44c9610e5 100644 --- a/form/src/main/java/org/dhis2/form/ui/FormViewModel.kt +++ b/form/src/main/java/org/dhis2/form/ui/FormViewModel.kt @@ -245,6 +245,19 @@ class FormViewModel( } val result = processUserAction(rowAction) + if ( + it is FormIntent.OnSaveCustomIntent && + !it.error && + !it.value.isNullOrBlank() && + it.simprintsExternalCredential != null && + result.valueStoreResult in + listOf(ValueStoreResult.VALUE_CHANGED, ValueStoreResult.VALUE_HAS_NOT_CHANGED) + ) { + val credentialResult = repository.saveSimprintsExternalCredential(it.uid, it.simprintsExternalCredential) + if (credentialResult != null) { + return Pair(rowAction, credentialResult) + } + } return Pair(rowAction, result) } diff --git a/form/src/main/java/org/dhis2/form/ui/intent/FormIntent.kt b/form/src/main/java/org/dhis2/form/ui/intent/FormIntent.kt index f148681a1f4..30c415bec29 100644 --- a/form/src/main/java/org/dhis2/form/ui/intent/FormIntent.kt +++ b/form/src/main/java/org/dhis2/form/ui/intent/FormIntent.kt @@ -30,6 +30,7 @@ sealed class FormIntent { val uid: String, val value: String?, val error: Boolean, + val simprintsExternalCredential: String? = null, ) : FormIntent() data class OnQrCodeScanned( diff --git a/form/src/test/java/org/dhis2/form/data/EnrollmentRepositoryTest.kt b/form/src/test/java/org/dhis2/form/data/EnrollmentRepositoryTest.kt index 1d66933b4a1..db58f254a7f 100644 --- a/form/src/test/java/org/dhis2/form/data/EnrollmentRepositoryTest.kt +++ b/form/src/test/java/org/dhis2/form/data/EnrollmentRepositoryTest.kt @@ -8,17 +8,41 @@ import org.dhis2.form.model.EnrollmentMode import org.dhis2.form.model.FieldUiModel import org.dhis2.form.model.SectionUiModelImpl.Companion.SINGLE_SECTION_UID import org.dhis2.form.ui.FieldViewModelFactory +import org.dhis2.form.ui.FieldViewModelFactoryImpl +import org.dhis2.form.ui.provider.AutoCompleteProvider +import org.dhis2.form.ui.provider.DisplayNameProvider import org.dhis2.form.ui.provider.EnrollmentFormLabelsProvider +import org.dhis2.form.ui.provider.HintProvider +import org.dhis2.form.ui.provider.KeyboardActionProvider +import org.dhis2.form.ui.provider.LegendValueProvider +import org.dhis2.form.ui.provider.UiEventTypesProvider import org.dhis2.mobile.commons.customintents.CustomIntentRepository +import org.dhis2.mobile.commons.model.CustomIntentActionTypeModel +import org.dhis2.mobile.commons.model.CustomIntentModel +import org.hisp.dhis.android.core.common.ObjectStyle +import org.hisp.dhis.android.core.common.ObjectWithUid +import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.program.ProgramSection +import org.hisp.dhis.android.core.program.ProgramTrackedEntityAttribute +import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttribute +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Before import org.junit.Test -import org.mockito.Mockito.mock import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock import org.mockito.kotlin.whenever class EnrollmentRepositoryTest { - private val fieldFactory: FieldViewModelFactory = mock() + private val fieldFactory: FieldViewModelFactory = + FieldViewModelFactoryImpl( + hintProvider = mock(), + displayNameProvider = mock(), + uiEventTypesProvider = mock(), + keyboardActionProvider = mock(), + legendValueProvider = mock(), + autoCompleteProvider = mock(), + ) private val conf: EnrollmentConfiguration = mock() private val enrollmentMode: EnrollmentMode = mock() private val enrolmentFormLabelsProvider: EnrollmentFormLabelsProvider = mock() @@ -60,6 +84,7 @@ class EnrollmentRepositoryTest { whenever(conf.program()?.displayEnrollmentDateLabel()) doReturn "Enrollment Date" whenever(enrolmentFormLabelsProvider.provideEnrollmentDateDefaultLabel("Program_UID")) doReturn "Enrollment Date" + whenever(enrolmentFormLabelsProvider.provideEnrollmentOrgUnitLabel()) doReturn "Organisation unit" whenever( conf @@ -127,4 +152,100 @@ class EnrollmentRepositoryTest { whenever(enrolmentFormLabelsProvider.provideSingleSectionLabel()) doReturn "enrollment label" assertTrue(repository.list().blockingFirst().count() == 5) } + + @Test + fun `should add empty capture only biometrics`() { + configureBiometricsCaptureOnlyAttribute(value = null) + repository = createRepository(BIOMETRICS_ATTRIBUTE_UID) + + val result = repository.list().blockingFirst() + val generalRegistrationIndex = + result.indexOfFirst { it.uid == EnrollmentRepository.ENROLLMENT_DATA_SECTION_UID } + val biometricsField = result[generalRegistrationIndex + 1] + + assertEquals(BIOMETRICS_ATTRIBUTE_UID, biometricsField.uid) + assertEquals(EnrollmentRepository.ENROLLMENT_DATA_SECTION_UID, biometricsField.programStageSection) + assertEquals("Biometrics", biometricsField.label) + assertTrue(biometricsField.customIntent != null) + } + + @Test + fun `should omit capture only biometrics when already captured`() { + configureBiometricsCaptureOnlyAttribute(value = "captured-guid") + repository = createRepository(BIOMETRICS_ATTRIBUTE_UID) + + val result = repository.list().blockingFirst() + + assertFalse(result.any { it.uid == BIOMETRICS_ATTRIBUTE_UID }) + } + + @Test + fun `should keep attributes following capture only biometrics in their section`() { + val biometrics = configureBiometricsCaptureOnlyAttribute(value = null) + val following = stubProgramAttribute(FOLLOWING_ATTRIBUTE_UID, "Following") + whenever(programSection.attributes()) doReturn listOf(biometrics, following) + repository = createRepository(BIOMETRICS_ATTRIBUTE_UID) + + val result = repository.list().blockingFirst() + val sectionIndex = result.indexOfFirst { it.uid == programSection.uid() } + + assertEquals(FOLLOWING_ATTRIBUTE_UID, result[sectionIndex + 1].uid) + } + + private fun configureBiometricsCaptureOnlyAttribute(value: String?): TrackedEntityAttribute { + whenever(conf.sections()) doReturn listOf(programSection) + whenever(programSection.attributes()) doReturn emptyList() + whenever(conf.conflicts()) doReturn emptyList() + whenever( + customIntentRepository.getCustomIntent( + BIOMETRICS_ATTRIBUTE_UID, + null, + CustomIntentActionTypeModel.DATA_ENTRY, + ), + ) doReturn mock() + return stubProgramAttribute(BIOMETRICS_ATTRIBUTE_UID, "Biometrics", value) + } + + private fun stubProgramAttribute( + attributeUid: String, + label: String, + value: String? = null, + ): TrackedEntityAttribute { + val programAttribute: ProgramTrackedEntityAttribute = + mock { + on { trackedEntityAttribute() } doReturn ObjectWithUid.create(attributeUid) + on { mandatory() } doReturn false + on { allowFutureDate() } doReturn false + } + whenever(conf.programAttribute(attributeUid)) doReturn programAttribute + + val trackedEntityAttribute: TrackedEntityAttribute = + mock { + on { uid() } doReturn attributeUid + on { displayFormName() } doReturn label + on { valueType() } doReturn ValueType.TEXT + on { optionSet() } doReturn null + on { generated() } doReturn false + on { style() } doReturn ObjectStyle.builder().build() + } + whenever(conf.trackedEntityAttribute(attributeUid)) doReturn trackedEntityAttribute + whenever(conf.attributeValue(attributeUid)) doReturn value + return trackedEntityAttribute + } + + private fun createRepository(biometricsCaptureOnlyAttributeId: String? = null): DataEntryRepository = + EnrollmentRepository( + fieldFactory = fieldFactory, + conf = conf, + enrollmentMode = enrollmentMode, + enrollmentFormLabelsProvider = enrolmentFormLabelsProvider, + metadataIconProvider = metadataIconProvider, + customIntentRepository = customIntentRepository, + biometricsCaptureOnlyAttributeIdProvider = { biometricsCaptureOnlyAttributeId }, + ) + + private companion object { + const val BIOMETRICS_ATTRIBUTE_UID = "biometrics" + const val FOLLOWING_ATTRIBUTE_UID = "following-attribute" + } } diff --git a/form/src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt b/form/src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt index 9bffcd39b76..866e1b947fc 100644 --- a/form/src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt +++ b/form/src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt @@ -5,7 +5,9 @@ import io.reactivex.Flowable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.dhis2.commons.prefs.PreferenceProvider +import org.dhis2.commons.simprints.repository.SimprintsD2Repository import org.dhis2.form.model.ActionType import org.dhis2.form.model.EventCategory import org.dhis2.form.model.FieldUiModel @@ -40,9 +42,12 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.atLeast +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doReturn import org.mockito.kotlin.doReturnConsecutively +import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -55,6 +60,7 @@ class FormRepositoryImplTest { private val fieldErrorMessageProvider: FieldErrorMessageProvider = mock() private val displayNameProvider: DisplayNameProvider = mock() private val legendValueProvider: LegendValueProvider = mock() + private val simprintsD2Repository: SimprintsD2Repository = mock() private lateinit var repository: FormRepositoryImpl @Before @@ -96,18 +102,7 @@ class FormRepositoryImplTest { ) } - repository = - FormRepositoryImpl( - formValueStore, - fieldErrorMessageProvider, - displayNameProvider, - dataEntryRepository, - ruleEngineHelper, - rulesUtilsProvider, - legendValueProvider, - false, - preferenceProvider, - ) + repository = createRepository() repository.fetchFormItems() } @@ -277,6 +272,49 @@ class FormRepositoryImplTest { ) } + @Test + fun `Should show capture only biometrics until a value is captured`() = + runBlocking { + whenever(dataEntryRepository.list()) doReturn + Flowable.just(provideBiometricsCaptureOnlyItemList()) + repository = createRepository(biometricsCaptureOnlyAttributeId = "biometrics") + + val initialList = repository.fetchFormItems() + + assertEquals( + listOf("section1", "biometrics", "uid001"), + initialList.map { it.uid }, + ) + assertEquals(2, initialList.section("section1").totalFields) + + repository.updateValueOnList("biometrics", "captured-guid", ValueType.TEXT) + val capturedList = repository.composeList() + + assertFalse(capturedList.any { it.uid == "biometrics" }) + assertEquals(1, capturedList.section("section1").totalFields) + } + + @Test + fun `Should let program rules hide empty capture only biometrics`() = + runBlocking { + whenever(dataEntryRepository.list()) doReturn + Flowable.just(provideBiometricsCaptureOnlyItemList()) + whenever( + rulesUtilsProvider.applyRuleEffects(any(), any(), any(), any()), + ).thenAnswer { invocation -> + invocation.getArgument>(1).remove("biometrics") + null + } + repository = createRepository(biometricsCaptureOnlyAttributeId = "biometrics") + + val result = repository.fetchFormItems() + + assertEquals( + listOf("section1", "uid001"), + result.map { it.uid }, + ) + } + @Test fun `Should remove sections with no fields`() = runBlocking { @@ -667,6 +705,22 @@ class FormRepositoryImplTest { sectionField("field2", "section2"), ) + private fun provideBiometricsCaptureOnlyItemList() = + listOf( + section1(), + FieldUiModelImpl( + uid = "biometrics", + value = null, + label = "Biometrics", + valueType = ValueType.TEXT, + programStageSection = "section1", + optionSetConfiguration = null, + autocompleteList = null, + customIntent = registerLastIntent(), + ), + sectionField("uid001", "section1"), + ) + private fun sectionField( uid: String, sectionUid: String, @@ -816,6 +870,100 @@ class FormRepositoryImplTest { ), ) + private fun createRepository( + biometricsCaptureOnlyAttributeId: String? = null, + externalCredentialAttributeId: String? = null, + ) = + FormRepositoryImpl( + formValueStore, + fieldErrorMessageProvider, + displayNameProvider, + dataEntryRepository, + ruleEngineHelper, + rulesUtilsProvider, + legendValueProvider, + false, + preferenceProvider, + { biometricsCaptureOnlyAttributeId }, + { externalCredentialAttributeId }, + simprintsD2Repository, + ) + + @Test + fun `Configured external credential stays read only after form reload without program rule effects`() = + runTest { + repository = createRepository(externalCredentialAttributeId = "uid001") + + repeat(2) { + val field = repository.fetchFormItems().first { it.uid == "uid001" } + assertFalse(field.editable) + } + } + + @Test + fun `Saving Simprints external credential updates form value before program rules run`() = + runTest { + whenever(formValueStore.enrollmentUid()) doReturn "enrollment" + whenever( + simprintsD2Repository.blockingSaveEnrollmentExternalCredential("enrollment", "biometrics", "new-credential"), + ) doReturn "uid001" + + val result = repository.saveSimprintsExternalCredential("biometrics", "new-credential") + + assertEquals(StoreResult("uid001", ValueStoreResult.VALUE_CHANGED), result) + assertEquals("new-credential", repository.composeList().first { it.uid == "uid001" }.value) + } + + @Test + fun `Loading refresh after Simprints external credential save must not reuse the hide empty rule`() = + runTest { + val hideEmptyExternalCredential = + RuleEffect( + "hide-credential", + RuleAction(null, ProgramRuleActionType.HIDEFIELD.name, mapOf("field" to "uid001")), + null, + ) + whenever(ruleEngineHelper.evaluate()) doReturn listOf(hideEmptyExternalCredential) + repository.composeList() + whenever(formValueStore.enrollmentUid()) doReturn "enrollment" + whenever( + simprintsD2Repository.blockingSaveEnrollmentExternalCredential("enrollment", "biometrics", "new-credential"), + ) doReturn "uid001" + whenever(ruleEngineHelper.evaluate()) doReturn emptyList() + clearInvocations(rulesUtilsProvider) + + repository.saveSimprintsExternalCredential("biometrics", "new-credential") + repository.composeList(skipProgramRules = true) + + verify(rulesUtilsProvider).applyRuleEffects(eq(false), any(), eq(emptyList()), eq(formValueStore)) + } + + @Test + fun `Simprints external credential save error is skipped without changing form value`() = + runTest { + whenever(formValueStore.enrollmentUid()) doReturn "enrollment" + whenever( + simprintsD2Repository.blockingSaveEnrollmentExternalCredential("enrollment", "biometrics", "new-credential"), + ).thenThrow(IllegalStateException("save failed")) + val previousValue = repository.composeList().first { it.uid == "uid001" }.value + + val result = repository.saveSimprintsExternalCredential("biometrics", "new-credential") + + assertNull(result) + assertEquals(previousValue, repository.composeList().first { it.uid == "uid001" }.value) + } + + @Test + fun `Simprints external credential is not saved without an enrollment uid`() = + runTest { + whenever(formValueStore.enrollmentUid()) doReturn null + + val result = repository.saveSimprintsExternalCredential("biometrics", "new-credential") + + assertNull(result) + verify(simprintsD2Repository, times(0)).blockingSaveEnrollmentExternalCredential(any(), any(), any()) + } + @Test fun `reEvaluateRequestParams should call dataEntryRepository and map results`() { val customIntentUid = "custom-intent-uid" diff --git a/form/src/test/java/org/dhis2/form/data/FormValueStoreTest.kt b/form/src/test/java/org/dhis2/form/data/FormValueStoreTest.kt index 5899c327e98..19661b0ef06 100644 --- a/form/src/test/java/org/dhis2/form/data/FormValueStoreTest.kt +++ b/form/src/test/java/org/dhis2/form/data/FormValueStoreTest.kt @@ -28,6 +28,7 @@ import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValueObjec import org.hisp.dhis.android.core.trackedentity.TrackedEntityDataValue import org.hisp.dhis.android.core.trackedentity.TrackedEntityDataValueObjectRepository import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -114,6 +115,18 @@ class FormValueStoreTest { ) } + @Test + fun `Should resolve enrollment uid only when an enrollment repository exists`() { + val mockedEnrollment: Enrollment = + mock { + on { uid() } doReturn "enrollmentUid" + } + whenever(enrollmentRepository.blockingGet()) doReturn mockedEnrollment + + assertEquals("enrollmentUid", attrValueStore.enrollmentUid()) + assertNull(deValueStore.enrollmentUid()) + } + @Test(expected = IllegalArgumentException::class) fun `Should throw exception when saving for data value entry mode`() { dvValueStore.save( 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 fe562a3f57b..c4c9131bbc2 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 @@ -5,10 +5,13 @@ 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.arch.repositories.scope.RepositoryScope import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.event.Event import org.hisp.dhis.android.core.event.EventCollectionRepository import org.hisp.dhis.android.core.event.EventObjectRepository +import org.hisp.dhis.android.core.option.Option +import org.hisp.dhis.android.core.option.OptionCollectionRepository import org.hisp.dhis.android.core.trackedentity.TrackedEntityDataValue import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -32,6 +35,10 @@ class FormHistoryChartRepositoryTest { private val followUpEvents: EventCollectionRepository = mock() private val deletedFilter: BooleanFilterConnector = mock() private val activeFollowUpEvents: EventCollectionRepository = mock() + private val optionCollection: OptionCollectionRepository = mock() + private val optionSetFilter: StringFilterConnector = mock() + private val optionsByOptionSet: OptionCollectionRepository = mock() + private val orderedOptions: OptionCollectionRepository = mock() private val repository = FormHistoryChartRepository(CURRENT_EVENT_UID, d2) @Test @@ -82,13 +89,14 @@ class FormHistoryChartRepositoryTest { optionSetConfiguration = null, autocompleteList = null, ), - configs = listOf(getConfig()), + configs = listOf(getConfig(isYAxisInverted = true)), ) assertEquals("Weight", chart?.title) assertEquals(listOf("0", "1", "2", "3"), chart?.labels) assertEquals(listOf(8f, 9.5f, null, null), chart?.values) assertEquals(1, chart?.currentValueIndex) + assertEquals(true, chart?.isYAxisInverted) assertEquals(1, chart?.displayMaxDecimalPlaces) } @@ -137,6 +145,62 @@ class FormHistoryChartRepositoryTest { assertEquals(1, chart?.currentValueIndex) } + @Test + fun `getChart should plot option set values in metadata order`() { + val currentEvent = + getEvent( + uid = CURRENT_EVENT_UID, + eventDate = Date(2_000), + dataValues = + listOf( + dataValue(VISIT_NUMBER_UID, "1"), + dataValue(DATA_ELEMENT_UID, "mam"), + ), + ) + stubCurrentEvent(currentEvent) + stubFollowUpEvents( + listOf( + getEvent( + uid = "previous", + eventDate = Date(1_000), + dataValues = + listOf( + dataValue(VISIT_NUMBER_UID, "0"), + dataValue(DATA_ELEMENT_UID, "sam"), + ), + ), + currentEvent, + ), + ) + stubOptions( + option(code = "ok", displayName = "Ok", sortOrder = 0), + option(code = "mam", displayName = "MAM", sortOrder = 1), + option(code = "sam", displayName = "SAM", sortOrder = 2), + ) + + val chart = + repository.getChart( + fieldUiModel = + FieldUiModelImpl( + uid = DATA_ELEMENT_UID, + value = "Ok", + label = "Nutrition Status", + valueType = ValueType.TEXT, + optionSet = OPTION_SET_UID, + optionSetConfiguration = null, + autocompleteList = null, + ), + configs = listOf(getConfig()), + ) + + assertEquals(listOf("Ok", "MAM", "SAM"), chart?.categories?.map { it.displayName }) + assertEquals(listOf(2f, 0f, null, null), chart?.values) + assertEquals(listOf(2f, 1f, null, null), chart?.withCurrentValue("mam")?.values) + assertEquals(listOf(2f, 2f, null, null), chart?.withCurrentValue("SAM")?.values) + assertEquals(listOf(2f, null, null, null), chart?.withCurrentValue(null)?.values) + assertEquals(listOf(2f, null, null, null), chart?.withCurrentValue("unknown")?.values) + } + @Test fun `getChart should return null when field is not configured`() { stubCurrentEvent(getEvent(uid = CURRENT_EVENT_UID)) @@ -277,13 +341,14 @@ class FormHistoryChartRepositoryTest { verify(activeFollowUpEvents, times(1)).blockingGet() } - private fun getConfig() = + private fun getConfig(isYAxisInverted: Boolean = false) = DataElementHistoryChartConfig( programId = PROGRAM_UID, followUpVisitProgramStageId = PROGRAM_STAGE_UID, dataElementId = DATA_ELEMENT_UID, xAxisVisitNumberDataElementId = VISIT_NUMBER_UID, followUpVisitMaxNumber = 3, + isYAxisInverted = isYAxisInverted, displayMaxDecimalPlaces = 1, ) @@ -309,6 +374,30 @@ class FormHistoryChartRepositoryTest { whenever(activeFollowUpEvents.blockingGet()) doReturn eventsToReturn } + private fun stubOptions(vararg options: Option) { + whenever(d2.optionModule().options()) doReturn optionCollection + whenever(optionCollection.byOptionSetUid()) doReturn optionSetFilter + whenever(optionSetFilter.eq(OPTION_SET_UID)) doReturn optionsByOptionSet + whenever( + optionsByOptionSet.orderBySortOrder(RepositoryScope.OrderByDirection.ASC), + ) doReturn orderedOptions + whenever(orderedOptions.blockingGet()) doReturn options.toList() + } + + private fun option( + code: String, + displayName: String, + sortOrder: Int, + ): Option = + Option + .builder() + .uid("$code-option") + .code(code) + .name(displayName) + .displayName(displayName) + .sortOrder(sortOrder) + .build() + private fun getEvent( uid: String, eventDate: Date = Date(1_000), @@ -342,5 +431,6 @@ class FormHistoryChartRepositoryTest { const val PROGRAM_STAGE_UID = "follow-stage" const val DATA_ELEMENT_UID = "weight" const val VISIT_NUMBER_UID = "visit-number" + const val OPTION_SET_UID = "nutrition-status-options" } } 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 index 55ef40cb33d..776809376c6 100644 --- 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 @@ -24,4 +24,16 @@ class IntegerAwareValueFormatterTest { assertEquals("9", formatter.getFormattedValue(9f)) } + + @Test + fun `category formatter should use labels only for exact category positions`() { + val formatter = HistoryChartCategoryFormatter(listOf("Value1", "Value2", "Value3")) + + assertEquals("Value1", formatter.getFormattedValue(0f)) + assertEquals("Value2", formatter.getFormattedValue(1f)) + assertEquals("Value3", formatter.getFormattedValue(2f)) + assertEquals("", formatter.getFormattedValue(1.5f)) + assertEquals("", formatter.getFormattedValue(-1f)) + assertEquals("", formatter.getFormattedValue(3f)) + } } diff --git a/form/src/test/java/org/dhis2/form/ui/FormViewModelTest.kt b/form/src/test/java/org/dhis2/form/ui/FormViewModelTest.kt index 086676fdb15..aaf60c3214c 100644 --- a/form/src/test/java/org/dhis2/form/ui/FormViewModelTest.kt +++ b/form/src/test/java/org/dhis2/form/ui/FormViewModelTest.kt @@ -17,6 +17,8 @@ import org.dhis2.form.model.ActionType import org.dhis2.form.model.FieldUiModel import org.dhis2.form.model.FieldUiModelImpl import org.dhis2.form.model.RowAction +import org.dhis2.form.model.StoreResult +import org.dhis2.form.model.ValueStoreResult import org.dhis2.form.simprints.ramp.model.FormHistoryChart import org.dhis2.form.ui.event.RecyclerViewUiEvents import org.dhis2.form.ui.intent.FormIntent @@ -25,13 +27,16 @@ import org.dhis2.form.ui.provider.FormResultDialogProvider import org.dhis2.mobile.commons.model.CustomIntentRequestArgumentModel import org.hisp.dhis.android.core.common.ValueType import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doReturn +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -237,6 +242,68 @@ class FormViewModelTest { verify(repository).save(fieldUid, value, null) } + @Test + fun `Should save SID external credential after GUID and evaluate program rules`() = + runTest { + whenever(repository.save("biometrics", "guid", null)) doReturn + StoreResult("biometrics", ValueStoreResult.VALUE_HAS_NOT_CHANGED) + whenever(repository.saveSimprintsExternalCredential("biometrics", "credential-value")) doReturn + StoreResult("credential-attribute", ValueStoreResult.VALUE_CHANGED) + + viewModel.submitIntent( + FormIntent.OnSaveCustomIntent("biometrics", "guid", false, "credential-value"), + ) + advanceUntilIdle() + + inOrder(repository) { + verify(repository).save("biometrics", "guid", null) + verify(repository).saveSimprintsExternalCredential("biometrics", "credential-value") + verify(repository).composeList(false) + } + } + + @Test + fun `Should not save SID external credential when GUID save fails`() = + runTest { + whenever(repository.save("biometrics", "guid", null)) doReturn + StoreResult("biometrics", ValueStoreResult.ERROR_UPDATING_VALUE) + + viewModel.submitIntent( + FormIntent.OnSaveCustomIntent("biometrics", "guid", false, "credential-value"), + ) + advanceUntilIdle() + + verify(repository, never()).saveSimprintsExternalCredential(any(), any()) + } + + @Test + fun `Should not save SID external credential from failed custom intent`() = + runTest { + viewModel.submitIntent( + FormIntent.OnSaveCustomIntent("biometrics", "guid", true, "credential-value"), + ) + advanceUntilIdle() + + verify(repository, never()).saveSimprintsExternalCredential(any(), any()) + } + + @Test + fun `Should keep GUID result when SID external credential cannot be saved`() = + runTest { + whenever(repository.save("biometrics", "guid", null)) doReturn + StoreResult("biometrics", ValueStoreResult.VALUE_CHANGED) + whenever(repository.saveSimprintsExternalCredential("biometrics", "credential-value")) doReturn null + + viewModel.submitIntent( + FormIntent.OnSaveCustomIntent("biometrics", "guid", false, "credential-value"), + ) + advanceUntilIdle() + + assertNull(viewModel.showToast.value) + assertEquals("biometrics", viewModel.savedValue.value?.id) + verify(repository).composeList(false) + } + @Test fun `Should handle OnSaveCustomIntent with error`() = runTest {